├── .dockerignore ├── .github ├── pull_request_template.md ├── scripts │ ├── build_and_push_edge.sh │ └── build_and_push_release.sh └── workflows │ ├── build_project.yml │ ├── build_push_docker_edge.yml │ ├── build_push_docker_release.yml │ └── prettier_formatting_check.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── package-lock.json ├── package.json ├── readme.md ├── src ├── api │ ├── ApiManager.ts │ ├── CliApiManager.ts │ └── HttpClient.ts ├── commands │ ├── Command.ts │ ├── api.ts │ ├── caprover.ts │ ├── deploy.ts │ ├── list.ts │ ├── login.ts │ ├── logout.ts │ └── serversetup.ts ├── models │ ├── AppDef.ts │ ├── IBuildLogs.ts │ ├── ICaptainDefinition.ts │ ├── IHashMapGeneric.ts │ ├── IOneClickAppModels.ts │ ├── IRegistryInfo.ts │ ├── IVersionInfo.ts │ └── storage │ │ └── StoredObjects.ts └── utils │ ├── CliHelper.ts │ ├── Constants.ts │ ├── DeployHelper.ts │ ├── ErrorFactory.ts │ ├── Logger.ts │ ├── SpinnerHelper.ts │ ├── StdOutUtil.ts │ ├── StorageHelper.ts │ ├── Utils.ts │ └── ValidationsHandler.ts ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # add git-ignore syntax here of things you don't want copied into docker image 2 | 3 | .git 4 | 5 | /node_modules 6 | /built 7 | .idea 8 | npm-debug.log 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | First of all, thank you for your contribution! 😄 2 | 3 | Please make sure you have read [contribution guidelines](https://github.com/caprover/caprover/blob/master/CONTRIBUTING.md#before-contributing). 4 | 5 | 6 | 7 | ### Most important items 8 | 9 | - Make sure to communicate your proposed changes ob our Slack channel beforehand. 10 | - Large PRs (50+ lines of code) will get rejected due to increased difficulty for review - unless they have been communicated beforehand with project maintainers. 11 | - **Refactoring work will get rejected** unless it's been communicated with project's maintainers **beforehand**. There is a thousand ways to write the same code. Every time a code is changed, there is a potential for a new bug. We don't want to refactor the code just for the sake of refactoring. 12 | 13 | 14 | These rules are strictly enforced to make sure that we can maintain the project moving forward. 15 | -------------------------------------------------------------------------------- /.github/scripts/build_and_push_edge.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit early if any command fails 4 | set -e 5 | 6 | # Print all commands 7 | set -x 8 | 9 | pwd 10 | 11 | # ensure you're not running it on local machine 12 | if [ -z "$CI" ] || [ -z "$GITHUB_REF" ]; then 13 | echo "Running on a local machine! Exiting!" 14 | exit 127 15 | else 16 | echo "Running on CI" 17 | fi 18 | 19 | 20 | CAPROVER_VERSION=edge 21 | IMAGE_NAME=caprover/cli-caprover 22 | 23 | if [ ! -f ./package-lock.json ]; then 24 | echo "package-lock.json not found!" 25 | exit 1; 26 | fi 27 | 28 | 29 | # BRANCH=$(git rev-parse --abbrev-ref HEAD) 30 | # On Github the line above does not work, instead: 31 | BRANCH=${GITHUB_REF##*/} 32 | echo "on branch $BRANCH" 33 | if [[ "$BRANCH" != "master" ]]; then 34 | echo 'Not on master branch! Aborting script!'; 35 | exit 1; 36 | fi 37 | 38 | 39 | 40 | 41 | 42 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 43 | export DOCKER_CLI_EXPERIMENTAL=enabled 44 | docker buildx ls 45 | docker buildx create --name mybuilder 46 | docker buildx use mybuilder 47 | 48 | docker buildx build --platform linux/amd64,linux/arm64,linux/arm -t $IMAGE_NAME:$CAPROVER_VERSION -f Dockerfile --push . 49 | 50 | -------------------------------------------------------------------------------- /.github/scripts/build_and_push_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit early if any command fails 4 | set -e 5 | 6 | # Print all commands 7 | set -x 8 | 9 | pwd 10 | 11 | 12 | function readJson { 13 | UNAMESTR=`uname` 14 | if [[ "$UNAMESTR" == 'Linux' ]]; then 15 | SED_EXTENDED='-r' 16 | elif [[ "$UNAMESTR" == 'Darwin' ]]; then 17 | SED_EXTENDED='-E' 18 | fi; 19 | 20 | VALUE=`grep -m 1 "\"${2}\"" ${1} | sed ${SED_EXTENDED} 's/^ *//;s/.*: *"//;s/",?//'` 21 | 22 | if [ ! "$VALUE" ]; then 23 | echo "Error: Cannot find \"${2}\" in ${1}" >&2; 24 | exit 1; 25 | else 26 | echo $VALUE ; 27 | fi; 28 | } 29 | 30 | if [ ! -f ./package-lock.json ]; then 31 | echo "package-lock.json not found!" 32 | exit 1; 33 | fi 34 | 35 | IMAGE_NAME=caprover/cli-caprover 36 | CAPROVER_VERSION=`readJson package.json version` || exit 1; 37 | 38 | echo $IMAGE_NAME 39 | echo $CAPROVER_VERSION 40 | 41 | 42 | # ensure you're not running it on local machine 43 | if [ -z "$CI" ] || [ -z "$GITHUB_REF" ]; then 44 | echo "Running on a local machine! Exiting!" 45 | exit 127 46 | else 47 | echo "Running on CI" 48 | fi 49 | 50 | 51 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 52 | 53 | export DOCKER_CLI_EXPERIMENTAL=enabled 54 | docker buildx ls 55 | docker buildx create --name mybuilder 56 | docker buildx use mybuilder 57 | 58 | docker buildx build --platform linux/amd64,linux/arm64,linux/arm -t $IMAGE_NAME:$CAPROVER_VERSION -t $IMAGE_NAME:latest -f Dockerfile --push . -------------------------------------------------------------------------------- /.github/workflows/build_project.yml: -------------------------------------------------------------------------------- 1 | name: Run build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 20 19 | - run: npm ci && npm run build 20 | -------------------------------------------------------------------------------- /.github/workflows/build_push_docker_edge.yml: -------------------------------------------------------------------------------- 1 | name: Build and push the edge image 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | run-pre-checks: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | - run: | 17 | npm ci 18 | npm run build 19 | npm run formatter 20 | npm run tslint 21 | build-publish-docker-hub: 22 | needs: run-pre-checks 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: azure/docker-login@v1 26 | with: 27 | username: ${{ secrets.REGISTRY_USERNAME }} 28 | password: ${{ secrets.REGISTRY_PASSWORD }} 29 | - uses: actions/checkout@v3 30 | - name: Build and Push Edge to DockerHub 31 | shell: bash 32 | run: ./.github/scripts/build_and_push_edge.sh 33 | -------------------------------------------------------------------------------- /.github/workflows/build_push_docker_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and push the release image 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | run-pre-checks: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 20 14 | - run: | 15 | npm ci 16 | npm run build 17 | npm run formatter 18 | npm run tslint 19 | build-publish-docker-hub: 20 | needs: run-pre-checks 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: azure/docker-login@v1 24 | with: 25 | username: ${{ secrets.REGISTRY_USERNAME }} 26 | password: ${{ secrets.REGISTRY_PASSWORD }} 27 | - uses: actions/checkout@v3 28 | - name: Build and Push Edge to DockerHub 29 | shell: bash 30 | run: ./.github/scripts/build_and_push_release.sh 31 | -------------------------------------------------------------------------------- /.github/workflows/prettier_formatting_check.yml: -------------------------------------------------------------------------------- 1 | name: Check Prettier Formatting and TSLint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 20 19 | - run: npm ci && npm run formatter && npm run tslint 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #################### 2 | #### IMPORTANT ##### 3 | #################### 4 | # Make sure to update .npmignore and .dockerignore when updating this file. 5 | 6 | /node_modules 7 | /built 8 | .idea 9 | npm-debug.log 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | # /built 3 | .idea 4 | npm-debug.log 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | node_modules/ 4 | coverage/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | RUN apk update && apk upgrade && \ 3 | apk add --no-cache bash git openssh 4 | 5 | RUN mkdir -p /usr/src/app 6 | WORKDIR /usr/src/app 7 | 8 | COPY . /usr/src/app 9 | 10 | RUN npm install && \ 11 | npm cache clean --force && \ 12 | npm run build 13 | 14 | ENV NODE_ENV production 15 | 16 | WORKDIR /usr/src/app/built/commands 17 | ENV PATH="/usr/src/app/built/commands:${PATH}" 18 | 19 | RUN mv caprover.js caprover 20 | 21 | 22 | 23 | CMD ["echo" , "'Usage: docker run -it caprover/cli-caprover:VERSION_HERE caprover serversetup'"] 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caprover", 3 | "version": "2.3.1", 4 | "description": "CLI tool for CapRover. See CapRover.com for more details.", 5 | "main": "./built/commands/caprover.js", 6 | "scripts": { 7 | "tslint": "tslint -c tslint.json -p tsconfig.json", 8 | "formatter": "prettier --check ./src/**/*.ts", 9 | "formatter-write": "prettier --write ./src/**/*.ts", 10 | "build": "npm run tslint && rm -rf ./built && npx tsc && chmod -R +x ./built " 11 | }, 12 | "bin": { 13 | "caprover": "./built/commands/caprover.js", 14 | "caprover-deploy": "./built/commands/deploy.js", 15 | "caprover-list": "./built/commands/list.js", 16 | "caprover-login": "./built/commands/login.js", 17 | "caprover-logout": "./built/commands/logout.js", 18 | "caprover-serversetup": "./built/commands/serversetup.js", 19 | "caprover-api": "./built/commands/api.js" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/caprover/caprover-cli" 24 | }, 25 | "keywords": [ 26 | "Docker", 27 | "Automated", 28 | "Deployment", 29 | "Heroku", 30 | "Free", 31 | "NodeJS", 32 | "PHP", 33 | "Nginx", 34 | "Server", 35 | "Container" 36 | ], 37 | "engines": { 38 | "node": ">=20" 39 | }, 40 | "author": "Kasra Bigdeli", 41 | "license": "Apache-2.0", 42 | "lint-staged": { 43 | "./**/*.{js}": [ 44 | "co-eslint", 45 | "co-prettier --write", 46 | "git add" 47 | ] 48 | }, 49 | "dependencies": { 50 | "chalk": "^5.3.0", 51 | "command-exists": "^1.2.9", 52 | "commander": "^12.1.0", 53 | "configstore": "^7.0.0", 54 | "fs-extra": "^11.2.0", 55 | "inquirer": "^6.5.0", 56 | "js-yaml": "^4.1.0", 57 | "ora": "^8.1.1", 58 | "progress": "^2.0.3", 59 | "request": "^2.88.2", 60 | "request-promise": "^4.2.6", 61 | "update-notifier": "^7.3.1" 62 | }, 63 | "devDependencies": { 64 | "@types/command-exists": "^1.2.3", 65 | "@types/configstore": "^6.0.2", 66 | "@types/fs-extra": "^11.0.4", 67 | "@types/inquirer": "^6.5.0", 68 | "@types/js-yaml": "^4.0.9", 69 | "@types/node": "^22.1.0", 70 | "@types/progress": "^2.0.7", 71 | "@types/request-promise": "^4.1.51", 72 | "@types/update-notifier": "^6.0.8", 73 | "prettier": "^3.4.2", 74 | "tslint": "^5.20.1", 75 | "typescript": "^5.7.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # CapRover CLI 2 | 3 | Command Line Interface for CapRover. 4 | 5 | CapRover is a modern automated app deployment & web server manager. 6 | - Deploy apps in your own space 7 | - Secure your services over HTTPS for FREE 8 | - Scale in seconds 9 | - Focus on your apps! Not the bells and whistles just to run your apps! 10 | 11 | Fore more information see CapRover.com 12 | 13 | Always refer to the documentation bundled in CLI as it is the most updated one. You can view the help by `caprover --help` or `caprover deploy --help` or etc. 14 | 15 | ## Getting started 16 | 17 | This guide assumes that you have started CapRover on a linux server and setup your DNS (see [CapRover Setup](https://caprover.com/docs/get-started.html#caprover-setup)). 18 | 19 | You can use this CLI tool to perform initial CapRover server setup, and to deploy your apps. 20 | 21 | Before anything, install the CLI tool using npm: 22 | ``` 23 | npm install -g caprover 24 | ``` 25 | 26 | ### Usage 27 | 28 | You can use the CLI by typing `caprover` in your console. 29 | 30 | The CLI has several commands, if invoked without a command it display the usage summary: 31 | ``` 32 | Usage: caprover [options] [command] 33 | 34 | CLI tool for CapRover. See CapRover.com for more details. 35 | 36 | Options: 37 | -V, --version output the version number 38 | -h, --help output usage information 39 | 40 | Commands: 41 | serversetup|setup [options] Performs necessary actions to prepare CapRover on your server. 42 | login [options] Login to a CapRover machine. You can be logged in to multiple machines simultaneously. 43 | list|ls List all CapRover machines currently logged in. 44 | logout [options] Logout from a CapRover machine and clear auth info. 45 | deploy [options] Deploy your app to a specific CapRover machine. You'll be prompted for missing parameters. 46 | api [options] Call a generic API on a specific CapRover machine. Use carefully only if you really know what you are doing! 47 | ``` 48 | 49 | ## Commands 50 | 51 | Almost all commands require some data to work. Data for commands can be provided from different sources: 52 | - Enviroment variables: using variables names specified in command help (note that variable must be exported, or you have to define it inline before the cli command, eg: `ENV_VAR=value caprover command`); 53 | - Configuration file: JSON or YAML, specifing the file name with an option or its environment variable (usually `-c, --configFile` and `CAPROVER_CONFIG_FILE`), command options names define the keys of the configuration file; 54 | - Command options: using command options flags directly on the command line; 55 | - Input prompt: for those data that is not provided from other sources, but needed for the command to work, you'll be prompted to input them during command execution. 56 | 57 | If the same data is provided from different sources, the priority order reflects the above list (the following ones overwrite the previous ones), except for input prompt that is used only if that data is not provided from others sources. 58 | 59 | View help for a command to know more details to that command, by running: 60 | ``` 61 | caprover [command] --help 62 | ``` 63 | 64 | ### Server Setup 65 | 66 | The very first thing you need to do is to setup your CapRover server. You can either do this by visiting `HTTP://IP_ADDRESS_OF_SERVER:3000` in your browser, or the recommended way which is the command line tool. 67 | Simply run: 68 | ``` 69 | caprover serversetup 70 | ``` 71 | 72 | Follow the steps as instructed: enter IP address of server and the root domain to be used with this CapRover instance. If you don't know what CapRover root domain is, please visit CapRover.com for documentation. This is a very crucial step. 73 | After that, you'll be asked to change your CapRover server password, and to enter your email address. This should be a valid email address as it will be used in your SSL certificates. 74 | After HTTPS is enabled, you'll be asked to enter a name for this CapRover machine, to store auth credential locally. And... Your are done! Go to Deploy section below to read more about app deployment. 75 | 76 | For automation purposes, you can provide necessary data before to be prompted for them, for example using a config file like: 77 | ```json 78 | { 79 | "caproverIP": "123.123.123.123", 80 | "caproverPassword": "captain42", 81 | "caproverRootDomain": "root.domain.com", 82 | "newPassword": "rAnDoMpAsSwOrD", 83 | "certificateEmail": "email@gmail.com", 84 | "caproverName": "my-machine-123-123-123-123" 85 | } 86 | ``` 87 | And then running: 88 | ``` 89 | caprover serversetup -c /path/to/config.json 90 | ``` 91 | *Note*: you can also use either YAML or JSON. 92 | 93 | ### Login 94 | 95 | *If you've done the "Server Setup" process through the command line, you can skip "Login" step because your auth credential are automatically stored in the last step of setup.* 96 | 97 | This command does login to your CapRover server and store your auth credential locally. 98 | It is recommended that at this point you have already set up HTTPS. Login over insecure, plain HTTP is not recommended. 99 | 100 | To login to your CapRover server, simply run the following command and answer the questions: 101 | ``` 102 | caprover login 103 | ``` 104 | 105 | If operation finishes successfully, you will be prompted with a success message. 106 | 107 | *Note*: you can be logged in to several CapRover servers at the same time; this is particularly useful if you have separate staging and production servers. 108 | 109 | For automation purposes, you can provide necessary data before to be prompted for them, for example using a config file like: 110 | ```json 111 | { 112 | "caproverUrl": "captain.root.domain.com", 113 | "caproverPassword": "captain42", 114 | "caproverName": "testing-1" 115 | } 116 | ``` 117 | And then running: 118 | ``` 119 | caprover login -c /path/to/config.json 120 | ``` 121 | *Note*: you can also use either YAML or JSON. 122 | 123 | ### Deploy 124 | 125 | Use this command to deploy your application. Deploy via caprover CLI supports 4 deployments methods: captain-definition file, Dockerfile, tar file, and image name (see [Captain Definition File](https://caprover.com/docs/captain-definition-file.html) for more info). 126 | 127 | Simply run the following command and answers questions: 128 | ``` 129 | caprover deploy 130 | ``` 131 | 132 | You will then see your application being uploaded, after that, your application getting built. 133 | 134 | *Note*: based on your deployment method, the build process could take multiple minutes, please be patient! 135 | 136 | For automation purposes, you can provide necessary data before to be prompted for them, for example directly on the command line by running: 137 | ``` 138 | caprover deploy -n machine-name -a app-name -b branchName 139 | ``` 140 | *Note*: you must be logged in to "machine-name". 141 | 142 | This can be useful if you want to integrate to CI/CD pipelines. 143 | 144 | See command help to know more details and deployments methods. 145 | 146 | ### List 147 | 148 | Use this command to see a list of CapRover machines you are currently logged in to. 149 | Run the following command: 150 | ``` 151 | caprover list 152 | ``` 153 | 154 | ### Logout 155 | 156 | Use this command to logout from a CapRover machine and clear auth info. 157 | Run the following command and choose a CapRover machine: 158 | ``` 159 | caprover logout 160 | ``` 161 | 162 | ### API 163 | 164 | Use this command to call a generic API on a CapRover machine, specifying API path, method (GET or POST), and data. There is no official document for the API commands at this point as it is subject to change at any point. But you can use [ApiManager.ts](https://github.com/caprover/caprover-cli/blob/master/src/api/ApiManager.ts) as a starting point. 165 | 166 | ``` 167 | caprover api 168 | ``` 169 | 170 | For automation purposes, you can provide necessary data before to be prompted for them, for example using a config file like: 171 | ```json 172 | { 173 | "caproverName": "server-1", 174 | "path": "/user/apps/appDefinitions/unusedImages", 175 | "method": "GET", 176 | "data": { 177 | "mostRecentLimit": "3" 178 | } 179 | } 180 | ``` 181 | And then running (using environment variable for config file value): 182 | ``` 183 | CAPROVER_CONFIG_FILE='/path/to/config.json' caprover api -o output.json 184 | ``` 185 | 186 | *Note*: use carefully only if you really know what you are doing! 187 | -------------------------------------------------------------------------------- /src/api/ApiManager.ts: -------------------------------------------------------------------------------- 1 | import HttpClient from './HttpClient' 2 | import Logger from '../utils/Logger' 3 | import { IRegistryInfo } from '../models/IRegistryInfo' 4 | import { ICaptainDefinition } from '../models/ICaptainDefinition' 5 | import { IVersionInfo } from '../models/IVersionInfo' 6 | import { IAppDef } from '../models/AppDef' 7 | import * as fs from 'fs-extra' 8 | import IBuildLogs from '../models/IBuildLogs' 9 | 10 | export default class ApiManager { 11 | private static lastKnownPassword: string = process.env 12 | .REACT_APP_DEFAULT_PASSWORD 13 | ? process.env.REACT_APP_DEFAULT_PASSWORD + '' 14 | : 'captain42' 15 | private static authToken: string = !!process.env.REACT_APP_IS_DEBUG 16 | ? // tslint:disable-next-line: max-line-length 17 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7Im5hbWVzcGFjZSI6ImNhcHRhaW4iLCJ0b2tlblZlcnNpb24iOiI5NmRjM2U1MC00ZDk3LTRkNmItYTIzMS04MmNiZjY0ZTA2NTYifSwiaWF0IjoxNTQ1OTg0MDQwLCJleHAiOjE1ODE5ODQwNDB9.uGJyhb2JYsdw9toyMKX28bLVuB0PhnS2POwEjKpchww' 18 | : '' 19 | 20 | private http: HttpClient 21 | 22 | constructor( 23 | baseUrl: string, 24 | private appToken: string | undefined, 25 | private authTokenSaver: (authToken: string) => Promise 26 | ) { 27 | const self = this 28 | 29 | this.http = new HttpClient( 30 | baseUrl, 31 | appToken, 32 | ApiManager.authToken, 33 | function () { 34 | return self.getAuthToken(ApiManager.lastKnownPassword) 35 | } 36 | ) 37 | } 38 | 39 | callApi( 40 | path: string, 41 | method: 'GET' | 'POST' /*| 'POST_DATA' Not used */, 42 | data: any 43 | ) { 44 | const http = this.http 45 | 46 | return Promise.resolve().then(http.fetch(method, path, data)) 47 | } 48 | 49 | destroy() { 50 | this.http.destroy() 51 | } 52 | 53 | setAuthToken(authToken: string) { 54 | ApiManager.authToken = authToken 55 | this.http.setAuthToken(authToken) 56 | } 57 | 58 | static isLoggedIn() { 59 | return !!ApiManager.authToken 60 | } 61 | 62 | getAuthToken(password: string) { 63 | const http = this.http 64 | ApiManager.lastKnownPassword = password 65 | let authTokenFetched = '' 66 | 67 | const self = this 68 | return Promise.resolve() // 69 | .then( 70 | http.fetch(http.POST, '/login', { 71 | password, 72 | otpToken: process.env.CAPROVER_OTP_TOKEN 73 | }) 74 | ) 75 | .then(function (data) { 76 | authTokenFetched = data.token 77 | self.setAuthToken(authTokenFetched) 78 | return authTokenFetched 79 | }) 80 | .then(self.authTokenSaver) 81 | .then(function () { 82 | return authTokenFetched 83 | }) 84 | } 85 | 86 | getCaptainInfo() { 87 | const http = this.http 88 | 89 | return Promise.resolve() // 90 | .then(http.fetch(http.GET, '/user/system/info', {})) 91 | } 92 | 93 | updateRootDomain(rootDomain: string) { 94 | const http = this.http 95 | 96 | return Promise.resolve() // 97 | .then( 98 | http.fetch(http.POST, '/user/system/changerootdomain', { 99 | rootDomain 100 | }) 101 | ) 102 | } 103 | 104 | enableRootSsl(emailAddress: string) { 105 | const http = this.http 106 | 107 | return Promise.resolve() // 108 | .then( 109 | http.fetch(http.POST, '/user/system/enablessl', { 110 | emailAddress 111 | }) 112 | ) 113 | } 114 | 115 | forceSsl(isEnabled: boolean) { 116 | const http = this.http 117 | 118 | return Promise.resolve() // 119 | .then(http.fetch(http.POST, '/user/system/forcessl', { isEnabled })) 120 | } 121 | 122 | getAllApps() { 123 | const http = this.http 124 | 125 | return Promise.resolve() // 126 | .then(http.fetch(http.GET, '/user/apps/appDefinitions', {})) 127 | } 128 | 129 | fetchBuildLogs(appName: string): Promise { 130 | const http = this.http 131 | 132 | return Promise.resolve() // 133 | .then(http.fetch(http.GET, '/user/apps/appData/' + appName, {})) 134 | } 135 | 136 | uploadAppData(appName: string, file: fs.ReadStream, gitHash: string) { 137 | const http = this.http 138 | return Promise.resolve() // 139 | .then( 140 | http.fetch( 141 | http.POST_DATA, 142 | '/user/apps/appData/' + appName + '?detached=1', 143 | { sourceFile: file, gitHash } 144 | ) 145 | ) 146 | } 147 | 148 | uploadCaptainDefinitionContent( 149 | appName: string, 150 | captainDefinition: ICaptainDefinition, 151 | gitHash: string, 152 | detached: boolean 153 | ) { 154 | const http = this.http 155 | 156 | return Promise.resolve() // 157 | .then( 158 | http.fetch( 159 | http.POST, 160 | '/user/apps/appData/' + 161 | appName + 162 | (detached ? '?detached=1' : ''), 163 | { 164 | captainDefinitionContent: 165 | JSON.stringify(captainDefinition), 166 | gitHash 167 | } 168 | ) 169 | ) 170 | } 171 | 172 | updateConfigAndSave(appName: string, appDefinition: IAppDef) { 173 | const instanceCount = appDefinition.instanceCount 174 | const envVars = appDefinition.envVars 175 | const notExposeAsWebApp = appDefinition.notExposeAsWebApp 176 | const forceSsl = appDefinition.forceSsl 177 | const volumes = appDefinition.volumes 178 | const ports = appDefinition.ports 179 | const nodeId = appDefinition.nodeId 180 | const appPushWebhook = appDefinition.appPushWebhook 181 | const customNginxConfig = appDefinition.customNginxConfig 182 | const preDeployFunction = appDefinition.preDeployFunction 183 | const http = this.http 184 | 185 | return Promise.resolve() // 186 | .then( 187 | http.fetch(http.POST, '/user/apps/appDefinitions/update', { 188 | appName, 189 | instanceCount, 190 | notExposeAsWebApp, 191 | forceSsl, 192 | volumes, 193 | ports, 194 | customNginxConfig, 195 | appPushWebhook, 196 | nodeId, 197 | preDeployFunction, 198 | envVars 199 | }) 200 | ) 201 | } 202 | 203 | registerNewApp(appName: string, hasPersistentData: boolean) { 204 | const http = this.http 205 | 206 | return Promise.resolve() // 207 | .then( 208 | http.fetch(http.POST, '/user/apps/appDefinitions/register', { 209 | appName, 210 | hasPersistentData 211 | }) 212 | ) 213 | } 214 | 215 | deleteApp(appName: string) { 216 | const http = this.http 217 | 218 | return Promise.resolve() // 219 | .then( 220 | http.fetch(http.POST, '/user/apps/appDefinitions/delete', { 221 | appName 222 | }) 223 | ) 224 | } 225 | 226 | enableSslForBaseDomain(appName: string) { 227 | const http = this.http 228 | 229 | return Promise.resolve() // 230 | .then( 231 | http.fetch( 232 | http.POST, 233 | '/user/apps/appDefinitions/enablebasedomainssl', 234 | { 235 | appName 236 | } 237 | ) 238 | ) 239 | } 240 | 241 | attachNewCustomDomainToApp(appName: string, customDomain: string) { 242 | const http = this.http 243 | 244 | return Promise.resolve() // 245 | .then( 246 | http.fetch( 247 | http.POST, 248 | '/user/apps/appDefinitions/customdomain', 249 | { 250 | appName, 251 | customDomain 252 | } 253 | ) 254 | ) 255 | } 256 | 257 | enableSslForCustomDomain(appName: string, customDomain: string) { 258 | const http = this.http 259 | 260 | return Promise.resolve() // 261 | .then( 262 | http.fetch( 263 | http.POST, 264 | '/user/apps/appDefinitions/enablecustomdomainssl', 265 | { 266 | appName, 267 | customDomain 268 | } 269 | ) 270 | ) 271 | } 272 | 273 | removeCustomDomain(appName: string, customDomain: string) { 274 | const http = this.http 275 | 276 | return Promise.resolve() // 277 | .then( 278 | http.fetch( 279 | http.POST, 280 | '/user/apps/appDefinitions/removecustomdomain', 281 | { 282 | appName, 283 | customDomain 284 | } 285 | ) 286 | ) 287 | } 288 | 289 | getLoadBalancerInfo() { 290 | const http = this.http 291 | 292 | return Promise.resolve() // 293 | .then(http.fetch(http.GET, '/user/system/loadbalancerinfo', {})) 294 | } 295 | 296 | getNetDataInfo() { 297 | const http = this.http 298 | 299 | return Promise.resolve() // 300 | .then(http.fetch(http.GET, '/user/system/netdata', {})) 301 | } 302 | 303 | updateNetDataInfo(netDataInfo: any) { 304 | const http = this.http 305 | 306 | return Promise.resolve() // 307 | .then( 308 | http.fetch(http.POST, '/user/system/netdata', { netDataInfo }) 309 | ) 310 | } 311 | 312 | changePass(oldPassword: string, newPassword: string) { 313 | const http = this.http 314 | 315 | return Promise.resolve() // 316 | .then( 317 | http.fetch(http.POST, '/user/changepassword', { 318 | oldPassword, 319 | newPassword 320 | }) 321 | ) 322 | } 323 | 324 | getVersionInfo(): Promise { 325 | const http = this.http 326 | 327 | return Promise.resolve() // 328 | .then(http.fetch(http.GET, '/user/system/versioninfo', {})) 329 | } 330 | 331 | performUpdate(latestVersion: string) { 332 | const http = this.http 333 | 334 | return Promise.resolve() // 335 | .then( 336 | http.fetch(http.POST, '/user/system/versioninfo', { 337 | latestVersion 338 | }) 339 | ) 340 | } 341 | 342 | getNginxConfig() { 343 | const http = this.http 344 | 345 | return Promise.resolve() // 346 | .then(http.fetch(http.GET, '/user/system/nginxconfig', {})) 347 | } 348 | 349 | setNginxConfig(customBase: string, customCaptain: string) { 350 | const http = this.http 351 | 352 | return Promise.resolve() // 353 | .then( 354 | http.fetch(http.POST, '/user/system/nginxconfig', { 355 | baseConfig: { customValue: customBase }, 356 | captainConfig: { customValue: customCaptain } 357 | }) 358 | ) 359 | } 360 | 361 | getUnusedImages(mostRecentLimit: number) { 362 | const http = this.http 363 | return Promise.resolve() // 364 | .then( 365 | http.fetch(http.GET, '/user/apps/appDefinitions/unusedImages', { 366 | mostRecentLimit: mostRecentLimit + '' 367 | }) 368 | ) 369 | } 370 | 371 | deleteImages(imageIds: string[]) { 372 | const http = this.http 373 | 374 | return Promise.resolve() // 375 | .then( 376 | http.fetch( 377 | http.POST, 378 | '/user/apps/appDefinitions/deleteImages', 379 | { 380 | imageIds 381 | } 382 | ) 383 | ) 384 | } 385 | 386 | getDockerRegistries() { 387 | const http = this.http 388 | 389 | return Promise.resolve() // 390 | .then(http.fetch(http.GET, '/user/registries', {})) 391 | } 392 | 393 | enableSelfHostedDockerRegistry() { 394 | const http = this.http 395 | 396 | return Promise.resolve() // 397 | .then( 398 | http.fetch( 399 | http.POST, 400 | '/user/system/selfhostregistry/enableregistry', 401 | {} 402 | ) 403 | ) 404 | } 405 | 406 | disableSelfHostedDockerRegistry() { 407 | const http = this.http 408 | 409 | return Promise.resolve() // 410 | .then( 411 | http.fetch( 412 | http.POST, 413 | '/user/system/selfhostregistry/disableregistry', 414 | {} 415 | ) 416 | ) 417 | } 418 | 419 | addDockerRegistry(dockerRegistry: IRegistryInfo) { 420 | const http = this.http 421 | 422 | return Promise.resolve() // 423 | .then( 424 | http.fetch(http.POST, '/user/registries/insert', { 425 | ...dockerRegistry 426 | }) 427 | ) 428 | } 429 | 430 | updateDockerRegistry(dockerRegistry: IRegistryInfo) { 431 | const http = this.http 432 | 433 | return Promise.resolve() // 434 | .then( 435 | http.fetch(http.POST, '/user/registries/update', { 436 | ...dockerRegistry 437 | }) 438 | ) 439 | } 440 | 441 | deleteDockerRegistry(registryId: string) { 442 | const http = this.http 443 | 444 | return Promise.resolve() // 445 | .then( 446 | http.fetch(http.POST, '/user/registries/delete', { 447 | registryId 448 | }) 449 | ) 450 | } 451 | 452 | setDefaultPushDockerRegistry(registryId: string) { 453 | const http = this.http 454 | 455 | return Promise.resolve() // 456 | .then( 457 | http.fetch(http.POST, '/user/registries/setpush', { 458 | registryId 459 | }) 460 | ) 461 | } 462 | 463 | getAllNodes() { 464 | const http = this.http 465 | 466 | return Promise.resolve() // 467 | .then(http.fetch(http.GET, '/user/system/nodes', {})) 468 | } 469 | 470 | addDockerNode( 471 | nodeType: string, 472 | privateKey: string, 473 | remoteNodeIpAddress: string, 474 | captainIpAddress: string 475 | ) { 476 | const http = this.http 477 | 478 | return Promise.resolve() // 479 | .then( 480 | http.fetch(http.POST, '/user/system/nodes', { 481 | nodeType, 482 | privateKey, 483 | remoteNodeIpAddress, 484 | captainIpAddress 485 | }) 486 | ) 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/api/CliApiManager.ts: -------------------------------------------------------------------------------- 1 | import Constants from '../utils/Constants' 2 | import StorageHelper from '../utils/StorageHelper' 3 | import { IHashMapGeneric } from '../models/IHashMapGeneric' 4 | import { IMachine } from '../models/storage/StoredObjects' 5 | import ApiManager from './ApiManager' 6 | 7 | function hashCode(str: string) { 8 | let hash = 0 9 | let i 10 | let chr 11 | if (str.length === 0) { 12 | return hash 13 | } 14 | for (i = 0; i < str.length; i++) { 15 | chr = str.charCodeAt(i) 16 | hash = (hash << 5) - hash + chr 17 | hash |= 0 // Convert to 32bit integer 18 | } 19 | return hash 20 | } 21 | 22 | export default class CliApiManager { 23 | static instances: IHashMapGeneric = {} 24 | 25 | static get(capMachine: IMachine) { 26 | const hashKey = 'v' + hashCode(capMachine.baseUrl) 27 | if (!CliApiManager.instances[hashKey]) { 28 | CliApiManager.instances[hashKey] = new ApiManager( 29 | capMachine.baseUrl + Constants.BASE_API_PATH, 30 | capMachine.appToken, 31 | function (token) { 32 | capMachine.authToken = token 33 | if (capMachine.name) { 34 | StorageHelper.get().saveMachine(capMachine) 35 | } 36 | return Promise.resolve() 37 | } 38 | ) 39 | } 40 | 41 | CliApiManager.instances[hashKey].setAuthToken(capMachine.authToken) 42 | 43 | return CliApiManager.instances[hashKey] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import ErrorFactory from '../utils/ErrorFactory' 2 | import Logger from '../utils/Logger' 3 | import * as Request from 'request-promise' 4 | 5 | const TOKEN_HEADER = 'x-captain-auth' 6 | const APP_TOKEN_HEADER = 'x-captain-app-token' 7 | const NAMESPACE = 'x-namespace' 8 | const CAPTAIN = 'captain' 9 | 10 | export default class HttpClient { 11 | readonly GET = 'GET' 12 | readonly POST = 'POST' 13 | readonly POST_DATA = 'POST_DATA' 14 | isDestroyed = false 15 | 16 | constructor( 17 | private baseUrl: string, 18 | private appToken: string | undefined, 19 | private authToken: string, 20 | private onAuthFailure: () => Promise 21 | ) { 22 | // 23 | } 24 | 25 | createHeaders() { 26 | const headers: any = {} 27 | if (this.authToken) { 28 | headers[TOKEN_HEADER] = this.authToken 29 | } 30 | 31 | if (this.appToken) { 32 | headers[APP_TOKEN_HEADER] = this.appToken 33 | } 34 | 35 | headers[NAMESPACE] = CAPTAIN 36 | 37 | // check user/appData or apiManager.uploadAppData before changing this signature. 38 | return headers 39 | } 40 | 41 | setAuthToken(authToken: string) { 42 | this.authToken = authToken 43 | } 44 | 45 | destroy() { 46 | this.isDestroyed = true 47 | } 48 | 49 | fetch( 50 | method: 'GET' | 'POST' | 'POST_DATA', 51 | endpoint: string, 52 | variables: any 53 | ) { 54 | const self = this 55 | return function (): Promise { 56 | return Promise.resolve() // 57 | .then(function () { 58 | if (!process.env.REACT_APP_IS_DEBUG) { 59 | return Promise.resolve() 60 | } 61 | return new Promise(function (res) { 62 | setTimeout(res, 500) 63 | }) 64 | }) 65 | .then(function () { 66 | return self.fetchInternal(method, endpoint, variables) // 67 | }) 68 | .then(function (data) { 69 | if ( 70 | data.status === ErrorFactory.STATUS_AUTH_TOKEN_INVALID 71 | ) { 72 | return self 73 | .onAuthFailure() // 74 | .then(function () { 75 | return self 76 | .fetchInternal(method, endpoint, variables) 77 | .then(function (newRequestResponse) { 78 | return newRequestResponse 79 | }) 80 | }) 81 | } else { 82 | return data 83 | } 84 | }) 85 | .then(function (data) { 86 | if ( 87 | data.status !== ErrorFactory.OKAY && 88 | data.status !== ErrorFactory.OKAY_BUILD_STARTED 89 | ) { 90 | throw ErrorFactory.createError( 91 | data.status || ErrorFactory.UNKNOWN_ERROR, 92 | data.description || '' 93 | ) 94 | } 95 | return data 96 | }) 97 | .then(function (data) { 98 | // tslint:disable-next-line: max-line-length 99 | // These two blocks are clearly memory leaks! But I don't have time to fix them now... I need to CANCEL the promise, but since I don't 100 | // have CANCEL method on the native Promise, I return a promise that will never RETURN if the HttpClient is destroyed. 101 | // tslint:disable-next-line: max-line-length 102 | // Will fix them later... but it shouldn't be a big deal anyways as it's only a problem when user navigates away from a page before the 103 | // network request returns back. 104 | return new Promise(function (resolve, reject) { 105 | // data.data here is the "data" field inside the API response! {status: 100, description: "Login succeeded", data: {…}} 106 | if (!self.isDestroyed) { 107 | return resolve(data.data) 108 | } 109 | Logger.dev('Destroyed then not called') 110 | }) 111 | }) 112 | .catch(function (error) { 113 | // Logger.log(''); 114 | // Logger.error(error.message || error); 115 | return new Promise(function (resolve, reject) { 116 | if (!self.isDestroyed) { 117 | return reject(error) 118 | } 119 | Logger.dev('Destroyed catch not called') 120 | }) 121 | }) 122 | } 123 | } 124 | 125 | fetchInternal( 126 | method: 'GET' | 'POST' | 'POST_DATA', 127 | endpoint: string, 128 | variables: any 129 | ) { 130 | if (method === this.GET) { 131 | return this.getReq(endpoint, variables) 132 | } 133 | 134 | if (method === this.POST || method === this.POST_DATA) { 135 | return this.postReq(endpoint, variables, method) 136 | } 137 | 138 | throw new Error('Unknown method: ' + method) 139 | } 140 | 141 | getReq(endpoint: string, variables: any) { 142 | const self = this 143 | 144 | return Request.get(this.baseUrl + endpoint, { 145 | headers: self.createHeaders(), 146 | qs: variables, 147 | json: true 148 | }).then(function (data) { 149 | return data 150 | }) 151 | } 152 | 153 | postReq( 154 | endpoint: string, 155 | variables: any, 156 | method: 'GET' | 'POST' | 'POST_DATA' 157 | ) { 158 | const self = this 159 | 160 | if (method === this.POST_DATA) { 161 | return Request.post(this.baseUrl + endpoint, { 162 | headers: self.createHeaders(), 163 | formData: variables, 164 | json: true 165 | }).then(function (data) { 166 | return data 167 | }) 168 | } 169 | 170 | return Request.post(this.baseUrl + endpoint, { 171 | headers: self.createHeaders(), 172 | body: variables, 173 | json: true 174 | }).then(function (data) { 175 | return data 176 | }) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/commands/Command.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, join } from 'path' 2 | import { pathExistsSync } from 'fs-extra' 3 | import { readFileSync } from 'fs' 4 | import * as yaml from 'js-yaml' 5 | import { Command as CommanderStatic } from 'commander' 6 | import * as inquirer from 'inquirer' 7 | import Constants from '../utils/Constants' 8 | import StdOutUtil from '../utils/StdOutUtil' 9 | 10 | export interface IOptionAlias { 11 | name: string 12 | char?: string 13 | env?: string 14 | hide?: boolean 15 | } 16 | 17 | export interface IOption extends inquirer.ListQuestionOptions, IOptionAlias { 18 | name: string 19 | aliases?: IOptionAlias[] 20 | preProcessParam?: (param?: IParam) => void 21 | } 22 | 23 | export interface ICommandLineOptions { 24 | [option: string]: string | boolean 25 | } 26 | 27 | export enum ParamType { 28 | Config, 29 | CommandLine, 30 | Question, 31 | Env, 32 | Default 33 | } 34 | 35 | export interface IParam { 36 | value: any 37 | from: ParamType 38 | } 39 | 40 | export interface IParams { 41 | [param: string]: IParam 42 | } 43 | 44 | function isFunction(value: any): boolean { 45 | return value instanceof Function 46 | } 47 | 48 | function getValue( 49 | value?: T | ((...args: any) => T), 50 | ...args: any 51 | ): T | undefined { 52 | return value instanceof Function ? value(...args) : value 53 | } 54 | 55 | const CONFIG_FILE_NAME: string = Constants.COMMON_KEYS.conf 56 | 57 | type IOptionAliasWithDetails = IOptionAlias & { 58 | aliasTo: string 59 | } 60 | 61 | export default abstract class Command { 62 | protected abstract command: string 63 | 64 | protected aliases?: string[] = undefined 65 | 66 | protected usage?: string = undefined 67 | 68 | protected description?: string = undefined 69 | 70 | protected options?: IOption[] | ((params?: IParams) => IOption[]) 71 | 72 | protected configFileProvided = false 73 | 74 | constructor(private program: CommanderStatic) { 75 | if (!program) { 76 | throw new Error('program is null') 77 | } 78 | } 79 | 80 | private getCmdLineFlags(alias: IOptionAlias, type?: string): string { 81 | return ( 82 | (alias.char ? `-${alias.char}, ` : '') + 83 | `--${alias.name}` + 84 | (type !== 'confirm' ? ' ' : '') 85 | ) 86 | } 87 | 88 | private getCmdLineDescription( 89 | option: IOption, 90 | spaces: string, 91 | alias?: IOptionAlias 92 | ): string { 93 | const msg = alias 94 | ? `same as --${option.name}` 95 | : getValue(option.message) || '' 96 | const env = alias ? alias.env : option.env 97 | return (msg + (env ? ` (env: ${env})` : '')) 98 | .split('\n') 99 | .reduce( 100 | (acc, l) => (!acc ? l.trim() : `${acc}\n${spaces}${l.trim()}`), 101 | '' 102 | ) 103 | } 104 | 105 | private getOptions(params?: IParams): IOption[] { 106 | return getValue(this.options, params) || [] 107 | } 108 | 109 | protected findParamValue( 110 | params: IParams | undefined, 111 | name: string 112 | ): IParam | undefined { 113 | return params && params[name] 114 | } 115 | 116 | protected paramValue( 117 | params: IParams | undefined, 118 | name: string 119 | ): T | undefined { 120 | return params && params[name] && params[name].value 121 | } 122 | 123 | protected paramFrom( 124 | params: IParams | undefined, 125 | name: string 126 | ): ParamType | undefined { 127 | return params && params[name] && params[name].from 128 | } 129 | 130 | protected getDefaultConfigFileOption( 131 | preProcessParam?: (param?: IParam) => void 132 | ): IOption { 133 | return { 134 | name: CONFIG_FILE_NAME, 135 | char: 'c', 136 | env: 'CAPROVER_CONFIG_FILE', 137 | message: 138 | 'path of the file where all parameters are defined in JSON or YAML format\n' + 139 | "see others options to know config file parameters' names\n" + 140 | 'this is mainly for automation purposes, see docs', 141 | preProcessParam 142 | } 143 | } 144 | 145 | build() { 146 | if (!this.command) { 147 | throw new Error('Empty command name') 148 | } 149 | 150 | const cmd = this.program.command(this.command) 151 | if (this.aliases && this.aliases.length) { 152 | this.aliases.forEach((alias) => alias && cmd.alias(alias)) 153 | } 154 | if (this.description) { 155 | cmd.description(this.description) 156 | } 157 | if (this.usage) { 158 | cmd.usage(this.usage) 159 | } 160 | 161 | const options = this.getOptions().filter( 162 | (opt) => opt && opt.name && !opt.hide 163 | ) 164 | const spaces = ' '.repeat( 165 | options.reduce( 166 | (max, opt) => 167 | Math.max( 168 | max, 169 | this.getCmdLineFlags(opt, opt.type).length, 170 | (opt.aliases || []) 171 | .filter( 172 | (alias) => alias && alias.name && !alias.hide 173 | ) 174 | .reduce( 175 | (amax, a) => 176 | Math.max( 177 | amax, 178 | this.getCmdLineFlags(a, opt.type).length 179 | ), 180 | 0 181 | ) 182 | ), 183 | 0 184 | ) + 4 185 | ) 186 | options.forEach((opt) => { 187 | cmd.option( 188 | this.getCmdLineFlags(opt, opt.type), 189 | this.getCmdLineDescription(opt, spaces), 190 | getValue(opt.default) 191 | ) 192 | if (opt.aliases) { 193 | opt.aliases 194 | .filter((alias) => alias && alias.name && !alias.hide) 195 | .forEach((alias) => 196 | cmd.option( 197 | this.getCmdLineFlags(alias, opt.type), 198 | this.getCmdLineDescription(opt, spaces, alias) 199 | ) 200 | ) 201 | } 202 | }) 203 | 204 | cmd.action(async (...allParams: any[]) => { 205 | if (allParams.length > 1) { 206 | StdOutUtil.printError( 207 | `Positional parameter not supported: ${allParams[0]}\n`, 208 | true 209 | ) 210 | } 211 | 212 | const cmdLineOptions = await this.preAction(allParams[0]) 213 | const optionAliases: IOptionAliasWithDetails[] = this.getOptions() 214 | .filter((opt) => opt && opt.name) 215 | .reduce( 216 | (acc, opt) => [ 217 | ...acc, 218 | { ...opt, aliasTo: opt.name }, 219 | ...(opt.aliases || []) 220 | .filter((alias) => alias && alias.name) 221 | .map((alias) => ({ ...alias, aliasTo: opt.name })) 222 | ], 223 | [] 224 | ) 225 | 226 | if (cmdLineOptions) { 227 | this.action(await this.getParams(cmdLineOptions, optionAliases)) 228 | } 229 | }) 230 | } 231 | 232 | protected async preAction( 233 | cmdLineoptions: ICommandLineOptions 234 | ): Promise { 235 | if (this.description) { 236 | StdOutUtil.printMessage(this.description + '\n') 237 | } 238 | return Promise.resolve(cmdLineoptions) 239 | } 240 | 241 | private async getParams( 242 | cmdLineOptions: ICommandLineOptions, 243 | optionAliases: IOptionAliasWithDetails[] 244 | ): Promise { 245 | const params: IParams = {} 246 | 247 | // Read params from env variables 248 | optionAliases 249 | .filter((opta) => opta.env && opta.env in process.env) 250 | .forEach( 251 | (opta) => 252 | (params[opta.aliasTo] = { 253 | value: process.env[opta.env!], 254 | from: ParamType.Env 255 | }) 256 | ) 257 | 258 | // Get config file name from env variables or command line options 259 | let file: string | null = optionAliases 260 | .filter( 261 | (opta) => 262 | cmdLineOptions && 263 | opta.aliasTo === CONFIG_FILE_NAME && 264 | opta.name in cmdLineOptions 265 | ) 266 | .reduce((prev, opta) => cmdLineOptions[opta.name] as string, null) 267 | if (params[CONFIG_FILE_NAME]) { 268 | if (file === null) { 269 | file = params[CONFIG_FILE_NAME].value 270 | } 271 | delete params[CONFIG_FILE_NAME] 272 | } 273 | optionAliases = optionAliases.filter( 274 | (opta) => opta.aliasTo !== CONFIG_FILE_NAME 275 | ) 276 | 277 | if (file) { 278 | // Read params from config file 279 | const filePath = isAbsolute(file) ? file : join(process.cwd(), file) 280 | if (!pathExistsSync(filePath)) { 281 | StdOutUtil.printError(`File not found: ${filePath}\n`, true) 282 | } 283 | 284 | let config: any 285 | try { 286 | const fileContent = readFileSync(filePath, 'utf8').trim() 287 | if (fileContent && fileContent.length) { 288 | if ( 289 | fileContent.startsWith('{') || 290 | fileContent.startsWith('[') 291 | ) { 292 | config = JSON.parse(fileContent) 293 | } else { 294 | config = yaml.load(fileContent) 295 | } 296 | } 297 | 298 | if (!config) { 299 | throw new Error('Config file is empty!!') 300 | } 301 | } catch (error) { 302 | StdOutUtil.printError( 303 | `Error reading config file: ${error.message || error}\n`, 304 | true 305 | ) 306 | } 307 | 308 | this.configFileProvided = true 309 | optionAliases 310 | .filter((opta) => opta.name in config) 311 | .forEach( 312 | (opta) => 313 | (params[opta.aliasTo] = { 314 | value: config[opta.name], 315 | from: ParamType.Config 316 | }) 317 | ) 318 | } 319 | 320 | if (cmdLineOptions) { 321 | // Overwrite params from command line options 322 | optionAliases 323 | .filter((opta) => opta.name in cmdLineOptions) 324 | .forEach( 325 | (opta) => 326 | (params[opta.aliasTo] = { 327 | value: cmdLineOptions[opta.name], 328 | from: ParamType.CommandLine 329 | }) 330 | ) 331 | } 332 | 333 | const options = this.getOptions(params).filter((opt) => opt && opt.name) 334 | let q = false 335 | for (const option of options) { 336 | const name = option.name! 337 | let param = params[name] 338 | if (param) { 339 | // Filter and validate already provided params 340 | if (option.filter) { 341 | param.value = await option.filter(param.value) 342 | } 343 | if (option.validate) { 344 | const err = await option.validate(param.value) 345 | if (err !== true) { 346 | StdOutUtil.printError( 347 | `${q ? '\n' : ''}${err || 'Error!'}\n`, 348 | true 349 | ) 350 | } 351 | } 352 | } else if (name !== CONFIG_FILE_NAME) { 353 | // Questions for missing params 354 | if (!isFunction(option.message)) { 355 | option.message += ':' 356 | } 357 | const answer = await inquirer.prompt([option]) 358 | if (name in answer) { 359 | q = true 360 | param = params[name] = { 361 | value: answer[name], 362 | from: ParamType.Question 363 | } 364 | } 365 | } 366 | if (option.preProcessParam) { 367 | await option.preProcessParam(param) 368 | } 369 | } 370 | if (q) { 371 | StdOutUtil.printMessage('') 372 | } 373 | 374 | return params 375 | } 376 | 377 | /** 378 | * This method gets called once all the required information has been collected, either manually 379 | * using the questions, or directly via the params and etc. 380 | * 381 | * @param params 382 | */ 383 | protected abstract action(params: IParams): Promise 384 | } 385 | -------------------------------------------------------------------------------- /src/commands/api.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { isAbsolute, join } from 'path' 4 | import { writeFileSync } from 'fs' 5 | import Constants from '../utils/Constants' 6 | import Utils from '../utils/Utils' 7 | import StdOutUtil from '../utils/StdOutUtil' 8 | import CliHelper from '../utils/CliHelper' 9 | import CliApiManager from '../api/CliApiManager' 10 | import { 11 | getErrorForDomain, 12 | getErrorForPassword, 13 | getErrorForMachineName, 14 | userCancelOperation 15 | } from '../utils/ValidationsHandler' 16 | import { IMachine } from '../models/storage/StoredObjects' 17 | import Command, { 18 | IParams, 19 | IOption, 20 | ParamType, 21 | ICommandLineOptions, 22 | IParam 23 | } from './Command' 24 | 25 | const K = Utils.extendCommonKeys({ 26 | path: 'path', 27 | method: 'method', 28 | data: 'data', 29 | out: 'output' 30 | }) 31 | 32 | export default class Api extends Command { 33 | protected command = 'api' 34 | 35 | protected usage = 36 | '[options]\n' + 37 | ' api -c file\n' + 38 | ' api [-c file] [-n name] [-t path] [-m method] [-d dataJsonString]\n' + 39 | ' api [-c file] -u url [-p password] [-n name] [-t path] [-m method] [-d dataJsonString]\n' + 40 | ' Use --caproverName to use an already logged in CapRover machine\n' + 41 | ' Use --caproverUrl and --caproverPassword to login on the fly to a CapRover machine, if also --caproverName is present, login credetials are stored locally' 42 | 43 | protected description = 44 | 'Call a generic API on a specific CapRover machine. Use carefully only if you really know what you are doing!' 45 | 46 | private machines = CliHelper.get().getMachinesAsOptions() 47 | 48 | private machine: IMachine 49 | 50 | protected options = (params?: IParams): IOption[] => [ 51 | this.getDefaultConfigFileOption(), 52 | { 53 | name: K.url, 54 | char: 'u', 55 | env: 'CAPROVER_URL', 56 | type: 'input', 57 | message: `CapRover machine URL address, it is "[http[s]://][${Constants.ADMIN_DOMAIN}.]your-captain-root.domain"`, 58 | when: false, 59 | filter: (url: string) => Utils.cleanAdminDomainUrl(url) || url, // If not cleaned url, leave url to fail validation with correct error 60 | validate: (url: string) => getErrorForDomain(url, true) 61 | }, 62 | { 63 | name: K.pwd, 64 | char: 'p', 65 | env: 'CAPROVER_PASSWORD', 66 | type: 'password', 67 | message: 'CapRover machine password', 68 | when: !!this.findParamValue(params, K.url), 69 | validate: (password: string) => getErrorForPassword(password) 70 | }, 71 | { 72 | name: K.name, 73 | char: 'n', 74 | env: 'CAPROVER_NAME', 75 | message: params 76 | ? 'select the CapRover machine name you want to call API to' 77 | : 'CapRover machine name, to load/store credentials', 78 | type: 'list', 79 | choices: this.machines, 80 | when: !this.findParamValue(params, K.url), 81 | filter: (name: string) => 82 | !this.findParamValue(params, K.name) 83 | ? userCancelOperation(!name, true) || name 84 | : name.trim(), 85 | validate: !this.findParamValue(params, K.url) 86 | ? (name: string) => getErrorForMachineName(name, true) 87 | : undefined 88 | }, 89 | CliHelper.get().getEnsureAuthenticationOption( 90 | '', 91 | () => this.paramValue(params, K.url), 92 | () => this.paramValue(params, K.pwd), 93 | () => this.paramValue(params, K.name), 94 | async (machine: IMachine) => { 95 | this.machine = machine 96 | try { 97 | await CliApiManager.get(machine).getCaptainInfo() 98 | } catch (e) { 99 | StdOutUtil.printError( 100 | `\nSomething bad happened during calling API to ${StdOutUtil.getColoredMachineUrl( 101 | machine.baseUrl 102 | )}.\n${e.message || e}`, 103 | true 104 | ) 105 | } 106 | } 107 | ), 108 | { 109 | name: K.path, 110 | char: 't', 111 | env: 'CAPROVER_API_PATH', 112 | message: 113 | 'API path to call, starting with / (eg. "/user/system/info")', 114 | type: 'input', 115 | default: params && '/user/system/info', 116 | filter: (path: string) => path.trim(), 117 | validate: (path: string) => 118 | path && path.startsWith('/') 119 | ? true 120 | : 'Please enter a valid path.' 121 | }, 122 | { 123 | name: K.method, 124 | char: 'm', 125 | env: 'CAPROVER_API_METHOD', 126 | message: params 127 | ? 'select the API method you want to call' 128 | : `API method to call, one of: ${CliHelper.get().getApiMethodsDescription()}`, 129 | type: 'list', 130 | default: params && 'GET', 131 | choices: CliHelper.get().getApiMethodsAsOptions(), 132 | filter: (method: string) => 133 | !this.findParamValue(params, K.method) 134 | ? userCancelOperation(!method, true) || method 135 | : method.trim(), 136 | validate: (method: string) => 137 | method && Constants.API_METHODS.includes(method) 138 | ? true 139 | : `Please enter a valid method, one of: ${CliHelper.get().getApiMethodsDescription()}` 140 | }, 141 | { 142 | name: K.data, 143 | char: 'd', 144 | env: 'CAPROVER_API_DATA', 145 | message: 146 | 'API data JSON string' + 147 | (params 148 | ? '' 149 | : ' (or also JSON object from config file), for "GET" method they are interpreted as querystring values to be appended to the path'), 150 | type: 'input', 151 | filter: (data) => { 152 | if (data && typeof data === 'string') { 153 | try { 154 | return JSON.parse(data) 155 | } catch { 156 | // do nothing 157 | } 158 | } 159 | return data 160 | }, 161 | validate: (data) => { 162 | if (data && typeof data === 'string') { 163 | try { 164 | JSON.parse(data) 165 | } catch (e) { 166 | return e as string 167 | } 168 | } 169 | return true 170 | } 171 | }, 172 | { 173 | name: K.out, 174 | char: 'o', 175 | env: 'CAPROVER_API_OUTPUT', 176 | message: 177 | 'where to log API response output: if "true" log to console, if "false" suppress output, otherwise log to specified file (overwrite already existing)', 178 | type: 'input', 179 | default: 'true', 180 | filter: (out: string) => { 181 | if (!out) { 182 | return 'false' 183 | } 184 | out = out.trim() || 'false' 185 | if (out === 'true' || out === 'false' || isAbsolute(out)) { 186 | return out 187 | } 188 | return join(process.cwd(), out) 189 | } 190 | }, 191 | { 192 | name: 'confirmedToCall', 193 | type: 'confirm', 194 | message: 'are you sure you want to procede?', 195 | default: true, 196 | hide: true, 197 | when: () => 198 | this.paramFrom(params, K.name) === ParamType.Question || 199 | this.paramFrom(params, K.path) === ParamType.Question || 200 | this.paramFrom(params, K.data) === ParamType.Question, 201 | preProcessParam: (param: IParam) => 202 | param && userCancelOperation(!param.value) 203 | } 204 | ] 205 | 206 | protected async preAction( 207 | cmdLineoptions: ICommandLineOptions 208 | ): Promise { 209 | StdOutUtil.printMessage( 210 | 'Call generic CapRover API [Experimental Feature]...\n' 211 | ) 212 | return Promise.resolve(cmdLineoptions) 213 | } 214 | 215 | protected async action(params: IParams): Promise { 216 | try { 217 | const resp = await CliApiManager.get(this.machine).callApi( 218 | this.findParamValue(params, K.path)!.value, 219 | this.findParamValue(params, K.method)!.value, 220 | this.paramValue(params, K.data) 221 | ) 222 | StdOutUtil.printGreenMessage(`API call completed successfully!\n`) 223 | 224 | const out = this.paramValue(params, K.out)! 225 | const data = JSON.stringify(resp, null, 2) 226 | if (out === 'true') { 227 | StdOutUtil.printMessage(data + '\n') 228 | } else if (out !== 'false') { 229 | try { 230 | writeFileSync(out, data) 231 | } catch (e) { 232 | StdOutUtil.printWarning( 233 | `Error writing API response to file: "${out}".\n` 234 | ) 235 | } 236 | } 237 | } catch (error) { 238 | StdOutUtil.printError( 239 | `\nSomething bad happened calling API ${StdOutUtil.getColoredMachineUrl( 240 | this.paramValue(params, K.path)! 241 | )} at ${ 242 | this.machine.name 243 | ? StdOutUtil.getColoredMachineName(this.machine.name) 244 | : StdOutUtil.getColoredMachineUrl(this.machine.baseUrl) 245 | }.` 246 | ) 247 | StdOutUtil.errorHandler(error) 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/commands/caprover.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // tslint:disable-next-line: no-var-requires 4 | const packagejson = require('../../package.json') 5 | import updateNotifier from 'update-notifier' 6 | 7 | const versionOfNode = Number(process.version.split('.')[0]) 8 | 9 | if (versionOfNode < 8) { 10 | console.log( 11 | 'Unsupported version of node. You need to update your local NodeJS version.' 12 | ) 13 | process.exit(1) 14 | } 15 | 16 | updateNotifier({ pkg: packagejson }).notify({ isGlobal: true }) 17 | 18 | import StdOutUtil from '../utils/StdOutUtil' 19 | import { program } from 'commander' 20 | 21 | // Command actions 22 | import Command from './Command' 23 | import Login from './login' 24 | import List from './list' 25 | import Logout from './logout' 26 | import Deploy from './deploy' 27 | import ServerSetup from './serversetup' 28 | import Api from './api' 29 | 30 | console.log('') 31 | console.log('') 32 | 33 | // Setup 34 | program.version(packagejson.version).description(packagejson.description) 35 | 36 | // Commands 37 | const commands: Command[] = [ 38 | new ServerSetup(program), 39 | new Login(program), 40 | new List(program), 41 | new Logout(program), 42 | new Deploy(program), 43 | new Api(program) 44 | ] 45 | commands.forEach((c) => c.build()) 46 | 47 | // Error on unknown commands 48 | program.on('command:*', () => { 49 | const wrongCommands = program.args.join(' ') 50 | StdOutUtil.printError( 51 | `Invalid command: ${wrongCommands}\nSee --help for a list of available commands.\n`, 52 | true 53 | ) 54 | }) 55 | 56 | program.parse(process.argv) 57 | 58 | if (!process.argv.slice(2).length) { 59 | program.outputHelp() 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import Constants from '../utils/Constants' 4 | import Utils from '../utils/Utils' 5 | import StdOutUtil from '../utils/StdOutUtil' 6 | import StorageHelper from '../utils/StorageHelper' 7 | import CliHelper from '../utils/CliHelper' 8 | import DeployHelper from '../utils/DeployHelper' 9 | import CliApiManager from '../api/CliApiManager' 10 | import { 11 | validateIsGitRepository, 12 | validateDefinitionFile, 13 | getErrorForDomain, 14 | getErrorForPassword, 15 | getErrorForMachineName, 16 | userCancelOperation, 17 | getErrorForAppName, 18 | getErrorForBranchName 19 | } from '../utils/ValidationsHandler' 20 | import { IAppDef } from '../models/AppDef' 21 | import { 22 | IMachine, 23 | IDeployedDirectory, 24 | IDeployParams 25 | } from '../models/storage/StoredObjects' 26 | import Command, { 27 | IParams, 28 | IOption, 29 | ParamType, 30 | ICommandLineOptions, 31 | IParam 32 | } from './Command' 33 | 34 | const K = Utils.extendCommonKeys({ 35 | default: 'default', 36 | branch: 'branch', 37 | tar: 'tarFile', 38 | appToken: 'appToken', 39 | img: 'imageName' 40 | }) 41 | 42 | export default class Deploy extends Command { 43 | protected command = 'deploy' 44 | 45 | protected usage = 46 | '[options]\n' + 47 | ' deploy -d\n' + 48 | ' deploy -c file\n' + 49 | ' deploy [-c file] [-n name] [-a app] [-b branch | -t tarFile | -i image]\n' + 50 | ' deploy [-c file] -u url [-p password] [-n name] [-a app] [-b branch | -t tarFile | -i image]\n' + 51 | ' Use --caproverName to use an already logged in CapRover machine\n' + 52 | ' Use --caproverUrl and --caproverPassword to login on the fly to a CapRover machine, if also --caproverName is present, login credetials are stored locally\n' + 53 | ' Use one among --branch, --tarFile, --imageName' 54 | 55 | protected description = 56 | "Deploy your app to a specific CapRover machine. You'll be prompted for missing parameters." 57 | 58 | private machines = CliHelper.get().getMachinesAsOptions() 59 | 60 | private apps: IAppDef[] = [] 61 | 62 | private machine: IMachine 63 | 64 | protected options = (params?: IParams): IOption[] => [ 65 | { 66 | name: K.default, 67 | char: 'd', 68 | type: 'confirm', 69 | message: 70 | 'use previously entered values for the current directory, no others options are considered', 71 | when: false 72 | }, 73 | this.getDefaultConfigFileOption(() => 74 | this.validateDeploySource(params!) 75 | ), 76 | { 77 | name: K.url, 78 | char: 'u', 79 | env: 'CAPROVER_URL', 80 | aliases: [{ name: 'host', char: 'h' }], 81 | type: 'input', 82 | message: `CapRover machine URL address, it is "[http[s]://][${Constants.ADMIN_DOMAIN}.]your-captain-root.domain"`, 83 | when: false, 84 | filter: (url: string) => Utils.cleanAdminDomainUrl(url) || url, // If not cleaned url, leave url to fail validation with correct error 85 | validate: (url: string) => getErrorForDomain(url, true) 86 | }, 87 | { 88 | name: K.pwd, 89 | char: 'p', 90 | env: 'CAPROVER_PASSWORD', 91 | aliases: [{ name: 'pass' }], 92 | type: 'password', 93 | message: 'CapRover machine password', 94 | when: 95 | !!this.findParamValue(params, K.url) && 96 | !this.findParamValue(params, K.appToken), 97 | validate: (password: string) => getErrorForPassword(password) 98 | }, 99 | { 100 | name: K.name, 101 | char: 'n', 102 | env: 'CAPROVER_NAME', 103 | message: params 104 | ? 'select the CapRover machine name you want to deploy to' 105 | : 'CapRover machine name, to load/store credentials', 106 | type: 'list', 107 | choices: this.machines, 108 | when: !this.findParamValue(params, K.url), 109 | filter: (name: string) => 110 | !this.findParamValue(params, K.name) 111 | ? userCancelOperation(!name, true) || name 112 | : name.trim(), 113 | validate: !this.findParamValue(params, K.url) 114 | ? (name: string) => getErrorForMachineName(name, true) 115 | : undefined 116 | }, 117 | CliHelper.get().getEnsureAuthenticationOption( 118 | this.paramValue(params, K.appToken) || '', 119 | () => this.paramValue(params, K.url), 120 | () => this.paramValue(params, K.pwd), 121 | () => this.paramValue(params, K.name), 122 | async (machine: IMachine) => { 123 | this.machine = machine 124 | try { 125 | if (machine.appToken) { 126 | this.apps = [] 127 | return 128 | } 129 | 130 | this.apps = 131 | (await CliApiManager.get(machine).getAllApps()) 132 | .appDefinitions || [] 133 | } catch (e) { 134 | StdOutUtil.printError( 135 | `\nSomething bad happened during deployment to ${StdOutUtil.getColoredMachineUrl( 136 | machine.baseUrl 137 | )}.\n${e.message || e}`, 138 | true 139 | ) 140 | } 141 | } 142 | ), 143 | { 144 | name: K.app, 145 | char: 'a', 146 | env: 'CAPROVER_APP', 147 | aliases: [{ name: 'appName' }], 148 | message: params 149 | ? 'select the app name you want to deploy to' 150 | : 'app name to deploy to', 151 | type: 'list', 152 | choices: () => CliHelper.get().getAppsAsOptions(this.apps), 153 | filter: (app: string) => 154 | !this.findParamValue(params, K.app) 155 | ? userCancelOperation(!app, true) || app 156 | : app.trim(), 157 | validate: (app: string) => 158 | this.findParamValue(params, K.appToken) 159 | ? true 160 | : getErrorForAppName(this.apps, app) 161 | }, 162 | { 163 | name: K.branch, 164 | char: 'b', 165 | env: 'CAPROVER_BRANCH', 166 | message: 167 | 'git branch name to be deployed' + 168 | (!params 169 | ? ', current directory must be git root directory' 170 | : ''), 171 | type: 'input', 172 | default: 173 | params && (process.env.CAPROVER_DEFAULT_BRANCH || 'master'), 174 | when: 175 | !this.findParamValue(params, K.tar) && 176 | !this.findParamValue(params, K.img), 177 | validate: (branch: string) => getErrorForBranchName(branch) 178 | }, 179 | { 180 | name: K.tar, 181 | char: 't', 182 | env: 'CAPROVER_TAR_FILE', 183 | message: 184 | 'tar file to be uploaded, must contain captain-definition file', 185 | type: 'input', 186 | when: false 187 | }, 188 | { 189 | name: K.img, 190 | char: 'i', 191 | env: 'CAPROVER_IMAGE_NAME', 192 | message: 193 | 'image name to be deployed, it should either exist on server, or it has to be public, or on a private repository that CapRover has access to', 194 | type: 'input', 195 | when: false 196 | }, 197 | { 198 | name: K.appToken, 199 | char: 't', 200 | env: 'CAPROVER_APP_TOKEN', 201 | message: 'app Token', 202 | type: 'input', 203 | when: false 204 | }, 205 | { 206 | name: 'confirmedToDeploy', 207 | type: 'confirm', 208 | message: () => 209 | (this.findParamValue(params, K.branch) 210 | ? 'note that uncommitted and gitignored files (if any) will not be pushed to server! A' 211 | : 'a') + 're you sure you want to deploy?', 212 | default: true, 213 | hide: true, 214 | when: () => 215 | this.paramFrom(params, K.name) === ParamType.Question || 216 | this.paramFrom(params, K.app) === ParamType.Question || 217 | this.paramFrom(params, K.branch) === ParamType.Question, 218 | preProcessParam: (param: IParam) => 219 | param && userCancelOperation(!param.value) 220 | } 221 | ] 222 | 223 | protected async preAction( 224 | cmdLineoptions: ICommandLineOptions 225 | ): Promise { 226 | StdOutUtil.printMessage('Preparing deployment to CapRover...\n') 227 | 228 | const possibleApp = StorageHelper.get() 229 | .getDeployedDirectories() 230 | .find((dir: IDeployedDirectory) => dir.cwd === process.cwd()) 231 | if (cmdLineoptions[K.default]) { 232 | if (possibleApp && possibleApp.machineNameToDeploy) { 233 | if ( 234 | !StorageHelper.get().findMachine( 235 | possibleApp.machineNameToDeploy 236 | ) 237 | ) { 238 | StdOutUtil.printError( 239 | `You have to first login to ${StdOutUtil.getColoredMachineName( 240 | possibleApp.machineNameToDeploy 241 | )} CapRover machine to use previously saved deploy options from this directory with --default.\n`, 242 | true 243 | ) 244 | } 245 | this.options = (params?: IParams) => [ 246 | CliHelper.get().getEnsureAuthenticationOption( 247 | '', 248 | undefined, 249 | undefined, 250 | possibleApp.machineNameToDeploy, 251 | async (machine: IMachine) => { 252 | this.machine = machine 253 | try { 254 | this.apps = 255 | ( 256 | await CliApiManager.get( 257 | machine 258 | ).getAllApps() 259 | ).appDefinitions || [] 260 | } catch (e) { 261 | StdOutUtil.printError( 262 | `\nSomething bad happened during deployment to ${StdOutUtil.getColoredMachineName( 263 | machine.name 264 | )}.\n${e.message || e}`, 265 | true 266 | ) 267 | } 268 | 269 | const appErr = getErrorForAppName( 270 | this.apps, 271 | possibleApp.appName 272 | ) 273 | if (appErr !== true) { 274 | StdOutUtil.printError( 275 | `\n${appErr || 'Error!'}\n`, 276 | true 277 | ) 278 | } 279 | 280 | if (params) { 281 | params[K.app] = { 282 | value: possibleApp.appName, 283 | from: ParamType.Default 284 | } 285 | if (possibleApp.deploySource.branchToPush) { 286 | params[K.branch] = { 287 | value: possibleApp.deploySource 288 | .branchToPush, 289 | from: ParamType.Default 290 | } 291 | } else if ( 292 | possibleApp.deploySource.tarFilePath 293 | ) { 294 | params[K.tar] = { 295 | value: possibleApp.deploySource 296 | .tarFilePath, 297 | from: ParamType.Default 298 | } 299 | } else { 300 | params[K.img] = { 301 | value: possibleApp.deploySource 302 | .imageName, 303 | from: ParamType.Default 304 | } 305 | } 306 | this.validateDeploySource(params) 307 | } 308 | } 309 | ) 310 | ] 311 | return Promise.resolve({}) 312 | } else { 313 | StdOutUtil.printError( 314 | `Can't find previously saved deploy options from this directory, can't use --default.\n` 315 | ) 316 | StdOutUtil.printMessage('Falling back to asking questions...\n') 317 | } 318 | } else if ( 319 | possibleApp && 320 | possibleApp.machineNameToDeploy && 321 | StorageHelper.get().findMachine(possibleApp.machineNameToDeploy) 322 | ) { 323 | StdOutUtil.printTip('**** Protip ****') 324 | StdOutUtil.printMessage( 325 | `You seem to have deployed ${StdOutUtil.getColoredMachineName( 326 | possibleApp.appName 327 | )} from this directory in the past, use --default flag to avoid having to re-enter the information.\n` 328 | ) 329 | } 330 | 331 | return Promise.resolve(cmdLineoptions) 332 | } 333 | 334 | protected validateDeploySource(params: IParams) { 335 | if ( 336 | (this.findParamValue(params, K.branch) ? 1 : 0) + 337 | (this.findParamValue(params, K.tar) ? 1 : 0) + 338 | (this.findParamValue(params, K.img) ? 1 : 0) > 339 | 1 340 | ) { 341 | StdOutUtil.printError( 342 | 'Only one of branch, tarFile or imageName can be present in deploy.\n', 343 | true 344 | ) 345 | } 346 | if ( 347 | !this.findParamValue(params, K.tar) && 348 | !this.findParamValue(params, K.img) 349 | ) { 350 | validateIsGitRepository() 351 | validateDefinitionFile() 352 | } 353 | } 354 | 355 | protected async action(params: IParams): Promise { 356 | await this.deploy( 357 | { 358 | captainMachine: this.machine, 359 | deploySource: { 360 | branchToPush: this.paramValue(params, K.branch), 361 | tarFilePath: this.paramValue(params, K.tar), 362 | imageName: this.paramValue(params, K.img) 363 | }, 364 | appName: this.paramValue(params, K.app) 365 | }, 366 | this.apps.find( 367 | (app) => app.appName === this.paramValue(params, K.app) 368 | ) 369 | ) 370 | } 371 | 372 | private async deploy(deployParams: IDeployParams, app?: IAppDef) { 373 | try { 374 | if ( 375 | await new DeployHelper( 376 | app && app.hasDefaultSubDomainSsl 377 | ).startDeploy(deployParams) 378 | ) { 379 | StorageHelper.get().saveDeployedDirectory({ 380 | appName: deployParams.appName || '', 381 | cwd: process.cwd(), 382 | deploySource: deployParams.deploySource, 383 | machineNameToDeploy: deployParams.captainMachine 384 | ? deployParams.captainMachine.name 385 | : '' 386 | }) 387 | } 388 | } catch (error) { 389 | const errorMessage = error.message ? error.message : error 390 | StdOutUtil.printError( 391 | `\nSomething bad happened: cannot deploy ${StdOutUtil.getColoredAppName( 392 | deployParams.appName || '' 393 | )} at ${StdOutUtil.getColoredMachineName( 394 | deployParams.captainMachine 395 | ? deployParams.captainMachine.name || 396 | deployParams.captainMachine.baseUrl 397 | : '' 398 | )}.\n${errorMessage}\n`, 399 | true 400 | ) 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import StdOutUtil from '../utils/StdOutUtil' 4 | import StorageHelper from '../utils/StorageHelper' 5 | import Command, { IParams, ICommandLineOptions } from './Command' 6 | 7 | export default class List extends Command { 8 | protected command = 'list' 9 | 10 | protected aliases = ['ls'] 11 | 12 | protected description = 'List all CapRover machines currently logged in.' 13 | 14 | protected async preAction( 15 | cmdLineoptions: ICommandLineOptions 16 | ): Promise { 17 | StdOutUtil.printMessage('Logged in CapRover Machines:\n') 18 | return Promise.resolve(cmdLineoptions) 19 | } 20 | 21 | protected async action(params: IParams): Promise { 22 | const machines = StorageHelper.get().getMachines() 23 | machines.forEach(StdOutUtil.displayColoredMachine) 24 | if (machines.length) { 25 | StdOutUtil.printMessage('') 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/login.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import StdOutUtil from '../utils/StdOutUtil' 4 | import Constants from '../utils/Constants' 5 | import Utils from '../utils/Utils' 6 | import CliHelper from '../utils/CliHelper' 7 | import { 8 | getErrorForDomain, 9 | getErrorForPassword, 10 | getErrorForMachineName 11 | } from '../utils/ValidationsHandler' 12 | import Command, { IOption, IParams, ICommandLineOptions } from './Command' 13 | 14 | const K = Utils.extendCommonKeys({ 15 | https: 'hasRootHttps' 16 | }) 17 | 18 | export default class Login extends Command { 19 | protected command = 'login' 20 | 21 | protected description = 22 | 'Login to a CapRover machine. You can be logged in to multiple machines simultaneously.' 23 | 24 | protected options = (params?: IParams): IOption[] => [ 25 | this.getDefaultConfigFileOption(), 26 | { 27 | name: K.https, // Backward compatibility with config hasRootHttps parameter, eventually to remove when releasing v2 28 | hide: true, 29 | when: false 30 | }, 31 | { 32 | name: K.url, 33 | char: 'u', 34 | env: 'CAPROVER_URL', 35 | type: 'input', 36 | message: `CapRover machine URL address, it is "[http[s]://][${Constants.ADMIN_DOMAIN}.]your-captain-root.domain"`, 37 | default: params && Constants.SAMPLE_DOMAIN, 38 | filter: (url: string) => 39 | Utils.cleanAdminDomainUrl( 40 | url, 41 | this.paramValue(params, K.https) 42 | ) || url, // If not cleaned url, leave url to fail validation with correct error 43 | validate: (url: string) => getErrorForDomain(url) 44 | }, 45 | { 46 | name: K.pwd, 47 | char: 'p', 48 | env: 'CAPROVER_PASSWORD', 49 | type: 'password', 50 | message: 'CapRover machine password', 51 | validate: (password: string) => getErrorForPassword(password) 52 | }, 53 | { 54 | name: K.name, 55 | char: 'n', 56 | env: 'CAPROVER_NAME', 57 | type: 'input', 58 | message: 59 | 'CapRover machine name, with whom the login credentials are stored locally', 60 | default: params && CliHelper.get().findDefaultCaptainName(), 61 | filter: (name: string) => name.trim(), 62 | validate: (name: string) => getErrorForMachineName(name) 63 | } 64 | ] 65 | 66 | protected async preAction( 67 | cmdLineoptions: ICommandLineOptions 68 | ): Promise { 69 | StdOutUtil.printMessage('Login to a CapRover machine...\n') 70 | return Promise.resolve(cmdLineoptions) 71 | } 72 | 73 | protected async action(params: IParams): Promise { 74 | CliHelper.get().loginMachine( 75 | { 76 | authToken: '', 77 | baseUrl: this.findParamValue(params, K.url)!.value, 78 | name: this.findParamValue(params, K.name)!.value 79 | }, 80 | this.findParamValue(params, K.pwd)!.value 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/logout.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import StdOutUtil from '../utils/StdOutUtil' 4 | import Constants from '../utils/Constants' 5 | import CliHelper from '../utils/CliHelper' 6 | import { 7 | getErrorForMachineName, 8 | userCancelOperation 9 | } from '../utils/ValidationsHandler' 10 | import Command, { 11 | IParams, 12 | IOption, 13 | ICommandLineOptions, 14 | ParamType, 15 | IParam 16 | } from './Command' 17 | 18 | const K = Constants.COMMON_KEYS 19 | 20 | export default class List extends Command { 21 | protected command = 'logout' 22 | 23 | protected description = 24 | 'Logout from a CapRover machine and clear auth info.' 25 | 26 | private machines = CliHelper.get().getMachinesAsOptions() 27 | 28 | protected options = (params?: IParams): IOption[] => [ 29 | this.getDefaultConfigFileOption(), 30 | { 31 | name: K.name, 32 | char: 'n', 33 | env: 'CAPROVER_NAME', 34 | type: 'list', 35 | message: params 36 | ? 'select the CapRover machine name you want to logout from' 37 | : 'CapRover machine name to logout from', 38 | choices: this.machines, 39 | filter: (name: string) => 40 | !this.findParamValue(params, K.name) 41 | ? userCancelOperation(!name, true) || name 42 | : name.trim(), 43 | validate: (name: string) => getErrorForMachineName(name, true) 44 | }, 45 | { 46 | name: 'confirmedToLogout', 47 | type: 'confirm', 48 | message: () => 49 | 'are you sure you want to logout from this CapRover machine?', // Use function to not append ':' on question message generation 50 | default: false, 51 | hide: true, 52 | when: () => this.paramFrom(params, K.name) === ParamType.Question, 53 | preProcessParam: (param: IParam) => 54 | param && userCancelOperation(!param.value) 55 | } 56 | ] 57 | 58 | protected async preAction( 59 | cmdLineoptions: ICommandLineOptions 60 | ): Promise { 61 | StdOutUtil.printMessage('Logout from a CapRover machine...\n') 62 | return Promise.resolve(cmdLineoptions) 63 | } 64 | 65 | protected async action(params: IParams): Promise { 66 | CliHelper.get().logoutMachine( 67 | this.findParamValue(params, K.name)!.value 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/serversetup.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import Constants from '../utils/Constants' 4 | import StdOutUtil from '../utils/StdOutUtil' 5 | import Utils from '../utils/Utils' 6 | import CliHelper from '../utils/CliHelper' 7 | import StorageHelper from '../utils/StorageHelper' 8 | import ErrorFactory from '../utils/ErrorFactory' 9 | import SpinnerHelper from '../utils/SpinnerHelper' 10 | import { 11 | getErrorForIP, 12 | getErrorForPassword, 13 | getErrorForEmail, 14 | getErrorForMachineName 15 | } from '../utils/ValidationsHandler' 16 | import { IMachine } from '../models/storage/StoredObjects' 17 | import CliApiManager from '../api/CliApiManager' 18 | import Command, { 19 | IParams, 20 | IOption, 21 | ICommandLineOptions, 22 | IParam, 23 | ParamType 24 | } from './Command' 25 | 26 | const K = Utils.extendCommonKeys({ 27 | ip: 'caproverIP', 28 | root: 'caproverRootDomain', 29 | newPwd: 'newPassword', 30 | newPwdCheck: 'newPasswordCheck', 31 | email: 'certificateEmail' 32 | }) 33 | 34 | export default class ServerSetup extends Command { 35 | protected command = 'serversetup' 36 | 37 | protected aliases = ['setup'] 38 | 39 | protected description = 40 | 'Performs necessary actions to prepare CapRover on your server.' 41 | 42 | private machine: IMachine = { authToken: '', baseUrl: '', name: '' } 43 | 44 | private ip: string 45 | 46 | private password: string = Constants.DEFAULT_PASSWORD 47 | 48 | protected options = (params?: IParams): IOption[] => [ 49 | this.getDefaultConfigFileOption(() => this.preQuestions(params!)), 50 | { 51 | name: 'assumeYes', 52 | char: 'y', 53 | type: 'confirm', 54 | message: () => 55 | (params ? 'have you' : 'assume you have') + 56 | ' already started CapRover container on your server' + 57 | (params ? '?' : ''), // Use function to not append ':' on question message generation 58 | default: params && true, 59 | when: !this.configFileProvided, 60 | preProcessParam: (param?: IParam) => { 61 | if (param && !param.value) { 62 | StdOutUtil.printError( 63 | '\nCannot setup CapRover if container is not started!\n' 64 | ) 65 | StdOutUtil.printWarning( 66 | 'Start it by running the following line:' 67 | ) 68 | StdOutUtil.printMessage( 69 | 'docker run -p 80:80 -p 443:443 -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock -v /captain:/captain caprover/caprover' 70 | ) 71 | StdOutUtil.printMessage( 72 | '\nPlease read tutorial on CapRover.com to learn how to install CapRover on a server.\n', 73 | true 74 | ) 75 | } 76 | } 77 | }, 78 | { 79 | name: K.ip, 80 | char: 'i', 81 | env: 'CAPROVER_IP', 82 | aliases: [{ name: 'ipAddress', hide: true }], 83 | type: 'input', 84 | message: 'IP address of your server', 85 | default: params && Constants.SAMPLE_IP, 86 | filter: (ip: string) => ip.trim(), 87 | validate: (ip: string) => getErrorForIP(ip), 88 | preProcessParam: async (param: IParam) => { 89 | this.ip = param.value 90 | if (!this.findParamValue(params, K.pwd)) { 91 | // No password provided: try default password 92 | this.machine.authToken = await this.getAuthTokenFromIp(true) 93 | } 94 | } 95 | }, 96 | { 97 | name: K.pwd, 98 | char: 'p', 99 | env: 'CAPROVER_PASSWORD', 100 | aliases: [{ name: 'currentPassword', hide: true }], 101 | type: 'password', 102 | message: 'current CapRover password', 103 | when: () => !this.machine.authToken, // The default password didn't work 104 | validate: (password: string) => getErrorForPassword(password), 105 | preProcessParam: async (param?: IParam) => { 106 | if (param) { 107 | // Password provided 108 | this.password = param.value 109 | this.machine.authToken = await this.getAuthTokenFromIp() 110 | } 111 | } 112 | }, 113 | { 114 | name: K.root, 115 | char: 'r', 116 | env: 'CAPROVER_ROOT_DOMAIN', 117 | aliases: [{ name: 'rootDomain', hide: true }], 118 | type: 'input', 119 | message: 'CapRover server root domain', 120 | when: () => this.checkFreshInstallation(), // Server not already setupped 121 | filter: (domain: string) => Utils.cleanDomain(domain), 122 | validate: (domain: string) => 123 | domain 124 | ? true 125 | : // tslint:disable-next-line: max-line-length 126 | 'Please enter a valid root domain, for example use "test.yourdomain.com" if you setup your DNS to point "*.test.yourdomain.com" to the ip address of your server.', 127 | preProcessParam: async (param: IParam) => 128 | await this.updateRootDomain(param.value) 129 | }, 130 | { 131 | name: K.newPwd, 132 | char: 'w', 133 | env: 'CAPROVER_NEW_PASSWORD', 134 | type: 'password', 135 | message: `new CapRover password (min ${Constants.MIN_CHARS_FOR_PASSWORD} characters)`, 136 | when: () => this.password === Constants.DEFAULT_PASSWORD, 137 | validate: (password: string) => 138 | getErrorForPassword(password, Constants.MIN_CHARS_FOR_PASSWORD) 139 | }, 140 | { 141 | name: K.newPwdCheck, 142 | type: 'password', 143 | message: 'enter new CapRover password again', 144 | hide: true, 145 | when: () => this.paramFrom(params, K.newPwd) === ParamType.Question, 146 | validate: (password: string) => 147 | getErrorForPassword( 148 | password, 149 | this.paramValue(params, K.newPwd) 150 | ) 151 | }, 152 | { 153 | name: K.email, 154 | char: 'e', 155 | env: 'CAPROVER_CERTIFICATE_EMAIL', 156 | aliases: [{ name: 'emailForHttps', hide: true }], 157 | type: 'input', 158 | message: 159 | '"valid" email address to get certificate and enable HTTPS', 160 | filter: (email: string) => email.trim(), 161 | validate: (email: string) => getErrorForEmail(email), 162 | preProcessParam: (param: IParam) => 163 | this.enableSslAndChangePassword( 164 | param.value, 165 | this.paramValue(params, K.newPwd) 166 | ) 167 | }, 168 | { 169 | name: K.name, 170 | char: 'n', 171 | env: 'CAPROVER_NAME', 172 | aliases: [{ name: 'machineName', hide: true }], 173 | type: 'input', 174 | message: 175 | 'CapRover machine name, with whom the login credentials are stored locally', 176 | default: params && CliHelper.get().findDefaultCaptainName(), 177 | filter: (name: string) => name.trim(), 178 | validate: (name: string) => getErrorForMachineName(name), 179 | preProcessParam: (param?: IParam) => 180 | param && (this.machine.name = param.value) 181 | } 182 | ] 183 | 184 | protected async preAction( 185 | cmdLineoptions: ICommandLineOptions 186 | ): Promise { 187 | StdOutUtil.printMessage('Setup CapRover machine on your server...\n') 188 | return Promise.resolve(cmdLineoptions) 189 | } 190 | 191 | protected preQuestions(params: IParams) { 192 | if (this.findParamValue(params, K.name)) { 193 | const err = getErrorForMachineName( 194 | this.findParamValue(params, K.name)!.value 195 | ) 196 | if (err !== true) { 197 | StdOutUtil.printError(`${err || 'Error!'}\n`, true) 198 | } 199 | } 200 | } 201 | 202 | private async getAuthTokenFromIp(firstTry?: boolean): Promise { 203 | try { 204 | return await CliApiManager.get({ 205 | authToken: '', 206 | baseUrl: `http://${this.ip}:${Constants.SETUP_PORT}`, 207 | name: '' 208 | }).getAuthToken(this.password) 209 | } catch (e) { 210 | if ( 211 | firstTry && 212 | e.captainStatus === ErrorFactory.STATUS_WRONG_PASSWORD 213 | ) { 214 | return '' 215 | } 216 | if ((e + '').indexOf('Found. Redirecting to https://') >= 0) { 217 | StdOutUtil.printWarning( 218 | '\nYou may have already setup the server! Use caprover login to log into an existing server.' 219 | ) 220 | } else { 221 | StdOutUtil.printWarning( 222 | '\nYou may have specified a wrong IP address or not already started CapRover container on your server!' 223 | ) 224 | } 225 | StdOutUtil.errorHandler(e) 226 | return '' 227 | } 228 | } 229 | 230 | private async checkFreshInstallation(): Promise { 231 | try { 232 | const rootDomain: string = ( 233 | await CliApiManager.get({ 234 | authToken: this.machine.authToken, 235 | baseUrl: `http://${this.ip}:${Constants.SETUP_PORT}`, 236 | name: '' 237 | }).getCaptainInfo() 238 | ).rootDomain 239 | if (rootDomain) { 240 | StdOutUtil.printWarning( 241 | `\nYou may have already setup the server with root domain: ${rootDomain}! Use caprover login to log into an existing server.`, 242 | true 243 | ) 244 | } 245 | } catch (e) { 246 | StdOutUtil.errorHandler(e) 247 | } 248 | return true 249 | } 250 | 251 | private async updateRootDomain(rootDomain: string) { 252 | try { 253 | await CliApiManager.get({ 254 | authToken: this.machine.authToken, 255 | baseUrl: `http://${this.ip}:${Constants.SETUP_PORT}`, 256 | name: '' 257 | }).updateRootDomain(rootDomain) 258 | this.machine.baseUrl = `http://${Constants.ADMIN_DOMAIN}.${rootDomain}` 259 | } catch (e) { 260 | if (e.captainStatus === ErrorFactory.VERIFICATION_FAILED) { 261 | StdOutUtil.printError( 262 | `\nCannot verify that ${StdOutUtil.getColoredMachineUrl( 263 | rootDomain 264 | )} points to your server IP.` 265 | ) 266 | StdOutUtil.printError( 267 | `Are you sure that you setup your DNS to point "*.${rootDomain}" to ${this.ip}?` 268 | ) 269 | StdOutUtil.printError( 270 | `Double check your DNS, if everything looks correct note that DNS changes take up to 24 hours to work properly. Check with your Domain Provider.` 271 | ) 272 | } 273 | StdOutUtil.errorHandler(e) 274 | } 275 | } 276 | 277 | private async enableSslAndChangePassword( 278 | email: string, 279 | newPassword?: string 280 | ) { 281 | let forcedSsl = false 282 | try { 283 | SpinnerHelper.start('Enabling SSL... Takes a few seconds...') 284 | 285 | await CliApiManager.get(this.machine).enableRootSsl(email) 286 | this.machine.baseUrl = Utils.cleanAdminDomainUrl( 287 | this.machine.baseUrl, 288 | true 289 | )! 290 | await CliApiManager.get(this.machine).forceSsl(true) 291 | forcedSsl = true 292 | 293 | if (newPassword !== undefined) { 294 | await CliApiManager.get(this.machine).changePass( 295 | this.password, 296 | newPassword 297 | ) 298 | this.password = newPassword 299 | await CliApiManager.get(this.machine).getAuthToken( 300 | this.password 301 | ) 302 | } 303 | 304 | SpinnerHelper.stop() 305 | } catch (e) { 306 | if (forcedSsl) { 307 | StdOutUtil.printError( 308 | '\nServer is setup, but password was not changed due to an error. You cannot use serversetup again.' 309 | ) 310 | StdOutUtil.printError( 311 | `Instead, go to ${StdOutUtil.getColoredMachineUrl( 312 | this.machine.baseUrl 313 | )} and change your password on settings page.` 314 | ) 315 | StdOutUtil.printError( 316 | `Then use command to connect to your server.` 317 | ) 318 | } 319 | SpinnerHelper.fail() 320 | StdOutUtil.errorHandler(e) 321 | } 322 | } 323 | 324 | protected async action(params: IParams): Promise { 325 | StorageHelper.get().saveMachine(this.machine) 326 | StdOutUtil.printGreenMessage( 327 | `CapRover server setup completed: it is available as ${StdOutUtil.getColoredMachine( 328 | this.machine 329 | )}\n` 330 | ) 331 | StdOutUtil.printMessage('For more details and docs see CapRover.com\n') 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/models/AppDef.ts: -------------------------------------------------------------------------------- 1 | // COPIED FROM BACKEND CODE 2 | interface IHashMapGeneric { 3 | [id: string]: T 4 | } 5 | 6 | type IAllAppDefinitions = IHashMapGeneric 7 | 8 | export interface IAppEnvVar { 9 | key: string 10 | value: string 11 | } 12 | 13 | interface IAppVolume { 14 | containerPath: string 15 | volumeName?: string 16 | hostPath?: string 17 | } 18 | 19 | interface IAppPort { 20 | containerPort: number 21 | hostPort: number 22 | protocol?: 'udp' | 'tcp' 23 | 24 | publishMode?: 'ingress' | 'host' 25 | } 26 | 27 | export interface RepoInfo { 28 | repo: string 29 | branch: string 30 | user: string 31 | password: string 32 | } 33 | 34 | interface RepoInfoEncrypted { 35 | repo: string 36 | branch: string 37 | user: string 38 | passwordEncrypted: string 39 | } 40 | 41 | export interface IAppVersion { 42 | version: number 43 | deployedImageName?: string // empty if the deploy is not completed 44 | timeStamp: string 45 | gitHash: string | undefined 46 | } 47 | 48 | interface IAppCustomDomain { 49 | publicDomain: string 50 | hasSsl: boolean 51 | } 52 | 53 | interface IAppDefinitionBase { 54 | deployedVersion: number 55 | notExposeAsWebApp: boolean 56 | hasPersistentData: boolean 57 | hasDefaultSubDomainSsl: boolean 58 | 59 | forceSsl: boolean 60 | nodeId?: string 61 | instanceCount: number 62 | preDeployFunction?: string 63 | customNginxConfig?: string 64 | networks: string[] 65 | customDomain: IAppCustomDomain[] 66 | 67 | ports: IAppPort[] 68 | volumes: IAppVolume[] 69 | envVars: IAppEnvVar[] 70 | 71 | versions: IAppVersion[] 72 | } 73 | 74 | export interface IAppDef extends IAppDefinitionBase { 75 | appPushWebhook?: { 76 | repoInfo: RepoInfo 77 | tokenVersion?: string // On FrontEnd, these values are null, until they are assigned. 78 | pushWebhookToken?: string // On FrontEnd, these values are null, until they are assigned. 79 | } 80 | appName?: string 81 | isAppBuilding?: boolean 82 | } 83 | 84 | interface IAppDefSaved extends IAppDefinitionBase { 85 | appPushWebhook: 86 | | { 87 | tokenVersion: string 88 | repoInfo: RepoInfoEncrypted 89 | pushWebhookToken: string 90 | } 91 | | undefined 92 | } 93 | -------------------------------------------------------------------------------- /src/models/IBuildLogs.ts: -------------------------------------------------------------------------------- 1 | export default interface IBuildLogs { 2 | isAppBuilding: boolean 3 | isBuildFailed: boolean 4 | logs: { 5 | firstLineNumber: number 6 | lines: string[] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/models/ICaptainDefinition.ts: -------------------------------------------------------------------------------- 1 | export interface ICaptainDefinition { 2 | schemaVersion: number 3 | dockerfileLines?: string[] 4 | imageName?: string 5 | templateId?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/models/IHashMapGeneric.ts: -------------------------------------------------------------------------------- 1 | export interface IHashMapGeneric { 2 | [id: string]: T 3 | } 4 | -------------------------------------------------------------------------------- /src/models/IOneClickAppModels.ts: -------------------------------------------------------------------------------- 1 | import { IHashMapGeneric } from './IHashMapGeneric' 2 | 3 | export interface IOneClickAppIdentifier { 4 | name: string 5 | download_url: string 6 | } 7 | 8 | export interface IOneClickVariable { 9 | id: string 10 | label: string 11 | defaultValue?: string 12 | validRegex?: string 13 | description?: string 14 | } 15 | 16 | export interface IDockerComposeService { 17 | image?: string 18 | dockerFileLines?: string[] // This is our property, not DockerCompose. We use this instead of image if we need to extend the image. 19 | volumes?: string[] 20 | ports?: string[] 21 | environment?: IHashMapGeneric 22 | depends_on?: string[] 23 | } 24 | 25 | export interface IOneClickTemplate { 26 | captainVersion: number 27 | dockerCompose: { 28 | version: string 29 | services: IHashMapGeneric 30 | } 31 | instructions: { 32 | start: string 33 | end: string 34 | } 35 | variables: IOneClickVariable[] 36 | } 37 | -------------------------------------------------------------------------------- /src/models/IRegistryInfo.ts: -------------------------------------------------------------------------------- 1 | export interface IRegistryApi { 2 | registries: IRegistryInfo[] 3 | defaultPushRegistryId: string | undefined 4 | } 5 | 6 | export class IRegistryTypes { 7 | static readonly LOCAL_REG = 'LOCAL_REG' 8 | static readonly REMOTE_REG = 'REMOTE_REG' 9 | } 10 | 11 | type IRegistryType = 'LOCAL_REG' | 'REMOTE_REG' 12 | 13 | export interface IRegistryInfo { 14 | id: string 15 | registryUser: string 16 | registryPassword: string 17 | registryDomain: string 18 | registryImagePrefix: string 19 | registryType: IRegistryType 20 | } 21 | -------------------------------------------------------------------------------- /src/models/IVersionInfo.ts: -------------------------------------------------------------------------------- 1 | export interface IVersionInfo { 2 | currentVersion: string 3 | latestVersion: string 4 | canUpdate: boolean 5 | } 6 | -------------------------------------------------------------------------------- /src/models/storage/StoredObjects.ts: -------------------------------------------------------------------------------- 1 | export interface IMachine { 2 | authToken: string 3 | baseUrl: string 4 | appToken?: string 5 | name: string 6 | } 7 | 8 | export interface IOldSavedApp { 9 | cwd: string 10 | appName: string 11 | branchToPush: string 12 | machineToDeploy: IMachine 13 | } 14 | 15 | export interface IDeploySource { 16 | branchToPush?: string 17 | tarFilePath?: string 18 | imageName?: string 19 | } 20 | 21 | export interface IDeployedDirectory { 22 | cwd: string 23 | appName: string 24 | deploySource: IDeploySource 25 | machineNameToDeploy: string 26 | } 27 | 28 | export interface IDeployParams { 29 | deploySource: IDeploySource 30 | captainMachine?: IMachine 31 | appName?: string 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/CliHelper.ts: -------------------------------------------------------------------------------- 1 | import StorageHelper from './StorageHelper' 2 | import StdOutUtil from './StdOutUtil' 3 | import Constants from './Constants' 4 | import { 5 | getErrorForMachineName, 6 | getErrorForDomain, 7 | getErrorForPassword 8 | } from './ValidationsHandler' 9 | import { IMachine } from '../models/storage/StoredObjects' 10 | import { IAppDef } from '../models/AppDef' 11 | import CliApiManager from '../api/CliApiManager' 12 | import { IOption, IParams } from '../commands/Command' 13 | import ErrorFactory from './ErrorFactory' 14 | 15 | export default class CliHelper { 16 | static instance: CliHelper 17 | 18 | static get() { 19 | if (!CliHelper.instance) { 20 | CliHelper.instance = new CliHelper() 21 | } 22 | return CliHelper.instance 23 | } 24 | 25 | getAppsAsOptions(apps: IAppDef[]) { 26 | return [ 27 | { 28 | name: Constants.CANCEL_STRING, 29 | value: '', 30 | short: '' 31 | }, 32 | ...apps.map((app) => ({ 33 | name: `${app.appName}`, 34 | value: `${app.appName}`, 35 | short: `${app.appName}` 36 | })) 37 | ] 38 | } 39 | 40 | getMachinesAsOptions() { 41 | return [ 42 | { 43 | name: Constants.CANCEL_STRING, 44 | value: '', 45 | short: '' 46 | }, 47 | ...StorageHelper.get() 48 | .getMachines() 49 | .map((machine) => ({ 50 | name: `${StdOutUtil.getColoredMachine(machine)}`, 51 | value: `${machine.name}`, 52 | short: `${machine.name}` 53 | })) 54 | ] 55 | } 56 | 57 | getApiMethodsAsOptions() { 58 | return [ 59 | { 60 | name: Constants.CANCEL_STRING, 61 | value: '', 62 | short: '' 63 | }, 64 | ...Constants.API_METHODS.map((method) => ({ 65 | name: `${method}`, 66 | value: `${method}`, 67 | short: `${method}` 68 | })) 69 | ] 70 | } 71 | 72 | getApiMethodsDescription(): string { 73 | return Constants.API_METHODS.reduce( 74 | (acc, method) => (acc ? `${acc}, ` : '') + `"${method}"`, 75 | '' 76 | ) 77 | } 78 | 79 | async loginMachine(machine: IMachine, password: string) { 80 | try { 81 | const tokenToIgnore = 82 | await CliApiManager.get(machine).getAuthToken(password) 83 | StdOutUtil.printGreenMessage(`Logged in successfully.`) 84 | StdOutUtil.printMessage( 85 | `Authorization token is now saved as ${StdOutUtil.getColoredMachine( 86 | machine 87 | )}.\n` 88 | ) 89 | } catch (error) { 90 | if ( 91 | error.captainStatus === ErrorFactory.STATUS_ERROR_OTP_REQUIRED 92 | ) { 93 | StdOutUtil.printWarning( 94 | 'You must also pass CAPROVER_OTP_TOKEN environment variable, e.g. CAPROVER_OTP_TOKEN=123456; caprover login' 95 | ) 96 | return 97 | } 98 | const errorMessage = error.message ? error.message : error 99 | StdOutUtil.printError( 100 | `Something bad happened: cannot save ${StdOutUtil.getColoredMachine( 101 | machine 102 | )}.\n${errorMessage}\n` 103 | ) 104 | } 105 | } 106 | 107 | logoutMachine(machineName: string) { 108 | const removedMachine = StorageHelper.get().removeMachine(machineName) 109 | StdOutUtil.printMessage( 110 | `You are now logged out from ${StdOutUtil.getColoredMachine( 111 | removedMachine 112 | )}.\n` 113 | ) 114 | } 115 | 116 | findDefaultCaptainName() { 117 | let currentSuffix = 1 118 | const machines = StorageHelper.get() 119 | .getMachines() 120 | .map((machine) => machine.name) 121 | while (machines.includes(this.getCaptainFullName(currentSuffix))) { 122 | currentSuffix++ 123 | } 124 | return this.getCaptainFullName(currentSuffix) 125 | } 126 | 127 | getCaptainFullName(suffix: number) { 128 | return `captain-${suffix < 10 ? '0' : ''}${suffix}` 129 | } 130 | 131 | async ensureAuthentication( 132 | url?: string, 133 | password?: string, 134 | machineName?: string 135 | ): Promise { 136 | if (url) { 137 | // Auth to url 138 | const machine: IMachine = { baseUrl: url, name: '', authToken: '' } 139 | if (machineName) { 140 | // With machine name: also store credentials 141 | let err = getErrorForDomain(url) 142 | if (err !== true) { 143 | // Error for domain: can't store credentials 144 | StdOutUtil.printWarning( 145 | `\nCan't store store login credentials: ${ 146 | err || 'error!' 147 | }\n` 148 | ) 149 | } else { 150 | err = getErrorForMachineName(machineName) 151 | if (err !== true) { 152 | // Error for machine name: can't store credentials 153 | StdOutUtil.printWarning( 154 | `\nCan't store store login credentials: ${ 155 | err || 'error!' 156 | }\n` 157 | ) 158 | } else { 159 | machine.name = machineName 160 | } 161 | } 162 | } 163 | if (password) { 164 | // If password provided 165 | await CliApiManager.get(machine).getAuthToken(password) // Do auth 166 | } 167 | return machine 168 | } else if (machineName) { 169 | // Auth to stored machine name 170 | const machine = StorageHelper.get().findMachine(machineName) // Get stored machine 171 | if (!machine) { 172 | throw new Error(`Can't find stored machine "${machineName}"`) 173 | } // No stored machine: throw 174 | try { 175 | await CliApiManager.get(machine).getAllApps() // Get data with stored token 176 | } catch (e) { 177 | // Error getting data: token expired 178 | StdOutUtil.printWarning( 179 | `Your auth token for ${StdOutUtil.getColoredMachine( 180 | machine 181 | )} is not valid anymore, try to login again...` 182 | ) 183 | machine.authToken = '' // Remove expired token 184 | if (password) { 185 | // If password provided 186 | await CliApiManager.get(machine).getAuthToken(password) // Do auth 187 | } 188 | } 189 | return machine 190 | } 191 | throw new Error('Too few arguments, no url or machine name') 192 | } 193 | 194 | getEnsureAuthenticationOption( 195 | appToken: string, 196 | url?: string | (() => string | undefined), 197 | password?: string | (() => string | undefined), 198 | name?: string | (() => string | undefined), 199 | done?: (machine: IMachine) => void 200 | ): IOption { 201 | let machine: IMachine 202 | return { 203 | name: 'ensureAuthenticationPlaceholder', 204 | message: 'CapRover machine password', 205 | type: 'password', 206 | hide: true, 207 | when: async () => { 208 | StdOutUtil.printMessage('Ensuring authentication...') 209 | 210 | type typeOfValue = string | (() => string | undefined) 211 | type typeOfReturn = string | undefined 212 | 213 | const getVal = (value?: typeOfValue): typeOfReturn => { 214 | return value && value instanceof Function ? value() : value 215 | } 216 | const urlExtracted = getVal(url) 217 | const passwordExtracted = getVal(password) 218 | const nameExtracted = getVal(name) 219 | 220 | if (!!appToken) { 221 | machine = { 222 | baseUrl: urlExtracted || '', 223 | authToken: '', 224 | appToken, 225 | name: nameExtracted || '' 226 | } 227 | return false 228 | } 229 | 230 | try { 231 | machine = await CliHelper.get().ensureAuthentication( 232 | urlExtracted, 233 | passwordExtracted, 234 | nameExtracted 235 | ) 236 | return !machine.authToken 237 | } catch (e) { 238 | StdOutUtil.printError( 239 | `\nSomething bad happened during authentication to ${ 240 | urlExtracted 241 | ? StdOutUtil.getColoredMachineUrl(urlExtracted) 242 | : StdOutUtil.getColoredMachineName( 243 | nameExtracted || '' 244 | ) 245 | }.\n${e.message || e}`, 246 | true 247 | ) 248 | } 249 | return true 250 | }, 251 | validate: async (passwordToValidate: string) => { 252 | const err = getErrorForPassword(passwordToValidate) 253 | if (err !== true) { 254 | return err 255 | } 256 | try { 257 | await CliApiManager.get(machine).getAuthToken( 258 | passwordToValidate 259 | ) // Do auth 260 | } catch (e) { 261 | StdOutUtil.printError( 262 | `\nSomething bad happened during authentication to ${StdOutUtil.getColoredMachineUrl( 263 | machine.baseUrl 264 | )}.\n${e.message || e}`, 265 | true 266 | ) 267 | } 268 | return true 269 | }, 270 | preProcessParam: async () => done && (await done(machine)) 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/utils/Constants.ts: -------------------------------------------------------------------------------- 1 | const ADMIN_DOMAIN = process.env.CAPROVER_ADMIN_DOMAIN_OVERRIDE || 'captain' 2 | const SAMPLE_DOMAIN = `${ADMIN_DOMAIN}.captainroot.yourdomain.com` 3 | const SAMPLE_IP = '123.123.123.123' 4 | const DEFAULT_PASSWORD = 'captain42' 5 | const CANCEL_STRING = '-- CANCEL --' 6 | const SETUP_PORT = 3000 7 | const MIN_CHARS_FOR_PASSWORD = 8 8 | const BASE_API_PATH = '/api/v2' 9 | const API_METHODS = ['GET', 'POST'] 10 | 11 | export default { 12 | ADMIN_DOMAIN, 13 | SAMPLE_DOMAIN, 14 | SAMPLE_IP, 15 | DEFAULT_PASSWORD, 16 | CANCEL_STRING, 17 | SETUP_PORT, 18 | MIN_CHARS_FOR_PASSWORD, 19 | BASE_API_PATH, 20 | API_METHODS, 21 | COMMON_KEYS: { 22 | conf: 'configFile', 23 | url: 'caproverUrl', 24 | pwd: 'caproverPassword', 25 | name: 'caproverName', 26 | app: 'caproverApp' 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/DeployHelper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import * as path from 'path' 3 | import { exec, ExecException } from 'child_process' 4 | import * as ProgressBar from 'progress' 5 | import StdOutUtil from '../utils/StdOutUtil' 6 | import SpinnerHelper from '../utils/SpinnerHelper' 7 | import IBuildLogs from '../models/IBuildLogs' 8 | import { IMachine, IDeployParams } from '../models/storage/StoredObjects' 9 | import CliApiManager from '../api/CliApiManager' 10 | 11 | export default class DeployHelper { 12 | private lastLineNumberPrinted = -10000 // we want to show all lines to begin with! 13 | 14 | constructor(private ssl?: boolean) {} 15 | 16 | async startDeploy(deployParams: IDeployParams): Promise { 17 | const appName = deployParams.appName 18 | const branchToPush = deployParams.deploySource.branchToPush 19 | const tarFilePath = deployParams.deploySource.tarFilePath 20 | const imageName = deployParams.deploySource.imageName 21 | const machineToDeploy = deployParams.captainMachine 22 | 23 | if (!appName || !machineToDeploy) { 24 | StdOutUtil.printError( 25 | "Can't deploy: missing CapRover machine or app name.\n", 26 | true 27 | ) 28 | return false 29 | } 30 | 31 | if ( 32 | (branchToPush ? 1 : 0) + 33 | (imageName ? 1 : 0) + 34 | (tarFilePath ? 1 : 0) !== 35 | 1 36 | ) { 37 | StdOutUtil.printError( 38 | "Can't deploy: only one of branch, tarFile or imageName can be present.\n", 39 | true 40 | ) 41 | return false 42 | } 43 | 44 | let gitHash = '' 45 | let tarFileCreatedByCli = false 46 | const tarFileNameToDeploy = tarFilePath 47 | ? tarFilePath 48 | : 'temporary-captain-to-deploy.tar' 49 | const tarFileFullPath = tarFileNameToDeploy.startsWith('/') 50 | ? tarFileNameToDeploy 51 | : path.join(process.cwd(), tarFileNameToDeploy) 52 | 53 | if (branchToPush) { 54 | tarFileCreatedByCli = true 55 | StdOutUtil.printMessage(`Saving tar file to: "${tarFileFullPath}"`) 56 | gitHash = await this.gitArchiveFile(tarFileFullPath, branchToPush) 57 | } 58 | 59 | StdOutUtil.printMessage( 60 | `Deploying ${StdOutUtil.getColoredAppName(appName)} to ${ 61 | machineToDeploy.name 62 | ? StdOutUtil.getColoredMachineName(machineToDeploy.name) 63 | : StdOutUtil.getColoredMachineUrl(machineToDeploy.baseUrl) 64 | }...\n` 65 | ) 66 | try { 67 | if (imageName) { 68 | await CliApiManager.get( 69 | machineToDeploy 70 | ).uploadCaptainDefinitionContent( 71 | appName, 72 | { schemaVersion: 2, imageName }, 73 | '', 74 | true 75 | ) 76 | } else { 77 | await CliApiManager.get(machineToDeploy).uploadAppData( 78 | appName, 79 | this.getFileStream(tarFileFullPath), 80 | gitHash 81 | ) 82 | } 83 | 84 | StdOutUtil.printMessage('Building your source code...\n') 85 | 86 | if (machineToDeploy.appToken) { 87 | StdOutUtil.printMessage( 88 | `Deploying ${StdOutUtil.getColoredAppName( 89 | appName 90 | )} using app token is in progress. Build logs aren't retrieved when using app token.\nWait a few minutes until your app is built and deployed` 91 | ) 92 | } else { 93 | this.startFetchingBuildLogs(machineToDeploy, appName) 94 | } 95 | return true 96 | } catch (e) { 97 | throw e 98 | } finally { 99 | if (tarFileCreatedByCli && fs.pathExistsSync(tarFileFullPath)) { 100 | fs.removeSync(tarFileFullPath) 101 | } 102 | } 103 | } 104 | 105 | private gitArchiveFile(zipFileFullPath: string, branchToPush: string) { 106 | return new Promise(function (resolve, reject) { 107 | if (fs.pathExistsSync(zipFileFullPath)) { 108 | fs.removeSync(zipFileFullPath) 109 | } // Removes the temporary file created 110 | 111 | exec( 112 | `git archive --format tar --output "${zipFileFullPath}" ${branchToPush}`, 113 | ( 114 | error: ExecException | null, 115 | stdout1: string, 116 | stderr1: string 117 | ) => { 118 | if (error) { 119 | StdOutUtil.printError(`TAR file failed.\n${error}\n`) 120 | if (fs.pathExistsSync(zipFileFullPath)) { 121 | fs.removeSync(zipFileFullPath) 122 | } 123 | reject(new Error('TAR file failed')) 124 | return 125 | } 126 | 127 | exec( 128 | `git rev-parse ${branchToPush}`, 129 | ( 130 | err: ExecException | null, 131 | stdout2: string, 132 | stderr2: string 133 | ) => { 134 | const gitHash = (stdout2 || '').trim() 135 | 136 | if (err || !/^[a-f0-9]{40}$/.test(gitHash)) { 137 | StdOutUtil.printError( 138 | `Cannot find hash of last commit on branch "${branchToPush}": ${gitHash}\n${err}\n` 139 | ) 140 | if (fs.pathExistsSync(zipFileFullPath)) { 141 | fs.removeSync(zipFileFullPath) 142 | } 143 | reject(new Error('rev-parse failed')) 144 | return 145 | } 146 | 147 | StdOutUtil.printMessage( 148 | `Using last commit on "${branchToPush}": ${gitHash}\n` 149 | ) 150 | resolve(gitHash) 151 | } 152 | ) 153 | } 154 | ) 155 | }) 156 | } 157 | 158 | private getFileStream(zipFileFullPath: string) { 159 | const fileSize = fs.statSync(zipFileFullPath).size 160 | const fileStream = fs.createReadStream(zipFileFullPath) 161 | const barOpts = { width: 20, total: fileSize, clear: false } 162 | const bar = new ProgressBar( 163 | 'Uploading [:bar] :percent (ETA :etas)', 164 | barOpts 165 | ) 166 | 167 | fileStream.on('data', (chunk) => bar.tick(chunk.length)) 168 | fileStream.on('end', () => { 169 | StdOutUtil.printGreenMessage(`Upload done.\n`) 170 | StdOutUtil.printMessage( 171 | 'This might take several minutes. PLEASE BE PATIENT...\n' 172 | ) 173 | }) 174 | 175 | return fileStream 176 | } 177 | 178 | private async onLogRetrieved( 179 | data: IBuildLogs | undefined, 180 | machineToDeploy: IMachine, 181 | appName: string 182 | ) { 183 | if (data) { 184 | const lines = data.logs.lines 185 | const firstLineNumberOfLogs = data.logs.firstLineNumber 186 | let firstLinesToPrint = 0 187 | if (firstLineNumberOfLogs > this.lastLineNumberPrinted) { 188 | if (firstLineNumberOfLogs < 0) { 189 | // This is the very first fetch, probably firstLineNumberOfLogs is around -50 190 | firstLinesToPrint = -firstLineNumberOfLogs 191 | } else { 192 | StdOutUtil.printMessage('[[ TRUNCATED ]]') 193 | } 194 | } else { 195 | firstLinesToPrint = 196 | this.lastLineNumberPrinted - firstLineNumberOfLogs 197 | } 198 | this.lastLineNumberPrinted = firstLineNumberOfLogs + lines.length 199 | for (let i = firstLinesToPrint; i < lines.length; i++) { 200 | StdOutUtil.printMessage((lines[i] || '').trim()) 201 | } 202 | } 203 | 204 | if (data && !data.isAppBuilding) { 205 | if (!data.isBuildFailed) { 206 | let appUrl = machineToDeploy.baseUrl 207 | .replace('https://', 'http://') 208 | .replace('//captain.', `//${appName}.`) 209 | if (this.ssl) { 210 | appUrl = appUrl.replace('http://', 'https://') 211 | } 212 | StdOutUtil.printGreenMessage( 213 | `\nDeployed successfully ${StdOutUtil.getColoredAppName( 214 | appName 215 | )}` 216 | ) 217 | StdOutUtil.printGreenMessage( 218 | `App is available at ${StdOutUtil.getColoredMachineUrl( 219 | appUrl 220 | )}\n`, 221 | true 222 | ) 223 | } else { 224 | StdOutUtil.printError( 225 | `\nSomething bad happened. Cannot deploy ${StdOutUtil.getColoredAppName( 226 | appName 227 | )} at ${StdOutUtil.getColoredMachineName( 228 | machineToDeploy.name || machineToDeploy.baseUrl 229 | )}.\n`, 230 | true 231 | ) 232 | } 233 | } else { 234 | setTimeout( 235 | () => this.startFetchingBuildLogs(machineToDeploy, appName), 236 | 2000 237 | ) 238 | } 239 | } 240 | 241 | private async startFetchingBuildLogs( 242 | machineToDeploy: IMachine, 243 | appName: string 244 | ) { 245 | try { 246 | const data = 247 | await CliApiManager.get(machineToDeploy).fetchBuildLogs(appName) 248 | this.onLogRetrieved(data, machineToDeploy, appName) 249 | } catch (error) { 250 | StdOutUtil.printError( 251 | `\nSomething bad happened while retrieving ${StdOutUtil.getColoredAppName( 252 | appName 253 | )} app build logs.\n${error.message || error}\n` 254 | ) 255 | this.onLogRetrieved(undefined, machineToDeploy, appName) 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/utils/ErrorFactory.ts: -------------------------------------------------------------------------------- 1 | class ErrorFactory { 2 | readonly OKAY = 100 3 | readonly OKAY_BUILD_STARTED = 101 4 | 5 | readonly STATUS_ERROR_GENERIC = 1000 6 | readonly STATUS_ERROR_CAPTAIN_NOT_INITIALIZED = 1001 7 | readonly STATUS_ERROR_USER_NOT_INITIALIZED = 1101 8 | readonly STATUS_ERROR_NOT_AUTHORIZED = 1102 9 | readonly STATUS_ERROR_ALREADY_EXIST = 1103 10 | readonly STATUS_ERROR_BAD_NAME = 1104 11 | readonly STATUS_WRONG_PASSWORD = 1105 12 | readonly STATUS_AUTH_TOKEN_INVALID = 1106 13 | readonly VERIFICATION_FAILED = 1107 14 | readonly ILLEGAL_OPERATION = 1108 15 | readonly BUILD_ERROR = 1109 16 | readonly ILLEGAL_PARAMETER = 1110 17 | readonly NOT_FOUND = 1111 18 | readonly AUTHENTICATION_FAILED = 1112 19 | readonly STATUS_PASSWORD_BACK_OFF = 1113 20 | readonly STATUS_ERROR_OTP_REQUIRED = 1114 21 | readonly STATUS_ERROR_PRO_API_KEY_INVALIDATED = 1115 22 | 23 | readonly UNKNOWN_ERROR = 1999 24 | 25 | createError(status: number, message: string) { 26 | const e = new Error(message) as any 27 | e.captainStatus = status 28 | e.captainMessage = message 29 | return e 30 | } 31 | 32 | eatUpPromiseRejection() { 33 | return function (error: any) { 34 | // nom nom 35 | } 36 | } 37 | } 38 | 39 | export default new ErrorFactory() 40 | -------------------------------------------------------------------------------- /src/utils/Logger.ts: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | static log(s: string) { 3 | console.log(s) 4 | } 5 | 6 | static error(s: any) { 7 | // tslint:disable-next-line: no-console 8 | console.error(s) 9 | } 10 | 11 | static dev(s: string) { 12 | if (process.env.CLI_IS_DEBUG) { 13 | console.log('>>> ', s) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/SpinnerHelper.ts: -------------------------------------------------------------------------------- 1 | import ora from 'ora' 2 | 3 | class SpinnerHelper { 4 | private spinner: any 5 | 6 | start(message: string) { 7 | this.spinner = ora(message).start() 8 | } 9 | 10 | setColor(color: string) { 11 | this.spinner.color = color 12 | } 13 | 14 | stop() { 15 | this.spinner.stop() 16 | } 17 | 18 | succeed() { 19 | this.spinner.succeed() 20 | } 21 | 22 | fail() { 23 | this.spinner.fail() 24 | } 25 | } 26 | 27 | export default new SpinnerHelper() 28 | -------------------------------------------------------------------------------- /src/utils/StdOutUtil.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { IMachine } from '../models/storage/StoredObjects' 3 | 4 | class StdOutUtils { 5 | printMessage(message: string, exit: boolean | number = false) { 6 | console.log(message) 7 | if (exit !== false) { 8 | process.exit(exit === true ? 0 : exit) 9 | } 10 | } 11 | 12 | printGreenMessage(message: string, exit: boolean | number = false) { 13 | console.log(`${chalk.green(message)}`) 14 | if (exit !== false) { 15 | process.exit(exit === true ? 0 : exit) 16 | } 17 | } 18 | 19 | printMagentaMessage(message: string, exit: boolean | number = false) { 20 | console.log(`${chalk.magenta(message)}`) 21 | // tslint:disable-next-line: no-unused-expression 22 | exit && process.exit(0) 23 | } 24 | 25 | printError(error: string, exit: boolean | number = false) { 26 | console.log(`${chalk.bold.red(error)}`) 27 | if (exit !== false) { 28 | process.exit(exit === true ? 1 : exit) 29 | } 30 | } 31 | 32 | printWarning(warning: string, exit: boolean | number = false) { 33 | console.log(`${chalk.yellow(warning)}`) 34 | if (exit !== false) { 35 | process.exit(exit === true ? 1 : exit) 36 | } 37 | } 38 | 39 | printTip(tip: string, exit: boolean | number = false) { 40 | console.log(`${chalk.bold.green(tip)}`) 41 | if (exit !== false) { 42 | process.exit(exit === true ? 0 : exit) 43 | } 44 | } 45 | 46 | errorHandler(error: any) { 47 | if (error.captainStatus) { 48 | this.printError( 49 | `\nError Code: ${error.captainStatus} Message: ${error.captainMessage}\n`, 50 | true 51 | ) 52 | } else if (error.status) { 53 | this.printError( 54 | `\nError status: ${ 55 | error.status 56 | } Message: ${error.description || error.message}\n`, 57 | true 58 | ) 59 | } else { 60 | this.printError(`\nError: ${error}\n`, true) 61 | } 62 | } 63 | 64 | getColoredMachineName = (name: string): string => chalk.greenBright(name) 65 | 66 | getColoredMachineUrl = (url: string): string => chalk.bold.yellow(url) 67 | 68 | getColoredAppName = (name: string): string => chalk.magenta(name) 69 | 70 | getColoredMachine = (machine: IMachine): string => 71 | `${this.getColoredMachineName( 72 | machine.name 73 | )} at ${this.getColoredMachineUrl(machine.baseUrl)}` 74 | 75 | displayColoredMachine = (machine: IMachine) => 76 | console.log(`>> ${this.getColoredMachine(machine)}`) 77 | } 78 | 79 | export default new StdOutUtils() 80 | -------------------------------------------------------------------------------- /src/utils/StorageHelper.ts: -------------------------------------------------------------------------------- 1 | import { IMachine, IDeployedDirectory } from '../models/storage/StoredObjects' 2 | import ConfigStore from 'configstore' 3 | import Utils from './Utils' 4 | 5 | const CAP_MACHINES = 'CapMachines' 6 | const DEPLOYED_DIRS = 'DeployedDirs' 7 | 8 | export default class StorageHelper { 9 | static instance: StorageHelper 10 | 11 | static get() { 12 | if (!StorageHelper.instance) { 13 | StorageHelper.instance = new StorageHelper() 14 | } 15 | return StorageHelper.instance 16 | } 17 | 18 | private data: ConfigStore 19 | 20 | constructor() { 21 | this.data = new ConfigStore('caprover') 22 | } 23 | 24 | getMachines(): IMachine[] { 25 | return Utils.copyObject(this.data.get(CAP_MACHINES) || []) 26 | } 27 | 28 | findMachine(machineName: string) { 29 | return this.getMachines().find((m) => m.name === machineName) 30 | } 31 | 32 | removeMachine(machineName: string) { 33 | const machines = this.getMachines() 34 | const removedMachine = machines.filter( 35 | (machine) => machine.name === machineName 36 | )[0] 37 | const newMachines = machines.filter( 38 | (machine) => machine.name !== machineName 39 | ) 40 | this.data.set(CAP_MACHINES, newMachines) 41 | 42 | return removedMachine 43 | } 44 | 45 | saveMachine(machineToSaveOrUpdate: IMachine) { 46 | const currMachines = this.getMachines() 47 | let updatedMachine = false 48 | for (let index = 0; index < currMachines.length; index++) { 49 | const element = currMachines[index] 50 | if (element.name === machineToSaveOrUpdate.name) { 51 | updatedMachine = true 52 | currMachines[index] = machineToSaveOrUpdate 53 | break 54 | } 55 | } 56 | 57 | if (!updatedMachine) { 58 | currMachines.push(machineToSaveOrUpdate) 59 | } 60 | 61 | this.data.set(CAP_MACHINES, currMachines) 62 | } 63 | 64 | getDeployedDirectories(): IDeployedDirectory[] { 65 | return Utils.copyObject(this.data.get(DEPLOYED_DIRS) || []) 66 | } 67 | 68 | saveDeployedDirectory(directoryToSaveOrUpdate: IDeployedDirectory) { 69 | if ( 70 | !directoryToSaveOrUpdate || 71 | !directoryToSaveOrUpdate.appName || 72 | !directoryToSaveOrUpdate.cwd || 73 | !directoryToSaveOrUpdate.machineNameToDeploy 74 | ) { 75 | return 76 | } 77 | 78 | const currDirs = this.getDeployedDirectories() 79 | let updatedDir = false 80 | for (let index = 0; index < currDirs.length; index++) { 81 | const element = currDirs[index] 82 | if (element.cwd === directoryToSaveOrUpdate.cwd) { 83 | updatedDir = true 84 | currDirs[index] = directoryToSaveOrUpdate 85 | break 86 | } 87 | } 88 | 89 | if (!updatedDir) { 90 | currDirs.push(directoryToSaveOrUpdate) 91 | } 92 | 93 | this.data.set(DEPLOYED_DIRS, currDirs) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'url' 2 | import Constants from './Constants' 3 | 4 | const ADMIN_DOMAIN = Constants.ADMIN_DOMAIN 5 | 6 | const util = { 7 | extendCommonKeys( 8 | keys: T 9 | ): typeof Constants.COMMON_KEYS & T { 10 | return Object.assign({}, Constants.COMMON_KEYS, keys) 11 | }, 12 | 13 | copyObject(obj: T): T { 14 | return JSON.parse(JSON.stringify(obj)) as T 15 | }, 16 | 17 | generateUuidV4() { 18 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( 19 | /[xy]/g, 20 | function (c) { 21 | const r = (Math.random() * 16) | 0 22 | const v = c === 'x' ? r : (r & 0x3) | 0x8 23 | return v.toString(16) 24 | } 25 | ) 26 | }, 27 | 28 | getAnsiColorRegex() { 29 | const pattern = [ 30 | '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)', 31 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))' 32 | ].join('|') 33 | 34 | return new RegExp(pattern, 'g') 35 | }, 36 | 37 | cleanDomain(urlInput: string): string | undefined { 38 | if (!urlInput || !urlInput.length) { 39 | return undefined 40 | } 41 | try { 42 | let u = url.parse(urlInput) 43 | if (!u.protocol) { 44 | u = url.parse(`//${urlInput}`, false, true) 45 | } 46 | return u.hostname || undefined 47 | } catch (e) { 48 | return undefined 49 | } 50 | }, 51 | 52 | cleanAdminDomainUrl(urlInput: string, https?: boolean): string | undefined { 53 | if (!urlInput || !urlInput.length) { 54 | return undefined 55 | } 56 | const http = urlInput.toLowerCase().startsWith('http://') // If no protocol, defaults to https 57 | let cleanedUrl = util.cleanDomain(urlInput) 58 | if (!cleanedUrl) { 59 | return undefined 60 | } 61 | if (!cleanedUrl.startsWith(`${ADMIN_DOMAIN}.`)) { 62 | cleanedUrl = `${ADMIN_DOMAIN}.${cleanedUrl}` 63 | } 64 | return ( 65 | (https || (https === undefined && !http) ? 'https://' : 'http://') + 66 | cleanedUrl 67 | ) 68 | }, 69 | 70 | isIpAddress(ipaddress: string): boolean { 71 | // tslint:disable-next-line: max-line-length 72 | return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test( 73 | ipaddress 74 | ) 75 | }, 76 | 77 | isValidEmail(email: string): boolean { 78 | // tslint:disable-next-line: max-line-length 79 | const re = 80 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 81 | return re.test(String(email).toLowerCase()) 82 | } 83 | } 84 | 85 | export default util 86 | -------------------------------------------------------------------------------- /src/utils/ValidationsHandler.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import { execSync } from 'child_process' 3 | import { sync as commandExistsSync } from 'command-exists' 4 | import StdOutUtil from './StdOutUtil' 5 | import StorageHelper from './StorageHelper' 6 | import Constants from './Constants' 7 | import Utils from './Utils' 8 | import { IAppDef } from '../models/AppDef' 9 | const isWindows = process.platform === 'win32' 10 | 11 | export function validateIsGitRepository() { 12 | if (!fs.pathExistsSync('./.git')) { 13 | StdOutUtil.printError( 14 | 'You are not in a git root directory: this command will only deploy the current directory.\n' + 15 | 'Run "caprover deploy --help" to know more deployment options... (e.g. tar file or image name)\n', 16 | true 17 | ) 18 | } 19 | if (!commandExistsSync('git')) { 20 | StdOutUtil.printError( 21 | '"git" command not found: CapRover needs "git" to create tar file from your branch source files.\n' + 22 | 'Run "caprover deploy --help" to know more deployment options... (e.g. tar file or image name)\n', 23 | true 24 | ) 25 | } 26 | } 27 | 28 | export function validateDefinitionFile() { 29 | if (!fs.pathExistsSync('./captain-definition')) { 30 | if (fs.pathExistsSync('./Dockerfile')) { 31 | StdOutUtil.printWarning('**** Warning ****') 32 | StdOutUtil.printMessage( 33 | 'No captain-definition was found in main directory: falling back to Dockerfile.\n' 34 | ) 35 | } else { 36 | StdOutUtil.printWarning('**** Warning ****') 37 | StdOutUtil.printMessage( 38 | 'No captain-definition was found in main directory: unless you have specified a special path for your captain-definition, this build will fail!\n' 39 | ) 40 | } 41 | } else { 42 | let content = null 43 | try { 44 | content = JSON.parse( 45 | fs.readFileSync('./captain-definition', 'utf8') 46 | ) 47 | } catch (e) { 48 | StdOutUtil.printError( 49 | `captain-definition file is not a valid JSON!\n${ 50 | e.message || e 51 | }\n`, 52 | true 53 | ) 54 | } 55 | if (!content || !content.schemaVersion) { 56 | StdOutUtil.printError( 57 | 'captain-definition needs "schemaVersion": please see docs!\n', 58 | true 59 | ) 60 | } 61 | } 62 | } 63 | 64 | export function isNameValid(value: string): boolean { 65 | return !!(value && value.match(/^[-\d\w]+$/i) && !value.includes('--')) 66 | } 67 | 68 | export function getErrorForIP(value: string): true | string { 69 | value = value.trim() 70 | if (value === Constants.SAMPLE_IP) { 71 | return 'Enter a valid IP.' 72 | } 73 | if (!Utils.isIpAddress(value)) { 74 | return `This is an invalid IP: ${value}.` 75 | } 76 | return true 77 | } 78 | 79 | export function getErrorForDomain( 80 | value: string, 81 | skipAlreadyStored?: boolean 82 | ): true | string { 83 | if (value === Constants.SAMPLE_DOMAIN) { 84 | return 'Enter a valid URL.' 85 | } 86 | const cleaned = Utils.cleanAdminDomainUrl(value) 87 | if (!cleaned) { 88 | return `This is an invalid URL: ${StdOutUtil.getColoredMachineUrl( 89 | value 90 | )}.` 91 | } 92 | if (!skipAlreadyStored) { 93 | const found = StorageHelper.get() 94 | .getMachines() 95 | .find( 96 | (machine) => 97 | Utils.cleanAdminDomainUrl(machine.baseUrl) === cleaned 98 | ) 99 | if (found) { 100 | return `${StdOutUtil.getColoredMachineUrl( 101 | cleaned 102 | )} already exist as ${StdOutUtil.getColoredMachineName( 103 | found.name 104 | )} in your currently logged in machines. If you want to replace the existing entry, you have to first use command, and then re-login.` 105 | } 106 | } 107 | return true 108 | } 109 | 110 | export function getErrorForPassword( 111 | value: string, 112 | constraint?: number | string 113 | ): true | string { 114 | if (!value || !value.trim()) { 115 | return 'Please enter password.' 116 | } 117 | if (typeof constraint === 'number' && value.length < constraint) { 118 | return `Password is too short, min ${constraint} characters.` 119 | } 120 | if (typeof constraint === 'string' && value !== constraint) { 121 | return `Passwords do not match.` 122 | } 123 | return true 124 | } 125 | 126 | export function getErrorForMachineName( 127 | value: string, 128 | checkExisting?: boolean 129 | ): true | string { 130 | value = value.trim() 131 | const exist: boolean = StorageHelper.get().findMachine(value) ? true : false 132 | if (exist && !checkExisting) { 133 | return `${StdOutUtil.getColoredMachineName( 134 | value 135 | )} already exist. If you want to replace the existing entry, you have to first use command, and then re-login.` 136 | } 137 | if (checkExisting && !exist) { 138 | return `${StdOutUtil.getColoredMachineName( 139 | value 140 | )} CapRover machine does not exist.` 141 | } 142 | if (checkExisting || isNameValid(value)) { 143 | return true 144 | } 145 | return 'Please enter a valid CapRover machine name: small letters, numbers, single hyphen.' 146 | } 147 | 148 | export function getErrorForAppName( 149 | apps: IAppDef[], 150 | value: string 151 | ): true | string { 152 | value = value.trim() 153 | const app = apps.find((a) => a.appName === value) 154 | if (!app) { 155 | return `${StdOutUtil.getColoredAppName( 156 | value 157 | )} app does not exist on this CapRover machine.` 158 | } 159 | if (app.isAppBuilding) { 160 | return `${StdOutUtil.getColoredAppName( 161 | value 162 | )} app is currently in a building process.` 163 | } 164 | return true 165 | } 166 | 167 | export function getErrorForBranchName(value: string): true | string { 168 | if (!value || !value.trim()) { 169 | return 'Please enter branch name.' 170 | } 171 | value = value.trim() 172 | try { 173 | const cmd = isWindows 174 | ? execSync(`git rev-parse ${value} > NUL`) 175 | : execSync(`git rev-parse ${value} 2>/dev/null`) 176 | if (cmd) { 177 | return true 178 | } 179 | } catch (e) { 180 | // Do nothing 181 | } 182 | return `Cannot find hash of last commit on branch "${value}".` 183 | } 184 | 185 | export function getErrorForEmail(value: string): true | string { 186 | if (!value || !value.trim()) { 187 | return 'Please enter email.' 188 | } 189 | if (!Utils.isValidEmail(value)) { 190 | return 'Please enter a valid email.' 191 | } 192 | return true 193 | } 194 | 195 | export function userCancelOperation(cancel: boolean, c?: boolean): boolean { 196 | if (cancel) { 197 | StdOutUtil.printMessage( 198 | (c ? '\n' : '') + 199 | '\nOperation cancelled by the user!' + 200 | (!c ? '\n' : ''), 201 | true 202 | ) 203 | } 204 | return false 205 | } 206 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "strictNullChecks": true, 6 | "outDir": "./built", 7 | "noImplicitAny": true, 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "target": "es6", 11 | "skipLibCheck": true 12 | 13 | }, 14 | "include": [ 15 | "./src/**/*" 16 | ], 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [false], 9 | "member-access": [true, "no-public"], 10 | "ordered-imports": [false], 11 | "member-ordering": [false], 12 | "interface-name": [false], 13 | "arrow-parens": false, 14 | "object-literal-sort-keys": false, 15 | "only-arrow-functions": false, 16 | "space-before-function-paren": [false], 17 | "semicolon": false, 18 | "trailing-comma": [ 19 | true, 20 | { "multiline": "never", "singleline": "never" } 21 | ], 22 | "no-bitwise": false, 23 | "no-console": [true, "error"], 24 | "max-line-length": [ 25 | true, 26 | { 27 | "limit": 170, 28 | "ignore-pattern": "^// {(.*?)}" 29 | } 30 | ] 31 | }, 32 | "rulesDirectory": [] 33 | } 34 | --------------------------------------------------------------------------------