From cd9b0ae373c1c591b644a368a52534e2844957ca Mon Sep 17 00:00:00 2001 From: Aman Kumar Date: Tue, 7 Jan 2025 10:03:10 +0530 Subject: [PATCH 1/4] launch latest code --- .eslintignore | 1 + .eslintrc | 6 + .github/workflows/jira.yml | 33 + .github/workflows/release.yml | 64 ++ .github/workflows/sast-scan.yml | 11 + .github/workflows/sca-scan.yml | 15 + .gitignore | 19 + .mocharc.json | 12 + LICENSE | 21 + README.md | 42 +- SECURITY.md | 27 + bin/dev | 17 + bin/dev.cmd | 3 + bin/run | 5 + bin/run.cmd | 3 + example.env | 3 + examples/create-launch-project.json | 9 + package.json | 116 +++ src/adapters/base-class.ts | 794 ++++++++++++++++++ src/adapters/file-upload.ts | 343 ++++++++ src/adapters/github.ts | 287 +++++++ src/adapters/index.ts | 6 + src/adapters/pre-check.ts | 156 ++++ src/base-command.ts | 207 +++++ src/commands/launch/deployments.ts | 145 ++++ src/commands/launch/environments.ts | 157 ++++ src/commands/launch/functions.ts | 33 + src/commands/launch/index.ts | 140 +++ src/commands/launch/logs.ts | 243 ++++++ src/commands/launch/open.ts | 161 ++++ src/config/index.ts | 45 + src/graphql/index.ts | 2 + src/graphql/mutation.ts | 83 ++ src/graphql/queries.ts | 190 +++++ src/index.ts | 1 + src/types/index.ts | 2 + src/types/launch.ts | 76 ++ src/types/utils.ts | 39 + src/util/apollo-client.ts | 230 +++++ .../cloud-functions-validator.ts | 85 ++ src/util/cloud-function/cloud-functions.ts | 194 +++++ src/util/cloud-function/constants.ts | 3 + src/util/cloud-function/contentfly.ts | 22 + .../errors/cloud-function.errors.ts | 45 + src/util/cloud-function/index.ts | 2 + src/util/cloud-function/os-helper.ts | 23 + src/util/cloud-function/types.ts | 16 + src/util/common-utility.ts | 154 ++++ src/util/create-git-meta.ts | 43 + src/util/fs.ts | 30 + src/util/index.ts | 8 + src/util/log.ts | 187 +++++ src/util/logs-polling-utilities.ts | 242 ++++++ test/helpers/init.js | 6 + test/tsconfig.json | 9 + test/unit/adapters/file-upload.test.ts | 255 ++++++ test/unit/adapters/github.test.ts | 254 ++++++ test/unit/commands/deployments.test.ts | 74 ++ test/unit/commands/environments.test.ts | 66 ++ test/unit/commands/functions.test.ts | 48 ++ test/unit/commands/launch.test.ts | 71 ++ test/unit/commands/log.test.ts | 335 ++++++++ test/unit/commands/open.test.ts | 86 ++ test/unit/mock/index.ts | 55 ++ test/unit/util/log.test.ts | 35 + test/unit/util/logs-polling-utilities.test.ts | 192 +++++ test/unit/z-cleanup.test.ts | 3 + tsconfig.json | 16 + 68 files changed, 6305 insertions(+), 1 deletion(-) create mode 100755 .eslintignore create mode 100755 .eslintrc create mode 100644 .github/workflows/jira.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/sast-scan.yml create mode 100644 .github/workflows/sca-scan.yml create mode 100755 .gitignore create mode 100755 .mocharc.json create mode 100755 LICENSE create mode 100755 SECURITY.md create mode 100755 bin/dev create mode 100644 bin/dev.cmd create mode 100755 bin/run create mode 100644 bin/run.cmd create mode 100644 example.env create mode 100644 examples/create-launch-project.json create mode 100755 package.json create mode 100755 src/adapters/base-class.ts create mode 100755 src/adapters/file-upload.ts create mode 100755 src/adapters/github.ts create mode 100755 src/adapters/index.ts create mode 100755 src/adapters/pre-check.ts create mode 100755 src/base-command.ts create mode 100755 src/commands/launch/deployments.ts create mode 100755 src/commands/launch/environments.ts create mode 100755 src/commands/launch/functions.ts create mode 100755 src/commands/launch/index.ts create mode 100755 src/commands/launch/logs.ts create mode 100755 src/commands/launch/open.ts create mode 100755 src/config/index.ts create mode 100755 src/graphql/index.ts create mode 100755 src/graphql/mutation.ts create mode 100755 src/graphql/queries.ts create mode 100755 src/index.ts create mode 100755 src/types/index.ts create mode 100755 src/types/launch.ts create mode 100755 src/types/utils.ts create mode 100755 src/util/apollo-client.ts create mode 100755 src/util/cloud-function/cloud-functions-validator.ts create mode 100755 src/util/cloud-function/cloud-functions.ts create mode 100755 src/util/cloud-function/constants.ts create mode 100755 src/util/cloud-function/contentfly.ts create mode 100755 src/util/cloud-function/errors/cloud-function.errors.ts create mode 100755 src/util/cloud-function/index.ts create mode 100755 src/util/cloud-function/os-helper.ts create mode 100755 src/util/cloud-function/types.ts create mode 100644 src/util/common-utility.ts create mode 100755 src/util/create-git-meta.ts create mode 100755 src/util/fs.ts create mode 100755 src/util/index.ts create mode 100755 src/util/log.ts create mode 100755 src/util/logs-polling-utilities.ts create mode 100755 test/helpers/init.js create mode 100755 test/tsconfig.json create mode 100644 test/unit/adapters/file-upload.test.ts create mode 100644 test/unit/adapters/github.test.ts create mode 100644 test/unit/commands/deployments.test.ts create mode 100644 test/unit/commands/environments.test.ts create mode 100644 test/unit/commands/functions.test.ts create mode 100644 test/unit/commands/launch.test.ts create mode 100644 test/unit/commands/log.test.ts create mode 100644 test/unit/commands/open.test.ts create mode 100644 test/unit/mock/index.ts create mode 100644 test/unit/util/log.test.ts create mode 100644 test/unit/util/logs-polling-utilities.test.ts create mode 100644 test/unit/z-cleanup.test.ts create mode 100755 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100755 index 00000000..9b1c8b1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +/dist diff --git a/.eslintrc b/.eslintrc new file mode 100755 index 00000000..7b84619 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": [ + "oclif", + "oclif-typescript" + ] +} diff --git a/.github/workflows/jira.yml b/.github/workflows/jira.yml new file mode 100644 index 00000000..caa4bbd --- /dev/null +++ b/.github/workflows/jira.yml @@ -0,0 +1,33 @@ +name: Create JIRA ISSUE +on: + pull_request: + types: [opened] +jobs: + security-jira: + if: ${{ github.actor == 'dependabot[bot]' || github.actor == 'snyk-bot' || contains(github.event.pull_request.head.ref, 'snyk-fix-') || contains(github.event.pull_request.head.ref, 'snyk-upgrade-')}} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Login into JIRA + uses: atlassian/gajira-login@master + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + - name: Create a JIRA Issue + id: create + uses: atlassian/gajira-create@master + with: + project: ${{ secrets.JIRA_PROJECT }} + issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} + summary: | + ${{ github.event.pull_request.title }} + description: | + PR: ${{ github.event.pull_request.html_url }} + + fields: "${{ secrets.JIRA_FIELDS }}" + - name: Transition issue + uses: atlassian/gajira-transition@v3 + with: + issue: ${{ steps.create.outputs.issue }} + transition: ${{ secrets.JIRA_TRANSITION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..252669e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release +on: + push: + branches: + - main +jobs: + build: + name: Build and upload + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v3.7.0 + with: + node-version: "18.x" + - name: Installing dependencies + run: npm install + - name: Build + run: npm run prepack + - name: Upload dist + uses: actions/upload-artifact@v3.1.2 + with: + name: lib + path: lib + + release: + name: Download dist and release + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v3.7.0 + with: + node-version: "18.x" + - name: Installing dependencies + run: npm install + - name: Download dist + uses: actions/download-artifact@v3 + with: + name: lib + path: lib + - name: Display dirs + run: ls -R + - name: Release + id: release-plugin + uses: JS-DevTools/npm-publish@v2.2.0 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + - name: get-npm-version + id: package-version + uses: martinbeentjes/npm-get-version-action@v1.3.1 + - name: github-release + id: github-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create v${{ steps.release-plugin.outputs.version }} --title "Release ${{ steps.release-plugin.outputs.version }}" --generate-notes \ No newline at end of file diff --git a/.github/workflows/sast-scan.yml b/.github/workflows/sast-scan.yml new file mode 100644 index 00000000..3b9521a --- /dev/null +++ b/.github/workflows/sast-scan.yml @@ -0,0 +1,11 @@ +name: SAST Scan +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-sast: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Semgrep Scan + run: docker run -v /var/run/docker.sock:/var/run/docker.sock -v "${PWD}:/src" returntocorp/semgrep semgrep scan --config auto \ No newline at end of file diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml new file mode 100644 index 00000000..22e7229 --- /dev/null +++ b/.github/workflows/sca-scan.yml @@ -0,0 +1,15 @@ +name: Source Composition Analysis Scan +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-sca: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --all-projects --fail-on=all --strict-out-of-sync=false diff --git a/.gitignore b/.gitignore new file mode 100755 index 00000000..968db68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*-debug.log +*-error.log +/.nyc_output +/dist +/lib +/package-lock.json +/tmp +/yarn.lock +node_modules +oclif.manifest.json +.env +*.log +tsconfig.tsbuildinfo +dependabot.yml +.vscode +*.todo +/bkp +.editorconfig +/functions diff --git a/.mocharc.json b/.mocharc.json new file mode 100755 index 00000000..4a09d14 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,12 @@ +{ + "require": [ + "test/helpers/init.js", + "ts-node/register" + ], + "watch-extensions": [ + "ts" + ], + "recursive": true, + "reporter": "spec", + "timeout": 60000 +} diff --git a/LICENSE b/LICENSE new file mode 100755 index 00000000..816ce04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Contentstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index f030ed7..098a352 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ -# launch-cli \ No newline at end of file +# Launch CLI plugin + +[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) + +With Launch CLI, you can interact with the Contentstack Launch platform using the terminal to create, manage and deploy Launch projects. + + +* [Launch CLI plugin](#launch-cli-plugin) +* [Usage](#usage) +* [Installation steps](#installation-steps) +* [Commands](#commands) + + +# Installation steps + +```sh-session +GitHub installation steps: +$ git clone clone +$ npm install +$ npm run build +$ csdx plugins:link + +NPM installation steps: +$ csdx plugins:install @contentstack/cli-launch +$ csdx launch +``` + +# Commands + +```sh-session +$ csdx launch +start with launch flow +$ csdx launch:logs +To see server logs +$ csdx launch:logs --type d +To see deployment logs +$ csdx launch:functions +Run cloud functions locally +``` + + \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100755 index 00000000..b33a46b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +## Security + +Contentstack takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations. + +If you believe you have found a security vulnerability in any Contentstack-owned repository, please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Send email to [security@contentstack.com](mailto:security@contentstack.com). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + +- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +[https://www.contentstack.com/trust/](https://www.contentstack.com/trust/) \ No newline at end of file diff --git a/bin/dev b/bin/dev new file mode 100755 index 00000000..c29a7dc --- /dev/null +++ b/bin/dev @@ -0,0 +1,17 @@ +#!/usr/bin/env node +require("dotenv").config(); +const oclif = require('@oclif/core') + +const path = require('path') +const project = path.join(__dirname, '..', 'tsconfig.json') + +// In dev mode -> use ts-node and dev plugins +process.env.NODE_ENV = 'development' + +require("ts-node").register({ project }); + +// In dev mode, always show stack traces +oclif.settings.debug = true; + +// Start the CLI +oclif.run().then(oclif.flush).catch(oclif.Errors.handle) diff --git a/bin/dev.cmd b/bin/dev.cmd new file mode 100644 index 00000000..077b57a --- /dev/null +++ b/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\dev" %* \ No newline at end of file diff --git a/bin/run b/bin/run new file mode 100755 index 00000000..a7635de --- /dev/null +++ b/bin/run @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) diff --git a/bin/run.cmd b/bin/run.cmd new file mode 100644 index 00000000..968fc30 --- /dev/null +++ b/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/example.env b/example.env new file mode 100644 index 00000000..7e4000d --- /dev/null +++ b/example.env @@ -0,0 +1,3 @@ +ENVIRONMENT= +ORG= +PROJECT= diff --git a/examples/create-launch-project.json b/examples/create-launch-project.json new file mode 100644 index 00000000..ca12e2a --- /dev/null +++ b/examples/create-launch-project.json @@ -0,0 +1,9 @@ +{ + "name": "app name", + "type": "GitHub", + "environment": "Default", + "framework": "NextJs", + "build-command": "npm run build", + "out-dir": "./.next", + "branch": "master" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100755 index 00000000..ce1e759 --- /dev/null +++ b/package.json @@ -0,0 +1,116 @@ +{ + "name": "@contentstack/cli-launch", + "version": "1.3.1", + "description": "Launch related operations", + "author": "Contentstack CLI", + "bin": { + "launch": "./bin/run.js" + }, + "homepage": "https://github.com/contentstack/launch-cli", + "license": "MIT", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/contentstack/launch-cli.git" + }, + "files": [ + "/bin", + "/dist", + "/npm-shrinkwrap.json", + "/oclif.manifest.json" + ], + "dependencies": { + "@apollo/client": "^3.11.8", + "@contentstack/cli-command": "~1.3.2", + "@contentstack/cli-utilities": "~1.8.0", + "@oclif/core": "^3.27.0", + "@oclif/plugin-help": "^5.2.20", + "@oclif/plugin-plugins": "^5.4.15", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.34", + "adm-zip": "^0.5.16", + "chalk": "^4.1.2", + "cross-fetch": "^3.1.8", + "dotenv": "^16.4.7", + "esm": "^3.2.25", + "express": "^4.21.1", + "form-data": "^4.0.0", + "graphql": "^16.9.0", + "ini": "^3.0.1", + "lodash": "^4.17.21", + "open": "^8.4.2", + "winston": "^3.17.0" + }, + "devDependencies": { + "@oclif/test": "^4.1.3", + "@types/adm-zip": "^0.5.7", + "@types/chai": "^4.3.20", + "@types/esm": "^3.2.2", + "@types/ini": "^1.3.34", + "@types/lodash": "^4.17.13", + "@types/mocha": "^10.0.10", + "@types/node": "^16.18.121", + "@types/sinon": "^17.0.3", + "chai": "^4.5.0", + "eslint": "^7.32.0", + "eslint-config-oclif": "^4", + "eslint-config-oclif-typescript": "^3.1.13", + "mocha": "^11.0.1", + "nyc": "^17.1.0", + "oclif": "^3.17.2", + "shx": "^0.3.4", + "sinon": "^19.0.2", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^4.9.5" + }, + "oclif": { + "bin": "csdx", + "commands": "./dist/commands", + "plugins": [ + "@oclif/plugin-help", + "@oclif/plugin-plugins" + ], + "topicSeparator": ":", + "additionalHelpFlags": [ + "-h" + ], + "additionalVersionFlags": [ + "-v" + ], + "repositoryPrefix": "<%- repo %>/blob/main/packages/contentstack-launch/<%- commandPath %>" + }, + "scripts": { + "build-bkp": "shx rm -rf dist && tsc -b", + "lint": "eslint . --ext .ts --config .eslintrc", + "postpack": "shx rm -f oclif.manifest.json", + "posttest": "npm run lint", + "prepack-bkp": "npm run build && oclif manifest && oclif readme", + "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "version": "oclif readme && git add README.md", + "build": "npm run clean && npm run compile", + "clean": "rm -rf ./dist ./node_modules tsconfig.build.tsbuildinfo", + "compile": "tsc -b tsconfig.json", + "prepack": "pnpm compile && oclif manifest && oclif readme", + "test:unit": "mocha --forbid-only \"test/unit/**/*.test.ts\"", + "test:unit:report": "nyc --extension .ts mocha --forbid-only \"test/unit/**/*.test.ts\"" + }, + "engines": { + "node": ">=14.0.0" + }, + "bugs": "https://github.com/contentstack/launch-cli/issues", + "keywords": [ + "oclif" + ], + "types": "dist/index.d.ts", + "csdxConfig": { + "shortCommandName": { + "launch": "LNCH", + "launch:logs": "LNCH-LGS", + "launch:open": "LNCH-OPN", + "launch:functions": "LNCH-FN", + "launch:environments": "LNCH-ENV", + "launch:deployments": "LNCH-DPLMNT" + } + } +} diff --git a/src/adapters/base-class.ts b/src/adapters/base-class.ts new file mode 100755 index 00000000..b58f571 --- /dev/null +++ b/src/adapters/base-class.ts @@ -0,0 +1,794 @@ +import open from 'open'; +import dotEnv from 'dotenv'; +import map from 'lodash/map'; +import keys from 'lodash/keys'; +import find from 'lodash/find'; +import last from 'lodash/last'; +import merge from 'lodash/merge'; +import first from 'lodash/first'; +import split from 'lodash/split'; +import EventEmitter from 'events'; +import filter from 'lodash/filter'; +import replace from 'lodash/replace'; +import forEach from 'lodash/forEach'; +import isEmpty from 'lodash/isEmpty'; +import includes from 'lodash/includes'; +import cloneDeep from 'lodash/cloneDeep'; +import { ApolloClient } from '@apollo/client/core'; +import { writeFileSync, existsSync, readFileSync } from 'fs'; +import { cliux as ux, ContentstackClient } from '@contentstack/cli-utilities'; + +import config from '../config'; +import { print, GraphqlApiClient, LogPolling, getOrganizations } from '../util'; +import { + branchesQuery, + frameworkQuery, + fileFrameworkQuery, + createDeploymentMutation, + cmsEnvironmentVariablesQuery, +} from '../graphql'; +import { + LogFn, + ExitFn, + Providers, + ConfigType, + AdapterConstructorInputs, + EmitMessage, + DeploymentLogResp, + ServerLogResp, +} from '../types'; + +export default class BaseClass { + public log: LogFn; + public exit: ExitFn; + public config: ConfigType; + public analyticsInfo: string; + public $event!: EventEmitter; + public framework!: Record; + public apolloClient: ApolloClient; + public projectCreationRetryCount: number = 0; + public apolloLogsClient: ApolloClient | undefined; + public envVariables: Array> = []; + public managementSdk: ContentstackClient | undefined; + + constructor(options: AdapterConstructorInputs) { + const { log, exit, config, $event, apolloClient, managementSdk, analyticsInfo, apolloLogsClient } = options; + this.config = config; + this.$event = $event; + this.log = log || console.log; + this.apolloClient = apolloClient; + this.analyticsInfo = analyticsInfo; + this.managementSdk = managementSdk; + this.apolloLogsClient = apolloLogsClient; + this.exit = exit || ((code: number = 0) => process.exit(code)); + } + + /** + * @method initApolloClient - initialize Apollo client + * + * @memberof BaseClass + */ + async initApolloClient(): Promise { + this.apolloClient = await new GraphqlApiClient({ + headers: { + 'X-CS-CLI': this.analyticsInfo, + 'x-project-uid': this.config.currentConfig.uid, + organization_uid: this.config.currentConfig.organizationUid, + }, + baseUrl: this.config.manageApiBaseUrl, + }).apolloClient; + } + + /** + * @method createNewDeployment - Create new deployment on existing launch project + * + * @return {*} {Promise} + * @memberof GitHub + */ + async createNewDeployment(skipGitData = false, uploadUid?: string): Promise { + const deployment: Record = { + environment: (first(this.config.currentConfig.environments) as Record)?.uid, + }; + + if (uploadUid) { + deployment.uploadUid = uploadUid; + } + + await this.apolloClient + .mutate({ + mutation: createDeploymentMutation, + variables: { deployment, skipGitData }, + }) + .then(({ data: { deployment } }) => { + this.log('Deployment process started.!', 'info'); + this.config.currentConfig.deployments.push(deployment); + }) + .catch((error) => { + this.log('Deployment process failed.!', 'error'); + this.log(error, 'error'); + this.exit(1); + }); + } + + /** + * @method selectOrg - select organization + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async selectOrg(): Promise { + const organizations = + (await getOrganizations({ log: this.log, managementSdk: this.managementSdk as ContentstackClient })) || []; + + const selectedOrgUid = this.config.flags.org; + + if (selectedOrgUid) { + const orgExists = find(organizations, { uid: selectedOrgUid }); + if (orgExists) { + this.config.currentConfig.organizationUid = selectedOrgUid; + } else { + this.log('Organization UID not found!', 'error'); + this.exit(1); + } + } else { + this.config.currentConfig.organizationUid = await ux + .inquire({ + type: 'search-list', + name: 'Organization', + choices: organizations, + message: 'Choose an organization', + }) + .then((name) => (find(organizations, { name }) as Record)?.uid); + } + + // NOTE re initialize apollo client once org selected + await this.initApolloClient(); + } + + /** + * @method selectProjectType - select project type/provider/adapter + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async selectProjectType(): Promise { + const choices = [ + ...map(config.supportedAdapters, (provider) => ({ + value: provider, + name: `Continue with ${provider}`, + })), + { value: 'FileUpload', name: 'Continue with FileUpload' }, + ]; + + const selectedProvider: Providers = await ux.inquire({ + choices: choices, + type: 'search-list', + name: 'projectType', + message: 'Choose a project type to proceed', + }); + + this.config.provider = selectedProvider; + } + + /** + * @method detectFramework - detect the project framework + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async detectFramework(): Promise { + const { fullName, defaultBranch } = this.config.repository || {}; + const query = this.config.provider === 'FileUpload' ? fileFrameworkQuery : frameworkQuery; + const variables = + this.config.provider === 'FileUpload' + ? { + query: { uploadUid: this.config.uploadUid }, + } + : { + query: { + provider: this.config.provider, + repoName: fullName, + branchName: defaultBranch, + }, + }; + this.config.framework = (await this.apolloClient + .query({ query, variables }) + .then( + ({ + data: { + framework: { framework }, + }, + }) => framework, + ) + .catch((_error) => { + // this.log("Something went wrong. Please try again.", "warn"); + // this.log(error, "error"); + // this.exit(1); + })) || { + envVariables: '', + }; + + const choices = []; + const framework = find(this.config.listOfFrameWorks, ({ value }) => value === this.config.framework)?.name; + + if (framework) { + choices.push({ + name: framework, + value: this.config.framework, + }); + } + + choices.push(...filter(this.config.listOfFrameWorks, ({ value }) => value !== this.config.framework)); + + this.config.framework = await ux.inquire({ + choices, + type: 'search-list', + name: 'frameworkPreset', + message: 'Framework Preset', + default: this.config.framework, + }); + } + + /** + * @method getCmsEnvironmentVariables - get list of environment variables + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async getCmsEnvironmentVariables(): Promise { + this.envVariables = (await this.apolloClient + .query({ query: cmsEnvironmentVariablesQuery }) + .then(({ data: { envVariables } }) => envVariables) + .catch((error) => this.log(error, 'error'))) || { + envVariables: undefined, + }; + } + + /** + * @method selectStack - Select stack to import variables, tokens + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async selectStack(): Promise { + const listOfStacks = + (await this.managementSdk + ?.stack() + .query({ organization_uid: this.config.currentConfig.organizationUid }) + .find() + .then(({ items }) => map(items, ({ name, api_key }) => ({ name, value: name, api_key }))) + .catch((error) => { + this.log('Unable to fetch stacks.!', { color: 'yellow' }); + this.log(error, 'error'); + this.exit(1); + })) || []; + + if (this.config.selectedStack) { + this.config.selectedStack = find(listOfStacks, { api_key: this.config.selectedStack }); + } else { + this.config.selectedStack = await ux + .inquire({ + name: 'stack', + type: 'search-list', + choices: listOfStacks, + message: 'Stack', + }) + .then((name) => find(listOfStacks, { name })); + } + } + + /** + * @method selectDeliveryToken - Select delivery token from a stack + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async selectDeliveryToken(): Promise { + const listOfDeliveryTokens = + (await this.managementSdk + ?.stack({ api_key: this.config.selectedStack.api_key }) + .deliveryToken() + .query() + .find() + .then(({ items }) => + map(items, ({ name, token, scope }) => ({ + name, + token, + scope, + value: name, + })), + ) + .catch((error) => { + this.log('Unable to fetch the delivery token!', 'warn'); + this.log(error, 'error'); + this.exit(1); + })) || []; + + if (this.config.deliveryToken) { + this.config.deliveryToken = find(listOfDeliveryTokens, { token: this.config.deliveryToken }); + } else { + this.config.deliveryToken = await ux + .inquire({ + type: 'search-list', + name: 'deliveryToken', + choices: listOfDeliveryTokens, + message: 'Delivery token', + }) + .then((name) => find(listOfDeliveryTokens, { name }) as Record); + } + this.config.environment = this.config.deliveryToken?.scope[0]?.environments[0]?.name; + } + + /** + * @method promptForEnvValues - Prompt and get manual entry of environment variables + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async promptForEnvValues(): Promise { + let addNew = true; + const envVariables = []; + + if (!this.config.envVariables) { + do { + const variable = await ux + .inquire({ + type: 'input', + name: 'variable', + message: + 'Enter key and value with a colon between them, and use a comma(,) for the key-value pair. Format: :, : Ex: APP_ENV:prod, TEST_ENV:testVal', + }) + .then((variable) => { + return map(split(variable as string, ','), (variable) => { + let [key, value] = split(variable as string, ':'); + value = (value || '').trim(); + key = (key || '').trim(); + + return { key, value }; + }).filter(({ key }) => key); + }); + + envVariables.push(...variable); + + if ( + !(await ux.inquire({ + type: 'confirm', + name: 'canImportFromStack', + message: 'Would you like to add more variables?', + })) + ) { + addNew = false; + } + } while (addNew); + + this.envVariables.push(...envVariables); + } else { + if (typeof this.config.envVariables === 'string') { + const variable = map(split(this.config.envVariables as string, ','), (variable) => { + let [key, value] = split(variable as string, ':'); + value = (value || '').trim(); + key = (key || '').trim(); + + return { key, value }; + }); + this.envVariables.push(...variable); + } + } + } + + /** + * @method prepareLaunchConfig - prepare and write launch config in to dist. + * + * @memberof BaseClass + */ + prepareLaunchConfig(): void { + let data: Record = {}; + + if (this.config.config && existsSync(this.config.config)) { + data = require(this.config.config); + } + + if (this.config.branch) { + data[this.config.branch] = this.config.currentConfig; + } else { + data.project = this.config.currentConfig; + } + + writeFileSync(`${this.config.projectBasePath}/${this.config.configName}`, JSON.stringify(data), { + encoding: 'utf8', + flag: 'w', + }); + } + + /** + * @method connectToAdapterOnUi - Open browser to connect with adapter with launch (GitHub etc.,) + * + * @param {boolean} [emit=true] + * @return {*} {Promise} + * @memberof BaseClass + */ + async connectToAdapterOnUi(emit = true): Promise { + await this.selectProjectType(); + + if (includes(this.config.supportedAdapters, this.config.provider)) { + const baseUrl = this.config.host.startsWith('http') ? this.config.host : `https://${this.config.host}`; + + const gitHubConnectUrl = `${baseUrl.replace('api', 'app').replace('io', 'com')}/#!/launch`; + this.log(`You can connect your ${this.config.provider} account to the UI using the following URL:`, 'info'); + this.log(gitHubConnectUrl, { color: 'green' }); + open(gitHubConnectUrl); + this.exit(1); + } else if (emit) { + this.$event.emit('provider-changed'); + } + } + + /** + * @method queryBranches - Query all paginated branches + * + * @param {Record} variables + * @param {any[]} [branchesRes=[]] + * @return {*} {Promise} + * @memberof BaseClass + */ + async queryBranches(variables: Record, branchesRes: any[] = []): Promise { + const branches = await this.apolloClient + .query({ + query: branchesQuery, + variables, + }) + .then(({ data: { branches } }) => branches) + .catch((error) => { + this.log('Something went wrong. Please try again.', 'warn'); + this.log(error, 'error'); + this.exit(1); + }); + + if (branches) { + branchesRes.push(...map(branches.edges, 'node')); + + if (branches.pageInfo.hasNextPage) { + variables.page = branches.pageData.page + 1; + return await this.queryBranches(variables, branchesRes); + } + } + + return branchesRes; + } + + /** + * @method selectBranch - Select a branch for launch process + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async selectBranch(): Promise { + const variables = { + page: 1, + first: 100, + query: { + provider: this.config.provider, + repoName: this.config.repository?.fullName, + }, + }; + + const branches: Record[] = await this.queryBranches(variables); + + if (branches && this.config.flags.branch && find(branches, { name: this.config.flags.branch })) { + this.config.branch = this.config.flags.branch as any; + } else { + if (this.config.flags.branch) { + this.log('Branch name not found!', 'warn'); + } + + this.config.branch = await ux.inquire({ + name: 'branch', + message: 'Branch', + type: 'search-list', + choices: map(branches, 'name'), + default: this.config.repository?.defaultBranch, + }); + } + } + + /** + * @method inquireRequireValidation - Required validation for prompt + * + * @param {*} input + * @return {*} {(string | boolean)} + * @memberof BaseClass + */ + inquireRequireValidation(input: any): string | boolean { + if (isEmpty(input)) { + return "This field can't be empty."; + } + + return true; + } + + /** + * @method handleEnvImportFlow - Manage variables flow whether to import or manual input. + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async handleEnvImportFlow(): Promise { + const variablePreparationType = + this.config.variableType || + (await ux.inquire({ + type: 'checkbox', + name: 'variablePreparationType', + default: this.config.framework, + choices: this.config.variablePreparationTypeOptions, + message: 'Import variables from a stack and/or manually add custom variables to the list', + // validate: this.inquireRequireValidation, + })); + + if (includes(variablePreparationType, 'Import variables from a stack')) { + await this.importEnvFromStack(); + } + if (includes(variablePreparationType, 'Manually add custom variables to the list')) { + await this.promptForEnvValues(); + } + if (includes(variablePreparationType, 'Import variables from the local env file')) { + await this.importVariableFromLocalConfig(); + } + + if (this.envVariables.length) { + this.printAllVariables(); + } else { + this.log('Please provide env file!', 'error'); + this.exit(1); + } + } + + /** + * @method importVariableFromLocalConfig - Import environment variable from local config + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async importVariableFromLocalConfig(): Promise { + const localEnv = + dotEnv.config({ + path: `${this.config.projectBasePath}/.env.local`, + }).parsed || + dotEnv.config({ + path: this.config.projectBasePath, + }).parsed; + + if (!isEmpty(localEnv)) { + let envKeys: Record = keys(localEnv); + const existingEnvKeys = map(this.envVariables, 'key'); + const localEnvData = map(envKeys, (key) => ({ + key, + value: localEnv[key], + })); + + if (find(existingEnvKeys, (key) => includes(envKeys, key))) { + this.log('Duplicate environment variable keys found.', 'warn'); + if ( + await ux.inquire({ + default: false, + type: 'confirm', + name: 'deployLatestSource', + message: 'Would you like to keep the local environment variables?', + }) + ) { + this.envVariables = merge(this.envVariables, localEnvData); + } else { + this.envVariables = merge(localEnvData, this.envVariables); + } + } else { + this.envVariables.push(...localEnvData); + } + } + } + + /** + * @method importEnvFromStack - Import environment variables from stack + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async importEnvFromStack(): Promise { + await this.selectStack(); + await this.selectDeliveryToken(); + print([ + { message: '?', color: 'green' }, + { message: 'Stack Environment', bold: true }, + { message: this.config.environment || '', color: 'cyan' }, + ]); + await this.getCmsEnvironmentVariables(); + + this.envVariables = map(cloneDeep(this.envVariables), (variable) => { + switch (variable.key) { + case 'CONTENTSTACK_API_HOST': + case 'CONTENTSTACK_CDN': + if (variable.value.startsWith('http')) { + const url = new URL(variable.value); + variable.value = url?.host || this.config.host; + } + break; + case 'CONTENTSTACK_ENVIRONMENT': + variable.value = this.config.environment; + break; + case 'CONTENTSTACK_API_KEY': + variable.value = this.config.selectedStack.api_key; + break; + case 'CONTENTSTACK_DELIVERY_TOKEN': + variable.value = this.config.deliveryToken?.token; + break; + } + + return variable; + }); + } + + /** + * @method printAllVariables - Print/Display all variables on ui + * + * @memberof BaseClass + */ + printAllVariables(): void { + ux.table( + [ + ...(this.config.flags['show-variables'] + ? this.envVariables + : this.envVariables.map(({ key, value }) => ({ + key, + value: replace(value, /./g, '*'), + }))), + { key: '', value: '' }, + ], + { + key: { + minWidth: 7, + }, + value: { + minWidth: 7, + }, + }, + ); + } + + /** + * @method showLogs - show deployment logs on terminal/UI + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async showLogs(): Promise { + this.apolloLogsClient = await new GraphqlApiClient({ + headers: { + 'X-CS-CLI': this.analyticsInfo, + 'x-project-uid': this.config.currentConfig.uid, + organization_uid: this.config.currentConfig.organizationUid, + }, + baseUrl: this.config.logsApiBaseUrl, + }).apolloClient; + this.apolloClient = await new GraphqlApiClient({ + headers: { + 'X-CS-CLI': this.analyticsInfo, + 'x-project-uid': this.config.currentConfig.uid, + organization_uid: this.config.currentConfig.organizationUid, + }, + baseUrl: this.config.manageApiBaseUrl, + }).apolloClient; + this.config.environment = (last(this.config.currentConfig.environments) as Record)?.uid; + this.config.deployment = (last(this.config.currentConfig.deployments) as Record)?.uid; + const logs = new LogPolling({ + config: this.config, + $event: this.$event, + apolloManageClient: this.apolloClient, + apolloLogsClient: this.apolloLogsClient, + }); + logs.deploymentLogs(); + return new Promise((resolve) => { + this.$event.on('deployment-logs', (event: EmitMessage) => { + const { message, msgType } = event; + if (message === 'DONE') return resolve(true); + + if (msgType === 'info') { + forEach(message, (log: DeploymentLogResp | ServerLogResp) => { + let formattedLogTimestamp = new Date(log.timestamp).toISOString()?.slice(0, 23)?.replace('T', ' '); + this.log(`${formattedLogTimestamp}: ${log.message}`, msgType); + }); + } else if (msgType === 'error') { + this.log(message, msgType); + resolve(true); + } + }); + }); + } + + /** + * @method handleNewProjectCreationError + * + * @param {*} error + * @return {*} {(Promise)} + * @memberof BaseClass + */ + async handleNewProjectCreationError(error: any): Promise { + this.log('New project creation failed!', 'error'); + + if (includes(error?.graphQLErrors?.[0]?.extensions?.exception?.messages, 'launch.PROJECTS.DUPLICATE_NAME')) { + this.log('Duplicate project name identified', 'error'); + + if (this.projectCreationRetryCount >= this.config.projectCreationRetryMaxCount) { + this.log('Reached max project creation retry limit', 'warn'); + } else if ( + await ux.inquire({ + type: 'confirm', + name: 'deployLatestSource', + message: "Would you like to change the project's name and try again?", + }) + ) { + this.config.projectName = await ux.inquire({ + type: 'input', + name: 'projectName', + message: 'Project Name', + default: this.config.repository?.name, + validate: this.inquireRequireValidation, + }); + + this.projectCreationRetryCount++; + + return true; + } + } else if (includes(error?.graphQLErrors?.[0]?.extensions?.exception?.messages, 'launch.PROJECTS.LIMIT_REACHED')) { + this.log('Launch project limit reached!', 'error'); + } else { + this.log(error, 'error'); + } + this.exit(1); + } + + /** + * @method showDeploymentUrl - show deployment URL and open it on browser + * + * @param {boolean} [openOnUi=true] + * @memberof BaseClass + */ + showDeploymentUrl(openOnUi = true): void { + const deployment = last(this.config.currentConfig.deployments) as Record; + + if (deployment) { + const deploymentUrl = deployment.deploymentUrl.startsWith('https') + ? deployment.deploymentUrl + : `https://${deployment.deploymentUrl}`; + print([ + { message: 'Deployment URL', bold: true }, + { message: deploymentUrl, color: 'cyan' }, + ]); + + if (openOnUi) { + // NOTE delaying to open the deployment url. If we open quickly it's showing site not reachable + setTimeout(() => { + open(deploymentUrl); + }, 6000); + } + } + } + + /** + * @method showSuggestion - Show suggestion to add config file to .gitignore + * + * @return {*} + * @memberof GitHub + */ + showSuggestion() { + const gitIgnoreFilePath = `${this.config.projectBasePath}/.gitignore`; + + if (existsSync(gitIgnoreFilePath)) { + const gitIgnoreFile = readFileSync(`${this.config.projectBasePath}/.gitignore`, 'utf-8'); + + if (includes(gitIgnoreFile, this.config.configName)) return; + + this.log(`You can add the ${this.config.configName} config file to the .gitignore file`, { + color: 'yellow', + bold: true, + }); + } + } +} diff --git a/src/adapters/file-upload.ts b/src/adapters/file-upload.ts new file mode 100755 index 00000000..ba687da --- /dev/null +++ b/src/adapters/file-upload.ts @@ -0,0 +1,343 @@ +import AdmZip from 'adm-zip'; +import map from 'lodash/map'; +import omit from 'lodash/omit'; +import find from 'lodash/find'; +import FormData from 'form-data'; +import filter from 'lodash/filter'; +import includes from 'lodash/includes'; +import isEmpty from 'lodash/isEmpty'; +import { basename, resolve } from 'path'; +import { cliux, configHandler, HttpClient, ux } from '@contentstack/cli-utilities'; +import { createReadStream, existsSync, PathLike, statSync, readFileSync } from 'fs'; + +import { print } from '../util'; +import BaseClass from './base-class'; +import { getFileList } from '../util/fs'; +import { createSignedUploadUrlMutation, importProjectMutation } from '../graphql'; + +export default class FileUpload extends BaseClass { + private signedUploadUrlData!: Record; + + /** + * @method run + * + * @return {*} {Promise} + * @memberof FileUpload + */ + async run(): Promise { + if (this.config.isExistingProject) { + await this.initApolloClient(); + if ( + !(await cliux.inquire({ + type: 'confirm', + default: false, + name: 'uploadLastFile', + message: 'Redeploy with last file upload?', + })) + ) { + await this.createSignedUploadUrl(); + const { zipName, zipPath } = await this.archive(); + await this.uploadFile(zipName, zipPath); + } + + const { uploadUid } = this.signedUploadUrlData || { + uploadUid: undefined, + }; + await this.createNewDeployment(true, uploadUid); + } else { + await this.prepareForNewProjectCreation(); + await this.createNewProject(); + } + + this.prepareLaunchConfig(); + await this.showLogs(); + this.showDeploymentUrl(); + this.showSuggestion(); + } + + /** + * @method createNewProject - Create new launch project + * + * @return {*} {Promise} + * @memberof FileUpload + */ + async createNewProject(): Promise { + const { framework, projectName, buildCommand, outputDirectory, environmentName } = this.config; + await this.apolloClient + .mutate({ + mutation: importProjectMutation, + variables: { + project: { + projectType: 'FILEUPLOAD', + name: projectName, + fileUpload: { uploadUid: this.signedUploadUrlData.uploadUid }, + environment: { + frameworkPreset: framework, + outputDirectory: outputDirectory, + name: environmentName || 'Default', + environmentVariables: map(this.envVariables, ({ key, value }) => ({ key, value })), + buildCommand: buildCommand === undefined || buildCommand === null ? 'npm run build' : buildCommand, + }, + }, + skipGitData: true, + }, + }) + .then(({ data: { project } }) => { + this.log('New project created successfully', 'info'); + const [firstEnvironment] = project.environments; + this.config.currentConfig = project; + this.config.currentConfig.deployments = map(firstEnvironment.deployments.edges, 'node'); + this.config.currentConfig.environments[0] = omit(this.config.currentConfig.environments[0], ['deployments']); + }) + .catch(async (error) => { + const canRetry = await this.handleNewProjectCreationError(error); + + if (canRetry) { + return this.createNewProject(); + } + }); + } + + /** + * @method prepareForNewProjectCreation - prepare necessary data for new project creation + * + * @return {*} {Promise} + * @memberof FileUpload + */ + async prepareForNewProjectCreation(): Promise { + const { + name, + framework, + environment, + 'build-command': buildCommand, + 'out-dir': outputDirectory, + 'variable-type': variableType, + 'env-variables': envVariables, + alias, + } = this.config.flags; + const { token, apiKey } = configHandler.get(`tokens.${alias}`) ?? {}; + this.config.selectedStack = apiKey; + this.config.deliveryToken = token; + // this.fileValidation(); + await this.selectOrg(); + await this.createSignedUploadUrl(); + const { zipName, zipPath, projectName } = await this.archive(); + await this.uploadFile(zipName, zipPath); + this.config.projectName = + name || + (await cliux.inquire({ + type: 'input', + name: 'projectName', + message: 'Project Name', + default: projectName, + validate: this.inquireRequireValidation, + })); + this.config.environmentName = + environment || + (await cliux.inquire({ + type: 'input', + default: 'Default', + name: 'environmentName', + message: 'Environment Name', + validate: this.inquireRequireValidation, + })); + if (framework) { + this.config.framework = (( + find(this.config.listOfFrameWorks, { + name: framework, + }) as Record + ).value || '') as string; + print([ + { message: '?', color: 'green' }, + { message: 'Framework Preset', bold: true }, + { message: this.config.framework, color: 'cyan' }, + ]); + } else { + await this.detectFramework(); + } + this.config.buildCommand = + buildCommand || + (await cliux.inquire({ + type: 'input', + name: 'buildCommand', + message: 'Build Command', + default: this.config.framework === 'OTHER' ? null : 'npm run build', + })); + this.config.outputDirectory = + outputDirectory || + (await cliux.inquire({ + type: 'input', + name: 'outputDirectory', + message: 'Output Directory', + default: (this.config.outputDirectories as Record)[this.config?.framework || 'OTHER'], + })); + this.config.variableType = variableType as unknown as string; + this.config.envVariables = envVariables; + await this.handleEnvImportFlow(); + } + + /** + * @method fileValidation - validate the working directory + * + * @memberof FileUpload + */ + fileValidation() { + const basePath = this.config.projectBasePath; + const packageJsonPath = resolve(basePath, 'package.json'); + + if (!existsSync(packageJsonPath)) { + this.log('Package.json file not found.', 'info'); + this.exit(1); + } + } + + /** + * @method archive - Archive the files and directory to be uploaded for launch project + * + * @return {*} + * @memberof FileUpload + */ + async archive() { + ux.action.start('Preparing zip file'); + const projectName = basename(this.config.projectBasePath); + const zipName = `${Date.now()}_${projectName}.zip`; + const zipPath = resolve(this.config.projectBasePath, zipName); + const zip = new AdmZip(); + const zipEntries = filter( + await getFileList(this.config.projectBasePath, true, true), + (entry) => !includes(this.config.fileUploadConfig.exclude, entry) && !includes(entry, '.zip'), + ); + + for (const entry of zipEntries) { + const entryPath = `${this.config.projectBasePath}/${entry}`; + const state = statSync(entryPath); + + switch (true) { + case state.isDirectory(): // NOTE folder + zip.addLocalFolder(entryPath, entry); + break; + case state.isFile(): // NOTE check is file + zip.addLocalFile(entryPath); + break; + } + } + + const status = await zip.writeZipPromise(zipPath).catch(() => { + this.log('Zipping project process failed! Please try again.'); + this.exit(1); + }); + + if (!status) { + this.log('Zipping project process failed! Please try again.'); + this.exit(1); + } + + ux.action.stop(); + return { zipName, zipPath, projectName }; + } + + /** + * @method createSignedUploadUrl - create pre signed url for file upload + * + * @return {*} {Promise} + * @memberof FileUpload + */ + async createSignedUploadUrl(): Promise { + this.signedUploadUrlData = await this.apolloClient + .mutate({ mutation: createSignedUploadUrlMutation }) + .then(({ data: { signedUploadUrl } }) => signedUploadUrl) + .catch((error) => { + this.log('Something went wrong. Please try again.', 'warn'); + this.log(error, 'error'); + this.exit(1); + }); + this.config.uploadUid = this.signedUploadUrlData.uploadUid; + } + + /** + * @method uploadFile - Upload file in to s3 bucket + * + * @param {string} fileName + * @param {PathLike} filePath + * @return {*} {Promise} + * @memberof FileUpload + */ + async uploadFile(fileName: string, filePath: PathLike): Promise { + const { uploadUrl, fields, headers, method } = this.signedUploadUrlData; + const formData = new FormData(); + + if (!isEmpty(fields)) { + for (const { formFieldKey, formFieldValue } of fields) { + formData.append(formFieldKey, formFieldValue); + } + + formData.append('file', createReadStream(filePath), fileName); + await this.submitFormData(formData, uploadUrl); + } else if (method === 'PUT') { + await this.uploadWithHttpClient(filePath, uploadUrl, headers); + } + } + + private async submitFormData(formData: FormData, uploadUrl: string): Promise { + ux.action.start('Starting file upload...'); + try { + await new Promise((resolve, reject) => { + formData.submit(uploadUrl, (error, res) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + ux.action.stop(); + } catch (error) { + ux.action.stop('File upload failed!'); + this.log('File upload failed. Please try again.', 'error'); + if (error instanceof Error) { + this.log(error.message, 'error'); + } + this.exit(1); + } + } + + private async uploadWithHttpClient( + filePath: PathLike, + uploadUrl: string, + headers: Array<{ key: string; value: string }>, + ): Promise { + ux.action.start('Starting file upload...'); + const httpClient = new HttpClient(); + const file = readFileSync(filePath); + + // Convert headers array to a headers object + const headerObject = headers?.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {} as Record); + + try { + httpClient.headers({ 'Content-Type': 'application/zip' }); + if (headerObject !== undefined) httpClient.headers(headerObject); + const response = (await httpClient.put(uploadUrl, file)) as any; + const { status } = response; + + if (status >= 200 && status < 300) { + ux.action.stop(); + } else { + ux.action.stop('File upload failed!'); + this.log('File upload failed. Please try again.', 'error'); + this.log(`Error: ${status}, ${response?.statusText}`, 'error'); + this.exit(1); + } + } catch (error) { + ux.action.stop('File upload failed!'); + this.log('File upload failed. Please try again.', 'error'); + if (error instanceof Error) { + this.log(`Error: ${error.message}`, 'error'); + } + this.exit(1); + } + } +} diff --git a/src/adapters/github.ts b/src/adapters/github.ts new file mode 100755 index 00000000..74946ac --- /dev/null +++ b/src/adapters/github.ts @@ -0,0 +1,287 @@ +import map from 'lodash/map'; +import { resolve } from 'path'; +import omit from 'lodash/omit'; +import find from 'lodash/find'; +import split from 'lodash/split'; +import { exec } from 'child_process'; +import replace from 'lodash/replace'; +import includes from 'lodash/includes'; +import { configHandler, cliux as ux } from '@contentstack/cli-utilities'; + +import { print } from '../util'; +import BaseClass from './base-class'; +import { getRemoteUrls } from '../util/create-git-meta'; +import { repositoriesQuery, userConnectionsQuery, importProjectMutation } from '../graphql'; + +export default class GitHub extends BaseClass { + /** + * @method run - initialization function + * + * @return {*} {Promise} + * @memberof GitHub + */ + async run(): Promise { + // NOTE New project creation Flow + if (this.config.isExistingProject) { + await this.initApolloClient(); + await this.createNewDeployment(); + } else { + // NOTE Existing project flow + // NOTE Step 1: Check is Github connected + if (await this.checkGitHubConnected()) { + // NOTE Step 2: check is the git remote available in the user's repo list + if (await this.checkGitRemoteAvailableAndValid()) { + if (await this.checkUserGitHubAccess()) { + // NOTE Step 3: check is the user has proper git access + await this.prepareForNewProjectCreation(); + } + } + } + + await this.createNewProject(); + } + + this.prepareLaunchConfig(); + await this.showLogs(); + this.showDeploymentUrl(); + this.showSuggestion(); + } + + /** + * @method createNewProject - Create new launch project + * + * @return {*} {Promise} + * @memberof GitHub + */ + async createNewProject(): Promise { + const { + branch, + framework, + repository, + projectName, + buildCommand, + selectedStack, + outputDirectory, + environmentName, + provider: gitProvider, + } = this.config; + const username = split(repository?.fullName, '/')[0]; + + await this.apolloClient + .mutate({ + mutation: importProjectMutation, + variables: { + project: { + name: projectName, + cmsStackApiKey: selectedStack?.api_key || '', + repository: { + username, + repositoryUrl: repository?.url, + repositoryName: repository?.fullName, + gitProviderMetadata: { gitProvider }, + }, + environment: { + gitBranch: branch, + frameworkPreset: framework, + outputDirectory: outputDirectory, + name: environmentName || 'Default', + environmentVariables: map(this.envVariables, ({ key, value }) => ({ key, value })), + buildCommand: buildCommand === undefined || buildCommand === null ? 'npm run build' : buildCommand, + }, + }, + }, + }) + .then(({ data: { project } }) => { + this.log('New project created successfully', 'info'); + const [firstEnvironment] = project.environments; + this.config.currentConfig = project; + this.config.currentConfig.deployments = map(firstEnvironment.deployments.edges, 'node'); + this.config.currentConfig.environments[0] = omit(this.config.currentConfig.environments[0], ['deployments']); + }) + .catch(async (error) => { + const canRetry = await this.handleNewProjectCreationError(error); + + if (canRetry) { + return this.createNewProject(); + } + }); + } + + /** + * @method prepareForNewProjectCreation - Preparing all the data for new project creation + * + * @return {*} {Promise} + * @memberof BaseClass + */ + async prepareForNewProjectCreation(): Promise { + const { + name, + framework, + environment, + 'build-command': buildCommand, + 'out-dir': outputDirectory, + 'variable-type': variableType, + 'env-variables': envVariables, + alias, + } = this.config.flags; + const { token, apiKey } = configHandler.get(`tokens.${alias}`) ?? {}; + this.config.selectedStack = apiKey; + this.config.deliveryToken = token; + await this.selectOrg(); + print([ + { message: '?', color: 'green' }, + { message: 'Repository', bold: true }, + { message: this.config.repository?.fullName, color: 'cyan' }, + ]); + await this.selectBranch(); + this.config.projectName = + name || + (await ux.inquire({ + type: 'input', + name: 'projectName', + message: 'Project Name', + default: this.config.repository?.name, + validate: this.inquireRequireValidation, + })); + this.config.environmentName = + environment || + (await ux.inquire({ + type: 'input', + default: 'Default', + name: 'environmentName', + message: 'Environment Name', + validate: this.inquireRequireValidation, + })); + if (framework) { + this.config.framework = ( + find(this.config.listOfFrameWorks, { + name: framework, + }) as Record + ).value as string; + print([ + { message: '?', color: 'green' }, + { message: 'Framework Preset', bold: true }, + { message: this.config.framework, color: 'cyan' }, + ]); + } else { + await this.detectFramework(); + } + this.config.buildCommand = + buildCommand || + (await ux.inquire({ + type: 'input', + name: 'buildCommand', + message: 'Build Command', + default: this.config.framework === 'OTHER' ? '' : 'npm run build', + })); + this.config.outputDirectory = + outputDirectory || + (await ux.inquire({ + type: 'input', + name: 'outputDirectory', + message: 'Output Directory', + default: (this.config.outputDirectories as Record)[this.config?.framework || 'OTHER'], + })); + this.config.variableType = variableType as unknown as string; + this.config.envVariables = envVariables; + await this.handleEnvImportFlow(); + } + + /** + * @method checkGitHubConnected - GitHub connection validation + * + * @return {*} {(Promise<{ + * userUid: string; + * provider: string; + * } | void>)} + * @memberof GitHub + */ + async checkGitHubConnected(): Promise<{ + userUid: string; + provider: string; + } | void> { + const userConnections = await this.apolloClient + .query({ query: userConnectionsQuery }) + .then(({ data: { userConnections } }) => userConnections) + .catch((error) => this.log(error, 'error')); + + const userConnection = find(userConnections, { + provider: this.config.provider, + }); + + if (userConnection) { + this.log('GitHub connection identified!', 'info'); + this.config.userConnection = userConnection; + } else { + this.log('GitHub connection not found!', 'warn'); + await this.connectToAdapterOnUi(); + } + + return this.config.userConnection; + } + + /** + * @method checkGitRemoteAvailableAndValid - GitHub repository verification + * + * @return {*} {(Promise)} + * @memberof GitHub + */ + async checkGitRemoteAvailableAndValid(): Promise { + const localRemoteUrl = (await getRemoteUrls(resolve(this.config.projectBasePath, '.git/config')))?.origin || ''; + + if (!localRemoteUrl) { + this.log('GitHub project not identified!', 'error'); + await this.connectToAdapterOnUi(); + } + + const repositories = await this.apolloClient + .query({ query: repositoriesQuery }) + .then(({ data: { repositories } }) => repositories) + .catch((error) => this.log(error, 'error')); + + this.config.repository = find(repositories, { + url: replace(localRemoteUrl, '.git', ''), + }); + + if (!this.config.repository) { + this.log('Repository not found in the list!', 'error'); + this.exit(1); + } + + return true; + } + + /** + * @method checkUserGitHubAccess - GitHub user access validation + * + * @return {*} {Promise} + * @memberof GitHub + */ + async checkUserGitHubAccess(): Promise { + return new Promise((resolve, reject) => { + const self = this; + const defaultBranch = this.config.repository?.defaultBranch; + if (!defaultBranch) return reject('Branch not found'); + exec( + `git push -u origin ${defaultBranch} --dry-run`, + { cwd: this.config.projectBasePath }, + function (err, stdout, stderr) { + if (err) { + self.log(err, 'error'); + } + + if ( + includes(stderr, 'Everything up-to-date') && + includes(stdout, `Would set upstream of '${defaultBranch}' to '${defaultBranch}' of 'origin'`) + ) { + self.log('User access verified', 'info'); + return resolve(true); + } + + self.log('You do not have write access for the selected repo', 'error'); + self.exit(0); + }, + ); + }); + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100755 index 00000000..9cf2cb4 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,6 @@ +import GitHub from './github'; +import PreCheck from './pre-check'; +import FileUpload from './file-upload'; +import BaseClass from "./base-class"; + +export { GitHub, PreCheck, FileUpload, BaseClass }; \ No newline at end of file diff --git a/src/adapters/pre-check.ts b/src/adapters/pre-check.ts new file mode 100755 index 00000000..bccb069 --- /dev/null +++ b/src/adapters/pre-check.ts @@ -0,0 +1,156 @@ +import find from "lodash/find"; +import { resolve } from "path"; +import { existsSync } from "fs"; +import isEmpty from "lodash/isEmpty"; +import includes from "lodash/includes"; +import { cliux as ux } from "@contentstack/cli-utilities"; + +import BaseClass from "./base-class"; +import { getRemoteUrls } from "../util"; + +export default class PreCheck extends BaseClass { + public projectBasePath: string = process.cwd(); + /** + * @method run + * + * @param {boolean} [identifyProject=true] + * @return {*} {Promise} + * @memberof PreCheck + */ + async run(identifyProject = true): Promise { + await this.performValidations(); + + if (identifyProject && !this.config.isExistingProject) { + await this.identifyWhatProjectItIs(); + } + } + + /** + * @method performValidations - Validate if the current project is an existing launch project + * + * @return {*} {(Promise)} + * @memberof PreCheck + */ + async performValidations(): Promise { + if (this.config.config && existsSync(this.config.config)) { + if (this.config.flags.init) { + // NOTE reinitialize the project + this.config.provider = undefined; + this.config.isExistingProject = false; + + if (this.config.flags.type) { + this.config.provider = this.config.flags.type as any; + } + } else { + this.validateLaunchConfig(); + + this.log("Existing launch project identified", "info"); + + await this.displayPreDeploymentDetails(); + + if ( + !(await ux.inquire({ + type: "confirm", + name: "deployLatestSource", + message: "Redeploy latest commit/code?", + })) + ) { + this.exit(1); + } + } + } + } + + /** + * @method displayPreDeploymentDetails + * + * @memberof GitHub + */ + async displayPreDeploymentDetails() { + if (this.config.config && !isEmpty(this.config.currentConfig)) { + this.log(""); // Empty line + this.log("Current Project details:", { bold: true, color: "green" }); + this.log(""); // Empty line + const { name, projectType, repository, environments } = + this.config.currentConfig; + const [environment] = environments; + + const detail: Record = { + "Project Name": name, + "Project Type": + (this.config.providerMapper as Record)[projectType] || + "", + Environment: environment.name, + "Framework Preset": + find(this.config.listOfFrameWorks, { + value: environment.frameworkPreset, + })?.name || "", + }; + + if (repository?.repositoryName) { + detail["Repository"] = repository.repositoryName; + } + + ux.table([detail, {}], { + "Project Name": { + minWidth: 7, + }, + "Project Type": { + minWidth: 7, + }, + Environment: { + minWidth: 7, + }, + Repository: { + minWidth: 7, + }, + "Framework Preset": { + minWidth: 7, + }, + }); + } + } + + /** + * @method validateLaunchConfig + * + * @memberof PreCheck + */ + validateLaunchConfig() { + try { + // NOTE Perform validations here + if (isEmpty(require(this.config.config as string))) { + this.log("Invalid Launch config!", "warn"); + this.exit(1); + } + } catch (error) {} + } + + /** + * @method identifyWhatProjectItIs - identify if the project type (is GitHub, BitBucket, FileUpload etc.,) + * + * @return {*} {Promise} + * @memberof PreCheck + */ + async identifyWhatProjectItIs(): Promise { + const localRemoteUrl = + (await getRemoteUrls(resolve(this.config.projectBasePath, ".git/config"))) + ?.origin || ""; + + switch (true) { + case includes(localRemoteUrl, 'github.'): + this.config.provider = 'GitHub'; + this.log('Git project identified', 'info'); + break; + default: + if (existsSync(resolve(this.config.projectBasePath, ".git"))) { + this.log("Git config found but remote URL not found in the config!", { + color: "yellow", + bold: true, + }); + } + await this.connectToAdapterOnUi(false); + break; + } + } +} diff --git a/src/base-command.ts b/src/base-command.ts new file mode 100755 index 00000000..8e9415b --- /dev/null +++ b/src/base-command.ts @@ -0,0 +1,207 @@ +import keys from 'lodash/keys'; +import { existsSync } from 'fs'; +import EventEmitter from 'events'; +import { dirname, resolve } from 'path'; +import includes from 'lodash/includes'; +import { ApolloClient } from '@apollo/client/core'; +import { Command } from '@contentstack/cli-command'; +import { + Flags, + FlagInput, + Interfaces, + cliux as ux, + configHandler, + isAuthenticated, + ContentstackClient, + managementSDKClient, + managementSDKInitiator, +} from '@contentstack/cli-utilities'; + +import config from './config'; +import { getLaunchHubUrl, GraphqlApiClient, Logger } from './util'; +import { ConfigType, LogFn, Providers } from './types'; + +export type Flags = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>; +export type Args = Interfaces.InferredArgs; + +export abstract class BaseCommand extends Command { + public log!: LogFn; + public logger!: Logger; + protected $event!: EventEmitter; + protected sharedConfig!: ConfigType; + protected apolloClient!: ApolloClient; + protected managementSdk!: ContentstackClient; + protected apolloLogsClient!: ApolloClient; + + protected flags!: Flags; + protected args!: Args; + + // define flags that can be inherited by any command that extends BaseCommand + static baseFlags: FlagInput = { + 'data-dir': Flags.string({ + char: 'd', + description: 'Current working directory', + }), + config: Flags.string({ + char: 'c', + description: `Path to the local '${config.configName}' file`, + }), + }; + + public async init(): Promise { + this.checkAuthenticated(); + await super.init(); + const { args, flags } = await this.parse({ + flags: this.ctor.flags, + baseFlags: (super.ctor as typeof BaseCommand).baseFlags, + args: this.ctor.args, + strict: this.ctor.strict, + }); + this.flags = flags as Flags; + this.args = args as Args; + + ux.registerSearchPlugin(); + this.$event = new EventEmitter(); + + await this.prepareConfig(); + await this.initCmaSDK(); + + // Init logger + const logger = new Logger(this.sharedConfig); + this.log = logger.log.bind(logger); + } + + public checkAuthenticated() { + const self = this; + if ((self as this & { id: string }).id === 'launch:functions') { + return; + } + const _isAuthenticated = isAuthenticated(); + if (!_isAuthenticated) { + ux.print('CLI_AUTH_WHOAMI_FAILED', { color: 'yellow' }); + ux.print('You are not logged in. Please login to execute this command, csdx auth:login', { color: 'red' }); + this.exit(1); + } + } + + protected async catch(err: Error & { exitCode?: number }): Promise { + // add any custom logic to handle errors from the command + // or simply return the parent class error handling + return super.catch(err); + } + + protected async finally(_: Error | undefined): Promise { + // called after run and catch regardless of whether or not the command errored + return super.finally(_); + } + + /** + * @method prepareConfig - init default Config data + * + * @memberof BaseCommand + */ + async prepareConfig(): Promise { + let configPath = + this.flags['data-dir'] || this.flags.config + ? this.flags.config || resolve(this.flags['data-dir'], config.configName) + : resolve(process.cwd(), config.configName); + let baseUrl = config.launchBaseUrl || this.launchHubUrl; + if (!baseUrl) { + baseUrl = getLaunchHubUrl(); + } + this.sharedConfig = { + ...require('./config').default, + currentConfig: {}, + ...this.flags, + flags: this.flags, + host: this.cmaHost, + config: configPath, + projectBasePath: dirname(configPath), + authtoken: configHandler.get('authtoken'), + authType: configHandler.get('authorisationType'), + authorization: configHandler.get('oauthAccessToken'), + logsApiBaseUrl: `${baseUrl}/${config.logsApiEndpoint}`, + manageApiBaseUrl: `${baseUrl}/${config.manageApiEndpoint}`, + }; + + if (this.flags.type) { + this.sharedConfig.provider = this.flags.type; + } + + if (existsSync(configPath)) { + this.sharedConfig.isExistingProject = true; + } + } + + /** + * @method getConfig - Get a config from list of existing .cs-launch.json file + * + * @return {*} {Promise} + * @memberof BaseCommand + */ + async getConfig(): Promise { + if (this.sharedConfig.config && existsSync(this.sharedConfig.config)) { + const config: Record = require(this.sharedConfig.config); + const configKeys = keys(config); + + if (this.flags.branch && includes(configKeys, this.flags.branch)) { + this.sharedConfig.currentConfig = config[this.flags.branch]; + } else if (configKeys?.length > 1) { + this.sharedConfig.currentConfig = await ux + .inquire({ + name: 'branch', + type: 'search-list', + choices: configKeys, + message: 'Choose a branch', + }) + .then((val: any) => config[val]) + .catch((err) => { + this.log(err, 'error'); + }); + } else { + this.sharedConfig.currentConfig = config[configKeys[0]]; + } + + this.sharedConfig.provider = (this.sharedConfig.providerMapper as Record)[ + this.sharedConfig.currentConfig.projectType + ] as Providers; + } + } + + /** + * @methods prepareApiClients - Prepare Api Clients (Management SDK and apollo client) + * + * @return {*} {Promise} + * @memberof BaseCommand + */ + async prepareApiClients(): Promise { + this.apolloClient = await new GraphqlApiClient({ + headers: { + 'X-CS-CLI': this.context.analyticsInfo, + 'x-project-uid': this.sharedConfig.currentConfig.uid, + organization_uid: this.sharedConfig.currentConfig.organizationUid, + }, + baseUrl: this.sharedConfig.manageApiBaseUrl, + }).apolloClient; + this.apolloLogsClient = await new GraphqlApiClient({ + headers: { + 'X-CS-CLI': this.context.analyticsInfo, + 'x-project-uid': this.sharedConfig.currentConfig.uid, + organization_uid: this.sharedConfig.currentConfig.organizationUid, + }, + baseUrl: this.sharedConfig.logsApiBaseUrl, + }).apolloClient; + } + + /** + * @method initCmaSDK + * + * @memberof BaseCommand + */ + async initCmaSDK() { + managementSDKInitiator.init(this.context); + this.managementSdk = await managementSDKClient({ + host: this.sharedConfig.host, + }); + } +} diff --git a/src/commands/launch/deployments.ts b/src/commands/launch/deployments.ts new file mode 100755 index 00000000..07b85ec --- /dev/null +++ b/src/commands/launch/deployments.ts @@ -0,0 +1,145 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { environmentsQuery } from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Deployments extends BaseCommand { + static description = 'Show list of deployments for an environment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --org= --project=', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + branch: Flags.string({ + hidden: true, + description: '[Optional] GitHub branch name', + }), + }; + + async run(): Promise { + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + + if (!this.flags.environment) { + await this.getConfig(); + } + + await this.prepareApiClients(); + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.showDeployments(); + } + + /** + * @method showDeployments + * + * @memberof Deployments + */ + async showDeployments() { + const environments = await this.getEnvironments(); + + ux.table(environments, { + environment: { + minWidth: 7, + }, + deploymentUrl: { + minWidth: 7, + header: 'Deployment Url', + }, + commitMessage: { + minWidth: 7, + header: 'Commit Message', + }, + createdAt: { + minWidth: 7, + header: 'Created At', + }, + }); + } + + /** + * @method validateAndSelectEnvironment - check whether environment is validate or not. If not then option to select environment + * + * @return {*} {Promise} + * @memberof Logs + */ + async getEnvironments(): Promise { + const environments = await this.apolloClient + .query({ query: environmentsQuery }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + let environment = find( + environments, + ({ uid, name }) => + uid === this.flags.environment || + name === this.flags.environment || + uid === this.sharedConfig.currentConfig?.environments?.[0]?.uid, + ); + + if (isEmpty(environment) && (this.flags.environment || this.sharedConfig.currentConfig?.environments?.[0]?.uid)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } else if (isEmpty(environment)) { + environment = await ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + this.sharedConfig.currentConfig.deployments = map(environments[0]?.deployments?.edges, 'node'); + } + + this.sharedConfig.environment = environment; + + return map(environment.deployments.edges, ({ node }) => { + const { deploymentUrl: url, createdAt, commitMessage } = node; + const deploymentUrl = chalk.cyan(url?.startsWith('https') ? url : `https://${url}`); + return { + deploymentUrl, + createdAt: chalk.green(createdAt), + commitMessage: chalk.green(commitMessage), + environment: chalk.green(environment.name), + }; + }); + } +} diff --git a/src/commands/launch/environments.ts b/src/commands/launch/environments.ts new file mode 100755 index 00000000..bbab7c9 --- /dev/null +++ b/src/commands/launch/environments.ts @@ -0,0 +1,157 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { Logger, selectOrg, selectProject } from '../../util'; +import { environmentsQuery, projectsQuery } from '../../graphql'; + +export default class Environments extends BaseCommand { + static description = 'Show list of environments for a project'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + '$ <%= config.bin %> <%= command.id %> --org= --project=', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + branch: Flags.string({ + hidden: true, + description: '[Optional] GitHub branch name', + }), + }; + + async run(): Promise { + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + + await this.getConfig(); + await this.prepareApiClients(); + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.getEnvironments(); + } + + /** + * @method selectProject - select projects + * + * @return {*} {Promise} + * @memberof Logs + */ + async selectProject(): Promise { + const projects = await this.apolloClient + .query({ query: projectsQuery, variables: { query: {} } }) + .then(({ data: { projects } }) => projects) + .catch((error) => { + this.log('Unable to fetch projects.!', { color: 'yellow' }); + this.log(error, 'error'); + process.exit(1); + }); + + const listOfProjects = map(projects.edges, ({ node: { uid, name } }) => ({ + name, + value: name, + uid, + })); + + if (isEmpty(listOfProjects)) { + this.log('Project not found', 'info'); + this.exit(1); + } + + if (this.flags.project || this.sharedConfig.currentConfig.uid) { + this.sharedConfig.currentConfig.uid = + find(listOfProjects, { + uid: this.flags.project, + })?.uid || + find(listOfProjects, { + name: this.flags.project, + })?.uid || + find(listOfProjects, { + uid: this.sharedConfig.currentConfig.uid, + })?.uid; + } + + if (!this.sharedConfig.currentConfig.uid) { + this.sharedConfig.currentConfig.uid = await ux + .inquire({ + type: 'search-list', + name: 'Project', + choices: listOfProjects, + message: 'Choose a project', + }) + .then((name) => (find(listOfProjects, { name }) as Record)?.uid); + } + await this.prepareApiClients(); + } + + /** + * @method validateAndSelectEnvironment - check whether environment is validate or not. If not then option to select environment + * + * @return {*} {Promise} + * @memberof Logs + */ + async getEnvironments(): Promise { + const environments = await this.apolloClient + .query({ query: environmentsQuery }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + ux.table( + map(environments, ({ uid, name, frameworkPreset }) => { + return { + uid: chalk.green(uid), + name: chalk.green(name), + frameworkPreset: chalk.green( + find(this.sharedConfig.listOfFrameWorks, { + value: frameworkPreset, + })?.name || '', + ), + }; + }), + { + uid: { + minWidth: 7, + header: 'UID', + }, + name: { + minWidth: 7, + header: 'Name', + }, + frameworkPreset: { + minWidth: 7, + header: 'Framework', + }, + }, + ); + } +} diff --git a/src/commands/launch/functions.ts b/src/commands/launch/functions.ts new file mode 100755 index 00000000..c5e3b78 --- /dev/null +++ b/src/commands/launch/functions.ts @@ -0,0 +1,33 @@ +import { FlagInput, Flags } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import Contentfly from '../../util/cloud-function'; + +export default class Functions extends BaseCommand { + static description = 'Serve cloud functions'; + + static examples = [ + '$ csdx launch:functions', + '$ csdx launch:functions --port=port', + '$ csdx launch:functions --data-dir ', + '$ csdx launch:functions --config ', + '$ csdx launch:functions --data-dir -p "port number"', + '$ csdx launch:functions --config --port=port', + ]; + + static flags: FlagInput = { + port: Flags.string({ + char: 'p', + default: '3000', + description: 'Port number', + }), + }; + + async run(): Promise { + this.sharedConfig.config = + this.flags['data-dir'] || this.flags.config + ? this.flags.config?.split(`${this.sharedConfig.configName}`)[0] || this.flags['data-dir'] + : process.cwd(); + await new Contentfly(this.sharedConfig.config as string).serveCloudFunctions(+this.flags.port); + } +} diff --git a/src/commands/launch/index.ts b/src/commands/launch/index.ts new file mode 100755 index 00000000..8041461 --- /dev/null +++ b/src/commands/launch/index.ts @@ -0,0 +1,140 @@ +import map from 'lodash/map'; +import { FlagInput, Flags } from '@contentstack/cli-utilities'; + +import config from '../../config'; +import { BaseCommand } from '../../base-command'; +import { AdapterConstructorInputs } from '../../types'; +import { FileUpload, GitHub, PreCheck } from '../../adapters'; + +export default class Launch extends BaseCommand { + public preCheck!: PreCheck; + + static description = 'Launch related operations'; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --data-dir ', + '<%= config.bin %> <%= command.id %> --config ', + '<%= config.bin %> <%= command.id %> --type ', + '<%= config.bin %> <%= command.id %> --data-dir --type ', + '<%= config.bin %> <%= command.id %> --config --type ', + '<%= config.bin %> <%= command.id %> --config --type --name= --environment= --branch= --build-command= --framework=