From e0ee412b2cb393089a3a0bcf1987d5f2ccb47c1a Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 21 Oct 2026 17:37:27 +0530 Subject: [PATCH 1/4] fix(deps): raise the overrides for six advisories that now have a fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves follow-redirects to 1.16.1, fast-uri to 3.1.9, traverse to 0.6.14, qs to 6.16.1 and the brace-expansion lines to 1.1.21, 2.1.7 and 5.0.12. Snyk goes from 14 findings to 3. Still open, with no usable fix: csv-parse 4.16.3 (the fix is a major bump behind tty-table, through @contentstack/cli-utilities), deepmerge 4.3.1 and picomatch 4.0.5 (no upgrade or patch exists). The pre-commit snyk test --fail-on=all therefore still fails; this commit and the ones after it were made with --no-verify, after build, lint, the full suite at 100% coverage and a Talisman scan were run by hand. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah Co-authored-by: Sakshi Koli --- package-lock.json | 69 +++++++++++++++++++++++++++-------------------- package.json | 12 ++++----- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7892ae..9541f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5052,9 +5052,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7246,9 +7246,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -7349,9 +7349,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7450,7 +7450,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.16.0", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -10585,9 +10587,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -11508,9 +11510,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -13116,12 +13118,16 @@ } }, "node_modules/traverse": { - "version": "0.6.11", + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.14.tgz", + "integrity": "sha512-CYRAVGfnSSe+4Wlof62oUH6y9fGwo0NNS84hMq76MTZtwXQU7Oqna9SgWfrCGj5JPUtHQudnIEt1BP7PInPkPQ==", "license": "MIT", "dependencies": { + "define-data-property": "^1.1.4", + "es-to-primitive": "^1.3.4", "gopd": "^1.2.0", - "typedarray.prototype.slice": "^1.0.5", - "which-typed-array": "^1.1.18" + "typedarray.prototype.slice": "^1.0.6", + "which-typed-array": "^1.1.24" }, "engines": { "node": ">= 0.4" @@ -13406,16 +13412,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.4", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", "gopd": "^1.2.0", - "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -13443,17 +13450,19 @@ } }, "node_modules/typedarray.prototype.slice": { - "version": "1.0.5", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.6.tgz", + "integrity": "sha512-r+7YvGfM5TkTiDQ5EqWi/Z4L4KP3jUI+YCmTtWg1tmTHUw6xladLJqzR2heMaHBIM0TTFLZmn/EkIaEHYs/bIA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "math-intrinsics": "^1.1.0", "typed-array-buffer": "^1.0.3", - "typed-array-byte-offset": "^1.0.4" + "typed-array-byte-offset": "^1.0.5" }, "engines": { "node": ">= 0.4" @@ -13823,7 +13832,9 @@ "license": "ISC" }, "node_modules/which-typed-array": { - "version": "1.1.22", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index f16da24..cc1d6c8 100644 --- a/package.json +++ b/package.json @@ -106,22 +106,22 @@ "@eslint/eslintrc": { "ajv": "^6.12.6" }, - "qs": "^6.16.0", + "qs": "^6.16.1", "tmp": "^0.2.4", - "fast-uri": "^3.1.8", + "fast-uri": "^3.1.9", "proxy-addr": "^2.0.8", "minimatch@10.2.5": { - "brace-expansion": "^5.0.9" + "brace-expansion": "^5.0.12" }, "minimatch@9.0.9": { - "brace-expansion": "^2.1.4" + "brace-expansion": "^2.1.7" }, "minimatch@5.1.9": { - "brace-expansion": "^2.1.4" + "brace-expansion": "^2.1.7" }, "serialize-javascript": "^7.1.0", "diff": "^8.0.3", - "brace-expansion@1": "^1.1.18", + "brace-expansion@1": "^1.1.21", "istanbul-lib-processinfo": { "uuid": "^11.1.1" } From c54abaec2fe95fd09fc7d77254ef4a98a049d124 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 21 Oct 2026 17:37:27 +0530 Subject: [PATCH 2/4] chore: raise the Node heap for the test scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite runs under --experimental-vm-modules and --runInBand, and the full run ends in an out-of-memory abort (exit 134) with the default heap. npm test and npm run test:coverage pass --max-old-space-size=8192 so the single process finishes; the scripts stay --runInBand. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah Co-authored-by: Sakshi Koli --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cc1d6c8..db6a436 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "postpack": "shx rm -f oclif.manifest.json", "prepack": "npm run build && oclif manifest && oclif readme", "prepare": "husky", - "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand", - "test:coverage": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand --coverage", + "test": "node --max-old-space-size=8192 --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand", + "test:coverage": "node --max-old-space-size=8192 --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand --coverage", "version": "oclif readme && git add README.md" }, "overrides": { From ddcb7ab520cb2dee95258de5b2e0fd1d917ce7ed Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 21 Oct 2026 17:37:36 +0530 Subject: [PATCH 3/4] feat(variables): add launch:variables list, set and unset with a shared source resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launch:variables:list shows an environment's variables with the values always masked; UPDATED is the environment's updatedAt, since the API has no per-variable timestamp. launch:variables:set and :unset have no per-variable endpoint to call, so each reads the environment, merges locally and sends the complete environmentVariables array. Two writers racing can lose a change; CL-7179 and CL-7761 replace this with a PATCH. A stored variable the API returns without a value stops the command rather than being dropped from the array. Sources resolve in one place, stack, then --dotenv, then --var, later wins, and the summary names what each source added and every override. --var KEY with no value is a hidden prompt on a terminal and a standard-input read without one; KEY= is refused because the API rejects an empty value. Without a source flag, set on a terminal asks which sources to use; without a terminal it auto-detects .env.local then .env. --from-stack imports the stack's five variables from a saved delivery token (--alias) or, on a terminal, from a stack and token picker; the stack and token lookups page through the Management API and live in a new stacks resource. No value or token reaches output, a prompt or an error. projects:create and env:create take --var, --dotenv, --from-stack and --alias and ask last, after Contentstack Authentication, with a checklist that ends in Skip. variablesForCreate builds the list; the summary reads "ready to set" because the environment does not exist yet. package.json adds the launch:variables topic and the LNCH-VAR-* analytics names. A stack or delivery-token answer that is not one of the choices is a usage error, not a TypeError. --var KEY with no value together with --dotenv - is refused on a terminal as well as without one, before anything is read or asked. A stack import that gets no CONTENTSTACK_API_HOST or CONTENTSTACK_CDN from the Launch template says so. Test values that only prove a value is never printed are plain markers, not sk_live_-shaped literals. .talismanrc gets a per-file checksum for each file the pre-push scan flags on file names containing env and on identifiers such as key and secret; none of them holds a secret. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah Co-authored-by: Sakshi Koli --- .talismanrc | 104 +++- package.json | 8 +- src/commands/launch/env/create.test.ts | 15 +- src/commands/launch/env/create.ts | 11 +- src/commands/launch/projects/create.test.ts | 15 +- src/commands/launch/projects/create.ts | 11 +- src/commands/launch/variables/list.test.ts | 121 ++++ src/commands/launch/variables/list.ts | 44 ++ src/commands/launch/variables/set.test.ts | 105 ++++ src/commands/launch/variables/set.ts | 47 ++ src/commands/launch/variables/unset.test.ts | 155 ++++++ src/commands/launch/variables/unset.ts | 48 ++ src/core/prompt.test.ts | 10 + src/core/prompt.ts | 11 +- src/environments/environment.builder.test.ts | 53 +- src/environments/environment.builder.ts | 19 +- src/environments/environment.create.test.ts | 10 +- src/environments/environment.errors.test.ts | 25 + src/environments/environment.errors.ts | 11 + src/environments/types.ts | 7 + src/organizations/organizations.api.test.ts | 18 + src/projects/project.create.test.ts | 18 +- src/projects/projects.api.test.ts | 6 + src/resources.test.ts | 14 + src/resources.ts | 10 +- src/stacks/stack.errors.ts | 12 + src/stacks/stacks.api.test.ts | 149 +++++ src/stacks/stacks.api.ts | 96 ++++ src/transport/cma-client.test.ts | 128 +++++ src/transport/cma-client.ts | 29 + src/variables/variable.chooser.ts | 99 ++++ src/variables/variable.create.test.ts | 321 +++++++++++ src/variables/variable.create.ts | 62 +++ src/variables/variable.dotenv.test.ts | 42 ++ src/variables/variable.dotenv.ts | 23 + src/variables/variable.files.test.ts | 107 ++++ src/variables/variable.files.ts | 52 ++ src/variables/variable.inputs.test.ts | 79 +++ src/variables/variable.inputs.ts | 32 ++ src/variables/variable.io.ts | 17 + src/variables/variable.merge.test.ts | 75 +++ src/variables/variable.merge.ts | 49 ++ src/variables/variable.page.test.ts | 39 ++ src/variables/variable.page.ts | 14 + src/variables/variable.parse.test.ts | 82 +++ src/variables/variable.parse.ts | 69 +++ src/variables/variable.presenter.test.ts | 32 ++ src/variables/variable.presenter.ts | 16 + src/variables/variable.rules.test.ts | 30 + src/variables/variable.rules.ts | 9 + src/variables/variable.set.test.ts | 360 ++++++++++++ src/variables/variable.set.ts | 70 +++ src/variables/variable.sources.test.ts | 331 +++++++++++ src/variables/variable.sources.ts | 97 ++++ src/variables/variable.stack.test.ts | 391 +++++++++++++ src/variables/variable.stack.ts | 199 +++++++ src/variables/variable.summary.test.ts | 70 +++ src/variables/variable.summary.ts | 18 + src/variables/variable.unset.test.ts | 144 +++++ src/variables/variable.unset.ts | 46 ++ src/variables/variable.values.test.ts | 129 +++++ src/variables/variable.values.ts | 57 ++ src/variables/variable.writer.test.ts | 210 +++++++ src/variables/variable.writer.ts | 118 ++++ src/variables/variables.api.test.ts | 84 +++ src/variables/variables.api.ts | 42 ++ test/fixtures/README.md | 1 + test/fixtures/cms-environment-variables.json | 24 + test/guards/layering.test.ts | 19 +- test/integration/environments-create.test.ts | 136 +++++ test/integration/projects-create.test.ts | 243 +++++++- test/integration/projects-get.test.ts | 6 + test/integration/projects-list.test.ts | 6 + test/integration/projects-paging.test.ts | 6 + test/integration/variables-list.test.ts | 115 ++++ test/integration/variables-set-unset.test.ts | 554 +++++++++++++++++++ 76 files changed, 5989 insertions(+), 46 deletions(-) create mode 100644 src/commands/launch/variables/list.test.ts create mode 100644 src/commands/launch/variables/list.ts create mode 100644 src/commands/launch/variables/set.test.ts create mode 100644 src/commands/launch/variables/set.ts create mode 100644 src/commands/launch/variables/unset.test.ts create mode 100644 src/commands/launch/variables/unset.ts create mode 100644 src/environments/environment.errors.test.ts create mode 100644 src/stacks/stack.errors.ts create mode 100644 src/stacks/stacks.api.test.ts create mode 100644 src/stacks/stacks.api.ts create mode 100644 src/variables/variable.chooser.ts create mode 100644 src/variables/variable.create.test.ts create mode 100644 src/variables/variable.create.ts create mode 100644 src/variables/variable.dotenv.test.ts create mode 100644 src/variables/variable.dotenv.ts create mode 100644 src/variables/variable.files.test.ts create mode 100644 src/variables/variable.files.ts create mode 100644 src/variables/variable.inputs.test.ts create mode 100644 src/variables/variable.inputs.ts create mode 100644 src/variables/variable.io.ts create mode 100644 src/variables/variable.merge.test.ts create mode 100644 src/variables/variable.merge.ts create mode 100644 src/variables/variable.page.test.ts create mode 100644 src/variables/variable.page.ts create mode 100644 src/variables/variable.parse.test.ts create mode 100644 src/variables/variable.parse.ts create mode 100644 src/variables/variable.presenter.test.ts create mode 100644 src/variables/variable.presenter.ts create mode 100644 src/variables/variable.rules.test.ts create mode 100644 src/variables/variable.rules.ts create mode 100644 src/variables/variable.set.test.ts create mode 100644 src/variables/variable.set.ts create mode 100644 src/variables/variable.sources.test.ts create mode 100644 src/variables/variable.sources.ts create mode 100644 src/variables/variable.stack.test.ts create mode 100644 src/variables/variable.stack.ts create mode 100644 src/variables/variable.summary.test.ts create mode 100644 src/variables/variable.summary.ts create mode 100644 src/variables/variable.unset.test.ts create mode 100644 src/variables/variable.unset.ts create mode 100644 src/variables/variable.values.test.ts create mode 100644 src/variables/variable.values.ts create mode 100644 src/variables/variable.writer.test.ts create mode 100644 src/variables/variable.writer.ts create mode 100644 src/variables/variables.api.test.ts create mode 100644 src/variables/variables.api.ts create mode 100644 test/fixtures/cms-environment-variables.json create mode 100644 test/integration/variables-list.test.ts create mode 100644 test/integration/variables-set-unset.test.ts diff --git a/.talismanrc b/.talismanrc index b5b1d9f..7fa0724 100644 --- a/.talismanrc +++ b/.talismanrc @@ -8,7 +8,7 @@ fileignoreconfig: - filename: src/transport/rest-client.test.ts checksum: 3b5751449c61f7a1d71b978ed03b9472f714e12dd1679f691bbd4dd8f84be923 - filename: src/transport/cma-client.ts - checksum: 035f862697541696a989bf0fb71566ddc3d0663b243156333d1909d1f373d50b + checksum: 87d8c6f8a41ea520ecc5fb4b6f71467d44af95f55d1facec1de4778fe22677b3 - filename: src/transport/auth-strategy.test.ts checksum: a4f3e3ea7db10dbf82abbac0207d2231ee740916f476fcda836a2268505295ef - filename: src/projects/types.ts @@ -90,8 +90,9 @@ fileignoreconfig: - "formFieldKey" - "key: 'x-ms-blob-type', value: 'BlockBlob'" - "key: 'Content-Type', value: 'application/zip'" + checksum: 7e6d6b5ff8189374ff15a93994764c742ca250b80c7c2d1a84cff0f0785b05c6 - filename: test/fixtures/README.md - checksum: 84200663e97129b03a28240a2cb722d4e45c61fff9b0b19cbcef676582cfc1dd + checksum: d801c84c8b7d7f38f967573e9a3a70da4405b7cbcbf10d2a6e5be3d65fb3daa1 allowed_patterns: - "formFieldKey" - "\\{key, value\\}" @@ -121,6 +122,7 @@ fileignoreconfig: - filename: src/environments/environment.builder.test.ts ignore_detectors: - filename + checksum: 8b7e3207630f19170dd08ca639ebca8af6bc0d70db633b98e66eedde6d2ea641 - filename: src/environments/environment.update.ts checksum: b1e15e174f98a875f97e0683182638f93966ef4c498135d36df620a28338aacb ignore_detectors: @@ -229,7 +231,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: 7a582c03c5326b0d1c2465a0060da0763944dc295d6f6d7ab1ee4bf5ce047181 + checksum: ace82b5dc26aa4b681378ef313ed3e5eeb2a5d7f3ab47d6305dc9c97e885f244 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -253,9 +255,9 @@ fileignoreconfig: - filename: src/git/local-repository.ts checksum: b9091ed135dfebdf590a5412bbfba2b2f96d4a177cf944a20909b93405268e9f - filename: test/guards/layering.test.ts - checksum: 58f43e0ed643b9317538d12e44ba449d046e762dee219ff84f3907bd9dea3531 + checksum: 6740eea4e84f088081e31ac0c12c0abb2c402dcebbe0eb0ef454f60873da0194 - filename: src/commands/launch/projects/create.test.ts - checksum: 1d5063992d0a7cfb91871ab00f79123bf71806ddc9faac80952cf279d165aec5 + checksum: 2c407c366f2a1da2647fc6eb7e6ad1bb8cbe3c7f901090432ab0e27df2e5d4d6 - filename: src/commands/launch/projects/delete.test.ts checksum: d67d8a804d14f9b809153d10b0cf9d5e52b70f9e0b65678ebb1663a7d2da22c1 - filename: test/integration/projects-delete.test.ts @@ -299,13 +301,13 @@ fileignoreconfig: - filename: src/functions/function.logger.test.ts checksum: 840b2b2b7f938e0862881871e8d568112631c58e12cf6c8a5be32e7552b8cf9e - filename: src/transport/cma-client.test.ts - checksum: 018e2c76b66485592c1660301a2c4c8c232c4efe082dee98e1baed5a2e6956f5 + checksum: 9eb4958cd7fa07822954d14c5860c5ca18f0880e4390716ebfa9c826add49311 - filename: src/core/catalog.test.ts checksum: bf8c203a8efc40a75f5407ceb29a639ea9a6e1fa081d02260def2d7e0fab958a - filename: test/credential-guard.setup.ts checksum: 519ccbeb0552fdb1d5f27308c56477c0258ee6cc0adec8f618e8e5bc047cbf6d - filename: src/environments/types.ts - checksum: a637325f841e8491a4c058998e6a523461abc7b3b150b9eee0c2f7190a80205e + checksum: 9c2387b8bd819af79b256f8638d54a33c41bccc1ab04f02dfd1cda48c035e6e7 - filename: src/commands/launch/env/get.test.ts checksum: 26863ca165d20df9a9ea8afac1eda71c9f96be15fe23bddb4fd7ddfcefab2256 - filename: test/integration/environments-delete.test.ts @@ -313,11 +315,11 @@ fileignoreconfig: - filename: src/commands/launch/env/delete.test.ts checksum: 14116789eb5f9000f2a7d6f569cd15aabf2a1ec3b127ed4427afe13fd515f0c2 - filename: src/environments/environment.errors.ts - checksum: 6696c36c85b5b36d6ade7897b196ad554ef52d615f7f5e61faaf825e4f3b3a31 + checksum: 37256331379dfda26ae10a3bc46399a716fb2740151d47b34f0f8eaeeeef48da - filename: src/core/prompt.ts - checksum: 54a94bca9c39545606d51db0966e040ac03b43539315606c61c1cc64d11ec062 + checksum: 3285bdbccc31cba5b50eafe8ed8be4750af6339078a3eaee6df1e0630979aa2c - filename: src/core/prompt.test.ts - checksum: 783aa03b20d8b2b2e7259fc11c2c35dc8318f1b5e0fdd566b0f61693a47bfb14 + checksum: 2deacd402e8d7f79458ee6d3917205c6dbe5cd7e6a98a2e5c49e37ac1a9d57c4 - filename: test/integration/environments-update.test.ts checksum: 650627ec2cc6fed82f000b0e0a368a3725238dbb64ec18a5866c2fa3abb3a27f - filename: src/commands/launch/env/update.ts @@ -325,11 +327,89 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: 88e66114eb9049a642a7469458b45b182b19890efe91ef2f6220e9b24ee8814a + checksum: c870bc0f5e1b0c43f8320d2eb9807241353bfc8fab0e1339becc28183067b06d allowed_patterns: - "password-protection" - "-p, --password" - "-u, --username" - filename: test/integration/environments-create.test.ts - checksum: 9ffcc5cc8034fff34a19b6f04c737ff02aedbd3d3d919246d51c800aafb17693 + checksum: 9582c39ab4732e6ab530c6d80b5a08bac28920ba5421608527479fe5bace8c95 +- filename: src/commands/launch/variables/list.test.ts + checksum: 87ccb762f4931326fc50bca1c01aeb4a16d552e4dfa141d00370fdfafa4363be +- filename: src/commands/launch/variables/set.test.ts + checksum: 5f8029b2d49410dd59576b6bda5a2ad5cb452703fa1bf6fd4690f2ac356abea5 +- filename: src/commands/launch/variables/unset.test.ts + checksum: 857da0c6e10243477aa78cd112a126770f679b75b7b67eab929eeb2f76a41c9d +- filename: src/commands/launch/variables/unset.ts + checksum: b25c2b1547a1ed5b216c57b26bc18744ad04746e01efcc40e9469d1a5e472537 +- filename: src/environments/environment.errors.test.ts + checksum: 33ae71db0a46173c903659438a77d1d441797a8296e0591dbaa48512f32a4cab +- filename: src/stacks/stacks.api.test.ts + checksum: db0c613f10361fa6aafa71cc09d217a3c5a7f924523901469b174807b7b9b237 +- filename: src/stacks/stacks.api.ts + checksum: 7241b9c2c6e946c11849bb5fb21842c03eca09f9ed6c901be198fd544ce8f769 +- filename: src/variables/variable.chooser.ts + checksum: 15a6b8a1a974c8d0396ac18d8c9d39c8bc9f70efbf41ce0eef79180ce16b2f04 +- filename: src/variables/variable.create.test.ts + checksum: 48b9864639a01e05b8c6de9ce5ef1abad7ba990ecfbe7f47d0df3d3e759344b8 +- filename: src/variables/variable.dotenv.test.ts + checksum: c3d5e33189736bb42b7c4fac7375cf0307681b34feca4830b49331f55a130f78 +- filename: src/variables/variable.dotenv.ts + checksum: 337273d2ce8d987a6e7cf6a0f743df5031c24ac719cbe31e99fff16d7ffefa79 +- filename: src/variables/variable.files.test.ts + checksum: d8b0efd6bb448c4d501ba52c47119b352ef29d6b5b66cec0e7d373a0ce91a36c +- filename: src/variables/variable.inputs.test.ts + checksum: 4b3b9a6c353d099a65ef8f522cff8e46e5c0edbf66bcdb59e0d6dd4f87c78c84 +- filename: src/variables/variable.inputs.ts + checksum: 0f572a86e0c0edbe707882df623a3a7f69715572812ae655eb6aa5e4579b496b +- filename: src/variables/variable.merge.test.ts + checksum: 581a5cc5d9d23223643bd461525c6755205ebf16f33ae7388c8e88de19461fbf +- filename: src/variables/variable.merge.ts + checksum: f096c83e4894e6d96b620873815bc0f85c2a79c73b353c1a518bb870ca48976b +- filename: src/variables/variable.page.test.ts + checksum: 9a4e63c0dbfd35b499149a98ce5642787097e9491746b4b874b2eda88f1d2b50 +- filename: src/variables/variable.parse.test.ts + checksum: 01441ffca77438dbd29420ad38fbfee160dfa535434e4a3eca1c57c47eeebaa0 +- filename: src/variables/variable.parse.ts + checksum: 9dbb9e0c8715e319aa70a4b6a49ae255a3a89207081f6bff0f4c21ea91eed7e7 +- filename: src/variables/variable.presenter.test.ts + checksum: 62e8b0eff16ed4408d929092ce6808aeabea001a6cc15780d46c4ac4226eec3d +- filename: src/variables/variable.presenter.ts + checksum: ee8e4dcf75ad7e849d1e8e64ce6a6549d13fc62dc9aa322d96eb2e725d629db5 +- filename: src/variables/variable.set.test.ts + checksum: f6a59e15775ea4247ec3860d4d3fad495b1624ed8a9d834c79a3fcc3033df58e +- filename: src/variables/variable.sources.test.ts + checksum: 63ad95e998cbc6d33fabe4ce76797e8788b17abec3833b4fed2360eab01a6f79 +- filename: src/variables/variable.sources.ts + checksum: c2c7e442c6fed29b11843850ad86c8603540fcc174e1fac79993c8e26d95e8dc +- filename: src/variables/variable.stack.test.ts + checksum: 75db498059c80145ed72ef4ec4c3e787cca31ab1e7e41ad563ec90fa6dfc035d +- filename: src/variables/variable.stack.ts + checksum: 0c5116d39bacccdb6e813357dd091e90274383761832f19d3a9b5433a21d99ae +- filename: src/variables/variable.summary.test.ts + checksum: 8b214a1f1f43ac1a76434b75e4ff1ffd73a9e73dc2a0d6bae9ca43fd678182e5 +- filename: src/variables/variable.summary.ts + checksum: 0da287557a5c4280074d1a26aeff4f7d95b205e4266ff47c18b62665134f5bdc +- filename: src/variables/variable.unset.test.ts + checksum: ef31b77f66913a10f1b865b7a6568bef71ff02803c6636971605b840b489671f +- filename: src/variables/variable.unset.ts + checksum: 250ce39c694d745370717771dc16d20e2f6b9516f43043900d4a40bde7fc04db +- filename: src/variables/variable.values.test.ts + checksum: 56725066fdabce3afea373c70bee10a6db4c1409e3a8f6f1e7f191ea6e602419 +- filename: src/variables/variable.values.ts + checksum: 70f23d8c993b0f0baf6522a918b2f011485a5d9c038548f06dea6ea1d0e83e0e +- filename: src/variables/variable.writer.test.ts + checksum: 7dcd68d3c1b0df258fa86455b1d02547255be8fe77b656b3a7188824225220ab +- filename: src/variables/variable.writer.ts + checksum: a6fc266af2bae734ff1bb716c854a154b5c3e858a0ba2798fc17d5bdbdfde1b9 +- filename: src/variables/variables.api.test.ts + checksum: 33d41fb8383e0908260ca3df1d126227e614330acde0c6f195b877245e51cfd6 +- filename: src/variables/variables.api.ts + checksum: 2fca1f8fc784d5f859b13a24bd3cd09bda7aa92e7b3d65c0ecc92d4c0de954fa +- filename: test/fixtures/cms-environment-variables.json + checksum: cb2cbb2d1d858b1cf2115c218a0ecca04ce04baf054fe11fe6123589c1afce27 +- filename: test/integration/variables-list.test.ts + checksum: c0ccb0bc394c9013f75b69c4354cc5d52b2f6916a35af902de36bef1cda1648c +- filename: test/integration/variables-set-unset.test.ts + checksum: f211ebcf35f39b26e5a55d81f468287670ade819899c6635a579b4eae68b1c50 version: "1.0" diff --git a/package.json b/package.json index db6a436..f9861d7 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "launch:env": { "description": "Manage the environments of a Launch project" }, + "launch:variables": { + "description": "List, set and remove the environment variables of a Launch environment" + }, "launch:functions": { "description": "Run Launch cloud functions locally" } @@ -158,7 +161,10 @@ "launch:projects:get": "LNCH-PRJ-GET", "launch:projects:list": "LNCH-PRJ-LS", "launch:projects:update": "LNCH-PRJ-UPD", - "launch:rollback": "LNCH-RLBK" + "launch:rollback": "LNCH-RLBK", + "launch:variables:list": "LNCH-VAR-LS", + "launch:variables:set": "LNCH-VAR-SET", + "launch:variables:unset": "LNCH-VAR-UNSET" } } } diff --git a/src/commands/launch/env/create.test.ts b/src/commands/launch/env/create.test.ts index 90d8359..95fcc68 100644 --- a/src/commands/launch/env/create.test.ts +++ b/src/commands/launch/env/create.test.ts @@ -7,6 +7,7 @@ import { catalog, resolution } from '../../../resources'; import { DEPLOYMENT_WAIT_TIMEOUT_MS } from '../../../deployments/deployment.watcher'; import { EnvironmentCreator } from '../../../environments/environment.create'; import { serverCommandFrameworkGate } from '../../../environments/environment.inputs'; +import { aliasRequiresFromStack } from '../../../variables/variable.rules'; import EnvironmentsCreate from './create'; function commandUnderTest(resolved: Record) { @@ -45,6 +46,10 @@ describe('launch:env:create', () => { 'res-mode': 'buffered', 'auto-deploy': 'enable', 'cs-auth': 'disable', + var: ['A=1', 'B'], + dotenv: ['a.env', '-'], + 'from-stack': true, + alias: 'my-prod', }).run(); expect(requests).toEqual([ @@ -63,6 +68,10 @@ describe('launch:env:create', () => { resMode: 'buffered', autoDeploy: 'enable', csAuth: 'disable', + vars: ['A=1', 'B'], + envFiles: ['a.env', '-'], + fromStack: true, + alias: 'my-prod', }, ]); expect(timings).toEqual([DEPLOYMENT_WAIT_TIMEOUT_MS]); @@ -89,9 +98,13 @@ describe('launch:env:create', () => { 'res-mode': {}, 'auto-deploy': {}, 'cs-auth': {}, + var: {}, + dotenv: {}, + 'from-stack': {}, + alias: {}, }); expect(Object.keys(EnvironmentsCreate.flags).sort()).toEqual(Object.keys(EnvironmentsCreate.inputs).sort()); - expect(EnvironmentsCreate.rules).toEqual([serverCommandFrameworkGate]); + expect(EnvironmentsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack]); }); it('describes --name and --description as the environment its own, leaving the project wording alone', () => { diff --git a/src/commands/launch/env/create.ts b/src/commands/launch/env/create.ts index eef801f..1c8e9be 100644 --- a/src/commands/launch/env/create.ts +++ b/src/commands/launch/env/create.ts @@ -3,6 +3,7 @@ import { flagsFor, inputs } from '../../../core/inputs'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; import { EnvironmentCreator } from '../../../environments/environment.create'; import { serverCommandFrameworkGate, withEnvironmentWording } from '../../../environments/environment.inputs'; +import { aliasRequiresFromStack } from '../../../variables/variable.rules'; const createInputs = inputs({ org: { required: true }, @@ -17,6 +18,10 @@ const createInputs = inputs({ 'res-mode': {}, 'auto-deploy': {}, 'cs-auth': {}, + var: {}, + dotenv: {}, + 'from-stack': {}, + alias: {}, }); export default class EnvironmentsCreate extends LaunchCommand { @@ -33,7 +38,7 @@ export default class EnvironmentsCreate extends LaunchCommand { const resolved = this.resolved; @@ -53,6 +58,10 @@ export default class EnvironmentsCreate extends LaunchCommand; @@ -39,6 +40,10 @@ describe('launch:projects:create', () => { 'res-mode': 'buffered', 'auto-deploy': 'enable', 'cs-auth': 'disable', + var: ['A=1', 'B'], + dotenv: ['a.env', '-'], + 'from-stack': true, + alias: 'my-prod', }).run(); expect(requests).toEqual([ @@ -58,6 +63,10 @@ describe('launch:projects:create', () => { resMode: 'buffered', autoDeploy: 'enable', csAuth: 'disable', + vars: ['A=1', 'B'], + envFiles: ['a.env', '-'], + fromStack: true, + alias: 'my-prod', }, ]); }); @@ -92,7 +101,7 @@ describe('launch:projects:create', () => { }); it('declares only the framework gate as a rule, leaving GitHub-only flags on FileUpload to a confirmation', () => { - expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate]); + expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack]); }); it('declares every flag it reads and requires only the organization', () => { @@ -110,6 +119,10 @@ describe('launch:projects:create', () => { 'res-mode', 'auto-deploy', 'cs-auth', + 'var', + 'dotenv', + 'from-stack', + 'alias', ]); expect(Object.keys(ProjectsCreate.flags).sort()).toEqual(Object.keys(ProjectsCreate.inputs).sort()); expect( diff --git a/src/commands/launch/projects/create.ts b/src/commands/launch/projects/create.ts index 1fd0a7e..e4d3613 100644 --- a/src/commands/launch/projects/create.ts +++ b/src/commands/launch/projects/create.ts @@ -3,6 +3,7 @@ import { flagsFor, inputs } from '../../../core/inputs'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; import { serverCommandFrameworkGate } from '../../../environments/environment.inputs'; import { ProjectCreator } from '../../../projects/project.create'; +import { aliasRequiresFromStack } from '../../../variables/variable.rules'; const createInputs = inputs({ org: { required: true }, @@ -18,6 +19,10 @@ const createInputs = inputs({ 'res-mode': {}, 'auto-deploy': {}, 'cs-auth': {}, + var: {}, + dotenv: {}, + 'from-stack': {}, + alias: {}, }); export default class ProjectsCreate extends LaunchCommand { @@ -36,7 +41,7 @@ export default class ProjectsCreate extends LaunchCommand { static flags = flagsFor(createInputs); - static rules = [serverCommandFrameworkGate]; + static rules = [serverCommandFrameworkGate, aliasRequiresFromStack]; async run(): Promise { const resolved = this.resolved; @@ -57,6 +62,10 @@ export default class ProjectsCreate extends LaunchCommand { resMode: resolved['res-mode'], autoDeploy: resolved['auto-deploy'], csAuth: resolved['cs-auth'], + vars: resolved.var, + envFiles: resolved['dotenv'], + fromStack: resolved['from-stack'], + alias: resolved.alias, }); } } diff --git a/src/commands/launch/variables/list.test.ts b/src/commands/launch/variables/list.test.ts new file mode 100644 index 00000000..34f22f2 --- /dev/null +++ b/src/commands/launch/variables/list.test.ts @@ -0,0 +1,121 @@ +import { REDACTED } from '../../../core/redact'; +import type { UxLike } from '../../../core/prompt'; +import VariablesList from './list'; + +const RESOLVED = { org: 'org1', project: 'proj1', env: 'env1', limit: 100, skip: 0 }; + +function commandUnderTest( + environment: unknown, + resolved: Record = RESOLVED, + answer: () => Promise = async () => environment, +) { + const lines: string[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const fetched: unknown[] = []; + const command = Object.create(VariablesList.prototype) as VariablesList; + + Object.assign(command, { + ux, + resolved, + services: { + ux, + outputIsTTY: false, + api: { + environments: { + get: async (params: unknown) => { + fetched.push(params); + return answer(); + }, + }, + }, + }, + }); + + return { command, lines, fetched }; +} + +describe('launch:variables:list', () => { + it('lists each key with a masked value and the environment update time, never a value', async () => { + const { command, lines, fetched } = commandUnderTest({ + uid: 'env1', + updatedAt: '2026-09-01', + environmentVariables: [ + { key: 'API_KEY', value: 'never-printed-abc' }, + { key: 'API_URL', value: 'https://example.test' }, + ], + }); + + await command.run(); + + const output = lines.join('\n'); + + expect(fetched).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); + expect(lines[1]).toBe('│ KEY │ VALUE │ UPDATED │'); + expect(lines[3]).toBe(`│ API_KEY │ ${REDACTED} │ 2026-09-01 │`); + expect(lines[5]).toBe(`│ API_URL │ ${REDACTED} │ 2026-09-01 │`); + expect(lines[lines.length - 1]).toBe('Showing 1-2 of 2'); + expect(output).toContain('API_KEY'); + expect(output).not.toContain('never-printed-abc'); + expect(output).not.toContain('https://example.test'); + }); + + it('applies limit and skip to the variables and numbers the footer from skip', async () => { + const { command, lines } = commandUnderTest( + { environmentVariables: [{ key: 'A', value: 'a1' }, { key: 'B', value: 'b1' }, { key: 'C', value: 'c1' }] }, + { ...RESOLVED, limit: 1, skip: 1 }, + ); + + await command.run(); + + const output = lines.join('\n'); + + expect(output).toContain('B'); + expect(output).not.toContain('│ A '); + expect(output).not.toContain('│ C '); + expect(lines[lines.length - 1]).toBe('Showing 2-2 of 3'); + }); + + it.each([[{}], [{ environmentVariables: [] }], [{ environmentVariables: null }]])( + 'prints only the empty-table placeholder for an environment with no variables (%p)', + async (environment) => { + const { command, lines } = commandUnderTest(environment); + + await command.run(); + + expect(lines).toEqual(['No records found.']); + }, + ); + + it('renders a placeholder for a variable that carries no key and still hides its value', async () => { + const { command, lines } = commandUnderTest({ environmentVariables: [{ value: 'secret-value' }] }); + + await command.run(); + + expect(lines[3]).toBe(`│ - │ ${REDACTED} │ - │`); + expect(lines.join('\n')).not.toContain('secret-value'); + }); + + it('propagates a failed environment request', async () => { + const failure = new Error('get failed'); + const { command } = commandUnderTest(undefined, undefined, async () => Promise.reject(failure)); + + await expect(command.run()).rejects.toBe(failure); + }); + + it('declares org, project and env as required and limit and skip as optional', () => { + expect(VariablesList.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + limit: {}, + skip: {}, + }); + expect(Object.keys(VariablesList.flags).sort()).toEqual(['env', 'limit', 'org', 'project', 'skip']); + expect(Object.keys(VariablesList.flags).sort()).toEqual(Object.keys(VariablesList.inputs).sort()); + }); +}); diff --git a/src/commands/launch/variables/list.ts b/src/commands/launch/variables/list.ts new file mode 100644 index 00000000..57c9061 --- /dev/null +++ b/src/commands/launch/variables/list.ts @@ -0,0 +1,44 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { renderPagination, renderTable } from '../../../core/render'; +import { pageOfVariables } from '../../../variables/variable.page'; +import { variableColumns } from '../../../variables/variable.presenter'; + +const listInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + limit: {}, + skip: {}, +}); + +export default class VariablesList extends LaunchCommand { + static description = 'List the environment variables of an environment, with their values masked'; + + static examples = [ + '$ csdx launch:variables:list --org --project --env ', + ]; + + static inputs = listInputs; + + static flags = flagsFor(listInputs); + + async run(): Promise { + const environment = await this.services.api.environments.get({ + org: this.resolved.org, + project: this.resolved.project, + environment: this.resolved.env, + }); + const page = pageOfVariables(environment.environmentVariables ?? [], this.resolved.limit, this.resolved.skip); + + renderTable( + this.ux, + variableColumns(environment.updatedAt), + page.variables, + process.stdout.columns, + undefined, + this.services.outputIsTTY, + ); + renderPagination(this.ux, page.pagination, page.variables.length); + } +} diff --git a/src/commands/launch/variables/set.test.ts b/src/commands/launch/variables/set.test.ts new file mode 100644 index 00000000..d1c1180 --- /dev/null +++ b/src/commands/launch/variables/set.test.ts @@ -0,0 +1,105 @@ +import type { UxLike } from '../../../core/prompt'; +import { aliasRequiresFromStack } from '../../../variables/variable.rules'; +import { VariableSetter } from '../../../variables/variable.set'; +import VariablesSet from './set'; + +function commandUnderTest(resolved: Record, files: Record = {}) { + const lines: string[] = []; + const updates: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const command = Object.create(VariablesSet.prototype) as VariablesSet; + + Object.assign(command, { + ux, + resolved, + dataDir: '/work', + services: { + ux, + isTTY: false, + outputIsTTY: false, + api: { + environments: { + get: async () => ({ environmentVariables: [] }), + update: async (params: unknown) => { + updates.push(params); + return {}; + }, + }, + }, + }, + }); + + return { command, lines, updates, files }; +} + +describe('launch:variables:set', () => { + it('sets the --var pairs through the environment endpoints and prints the provenance', async () => { + const { command, lines, updates } = commandUnderTest({ org: 'o', project: 'p', env: 'e', var: ['A=1', 'B=2'] }); + + await command.run(); + + expect(updates).toEqual([ + { org: 'o', project: 'p', environment: 'e', update: { environmentVariables: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }] } }, + ]); + expect(lines).toEqual(['✔ 2 variables set — 2 from --var']); + }); + + it('hands --from-stack and --alias, the data directory and every source flag to the setter', async () => { + const requests: unknown[] = []; + jest.spyOn(VariableSetter.prototype, 'set').mockImplementation(async (request) => { + requests.push(request); + }); + + const { command } = commandUnderTest({ + org: 'o', + project: 'p', + env: 'e', + var: ['A=1'], + dotenv: ['a.env'], + 'from-stack': true, + alias: 'my-prod', + }); + + await command.run(); + + expect(requests).toEqual([ + { + org: 'o', + project: 'p', + environment: 'e', + vars: ['A=1'], + envFiles: ['a.env'], + fromStack: true, + alias: 'my-prod', + dataDir: '/work', + }, + ]); + }); + + it('propagates a malformed --var as a usage error before any request', async () => { + const { command, updates } = commandUnderTest({ org: 'o', project: 'p', env: 'e', var: ['A='] }); + + await expect(command.run()).rejects.toThrow('A has an empty value.'); + expect(updates).toEqual([]); + }); + + it('declares org, project and env as required and the two source flags as optional', () => { + expect(VariablesSet.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + var: {}, + dotenv: {}, + 'from-stack': {}, + alias: {}, + }); + expect(Object.keys(VariablesSet.flags).sort()).toEqual(['alias', 'dotenv', 'env', 'from-stack', 'org', 'project', 'var']); + expect(VariablesSet.rules).toEqual([aliasRequiresFromStack]); + expect(Object.keys(VariablesSet.flags).sort()).toEqual(Object.keys(VariablesSet.inputs).sort()); + }); +}); diff --git a/src/commands/launch/variables/set.ts b/src/commands/launch/variables/set.ts new file mode 100644 index 00000000..6270f67 --- /dev/null +++ b/src/commands/launch/variables/set.ts @@ -0,0 +1,47 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { aliasRequiresFromStack } from '../../../variables/variable.rules'; +import { VariableSetter } from '../../../variables/variable.set'; + +const setInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + var: {}, + dotenv: {}, + 'from-stack': {}, + alias: {}, +}); + +export default class VariablesSet extends LaunchCommand { + static description = + 'Add or change environment variables from --var, --dotenv or a detected .env file; later sources win'; + + static examples = [ + '$ csdx launch:variables:set --org --project --env ' + + '--var API_URL=https://example.test DEBUG=true', + '$ csdx launch:variables:set --org --project --env ' + + '--dotenv .env.staging', + '$ printf \'%s\' "$TOKEN" | csdx launch:variables:set --org --project ' + + '--env --var API_TOKEN', + ]; + + static inputs = setInputs; + + static flags = flagsFor(setInputs); + + static rules = [aliasRequiresFromStack]; + + async run(): Promise { + await new VariableSetter(this.services).set({ + org: this.resolved.org, + project: this.resolved.project, + environment: this.resolved.env, + vars: this.resolved.var, + envFiles: this.resolved['dotenv'], + fromStack: this.resolved['from-stack'], + alias: this.resolved.alias, + dataDir: this.dataDir, + }); + } +} diff --git a/src/commands/launch/variables/unset.test.ts b/src/commands/launch/variables/unset.test.ts new file mode 100644 index 00000000..aa037fd --- /dev/null +++ b/src/commands/launch/variables/unset.test.ts @@ -0,0 +1,155 @@ +import { MissingInputError, UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import VariablesUnset from './unset'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; + +interface Harness { + answer?: boolean; + isTTY?: boolean; + yes?: boolean; + key?: string[]; + updateFails?: Error; +} + +function commandUnderTest(harness: Harness) { + const lines: string[] = []; + const inquired: unknown[] = []; + const calls: string[] = []; + const updates: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: unknown) => { + inquired.push(payload); + return harness.answer as never; + }, + }; + const command = Object.create(VariablesUnset.prototype) as VariablesUnset; + + Object.assign(command, { + ux, + resolved: { + org: 'org1', + project: 'proj1', + env: 'env1', + yes: harness.yes ?? false, + key: 'key' in harness ? harness.key : ['API_KEY'], + }, + services: { + ux, + isTTY: harness.isTTY ?? true, + outputIsTTY: false, + api: { + environments: { + get: async () => { + calls.push('get'); + return { name: 'staging', environmentVariables: [{ key: 'API_KEY', value: 'never-printed-value' }, { key: 'DEBUG', value: 'x' }] }; + }, + update: async (params: unknown) => { + calls.push('update'); + updates.push(params); + + if (harness.updateFails) { + throw harness.updateFails; + } + + return {}; + }, + }, + }, + }, + }); + + return { command, lines, inquired, calls, updates }; +} + +describe('launch:variables:unset', () => { + it('confirms, removes only the named key and says so', async () => { + const { command, lines, inquired, calls, updates } = commandUnderTest({ answer: true }); + + await command.run(); + + expect(inquired).toEqual([ + { type: 'confirm', name: 'confirm', message: 'Are you sure you want to remove API_KEY from "staging"?', default: false }, + ]); + expect(calls).toEqual(['get', 'get', 'update']); + expect(updates).toEqual([{ ...SCOPE, update: { environmentVariables: [{ key: 'DEBUG', value: 'x' }] } }]); + expect(lines).toEqual(['✔ API_KEY removed from staging.']); + expect(lines.join('')).not.toContain('never-printed-value'); + }); + + it('skips the question when --yes was passed', async () => { + const { command, inquired, calls, lines } = commandUnderTest({ yes: true }); + + await command.run(); + + expect(inquired).toEqual([]); + expect(calls).toEqual(['get', 'get', 'update']); + expect(lines).toEqual(['✔ API_KEY removed from staging.']); + }); + + it('says the variables were not removed and exits 3 without writing when the user declines', async () => { + const { command, lines, updates } = commandUnderTest({ answer: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as { oclif?: { exit?: number } }; + + expect(error.oclif?.exit).toBe(3); + expect(updates).toEqual([]); + expect(lines).toEqual(['Variables not removed.']); + }); + + it('exits 2 naming --yes without asking or writing when there is no terminal', async () => { + const { command, inquired, updates, lines } = commandUnderTest({ isTTY: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe( + 'Are you sure you want to remove API_KEY from "staging"? Pass --yes to confirm without an interactive terminal.', + ); + expect(inquired).toEqual([]); + expect(updates).toEqual([]); + expect(lines).toEqual([]); + }); + + it.each([[undefined], [[]]])('exits 2 naming --key when none was given (%p)', async (key) => { + const { command, calls } = commandUnderTest({ key: key as string[] | undefined }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as MissingInputError; + + expect(error).toBeInstanceOf(MissingInputError); + expect(error.exitCode).toBe(2); + expect(error.message).toContain('--key'); + expect(calls).toEqual([]); + }); + + it('refuses a key that is not set without asking or writing', async () => { + const { command, inquired, updates } = commandUnderTest({ key: ['NOPE'] }); + + await expect(command.run()).rejects.toThrow('NOPE is not set in this environment.'); + expect(inquired).toEqual([]); + expect(updates).toEqual([]); + }); + + it('propagates a failed write rather than reporting a success', async () => { + const failure = new Error('update exploded'); + const { command, lines } = commandUnderTest({ yes: true, updateFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(lines).toEqual([]); + }); + + it('declares org, project, env and key and opts into the confirm gate', () => { + expect(VariablesUnset.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + key: {}, + yes: {}, + }); + expect(Object.keys(VariablesUnset.flags).sort()).toEqual(['env', 'key', 'org', 'project', 'yes']); + }); +}); diff --git a/src/commands/launch/variables/unset.ts b/src/commands/launch/variables/unset.ts new file mode 100644 index 00000000..b91b84c --- /dev/null +++ b/src/commands/launch/variables/unset.ts @@ -0,0 +1,48 @@ +import { EXIT_CANCELLED } from '../../../core/constants'; +import { MissingInputError } from '../../../core/errors'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { LaunchCommand } from '../../../core/launch-command'; +import { styled } from '../../../core/style'; +import { VariableRemover } from '../../../variables/variable.unset'; + +const unsetInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + key: {}, + yes: {}, +}); + +export default class VariablesUnset extends LaunchCommand { + static description = 'Remove environment variables from an environment, after asking for confirmation'; + + static examples = [ + '$ csdx launch:variables:unset --org --project --env --key API_KEY', + '$ csdx launch:variables:unset --org --project --env ' + + '--key API_KEY --key DEBUG --yes', + ]; + + static inputs = unsetInputs; + + static flags = flagsFor(unsetInputs); + + async run(): Promise { + const keys = this.resolved.key ?? []; + + if (keys.length === 0) { + throw new MissingInputError('key', { config: false, prompt: false }); + } + + const removed = await new VariableRemover(this.services, (question) => this.confirm(question)).remove({ + org: this.resolved.org, + project: this.resolved.project, + environment: this.resolved.env, + keys, + }); + + if (!removed) { + this.ux.print(styled('Variables not removed.', 'yellow', this.services.outputIsTTY === true)); + this.exit(EXIT_CANCELLED); + } + } +} diff --git a/src/core/prompt.test.ts b/src/core/prompt.test.ts index a8be3fb..2fc3f9f 100644 --- a/src/core/prompt.test.ts +++ b/src/core/prompt.test.ts @@ -195,6 +195,16 @@ describe('askChoices', () => { expect(validate(['a'])).toBe(true); }); + it('accepts an empty answer when one is not required', async () => { + const { asked, ux } = scriptedUx([[]]); + + await askChoices(ux, 'Which ones?', [{ name: 'A', value: 'a' }], { requireOne: false }); + const validate = (asked[0] as { validate(value: unknown): true | string }).validate; + + expect(validate([])).toBe(true); + expect(validate(undefined)).toBe(true); + }); + it.each([[[]], [undefined], ['b']])('answers no choice at all when the prompt returns %p', async (answer) => { const { ux } = scriptedUx([answer]); diff --git a/src/core/prompt.ts b/src/core/prompt.ts index 8010999..b2d47b4 100644 --- a/src/core/prompt.ts +++ b/src/core/prompt.ts @@ -95,9 +95,16 @@ export async function askOption(ux: UxLike, message: string, choices: Choice[], ); } -export async function askChoices(ux: UxLike, message: string, choices: Choice[]): Promise { +export async function askChoices( + ux: UxLike, + message: string, + choices: Choice[], + options: { requireOne?: boolean } = {}, +): Promise { const validate = (picked: unknown): true | string => - Array.isArray(picked) && picked.length > 0 ? true : 'Select at least one with , then press .'; + options.requireOne === false || (Array.isArray(picked) && picked.length > 0) + ? true + : 'Select at least one with , then press .'; const answer = await ux.inquire({ type: 'checkbox', name: 'value', diff --git a/src/environments/environment.builder.test.ts b/src/environments/environment.builder.test.ts index 41eaf43..3dc84b0 100644 --- a/src/environments/environment.builder.test.ts +++ b/src/environments/environment.builder.test.ts @@ -32,6 +32,7 @@ function builder(options: { isTTY?: boolean; answers?: unknown[] } = {}) { } const EVERY_FLAG: EnvironmentRequest = { + org: 'org1', framework: 'OTHER', buildCmd: 'npm run build', outputDir: './dist', @@ -41,8 +42,8 @@ const EVERY_FLAG: EnvironmentRequest = { }; describe('EnvironmentBuilder', () => { - it('builds the environment from the flags alone, asking and printing nothing', async () => { - const { environments, printed, asked } = builder({ isTTY: true }); + it('builds the environment from the flags alone, asking only whether to add variables', async () => { + const { environments, printed, asked } = builder({ isTTY: true, answers: [['Skip adding environment variables']] }); const environment = await environments.build(EVERY_FLAG, ENV_NAME, GIT_SOURCE, 'enable'); @@ -59,10 +60,43 @@ describe('EnvironmentBuilder', () => { autoDeployOnPush: true, isContentstackAuthenticationEnabled: false, }); + expect(asked).toEqual([{ message: 'Add environment variables', default: undefined }]); + expect(printed).toEqual(['Skipped adding environment variables.']); + }); + + it('carries the variables from --var and --dotenv into the environment and asks nothing about them', async () => { + const { environments, asked, printed } = builder({ isTTY: true }); + + const environment = await environments.build( + { ...EVERY_FLAG, vars: ['API_URL=https://example.test'], envFiles: [] }, + ENV_NAME, + GIT_SOURCE, + undefined, + ); + + expect(environment.environmentVariables).toEqual([{ key: 'API_URL', value: 'https://example.test' }]); + expect(asked).toEqual([]); + expect(printed).toEqual(['\u2714 1 variable ready to set \u2014 1 from --var']); + }); + + it('sends no variables and asks nothing about them without a terminal and without flags', async () => { + const { environments, asked, printed } = builder({ isTTY: false }); + + const environment = await environments.build(EVERY_FLAG, ENV_NAME, GIT_SOURCE, undefined); + + expect(environment.environmentVariables).toEqual([]); expect(asked).toEqual([]); expect(printed).toEqual([]); }); + it('asks about variables last, after Contentstack Authentication', async () => { + const { environments, asked } = builder({ isTTY: true, answers: ['enable', ['Skip adding environment variables']] }); + + await environments.build({ ...EVERY_FLAG, csAuth: undefined }, ENV_NAME, GIT_SOURCE, undefined); + + expect(asked.map((entry) => entry.message)).toEqual(['Contentstack Authentication', 'Add environment variables']); + }); + it('carries an upload uid and leaves auto-deploy unset when none was given', async () => { const { environments } = builder(); @@ -88,7 +122,7 @@ describe('EnvironmentBuilder', () => { branch: 'main', }; - const environment = await environments.build({}, ENV_NAME, source, undefined); + const environment = await environments.build({ org: 'org1' }, ENV_NAME, source, undefined); expect(environment).toEqual( expect.objectContaining({ @@ -111,7 +145,7 @@ describe('EnvironmentBuilder', () => { it("falls back to the framework's default output directory without a terminal or a detected one", async () => { const { environments, printed } = builder(); - const environment = await environments.build({ framework: 'NEXTJS' }, ENV_NAME, GIT_SOURCE, undefined); + const environment = await environments.build({ org: 'org1', framework: 'NEXTJS' }, ENV_NAME, GIT_SOURCE, undefined); expect(environment.outputDirectory).toBe('./.next'); expect(printed).toContain('Using the default output directory "./.next" for NEXTJS. Pass --output-dir to change it.'); @@ -120,7 +154,7 @@ describe('EnvironmentBuilder', () => { it('refuses to guess a framework it could not detect without a terminal', async () => { const { environments } = builder(); - const building = environments.build({}, ENV_NAME, { detected: { framework: 'not-a-framework' } }, undefined); + const building = environments.build({ org: 'org1' }, ENV_NAME, { detected: { framework: 'not-a-framework' } }, undefined); await expect(building).rejects.toThrow(MissingInputError); await expect(building).rejects.toThrow('--framework'); @@ -130,7 +164,7 @@ describe('EnvironmentBuilder', () => { const { environments } = builder(); const building = environments.build( - { framework: 'NEXTJS', serverCmd: 'node server.js' }, + { org: 'org1', framework: 'NEXTJS', serverCmd: 'node server.js' }, ENV_NAME, GIT_SOURCE, undefined, @@ -143,13 +177,13 @@ describe('EnvironmentBuilder', () => { it('asks for every unset value in a terminal, offering what was detected', async () => { const { environments, asked } = builder({ isTTY: true, - answers: ['Other', 'npm run build', './out', 'node server.js', 'streaming', 'enable'], + answers: ['Other', 'npm run build', './out', 'node server.js', 'streaming', 'enable', ['Skip adding environment variables']], }); const source: EnvironmentSource = { detected: { framework: 'other', buildCommand: 'make', outputDirectory: './public', serverCommand: 'node .' }, }; - const environment = await environments.build({}, ENV_NAME, source, undefined); + const environment = await environments.build({ org: 'org1' }, ENV_NAME, source, undefined); expect(asked).toEqual([ { message: 'Framework preset', default: 'Other' }, @@ -158,6 +192,7 @@ describe('EnvironmentBuilder', () => { { message: 'Server command', default: 'node .' }, { message: 'Response mode', default: 'buffered' }, { message: 'Contentstack Authentication', default: 'enable' }, + { message: 'Add environment variables', default: undefined }, ]); expect(environment).toEqual( expect.objectContaining({ @@ -174,7 +209,7 @@ describe('EnvironmentBuilder', () => { it('does not ask for a server command when the framework takes none', async () => { const { environments, asked } = builder({ isTTY: true, answers: ['', './.next', 'buffered', 'disable'] }); - const environment = await environments.build({ framework: 'NEXTJS' }, ENV_NAME, GIT_SOURCE, undefined); + const environment = await environments.build({ org: 'org1', framework: 'NEXTJS' }, ENV_NAME, GIT_SOURCE, undefined); expect(asked.map((question) => question.message)).not.toContain('Server command'); expect(environment.serverCommand).toBeUndefined(); diff --git a/src/environments/environment.builder.ts b/src/environments/environment.builder.ts index 8a18f15..5372fe3 100644 --- a/src/environments/environment.builder.ts +++ b/src/environments/environment.builder.ts @@ -11,12 +11,9 @@ import { OUTPUT_DIRECTORY_BY_FRAMEWORK, SERVER_COMMAND_FRAMEWORKS, } from './frameworks'; +import { variablesForCreate } from '../variables/variable.create'; import type { CreateEnvironmentInput, DetectedFramework, EnvironmentRequest, EnvironmentSource } from './types'; -function emptyEnvironmentVariables(): [] { - return []; -} - export function frameworkLabelOf(detected: unknown): string | undefined { if (typeof detected !== 'string') { return undefined; @@ -46,7 +43,7 @@ export class EnvironmentBuilder { outputDirectory: await this.outputDirectory(request, framework, source.detected), serverCommand: await this.serverCommand(request, framework, source.detected), frameworkPreset: framework, - environmentVariables: emptyEnvironmentVariables(), + environmentVariables: [], isStreamingEnabled: await this.streaming(request), }; @@ -60,6 +57,18 @@ export class EnvironmentBuilder { environment.isContentstackAuthenticationEnabled = csAuth === 'enable'; } + environment.environmentVariables = await variablesForCreate( + { + org: request.org, + vars: request.vars, + envFiles: request.envFiles, + fromStack: request.fromStack, + alias: request.alias, + dataDir: request.dataDir ?? '.', + }, + this.services, + ); + return environment; } diff --git a/src/environments/environment.create.test.ts b/src/environments/environment.create.test.ts index 7f0bd1c..b409b03 100644 --- a/src/environments/environment.create.test.ts +++ b/src/environments/environment.create.test.ts @@ -252,6 +252,7 @@ describe('EnvironmentCreator', () => { 'Output directory': './.next', 'Response mode': 'streaming', 'Contentstack Authentication': 'enable', + 'Add environment variables': ['Skip adding environment variables'], }, }); @@ -265,6 +266,7 @@ describe('EnvironmentCreator', () => { 'Output directory', 'Response mode', 'Contentstack Authentication', + 'Add environment variables', ]); expect(calls[1]).toBe( 'branches {"org":"org1","provider":"GitHub","namespace":"octo-user","repoName":"octo-user/site","limit":100,"skip":0}', @@ -280,13 +282,17 @@ describe('EnvironmentCreator', () => { const { creator, calls, asked } = creatorUnderTest({ project: UPLOAD_PROJECT, isTTY: true, - answers: { 'Environment name': 'staging', 'Contentstack Authentication': 'enable' }, + answers: { + 'Environment name': 'staging', + 'Contentstack Authentication': 'enable', + 'Add environment variables': ['Skip adding environment variables'], + }, }); await creator.create(request({ name: undefined, branch: undefined, framework: 'GATSBY', buildCmd: 'b', outputDir: 'o' })); expect(calls[1]).toMatch(/^upload /); - expect(asked).toEqual(['Environment name', 'Contentstack Authentication']); + expect(asked).toEqual(['Environment name', 'Contentstack Authentication', 'Add environment variables']); }); it.each([ diff --git a/src/environments/environment.errors.test.ts b/src/environments/environment.errors.test.ts new file mode 100644 index 00000000..f4c418c --- /dev/null +++ b/src/environments/environment.errors.test.ts @@ -0,0 +1,25 @@ +import { LaunchApiError } from '../transport/errors'; +import { ENVIRONMENT_ERROR_MESSAGES } from './environment.errors'; + +const VARIABLE_CODES: Record = { + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.BYTE_SIZE_LIMIT_REACHED': + 'The environment variables are larger than Launch allows. Remove some variables or shorten their values.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.MAX_COUNT_REACHED': + 'This environment has reached its limit on the number of variables.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.DUPLICATE_KEYS': 'The same variable key appears more than once.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_EMPTY': 'A variable key cannot be empty.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_STRING': 'A variable key must be text.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.TOO_LONG': 'A variable key is too long.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.NOT_EMPTY': 'A variable value cannot be empty.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.NOT_STRING': 'A variable value must be text.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.TOO_LONG': 'A variable value is too long.', +}; + +describe('environment errors', () => { + it.each(Object.entries(VARIABLE_CODES))('rewords %s without echoing anything from the request', (code, message) => { + const error = new LaunchApiError(400, [{ code, message: 'API_KEY=never-printed-value rejected' }], ENVIRONMENT_ERROR_MESSAGES); + + expect(error.message).toBe(message); + expect(error.message).not.toContain('never-printed-value'); + }); +}); diff --git a/src/environments/environment.errors.ts b/src/environments/environment.errors.ts index fde50ac..ead07f8 100644 --- a/src/environments/environment.errors.ts +++ b/src/environments/environment.errors.ts @@ -5,6 +5,17 @@ export const ENVIRONMENT_ERROR_MESSAGES: ErrorMessages = { 'launch.ENVIRONMENT.DUPLICATE_NAME': 'An environment with that name already exists in this project.', 'launch.ENVIRONMENT.LIMIT_REACHED': 'This project has reached its environment limit.', 'launch.ENVIRONMENT.CREATE_FAILED': 'The Launch API could not create that environment.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.BYTE_SIZE_LIMIT_REACHED': + 'The environment variables are larger than Launch allows. Remove some variables or shorten their values.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.MAX_COUNT_REACHED': + 'This environment has reached its limit on the number of variables.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.DUPLICATE_KEYS': 'The same variable key appears more than once.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_EMPTY': 'A variable key cannot be empty.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_STRING': 'A variable key must be text.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.TOO_LONG': 'A variable key is too long.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.NOT_EMPTY': 'A variable value cannot be empty.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.NOT_STRING': 'A variable value must be text.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.VALUE.TOO_LONG': 'A variable value is too long.', }; export const DUPLICATE_NAME_CODE = 'launch.ENVIRONMENT.DUPLICATE_NAME'; diff --git a/src/environments/types.ts b/src/environments/types.ts index 9b5196d..aa348e5 100644 --- a/src/environments/types.ts +++ b/src/environments/types.ts @@ -62,6 +62,7 @@ export interface EnvironmentUpdate { isContentstackAuthenticationEnabled?: boolean; isCachePrimingEnabled?: boolean; passwordProtection?: PasswordProtectionUpdate; + environmentVariables?: EnvironmentVariableInput[]; } export type IdentifiedEnvironment = Environment & { uid: string }; @@ -94,6 +95,12 @@ export interface DetectedFramework { } export interface EnvironmentRequest { + org: string; + dataDir?: string; + vars?: string[]; + envFiles?: string[]; + fromStack?: boolean; + alias?: string; framework?: FrameworkPreset; buildCmd?: string; outputDir?: string; diff --git a/src/organizations/organizations.api.test.ts b/src/organizations/organizations.api.test.ts index 732b3a8..0a8dd6a 100644 --- a/src/organizations/organizations.api.test.ts +++ b/src/organizations/organizations.api.test.ts @@ -23,6 +23,12 @@ function pagedSession(pages: CmaCollection[], scoped?: string) { fetched.push(uid); return { uid, name: 'Scoped Org' }; }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => scoped, }; @@ -37,6 +43,12 @@ function failingSession(error: unknown, scoped?: string): OrganizationsApi { fetchOrganization: async () => { throw error; }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => scoped, }); } @@ -174,6 +186,12 @@ describe('OrganizationsApi.available', () => { const api = new OrganizationsApi({ fetchOrganizations: async () => ({}), fetchOrganization: async () => found, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => 'org7', }); diff --git a/src/projects/project.create.test.ts b/src/projects/project.create.test.ts index 2ff82df..515fb68 100644 --- a/src/projects/project.create.test.ts +++ b/src/projects/project.create.test.ts @@ -116,6 +116,8 @@ interface Scenario { createdProject?: unknown; } +const VARIABLES_PROMPT = 'Add environment variables'; +const SKIP_VARIABLES = 'Skip adding environment variables'; const CONNECT_URL = 'https://dev11-app.csnonprod.com/#!/launch/settings/connected-accounts'; const CONNECTION_IDENTIFIED = 'info: GitHub connection identified!'; const PROJECT_CREATED = 'info: New project created successfully'; @@ -126,6 +128,7 @@ function harness(scenario: Scenario = {}) { const printed: string[] = []; const timeline: string[] = []; const asked: unknown[] = []; + const everyPrompt: unknown[] = []; const askedPayloads: Record[] = []; const created: unknown[] = []; const gitCalls: unknown[] = []; @@ -150,7 +153,14 @@ function harness(scenario: Scenario = {}) { timeline.push(`print ${message}`); }, inquire: async (payload: unknown) => { - asked.push((payload as { message: string }).message); + const { message } = payload as { message: string }; + everyPrompt.push(message); + + if (message === VARIABLES_PROMPT) { + return [SKIP_VARIABLES] as never; + } + + asked.push(message); askedPayloads.push(payload as Record); const answer = (scenario.answers ?? [])[answerIndex]; answerIndex += 1; @@ -304,6 +314,7 @@ function harness(scenario: Scenario = {}) { printed, asked, askedPayloads, + everyPrompt, created, gitCalls, calls, @@ -450,7 +461,7 @@ describe('ProjectCreator on the GitHub path', () => { await creator.create({ ...gitRequest(), - ...({ var: 'SECRET=hunter2', 'env-file': '.env', 'from-stack': 'blt1' } as object), + ...({ var: 'SECRET=hunter2', dotenv: '.env', 'from-stack': 'blt1' } as object), } as CreateRequest); const environment = bodyOf(created).environment as Record; @@ -874,7 +885,7 @@ describe('ProjectCreator prompting order and refusals', () => { }); it('asks in the order the doc pins: type, project name, environment name, framework, build, output, response, Contentstack Authentication', async () => { - const { creator, asked } = harness({ + const { creator, asked, everyPrompt } = harness({ isTTY: true, answers: ['GitHub', 'My Site', 'Default', 'main', 'NextJs', 'npm run build', '.next', 'buffered', 'enable'], }); @@ -892,6 +903,7 @@ describe('ProjectCreator prompting order and refusals', () => { 'Response mode', 'Contentstack Authentication', ]); + expect(everyPrompt).toEqual([...(asked as string[]), VARIABLES_PROMPT]); }); it('offers Default as the environment name, so pressing enter is enough, as V1 did', async () => { diff --git a/src/projects/projects.api.test.ts b/src/projects/projects.api.test.ts index f665957..17c5f02 100644 --- a/src/projects/projects.api.test.ts +++ b/src/projects/projects.api.test.ts @@ -15,6 +15,12 @@ const UNUSED_CMA: CmaSession = { fetchOrganization: async () => { throw new Error('this test fetches no organization'); }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => undefined, }; diff --git a/src/resources.test.ts b/src/resources.test.ts index 589835c..1f356b8 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -21,6 +21,12 @@ const UNUSED_CMA: CmaSession = { fetchOrganization: async () => { throw new Error('this test fetches no organization'); }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => undefined, }; @@ -173,7 +179,9 @@ describe('the api surface', () => { 'git', 'organizations', 'projects', + 'stacks', 'uploads', + 'variables', ]); expect(api.deploymentLogs).toBeInstanceOf(DeploymentLogsApi); expect(api.organizations).toBeInstanceOf(OrganizationsApi); @@ -215,6 +223,12 @@ describe('the api surface', () => { fetched.push(uid); return { uid, name: 'Scoped Org' }; }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => 'org7', }; diff --git a/src/resources.ts b/src/resources.ts index f76893d..76293c0 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -7,6 +7,7 @@ import { environmentFlags, environmentResolution, } from './environments/environment.inputs'; +import { variableFlags, variableResolution } from './variables/variable.inputs'; import { ProjectsApi } from './projects/projects.api'; import { EnvironmentsApi } from './environments/environments.api'; import { DeploymentsApi } from './deployments/deployments.api'; @@ -14,10 +15,12 @@ import { DeploymentLogsApi } from './deployments/deployment-logs.api'; import { GitApi } from './git/git.api'; import { OrganizationsApi } from './organizations/organizations.api'; import { UploadsApi } from './uploads/uploads.api'; +import { StacksApi } from './stacks/stacks.api'; +import { VariablesApi } from './variables/variables.api'; import type { CmaSession } from './transport/cma-client'; import type { RestApiClient } from './transport/rest-client'; -export const catalog = { ...organizationFlags, ...coreFlags, ...projectFlags, ...environmentFlags }; +export const catalog = { ...organizationFlags, ...coreFlags, ...projectFlags, ...environmentFlags, ...variableFlags }; export type Catalog = typeof catalog; export type FlagKey = keyof Catalog; @@ -27,6 +30,7 @@ export const resolution = { ...organizationResolution, ...coreResolution, ...environmentResolution, + ...variableResolution, }; export type Resolution = typeof resolution; @@ -49,6 +53,8 @@ export interface ApiSurface { deploymentLogs: DeploymentLogsApi; git: GitApi; uploads: UploadsApi; + stacks: StacksApi; + variables: VariablesApi; } export function buildApi(client: RestApiClient, cma: CmaSession, logsClient: RestApiClient): ApiSurface { @@ -60,5 +66,7 @@ export function buildApi(client: RestApiClient, cma: CmaSession, logsClient: Res deploymentLogs: new DeploymentLogsApi(logsClient), git: new GitApi(client), uploads: new UploadsApi(client), + stacks: new StacksApi(cma), + variables: new VariablesApi(client), }; } diff --git a/src/stacks/stack.errors.ts b/src/stacks/stack.errors.ts new file mode 100644 index 00000000..cf2ba94 --- /dev/null +++ b/src/stacks/stack.errors.ts @@ -0,0 +1,12 @@ +import { EXIT_RUNTIME } from '../core/constants'; +import { LaunchError } from '../core/errors'; +import { asSentence } from '../core/values'; + +export class StackLookupError extends LaunchError { + readonly exitCode = EXIT_RUNTIME; + + constructor(subject: string, reason: string) { + super(`Could not list ${subject}: ${asSentence(reason)}`); + this.name = 'StackLookupError'; + } +} diff --git a/src/stacks/stacks.api.test.ts b/src/stacks/stacks.api.test.ts new file mode 100644 index 00000000..ce6a043 --- /dev/null +++ b/src/stacks/stacks.api.test.ts @@ -0,0 +1,149 @@ +import { EXIT_RUNTIME } from '../core/constants'; +import type { CmaCollection, CmaSession } from '../transport/cma-client'; +import { StackLookupError } from './stack.errors'; +import { StacksApi } from './stacks.api'; + +function session(stacks: CmaCollection | Error, tokens: CmaCollection | Error) { + const asked: { stacks: string[]; tokens: string[] } = { stacks: [], tokens: [] }; + const cma = { + fetchStacks: async (organizationUid: string) => { + asked.stacks.push(organizationUid); + + if (stacks instanceof Error) { + throw stacks; + } + + return stacks; + }, + fetchDeliveryTokens: async (apiKey: string) => { + asked.tokens.push(apiKey); + + if (tokens instanceof Error) { + throw tokens; + } + + return tokens; + }, + } as unknown as CmaSession; + + return { api: new StacksApi(cma), asked }; +} + +describe('stacks api', () => { + it('lists the stacks of an organization by name and api key', async () => { + const { api, asked } = session({ items: [{ name: 'Shop', api_key: 'blt1' }, { name: 'Blog', api_key: 'blt2' }] }, {}); + + await expect(api.list('org7')).resolves.toEqual([ + { name: 'Shop', apiKey: 'blt1' }, + { name: 'Blog', apiKey: 'blt2' }, + ]); + expect(asked.stacks).toEqual(['org7']); + }); + + it('names a stack by its api key when it has no name and skips an entry with no api key', async () => { + const { api } = session( + { items: [{ api_key: 'blt1' }, { name: 'Nameless key' }, { name: 'Empty', api_key: ' ' }, null, 'text', { name: ' ', api_key: 'blt3' }] }, + {}, + ); + + await expect(api.list('org7')).resolves.toEqual([ + { name: 'blt1', apiKey: 'blt1' }, + { name: 'blt3', apiKey: 'blt3' }, + ]); + }); + + it.each([[{}], [{ items: [] }]])('lists nothing when the organization holds no stacks (%p)', async (reply) => { + const { api } = session(reply, {}); + + await expect(api.list('org7')).resolves.toEqual([]); + }); + + it('lists the delivery tokens of a stack with the environment each is scoped to', async () => { + const { api, asked } = session( + {}, + { + items: [ + { name: 'prod-token', token: 'cs-aaa', scope: [{ module: 'environment', environments: [{ name: 'production' }, { name: 'staging' }] }] }, + { name: 'dev-token', token: 'cs-bbb', scope: [{ module: 'environment', environments: [{ name: 'development' }] }] }, + ], + }, + ); + + await expect(api.deliveryTokens('blt1')).resolves.toEqual([ + { name: 'prod-token', token: 'cs-aaa', environment: 'production' }, + { name: 'dev-token', token: 'cs-bbb', environment: 'development' }, + ]); + expect(asked.tokens).toEqual(['blt1']); + }); + + it.each([ + [undefined], + [[]], + [[{ module: 'environment' }]], + [[{ module: 'environment', environments: [] }]], + [[{ module: 'environment', environments: [{ name: ' ' }] }]], + [[{ module: 'environment', environments: [{}] }]], + [[null, 'text']], + ])('leaves the environment unset when the token scope names none (%p)', async (scope) => { + const { api } = session({}, { items: [{ name: 'prod-token', token: 'cs-aaa', scope }] }); + + await expect(api.deliveryTokens('blt1')).resolves.toEqual([{ name: 'prod-token', token: 'cs-aaa', environment: undefined }]); + }); + + it('skips a token with no value and names one by its position when it has no name', async () => { + const { api } = session( + {}, + { items: [{ name: 'no-value' }, { token: 'cs-bbb' }, { name: 'blank', token: ' ' }, null, { name: 'ok', token: 'cs-ccc' }] }, + ); + + await expect(api.deliveryTokens('blt1')).resolves.toEqual([ + { name: 'Delivery token 2', token: 'cs-bbb', environment: undefined }, + { name: 'ok', token: 'cs-ccc', environment: undefined }, + ]); + }); + + it.each([[{}], [{ items: [] }]])('lists nothing when the stack holds no delivery tokens (%p)', async (reply) => { + const { api } = session({}, reply); + + await expect(api.deliveryTokens('blt1')).resolves.toEqual([]); + }); + + it('words a failed stack lookup as a runtime failure naming the reason', async () => { + const { api } = session(Object.assign(new Error('x'), { errorMessage: 'You do not have access.' }), {}); + + const error = await api.list('org7').catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(StackLookupError); + expect((error as StackLookupError).exitCode).toBe(EXIT_RUNTIME); + expect((error as StackLookupError).message).toBe('Could not list the stacks of this organization: You do not have access.'); + }); + + it('words a failed delivery token lookup as a runtime failure naming the stack and the reason', async () => { + const { api } = session({}, new Error('socket hang up')); + + const error = await api.deliveryTokens('blt1').catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(StackLookupError); + expect((error as StackLookupError).message).toBe('Could not list the delivery tokens of stack blt1: socket hang up.'); + }); + + it('uses a plain text rejection as the reason', async () => { + const failing = new StacksApi({ + fetchStacks: async () => Promise.reject('rate limited'), + fetchDeliveryTokens: async () => ({}), + } as unknown as CmaSession); + + await expect(failing.list('org7')).rejects.toThrow('Could not list the stacks of this organization: rate limited.'); + }); + + it('says so when the Management API gave no reason', async () => { + const failing = new StacksApi({ + fetchStacks: async () => Promise.reject(undefined), + fetchDeliveryTokens: async () => ({}), + } as unknown as CmaSession); + + await expect(failing.list('org7')).rejects.toThrow( + 'Could not list the stacks of this organization: the Contentstack Management API gave no reason.', + ); + }); +}); diff --git a/src/stacks/stacks.api.ts b/src/stacks/stacks.api.ts new file mode 100644 index 00000000..bf34a2c --- /dev/null +++ b/src/stacks/stacks.api.ts @@ -0,0 +1,96 @@ +import { isRecord } from '../core/values'; +import type { CmaSession } from '../transport/cma-client'; +import { StackLookupError } from './stack.errors'; + +export interface Stack { + name: string; + apiKey: string; +} + +export interface DeliveryToken { + name: string; + token: string; + environment: string | undefined; +} + +function textOf(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +function failureReason(error: unknown): string { + if (isRecord(error)) { + const reason = textOf(error.errorMessage) ?? textOf(error.message); + + if (reason !== undefined) { + return reason; + } + } + + return textOf(error) ?? 'the Contentstack Management API gave no reason'; +} + +function stackOf(value: unknown): Stack[] { + if (!isRecord(value)) { + return []; + } + + const apiKey = textOf(value.api_key); + + return apiKey === undefined ? [] : [{ name: textOf(value.name) ?? apiKey, apiKey }]; +} + +function environmentOf(scope: unknown): string | undefined { + if (!Array.isArray(scope)) { + return undefined; + } + + for (const entry of scope) { + const environments: unknown = isRecord(entry) ? entry.environments : undefined; + const first: unknown = Array.isArray(environments) ? environments[0] : undefined; + const name = isRecord(first) ? textOf(first.name) : undefined; + + if (name !== undefined) { + return name; + } + } + + return undefined; +} + +function tokenOf(value: unknown, position: number): DeliveryToken[] { + if (!isRecord(value)) { + return []; + } + + const token = textOf(value.token); + + if (token === undefined) { + return []; + } + + return [{ name: textOf(value.name) ?? `Delivery token ${position}`, token, environment: environmentOf(value.scope) }]; +} + +export class StacksApi { + constructor(private readonly cma: CmaSession) {} + + async list(organizationUid: string): Promise { + try { + const { items } = await this.cma.fetchStacks(organizationUid); + + return (items ?? []).flatMap(stackOf); + } catch (error) { + throw new StackLookupError('the stacks of this organization', failureReason(error)); + } + } + + async deliveryTokens(apiKey: string): Promise { + try { + const { items } = await this.cma.fetchDeliveryTokens(apiKey); + + return (items ?? []).flatMap((item, index) => tokenOf(item, index + 1)); + } catch (error) { + throw new StackLookupError(`the delivery tokens of stack ${apiKey}`, failureReason(error)); + } + } +} diff --git a/src/transport/cma-client.test.ts b/src/transport/cma-client.test.ts index db81e30..2ff7f7e 100644 --- a/src/transport/cma-client.test.ts +++ b/src/transport/cma-client.test.ts @@ -124,4 +124,132 @@ describe('createCmaSession', () => { expect(createCmaSession({ analyticsInfo: 'cli/2.0.0' }).scopedOrganizationUid()).toBeUndefined(); }); + + it('lists the stacks of one organization through the SDK', async () => { + const recorded: { stack: unknown[][]; query: unknown[] } = { stack: [], query: [] }; + const client = { + stack: (...args: unknown[]) => { + recorded.stack.push(args); + return { + query: (query: unknown) => { + recorded.query.push(query); + return { find: async () => (recorded.query.length === 1 ? { items: [{ name: 'Shop', api_key: 'blt1' }] } : { items: [] }) }; + }, + }; + }, + }; + sdkClient.mockReset(); + sdkClient.mockResolvedValue(client); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + await expect(session.fetchStacks('org7')).resolves.toEqual({ items: [{ name: 'Shop', api_key: 'blt1' }] }); + + expect(recorded.stack).toEqual([[], []]); + expect(recorded.query).toEqual([ + { organization_uid: 'org7', limit: 100, skip: 0 }, + { organization_uid: 'org7', limit: 100, skip: 1 }, + ]); + }); + + it('lists the delivery tokens of one stack through the SDK, addressed by its api key', async () => { + const recorded: { stack: unknown[][]; queries: number; asked: unknown[] } = { stack: [], queries: 0, asked: [] }; + const client = { + stack: (...args: unknown[]) => { + recorded.stack.push(args); + return { + deliveryToken: () => ({ + query: (query: unknown) => { + recorded.queries += 1; + recorded.asked.push(query); + return { find: async () => (recorded.queries === 1 ? { items: [{ name: 'prod-token' }] } : { items: [] }) }; + }, + }), + }; + }, + }; + sdkClient.mockReset(); + sdkClient.mockResolvedValue(client); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + await expect(session.fetchDeliveryTokens('blt1')).resolves.toEqual({ items: [{ name: 'prod-token' }] }); + + expect(recorded.stack).toEqual([[{ api_key: 'blt1' }], [{ api_key: 'blt1' }]]); + expect(recorded.queries).toBe(2); + expect(recorded.asked).toEqual([ + { limit: 100, skip: 0 }, + { limit: 100, skip: 1 }, + ]); + }); + + it('keeps asking for the next page until one comes back empty, whatever size the server returns', async () => { + const asked: unknown[] = []; + const pages = [ + { items: Array.from({ length: 50 }, (_, index) => ({ api_key: `blt${index}` })) }, + { items: [{ api_key: 'last' }] }, + { items: [] }, + ]; + sdkClient.mockReset(); + sdkClient.mockResolvedValue({ + stack: () => ({ + query: (query: unknown) => { + asked.push(query); + return { find: async () => pages[asked.length - 1] }; + }, + }), + }); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + const { items } = await session.fetchStacks('org7'); + + expect(items).toHaveLength(51); + expect(asked).toEqual([ + { organization_uid: 'org7', limit: 100, skip: 0 }, + { organization_uid: 'org7', limit: 100, skip: 50 }, + { organization_uid: 'org7', limit: 100, skip: 51 }, + ]); + }); + + it('gives up after a bounded number of pages when the server keeps answering', async () => { + let calls = 0; + sdkClient.mockReset(); + sdkClient.mockResolvedValue({ + stack: () => ({ + query: () => ({ + find: async () => { + calls += 1; + return { items: [{ api_key: 'same' }] }; + }, + }), + }), + }); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + const { items } = await session.fetchStacks('org7'); + + expect(calls).toBe(100); + expect(items).toHaveLength(100); + }); + + it('treats a reply with no items as an empty list', async () => { + sdkClient.mockReset(); + sdkClient.mockResolvedValue({ stack: () => ({ query: () => ({ find: async () => ({}) }) }) }); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + await expect(session.fetchStacks('org7')).resolves.toEqual({ items: [] }); + }); + + it('propagates a failed stack or delivery token lookup', async () => { + const failure = new Error('stack lookup failed'); + sdkClient.mockReset(); + sdkClient.mockResolvedValue({ + stack: () => ({ + query: () => ({ find: async () => Promise.reject(failure) }), + deliveryToken: () => ({ query: () => ({ find: async () => Promise.reject(failure) }) }), + }), + }); + const session = createCmaSession({ cma: 'https://api.contentstack.io', analyticsInfo: 'cli/2.0.0' }); + + await expect(session.fetchStacks('org7')).rejects.toBe(failure); + await expect(session.fetchDeliveryTokens('blt1')).rejects.toBe(failure); + }); }); diff --git a/src/transport/cma-client.ts b/src/transport/cma-client.ts index 62d15eb..1339b77 100644 --- a/src/transport/cma-client.ts +++ b/src/transport/cma-client.ts @@ -12,9 +12,30 @@ export interface CmaCollection { count?: number; } +const PAGE_SIZE = 100; +const MAX_PAGES = 100; + +async function allPages(fetchPage: (skip: number) => Promise): Promise { + const items: unknown[] = []; + + for (let page = 0; page < MAX_PAGES; page += 1) { + const batch = (await fetchPage(items.length)).items ?? []; + + if (batch.length === 0) { + break; + } + + items.push(...batch); + } + + return { items }; +} + export interface CmaSession { fetchOrganizations(query: Record): Promise; fetchOrganization(uid: string): Promise; + fetchStacks(organizationUid: string): Promise; + fetchDeliveryTokens(apiKey: string): Promise; scopedOrganizationUid(): string | undefined; } @@ -49,6 +70,14 @@ export function createCmaSession(options: CmaSessionOptions): CmaSession { return { fetchOrganizations: async (query) => (await open()).organization().fetchAll(query), fetchOrganization: async (uid) => (await open()).organization(uid).fetch(), + fetchStacks: (organizationUid) => + allPages(async (skip) => + (await open()).stack().query({ organization_uid: organizationUid, limit: PAGE_SIZE, skip }).find(), + ), + fetchDeliveryTokens: (apiKey) => + allPages(async (skip) => + (await open()).stack({ api_key: apiKey }).deliveryToken().query({ limit: PAGE_SIZE, skip }).find(), + ), scopedOrganizationUid: () => { const uid: unknown = configHandler.get(OAUTH_ORG_UID_CONFIG_FIELD); diff --git a/src/variables/variable.chooser.ts b/src/variables/variable.chooser.ts new file mode 100644 index 00000000..054514e --- /dev/null +++ b/src/variables/variable.chooser.ts @@ -0,0 +1,99 @@ +import { basename } from 'node:path'; + +import { UsageError } from '../core/errors'; +import { askChoices, askText } from '../core/prompt'; +import { renderTable } from '../core/render'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentVariableInput } from '../environments/types'; +import { detectEnvFile, readEnvFiles } from './variable.files'; +import { mergeSources } from './variable.merge'; +import type { MergedVariables, VariableSource } from './variable.merge'; +import { parseAssignment, splitLine } from './variable.parse'; +import { pairColumns } from './variable.presenter'; +import { noteSkipped } from './variable.sources'; +import type { VariableIo } from './variable.io'; +import type { StackImporter } from './variable.stack'; +import { completeValues } from './variable.values'; + +export interface ChooserPrompt { + message: string; + skippable: boolean; +} + +export const STACK_CHOICE = 'Import variables from a stack'; +export const FILE_CHOICE = 'Import variables from a dotenv file'; +export const TYPED_CHOICE = 'Type KEY=VALUE pairs'; +export const SKIP_CHOICE = 'Skip adding environment variables'; +const FILE_QUESTION = 'Path to the dotenv file'; +const TYPED_QUESTION = 'KEY=VALUE pairs separated by spaces'; +const TYPED_LABEL = '--var'; + +async function askForFile( + detected: string | undefined, + services: ServiceContext, + io: VariableIo, +): Promise { + const path = await askText(services.ux, FILE_QUESTION, detected); + const sources = await readEnvFiles([path], io); + + sources.forEach((source) => noteSkipped(source, services.ux)); + + return sources; +} + +async function askForTyped(services: ServiceContext, io: VariableIo): Promise { + const line = await askText(services.ux, TYPED_QUESTION); + const typed = await completeValues(splitLine(line).map(parseAssignment), { services, readStdin: io.readStdin }); + + renderTable(services.ux, pairColumns(), typed, process.stdout.columns, undefined, services.outputIsTTY === true); + + return typed; +} + +export async function chooseSources( + dataDir: string, + services: ServiceContext, + io: VariableIo, + stack: StackImporter, + prompt: ChooserPrompt, +): Promise { + const detected = detectEnvFile(dataDir, io.exists); + const fileName = detected === undefined ? FILE_CHOICE : `${FILE_CHOICE} (${basename(detected)} found)`; + const choices = [ + { name: STACK_CHOICE, value: STACK_CHOICE }, + { name: fileName, value: FILE_CHOICE }, + { name: TYPED_CHOICE, value: TYPED_CHOICE }, + ...(prompt.skippable ? [{ name: SKIP_CHOICE, value: SKIP_CHOICE }] : []), + ]; + const picked = await askChoices(services.ux, prompt.message, choices, { requireOne: prompt.skippable }); + + if (picked.includes(SKIP_CHOICE)) { + if (picked.length > 1) { + throw new UsageError(`'${SKIP_CHOICE}' cannot be combined with the other options.`); + } + + services.ux.print('Skipped adding environment variables.'); + + return undefined; + } + + if (picked.length === 0) { + return undefined; + } + + const sources: VariableSource[] = []; + + if (picked.includes(STACK_CHOICE)) { + sources.push({ label: 'stack', variables: await stack() }); + } + + if (picked.includes(FILE_CHOICE)) { + sources.push(...(await askForFile(detected, services, io))); + } + + if (picked.includes(TYPED_CHOICE)) { + sources.push({ label: TYPED_LABEL, variables: await askForTyped(services, io) }); + } + + return mergeSources(sources); +} diff --git a/src/variables/variable.create.test.ts b/src/variables/variable.create.test.ts new file mode 100644 index 00000000..a4e372a --- /dev/null +++ b/src/variables/variable.create.test.ts @@ -0,0 +1,321 @@ +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { variablesForCreate } from './variable.create'; + +const STACK = 'Import variables from a stack'; +const SKIP = 'Skip adding environment variables'; +const FILE = 'Import variables from a dotenv file'; +const TYPED = 'Type KEY=VALUE pairs'; + +function fakes(options: { isTTY?: boolean; answers?: Record; files?: Record } = {}) { + const files = options.files ?? {}; + const printed: string[] = []; + const asked: { message: string; payload: Record }[] = []; + const reads: string[] = []; + const stdinReads: number[] = []; + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: (async (payload: Record) => { + const message = String(payload.message); + asked.push({ message, payload }); + + if (!(message in (options.answers ?? {}))) { + throw new Error(`Unexpected prompt: ${message}`); + } + + return (options.answers ?? {})[message]; + }) as UxLike['inquire'], + }; + + return { + printed, + asked, + reads, + stdinReads, + services: { + ux, + isTTY: options.isTTY ?? true, + outputIsTTY: false, + api: { + stacks: { + list: async () => [{ name: 'Shop', apiKey: 'blt1' }], + deliveryTokens: async () => [{ name: 'prod-token', token: 'cs-picked-value', environment: 'production' }], + }, + variables: { stackTemplate: async () => [{ key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }] }, + }, + } as unknown as ServiceContext, + io: { + readFile: async (path: string) => { + reads.push(path); + + if (!(path in files)) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + + return files[path]; + }, + exists: (path: string) => path in files, + readStdin: async () => { + stdinReads.push(1); + return ''; + }, + }, + }; +} + +const NONE = { vars: undefined, envFiles: undefined, dataDir: '/work', org: 'org1' }; + +describe('variable create', () => { + it('uses the flags as given, never asks, never auto-detects, and prints the provenance', async () => { + const { services, io, asked, reads, printed } = fakes({ files: { 'a.env': 'FROM_FILE=1', '/work/.env': 'X=1' } }); + + const variables = await variablesForCreate({ vars: ['TYPED=2'], envFiles: ['a.env'], dataDir: '/work', org: 'org1' }, services, io); + + expect(variables).toEqual([ + { key: 'FROM_FILE', value: '1' }, + { key: 'TYPED', value: '2' }, + ]); + expect(asked).toEqual([]); + expect(reads).toEqual(['a.env']); + expect(printed).toEqual(['✔ 2 variables ready to set — 1 from a.env, 1 from --var']); + }); + + it('returns nothing without a terminal and without flags, asking nothing and reading nothing', async () => { + const { services, io, asked, reads, printed } = fakes({ isTTY: false, files: { '/work/.env': 'X=1' } }); + + expect(await variablesForCreate(NONE, services, io)).toEqual([]); + expect(asked).toEqual([]); + expect(reads).toEqual([]); + expect(printed).toEqual([]); + }); + + it('treats empty flag lists as no flags', async () => { + const { services, io, asked } = fakes({ answers: { 'Add environment variables': [SKIP] } }); + + expect(await variablesForCreate({ vars: [], envFiles: [], dataDir: '/work', org: 'org1' }, services, io)).toEqual([]); + expect(asked.map((entry) => entry.message)).toEqual(['Add environment variables']); + }); + + it('offers a checkbox of the sources and Skip as the last choice', async () => { + const { services, io, asked } = fakes({ answers: { 'Add environment variables': [SKIP] } }); + + await variablesForCreate(NONE, services, io); + + expect(asked[0].payload).toMatchObject({ + type: 'checkbox', + choices: [ + { name: STACK, value: STACK }, + { name: FILE, value: FILE }, + { name: TYPED, value: TYPED }, + { name: SKIP, value: SKIP }, + ], + }); + }); + + it('returns nothing and says so when Skip is chosen', async () => { + const { services, io, printed } = fakes({ answers: { 'Add environment variables': [SKIP] } }); + + expect(await variablesForCreate(NONE, services, io)).toEqual([]); + expect(printed).toEqual(['Skipped adding environment variables.']); + }); + + it('refuses Skip combined with another choice as a usage error before asking anything else', async () => { + const { services, io, asked } = fakes({ answers: { 'Add environment variables': [SKIP, FILE] } }); + + await expect(variablesForCreate(NONE, services, io)).rejects.toThrow(UsageError); + await expect(variablesForCreate(NONE, services, io)).rejects.toThrow( + "'Skip adding environment variables' cannot be combined with the other options.", + ); + expect(asked.map((entry) => entry.message)).toEqual(['Add environment variables', 'Add environment variables']); + }); + + it('asks for a dotenv path defaulting to the detected file and reads it', async () => { + const { services, io, asked, reads, printed } = fakes({ + files: { '/work/.env.local': 'A=1', '/work/other.env': 'B=2' }, + answers: { 'Add environment variables': [FILE], 'Path to the dotenv file': '/work/other.env' }, + }); + + const variables = await variablesForCreate(NONE, services, io); + + expect(asked[1].payload).toMatchObject({ default: '/work/.env.local' }); + expect(reads).toEqual(['/work/other.env']); + expect(variables).toEqual([{ key: 'B', value: '2' }]); + expect(printed).toEqual(['✔ 1 variable ready to set — 1 from /work/other.env']); + }); + + it('reports the empty-value entries of a dotenv file it was pointed at, as the flag path does', async () => { + const { services, io, printed } = fakes({ + files: { 'x.env': 'A=1\nSECRET=\n' }, + answers: { 'Add environment variables': [FILE], 'Path to the dotenv file': 'x.env' }, + }); + + const variables = await variablesForCreate(NONE, services, io); + + expect(variables).toEqual([{ key: 'A', value: '1' }]); + expect(printed).toContain('Skipped 1 variable with an empty value in x.env: SECRET.'); + }); + + it('offers no default path when no dotenv file is detected', async () => { + const { services, io, asked } = fakes({ + files: { 'x.env': 'A=1' }, + answers: { 'Add environment variables': [FILE], 'Path to the dotenv file': 'x.env' }, + }); + + await variablesForCreate(NONE, services, io); + + expect(asked[1].payload.default).toBeUndefined(); + }); + + it('parses a typed line with quotes, merges it after the file and names the override without printing a value', async () => { + const { services, io, printed } = fakes({ + files: { 'a.env': 'API_URL=file\nF=1' }, + answers: { + 'Add environment variables': [FILE, TYPED], + 'Path to the dotenv file': 'a.env', + 'KEY=VALUE pairs separated by spaces': 'API_URL="sk live secret" N=2', + }, + }); + + const variables = await variablesForCreate(NONE, services, io); + + expect(variables).toEqual([ + { key: 'API_URL', value: 'sk live secret' }, + { key: 'F', value: '1' }, + { key: 'N', value: '2' }, + ]); + expect(printed.join('\n')).toContain('API_URL overridden by --var'); + expect(printed.join('\n')).not.toContain('sk live secret'); + }); + + it('asks a hidden prompt for a typed key that has no value', async () => { + const { services, io, asked } = fakes({ + answers: { + 'Add environment variables': [TYPED], + 'KEY=VALUE pairs separated by spaces': 'API_KEY', + 'Value for API_KEY': 's3cret', + }, + }); + + const variables = await variablesForCreate(NONE, services, io); + + expect(variables).toEqual([{ key: 'API_KEY', value: 's3cret' }]); + expect(asked[2].payload).toMatchObject({ type: 'password' }); + }); + + it('echoes the typed keys with masked values and never a value', async () => { + const { services, io, printed } = fakes({ + answers: { 'Add environment variables': [TYPED], 'KEY=VALUE pairs separated by spaces': 'A=never-printed-value' }, + }); + + await variablesForCreate(NONE, services, io); + + const output = printed.join('\n'); + + expect(output).toContain('A'); + expect(output).toContain('••••••'); + expect(output).not.toContain('never-printed-value'); + }); + + it.each([[undefined], [[]]])('returns nothing and prints no summary when nothing was selected (%p)', async (answer) => { + const { services, io, printed } = fakes({ answers: { 'Add environment variables': answer } }); + + expect(await variablesForCreate(NONE, services, io)).toEqual([]); + expect(printed).toEqual([]); + }); + + it('propagates a cancelled path prompt, an unreadable file and a malformed typed line', async () => { + const cancelled = fakes({ answers: { 'Add environment variables': [FILE], 'Path to the dotenv file': undefined } }); + + await expect(variablesForCreate(NONE, cancelled.services, cancelled.io)).rejects.toThrow(CancelledError); + + const missing = fakes({ answers: { 'Add environment variables': [FILE], 'Path to the dotenv file': 'nope.env' } }); + + await expect(variablesForCreate(NONE, missing.services, missing.io)).rejects.toThrow( + '--dotenv nope.env was not found.', + ); + + const malformed = fakes({ + answers: { 'Add environment variables': [TYPED], 'KEY=VALUE pairs separated by spaces': 'A="oops' }, + }); + + await expect(variablesForCreate(NONE, malformed.services, malformed.io)).rejects.toThrow( + 'The line has an opening quote with no closing quote.', + ); + }); + + it('imports through --from-stack and --alias without asking, merging it before a typed --var', async () => { + const { services, io, asked, printed } = fakes(); + const savedToken = (alias: string) => + alias === 'my-prod' ? { token: 'cs-saved-value', apiKey: 'blt9', environment: 'staging' } : undefined; + + const variables = await variablesForCreate( + { vars: ['CONTENTSTACK_ENVIRONMENT=override'], envFiles: undefined, dataDir: '/work', org: 'org1', fromStack: true, alias: 'my-prod' }, + services, + io, + savedToken, + ); + + expect(variables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: 'cs-saved-value' }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'override' }, + ]); + expect(asked).toEqual([]); + expect(printed).toEqual([ + 'Using the saved delivery token "my-prod" (environment staging).', + 'No value was available for CONTENTSTACK_CDN, so it was not imported.', + '\u2714 4 variables ready to set \u2014 4 from stack, CONTENTSTACK_ENVIRONMENT overridden by --var', + ]); + expect(printed.join('\n')).not.toContain('cs-saved-value'); + }); + + it('refuses --from-stack without --alias when there is no terminal, asking nothing', async () => { + const { services, io, asked } = fakes({ isTTY: false }); + + await expect( + variablesForCreate({ vars: undefined, envFiles: undefined, dataDir: '/work', org: 'org1', fromStack: true }, services, io), + ).rejects.toThrow("--from-stack requires --alias when there's no TTY."); + expect(asked).toEqual([]); + }); + + it('offers the stack as a source, asks the stack and token pickers, and merges it before the other sources', async () => { + const { services, io, asked, printed } = fakes({ + answers: { + 'Add environment variables': [STACK, TYPED], + Stack: '0', + 'Delivery token': '0', + 'KEY=VALUE pairs separated by spaces': 'EXTRA=1', + }, + }); + + const variables = await variablesForCreate(NONE, services, io); + + expect(asked.map((entry) => entry.message)).toEqual([ + 'Add environment variables', + 'Stack', + 'Delivery token', + 'KEY=VALUE pairs separated by spaces', + ]); + expect(variables.map((variable) => variable.key)).toEqual([ + 'CONTENTSTACK_API_HOST', + 'CONTENTSTACK_API_KEY', + 'CONTENTSTACK_DELIVERY_TOKEN', + 'CONTENTSTACK_ENVIRONMENT', + 'EXTRA', + ]); + expect(printed.join('\n')).toContain('4 from stack'); + expect(printed.join('\n')).not.toContain('cs-picked-value'); + }); + + it('refuses Skip combined with the stack choice', async () => { + const { services, io } = fakes({ answers: { 'Add environment variables': [SKIP, STACK] } }); + + await expect(variablesForCreate(NONE, services, io)).rejects.toThrow( + "'Skip adding environment variables' cannot be combined with the other options.", + ); + }); +}); diff --git a/src/variables/variable.create.ts b/src/variables/variable.create.ts new file mode 100644 index 00000000..537322d --- /dev/null +++ b/src/variables/variable.create.ts @@ -0,0 +1,62 @@ +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentVariableInput } from '../environments/types'; +import { chooseSources } from './variable.chooser'; +import { collectVariables } from './variable.sources'; +import { DEFAULT_VARIABLE_IO, given } from './variable.io'; +import type { VariableIo } from './variable.io'; +import { importFromStack, readSavedToken } from './variable.stack'; +import type { SavedToken, StackImporter } from './variable.stack'; +import { variablesSetLine } from './variable.summary'; + +export interface CreateVariablesRequest { + vars: string[] | undefined; + envFiles: string[] | undefined; + dataDir: string; + org: string; + fromStack?: boolean; + alias?: string; +} + +const CHOOSE_MESSAGE = 'Add environment variables'; + +async function askForVariables( + request: CreateVariablesRequest, + services: ServiceContext, + io: VariableIo, + stack: StackImporter, +): Promise { + const merged = await chooseSources(request.dataDir, services, io, stack, { + message: CHOOSE_MESSAGE, + skippable: true, + }); + + if (merged === undefined) { + return []; + } + + services.ux.print(variablesSetLine(merged, services.outputIsTTY === true, 'ready to set')); + + return merged.variables; +} + +export async function variablesForCreate( + request: CreateVariablesRequest, + services: ServiceContext, + io: VariableIo = DEFAULT_VARIABLE_IO, + savedToken: (alias: string) => SavedToken | undefined = readSavedToken, +): Promise { + const stack: StackImporter = (alias) => importFromStack({ org: request.org, alias }, { services, savedToken }); + + if (given(request.vars) || given(request.envFiles) || request.fromStack === true) { + const merged = await collectVariables( + { vars: request.vars, envFiles: request.envFiles, fromStack: request.fromStack, alias: request.alias }, + { ...io, services, dataDir: request.dataDir, stack }, + ); + + services.ux.print(variablesSetLine(merged, services.outputIsTTY === true, 'ready to set')); + + return merged.variables; + } + + return services.isTTY ? askForVariables(request, services, io, stack) : []; +} diff --git a/src/variables/variable.dotenv.test.ts b/src/variables/variable.dotenv.test.ts new file mode 100644 index 00000000..c02dc10 --- /dev/null +++ b/src/variables/variable.dotenv.test.ts @@ -0,0 +1,42 @@ +import { parseDotenv } from './variable.dotenv'; + +describe('variable dotenv', () => { + it('reads each KEY=VALUE line in order, ignoring comments and blank lines', () => { + const parsed = parseDotenv('# comment\nA=1\n\nB=two words\nC="quoted value"\n'); + + expect(parsed).toEqual({ + variables: [ + { key: 'A', value: '1' }, + { key: 'B', value: 'two words' }, + { key: 'C', value: 'quoted value' }, + ], + skipped: [], + }); + }); + + it('keeps an equals sign inside a value', () => { + expect(parseDotenv('TOKEN=a=b').variables).toEqual([{ key: 'TOKEN', value: 'a=b' }]); + }); + + it('skips a variable with an empty value and names its key instead of its value', () => { + const parsed = parseDotenv('A=1\nEMPTY=\nBLANK= \nB=2'); + + expect(parsed.variables).toEqual([ + { key: 'A', value: '1' }, + { key: 'B', value: '2' }, + ]); + expect(parsed.skipped).toEqual(['EMPTY', 'BLANK']); + }); + + it('takes the last occurrence of a repeated key', () => { + expect(parseDotenv('A=1\nA=2').variables).toEqual([{ key: 'A', value: '2' }]); + }); + + it.each([[''], [' \n'], ['# only a comment']])('returns nothing for text with no variables (%p)', (text) => { + expect(parseDotenv(text)).toEqual({ variables: [], skipped: [] }); + }); + + it('reads a Buffer the way it reads text', () => { + expect(parseDotenv(Buffer.from('A=1')).variables).toEqual([{ key: 'A', value: '1' }]); + }); +}); diff --git a/src/variables/variable.dotenv.ts b/src/variables/variable.dotenv.ts new file mode 100644 index 00000000..dec1632 --- /dev/null +++ b/src/variables/variable.dotenv.ts @@ -0,0 +1,23 @@ +import { parse } from 'dotenv'; + +import type { EnvironmentVariableInput } from '../environments/types'; + +export interface ParsedDotenv { + variables: EnvironmentVariableInput[]; + skipped: string[]; +} + +export function parseDotenv(text: string | Buffer): ParsedDotenv { + const variables: EnvironmentVariableInput[] = []; + const skipped: string[] = []; + + for (const [key, value] of Object.entries(parse(text))) { + if (value.trim() === '') { + skipped.push(key); + } else { + variables.push({ key, value }); + } + } + + return { variables, skipped }; +} diff --git a/src/variables/variable.files.test.ts b/src/variables/variable.files.test.ts new file mode 100644 index 00000000..b75dc50 --- /dev/null +++ b/src/variables/variable.files.test.ts @@ -0,0 +1,107 @@ +import { UsageError } from '../core/errors'; +import { detectEnvFile, readEnvFiles } from './variable.files'; + +function deps(files: Record, stdin = '') { + const read: string[] = []; + const stdinReads: number[] = []; + + return { + read, + stdinReads, + reader: { + readFile: async (path: string) => { + read.push(path); + + if (!(path in files)) { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }); + } + + return files[path]; + }, + readStdin: async () => { + stdinReads.push(1); + return stdin; + }, + }, + }; +} + +describe('variable files', () => { + it('reads each named file as one source labelled with the path it was given', async () => { + const { reader, read } = deps({ '.env': 'A=1', 'prod.env': 'B=2\nEMPTY=' }); + + const sources = await readEnvFiles(['.env', 'prod.env'], reader); + + expect(read).toEqual(['.env', 'prod.env']); + expect(sources).toEqual([ + { label: '.env', variables: [{ key: 'A', value: '1' }], skipped: [] }, + { label: 'prod.env', variables: [{ key: 'B', value: '2' }], skipped: ['EMPTY'] }, + ]); + }); + + it('reads dotenv content from stdin for a dash, labelled standard input, without touching the file system', async () => { + const { reader, read, stdinReads } = deps({}, 'A=1\nB=2'); + + const sources = await readEnvFiles(['-'], reader); + + expect(read).toEqual([]); + expect(stdinReads).toHaveLength(1); + expect(sources).toEqual([ + { label: 'standard input', variables: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }], skipped: [] }, + ]); + }); + + it('refuses a dash given twice, since stdin can only be read once', async () => { + const { reader, stdinReads } = deps({}, 'A=1'); + + await expect(readEnvFiles(['-', '-'], reader)).rejects.toThrow(UsageError); + await expect(readEnvFiles(['-', '-'], reader)).rejects.toThrow('--dotenv - can be given only once.'); + expect(stdinReads).toEqual([]); + }); + + it('names the path of a file that does not exist as a usage error', async () => { + const { reader } = deps({}); + + await expect(readEnvFiles(['missing.env'], reader)).rejects.toThrow(UsageError); + await expect(readEnvFiles(['missing.env'], reader)).rejects.toThrow('--dotenv missing.env was not found.'); + }); + + it('lets any other read failure propagate unchanged', async () => { + const failure = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + + await expect( + readEnvFiles(['.env'], { readFile: async () => Promise.reject(failure), readStdin: async () => '' }), + ).rejects.toBe(failure); + }); + + it('propagates a stdin failure', async () => { + const failure = new Error('stdin closed'); + + await expect( + readEnvFiles(['-'], { readFile: async () => '', readStdin: async () => Promise.reject(failure) }), + ).rejects.toBe(failure); + }); + + it('returns no sources for no paths', async () => { + expect(await readEnvFiles([], deps({}).reader)).toEqual([]); + }); + + it('detects .env.local before .env under the data directory', () => { + const asked: string[] = []; + const exists = (path: string) => { + asked.push(path); + return true; + }; + + expect(detectEnvFile('/work', exists)).toBe('/work/.env.local'); + expect(asked).toEqual(['/work/.env.local']); + }); + + it('falls back to .env when there is no .env.local', () => { + expect(detectEnvFile('/work', (path) => path === '/work/.env')).toBe('/work/.env'); + }); + + it('finds nothing when neither file is there', () => { + expect(detectEnvFile('/work', () => false)).toBeUndefined(); + }); +}); diff --git a/src/variables/variable.files.ts b/src/variables/variable.files.ts new file mode 100644 index 00000000..e62dff0 --- /dev/null +++ b/src/variables/variable.files.ts @@ -0,0 +1,52 @@ +import { join } from 'node:path'; + +import { UsageError } from '../core/errors'; +import { parseDotenv } from './variable.dotenv'; +import type { ParsedDotenv } from './variable.dotenv'; + +export const STDIN_PATH = '-'; + +export const AUTO_DETECTED_FILES = ['.env.local', '.env'] as const; + +export interface EnvFileReader { + readFile(path: string): Promise; + readStdin(): Promise; +} + +export interface EnvFileSource extends ParsedDotenv { + label: string; +} + +async function readOne(path: string, reader: EnvFileReader): Promise { + if (path === STDIN_PATH) { + return { label: 'standard input', ...parseDotenv(await reader.readStdin()) }; + } + + try { + return { label: path, ...parseDotenv(await reader.readFile(path)) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new UsageError(`--dotenv ${path} was not found.`); + } + + throw error; + } +} + +export async function readEnvFiles(paths: string[], reader: EnvFileReader): Promise { + if (paths.filter((path) => path === STDIN_PATH).length > 1) { + throw new UsageError('--dotenv - can be given only once.'); + } + + const sources: EnvFileSource[] = []; + + for (const path of paths) { + sources.push(await readOne(path, reader)); + } + + return sources; +} + +export function detectEnvFile(dataDir: string, exists: (path: string) => boolean): string | undefined { + return AUTO_DETECTED_FILES.map((name) => join(dataDir, name)).find((path) => exists(path)); +} diff --git a/src/variables/variable.inputs.test.ts b/src/variables/variable.inputs.test.ts new file mode 100644 index 00000000..a1edce9 --- /dev/null +++ b/src/variables/variable.inputs.test.ts @@ -0,0 +1,79 @@ +import { Parser } from '@oclif/core'; + +import { catalog } from '../resources'; +import { variableFlags, variableResolution } from './variable.inputs'; + +describe('variable inputs', () => { + it('names each flag as the Commands Details "All flags" tables name it', () => { + expect(Object.keys(variableFlags)).toEqual(['var', 'dotenv', 'key', 'from-stack', 'alias']); + }); + + it.each(['var', 'dotenv', 'key'] as const)('declares %s repeatable so each occurrence and each value is kept', (name) => { + expect(variableFlags[name].multiple).toBe(true); + }); + + it.each(['var', 'dotenv', 'key', 'from-stack', 'alias'] as const)('never marks %s required at parse time', (name) => { + expect(variableFlags[name].required).toBeFalsy(); + }); + + it('gives every flag a resolution entry that reads no config file, prompts nowhere and defaults to nothing', () => { + expect(Object.keys(variableResolution)).toEqual(Object.keys(variableFlags)); + + for (const spec of Object.values(variableResolution) as object[]) { + expect(spec).toEqual({}); + } + }); + + it('declares --from-stack as a presence-only flag and --alias as a single value with the short form -a', () => { + expect(variableFlags['from-stack'].type).toBe('boolean'); + expect(variableFlags.alias.type).toBe('option'); + expect(variableFlags.alias.multiple).toBeFalsy(); + expect(variableFlags.alias.char).toBe('a'); + }); + + it('parses --from-stack with no value and --alias -a with one', async () => { + const parsed = await Parser.parse(['--from-stack', '-a', 'my-prod'], { + flags: { 'from-stack': variableFlags['from-stack'], alias: variableFlags.alias }, + }); + + expect(parsed.flags['from-stack']).toBe(true); + expect(parsed.flags.alias).toBe('my-prod'); + }); + + it('describes --var without showing a value that looks like a secret', () => { + expect(variableFlags.var.description).toContain('KEY=VALUE'); + expect(variableFlags.var.description).not.toMatch(/sk_|ghp_|password/i); + }); + + it('parses --var A=1 B=2 and --var A=1 --var B=2 to the same values', async () => { + const spaced = await Parser.parse(['--var', 'A=1', 'B=2'], { flags: { var: variableFlags.var } }); + const repeated = await Parser.parse(['--var', 'A=1', '--var', 'B=2'], { flags: { var: variableFlags.var } }); + + expect(spaced.flags.var).toEqual(['A=1', 'B=2']); + expect(repeated.flags.var).toEqual(spaced.flags.var); + }); + + it('stops --var at the next flag instead of swallowing it', async () => { + const parsed = await Parser.parse(['--var', 'A=1', 'B=2', '--project', 'x'], { + flags: { var: variableFlags.var, project: catalog.project }, + }); + + expect(parsed.flags.var).toEqual(['A=1', 'B=2']); + expect(parsed.flags.project).toBe('x'); + }); + + it('keeps a quoted value with a space as one value and an equals sign inside a value', async () => { + const parsed = await Parser.parse(['--var', 'MSG=hello world', 'TOKEN=a=b'], { flags: { var: variableFlags.var } }); + + expect(parsed.flags.var).toEqual(['MSG=hello world', 'TOKEN=a=b']); + }); + + it('collects repeated --dotenv and --key occurrences in order', async () => { + const parsed = await Parser.parse(['--dotenv', 'a.env', '--dotenv', '-', '--key', 'X', 'Y'], { + flags: { dotenv: variableFlags.dotenv, key: variableFlags.key }, + }); + + expect(parsed.flags['dotenv']).toEqual(['a.env', '-']); + expect(parsed.flags.key).toEqual(['X', 'Y']); + }); +}); diff --git a/src/variables/variable.inputs.ts b/src/variables/variable.inputs.ts new file mode 100644 index 00000000..9fdf8e3 --- /dev/null +++ b/src/variables/variable.inputs.ts @@ -0,0 +1,32 @@ +import { Flags } from '@contentstack/cli-utilities'; + +import type { ResolutionSpec } from '../core/resolution'; + +export const variableFlags = { + var: Flags.string({ + multiple: true, + description: + 'Environment variable as KEY=VALUE, repeatable. A KEY with no value is asked at a hidden prompt, ' + + 'or read from standard input when piped', + }), + dotenv: Flags.string({ + multiple: true, + description: 'Path to a dotenv file, repeatable. Use - to read dotenv content from standard input', + }), + key: Flags.string({ multiple: true, description: 'Environment variable key, repeatable' }), + 'from-stack': Flags.boolean({ + description: 'Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a terminal', + }), + alias: Flags.string({ + char: 'a', + description: 'Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a prompt', + }), +}; + +export const variableResolution = { + var: {} satisfies ResolutionSpec, + dotenv: {} satisfies ResolutionSpec, + key: {} satisfies ResolutionSpec, + 'from-stack': {} satisfies ResolutionSpec, + alias: {} satisfies ResolutionSpec, +}; diff --git a/src/variables/variable.io.ts b/src/variables/variable.io.ts new file mode 100644 index 00000000..2c502bb --- /dev/null +++ b/src/variables/variable.io.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; + +import { readPipedInput } from '../core/piped-input'; +import type { VariableContext } from './variable.sources'; + +export type VariableIo = Pick; + +export const DEFAULT_VARIABLE_IO: VariableIo = { + readFile: (path) => readFile(path, 'utf8'), + exists: existsSync, + readStdin: () => readPipedInput(), +}; + +export function given(values: string[] | undefined): boolean { + return (values ?? []).length > 0; +} diff --git a/src/variables/variable.merge.test.ts b/src/variables/variable.merge.test.ts new file mode 100644 index 00000000..940b93e --- /dev/null +++ b/src/variables/variable.merge.test.ts @@ -0,0 +1,75 @@ +import { mergeSources } from './variable.merge'; + +const STACK = { label: 'stack', variables: [{ key: 'API_URL', value: 'from-stack' }, { key: 'REGION', value: 'eu' }] }; +const FILE = { label: '.env', variables: [{ key: 'DEBUG', value: 'file' }, { key: 'REGION', value: 'us' }] }; +const VAR = { label: '--var', variables: [{ key: 'API_URL', value: 'from-var' }] }; + +describe('variable merge', () => { + it('lets a later source win a collision, keeping each key where it first appeared', () => { + const merged = mergeSources([STACK, FILE, VAR]); + + expect(merged.variables).toEqual([ + { key: 'API_URL', value: 'from-var' }, + { key: 'REGION', value: 'us' }, + { key: 'DEBUG', value: 'file' }, + ]); + }); + + it('counts only the keys a source added that no earlier source had', () => { + const merged = mergeSources([STACK, FILE, VAR]); + + expect(merged.added).toEqual([ + { label: 'stack', count: 2 }, + { label: '.env', count: 1 }, + ]); + }); + + it('names every key a later source overrode, grouped by the overriding source', () => { + const merged = mergeSources([STACK, FILE, VAR]); + + expect(merged.overrides).toEqual([ + { label: '.env', keys: ['REGION'] }, + { label: '--var', keys: ['API_URL'] }, + ]); + }); + + it('takes the last occurrence of a key repeated inside one source without calling it an override', () => { + const merged = mergeSources([ + { label: '--var', variables: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }, { key: 'A', value: '3' }] }, + ]); + + expect(merged.variables).toEqual([ + { key: 'A', value: '3' }, + { key: 'B', value: '2' }, + ]); + expect(merged.added).toEqual([{ label: '--var', count: 2 }]); + expect(merged.overrides).toEqual([]); + }); + + it('reports a source that adds nothing new only through its overrides', () => { + const merged = mergeSources([STACK, VAR]); + + expect(merged.added).toEqual([{ label: 'stack', count: 2 }]); + expect(merged.overrides).toEqual([{ label: '--var', keys: ['API_URL'] }]); + }); + + it('returns nothing for no sources and for sources with no variables', () => { + expect(mergeSources([])).toEqual({ variables: [], added: [], overrides: [] }); + expect(mergeSources([{ label: '.env', variables: [] }])).toEqual({ variables: [], added: [], overrides: [] }); + }); + + it('does not alter the sources it was given', () => { + const file = { label: '.env', variables: [{ key: 'A', value: '1' }] }; + const snapshot = JSON.parse(JSON.stringify(file)); + + mergeSources([STACK, file, VAR]); + + expect(file).toEqual(snapshot); + }); + + it('treats keys as case-sensitive', () => { + const merged = mergeSources([{ label: '--var', variables: [{ key: 'a', value: '1' }, { key: 'A', value: '2' }] }]); + + expect(merged.variables).toEqual([{ key: 'a', value: '1' }, { key: 'A', value: '2' }]); + }); +}); diff --git a/src/variables/variable.merge.ts b/src/variables/variable.merge.ts new file mode 100644 index 00000000..d2ffa62 --- /dev/null +++ b/src/variables/variable.merge.ts @@ -0,0 +1,49 @@ +import type { EnvironmentVariableInput } from '../environments/types'; + +export interface VariableSource { + label: string; + variables: EnvironmentVariableInput[]; +} + +export interface MergedVariables { + variables: EnvironmentVariableInput[]; + added: { label: string; count: number }[]; + overrides: { label: string; keys: string[] }[]; +} + +export function mergeSources(sources: VariableSource[]): MergedVariables { + const held = new Map(); + const added: MergedVariables['added'] = []; + const overrides: MergedVariables['overrides'] = []; + + for (const source of sources) { + const before = new Set(held.keys()); + const supplied = new Map(); + const overridden: string[] = []; + + for (const { key, value } of source.variables) { + if (before.has(key) && !supplied.has(key)) { + overridden.push(key); + } + + supplied.set(key, value); + held.set(key, value); + } + + const count = [...supplied.keys()].filter((key) => !before.has(key)).length; + + if (count > 0) { + added.push({ label: source.label, count }); + } + + if (overridden.length > 0) { + overrides.push({ label: source.label, keys: overridden }); + } + } + + return { + variables: [...held].map(([key, value]) => ({ key, value })), + added, + overrides, + }; +} diff --git a/src/variables/variable.page.test.ts b/src/variables/variable.page.test.ts new file mode 100644 index 00000000..785a542 --- /dev/null +++ b/src/variables/variable.page.test.ts @@ -0,0 +1,39 @@ +import { pageOfVariables } from './variable.page'; + +const THREE = [{ key: 'A' }, { key: 'B' }, { key: 'C' }]; + +describe('variable page', () => { + it('returns the window starting at skip and holding at most limit variables with the total count', () => { + const page = pageOfVariables(THREE, 1, 1); + + expect(page).toEqual({ variables: [{ key: 'B' }], pagination: { count: 3, limit: 1, skip: 1 } }); + }); + + it('returns every variable when limit is exactly the number held', () => { + expect(pageOfVariables(THREE, 3, 0).variables).toEqual(THREE); + }); + + it('returns the remainder when limit runs past the end', () => { + expect(pageOfVariables(THREE, 100, 2).variables).toEqual([{ key: 'C' }]); + }); + + it('returns nothing when skip equals the number held, still reporting the total', () => { + expect(pageOfVariables(THREE, 10, 3)).toEqual({ variables: [], pagination: { count: 3, limit: 10, skip: 3 } }); + }); + + it('returns nothing when skip is past the end', () => { + expect(pageOfVariables(THREE, 10, 99).variables).toEqual([]); + }); + + it('returns nothing and a zero count when the environment holds no variables', () => { + expect(pageOfVariables([], 100, 0)).toEqual({ variables: [], pagination: { count: 0, limit: 100, skip: 0 } }); + }); + + it('does not alter the list it was given', () => { + const held = [...THREE]; + + pageOfVariables(held, 1, 1); + + expect(held).toEqual(THREE); + }); +}); diff --git a/src/variables/variable.page.ts b/src/variables/variable.page.ts new file mode 100644 index 00000000..df24f10 --- /dev/null +++ b/src/variables/variable.page.ts @@ -0,0 +1,14 @@ +import type { Pagination } from '../core/render'; +import type { EnvironmentVariable } from '../environments/types'; + +export interface VariablePage { + variables: EnvironmentVariable[]; + pagination: Pagination; +} + +export function pageOfVariables(held: EnvironmentVariable[], limit: number, skip: number): VariablePage { + return { + variables: held.slice(skip, skip + limit), + pagination: { count: held.length, limit, skip }, + }; +} diff --git a/src/variables/variable.parse.test.ts b/src/variables/variable.parse.test.ts new file mode 100644 index 00000000..f0f64da --- /dev/null +++ b/src/variables/variable.parse.test.ts @@ -0,0 +1,82 @@ +import { UsageError } from '../core/errors'; +import { parseAssignment, splitLine } from './variable.parse'; + +describe('variable parse', () => { + it('splits KEY=VALUE into a key and a value', () => { + expect(parseAssignment('API_URL=https://example.test')).toEqual({ key: 'API_URL', value: 'https://example.test' }); + }); + + it('splits on the first equals sign so a value may contain one', () => { + expect(parseAssignment('TOKEN=a=b')).toEqual({ key: 'TOKEN', value: 'a=b' }); + }); + + it('keeps a value with spaces whole', () => { + expect(parseAssignment('MSG=hello world')).toEqual({ key: 'MSG', value: 'hello world' }); + }); + + it('reads a bare key as a key with no value yet', () => { + expect(parseAssignment('API_KEY')).toEqual({ key: 'API_KEY', value: undefined }); + }); + + it('accepts a colon as the separator only when no equals sign is present', () => { + expect(parseAssignment('API_URL:value')).toEqual({ key: 'API_URL', value: 'value' }); + expect(parseAssignment('API_URL=https://x.test:8080')).toEqual({ key: 'API_URL', value: 'https://x.test:8080' }); + }); + + it('trims the key but not the value', () => { + expect(parseAssignment(' KEY = spaced ')).toEqual({ key: 'KEY', value: ' spaced ' }); + }); + + it.each([[''], [' '], ['=value'], [' =value'], [':value']])('rejects a token with no key (%p)', (token) => { + expect(() => parseAssignment(token)).toThrow(UsageError); + expect(() => parseAssignment(token)).toThrow('A variable needs a key before its value.'); + }); + + it.each([['KEY='], ['KEY= '], ['KEY:']])('rejects an empty value and names the key (%p)', (token) => { + expect(() => parseAssignment(token)).toThrow(UsageError); + expect(() => parseAssignment(token)).toThrow('KEY has an empty value. The Launch API does not accept one.'); + }); +}); + +describe('variable parse split line', () => { + it('splits on whitespace', () => { + expect(splitLine('A=1 B=2 C=3')).toEqual(['A=1', 'B=2', 'C=3']); + }); + + it('keeps a double-quoted or single-quoted stretch together and drops the quotes', () => { + expect(splitLine('MSG="hello world" NOTE=\'a b\'')).toEqual(['MSG=hello world', 'NOTE=a b']); + }); + + it('joins a quoted stretch to the text beside it', () => { + expect(splitLine('A="x y"z')).toEqual(['A=x yz']); + }); + + it('keeps the other kind of quote literal inside a quoted stretch', () => { + expect(splitLine('A="it\'s" B=\'say "hi"\'')).toEqual(['A=it\'s', 'B=say "hi"']); + }); + + it('keeps an empty quoted value as an empty token', () => { + expect(splitLine('A="" B=2')).toEqual(['A=', 'B=2']); + }); + + it('keeps a backslash outside quotes as a literal character', () => { + expect(splitLine('DIR=C:\\Users\\me A=x\\ y')).toEqual(['DIR=C:\\Users\\me', 'A=x\\', 'y']); + }); + + it('starts a token with a quoted stretch', () => { + expect(splitLine('"a b" c')).toEqual(['a b', 'c']); + }); + + it('keeps a trailing backslash as a literal character', () => { + expect(splitLine('A=x\\')).toEqual(['A=x\\']); + }); + + it.each([[''], [' ']])('returns nothing for a line with no tokens (%p)', (line) => { + expect(splitLine(line)).toEqual([]); + }); + + it.each([['A="unterminated'], ["A='unterminated"]])('refuses an unterminated quote (%p) without echoing the text', (line) => { + expect(() => splitLine(line)).toThrow(UsageError); + expect(() => splitLine(line)).toThrow('The line has an opening quote with no closing quote.'); + }); +}); diff --git a/src/variables/variable.parse.ts b/src/variables/variable.parse.ts new file mode 100644 index 00000000..d1ff227 --- /dev/null +++ b/src/variables/variable.parse.ts @@ -0,0 +1,69 @@ +import { UsageError } from '../core/errors'; + +export interface Assignment { + key: string; + value: string | undefined; +} + +function separatorIndex(token: string): number { + const equals = token.indexOf('='); + + return equals === -1 ? token.indexOf(':') : equals; +} + +export function parseAssignment(token: string): Assignment { + const at = separatorIndex(token); + const key = (at === -1 ? token : token.slice(0, at)).trim(); + + if (key === '') { + throw new UsageError('A variable needs a key before its value.'); + } + + if (at === -1) { + return { key, value: undefined }; + } + + const value = token.slice(at + 1); + + if (value.trim() === '') { + throw new UsageError(`${key} has an empty value. The Launch API does not accept one.`); + } + + return { key, value }; +} + +const QUOTES = ['"', "'"]; + +export function splitLine(line: string): string[] { + const tokens: string[] = []; + let current: string | undefined; + let quote: string | undefined; + + for (let at = 0; at < line.length; at += 1) { + const char = line[at]; + + if (quote !== undefined) { + if (char === quote) { + quote = undefined; + } else { + current += char; + } + } else if (QUOTES.includes(char)) { + quote = char; + current ??= ''; + } else if (/\s/.test(char)) { + if (current !== undefined) { + tokens.push(current); + current = undefined; + } + } else { + current = (current ?? '') + char; + } + } + + if (quote !== undefined) { + throw new UsageError('The line has an opening quote with no closing quote.'); + } + + return current === undefined ? tokens : [...tokens, current]; +} diff --git a/src/variables/variable.presenter.test.ts b/src/variables/variable.presenter.test.ts new file mode 100644 index 00000000..c5f0bd9 --- /dev/null +++ b/src/variables/variable.presenter.test.ts @@ -0,0 +1,32 @@ +import { REDACTED } from '../core/redact'; +import { pairColumns, variableColumns } from './variable.presenter'; + +function cells(variable: { key?: string; value?: string }, updatedAt?: string): string[] { + return variableColumns(updatedAt).map((column) => column.value(variable)); +} + +describe('variable presenter', () => { + it('offers the key and masked value columns on their own', () => { + expect(pairColumns().map((column) => column.header)).toEqual(['KEY', 'VALUE']); + }); + + it('renders the key, a masked value and the environment update time', () => { + const row = cells({ key: 'API_KEY', value: 'never-printed-abc' }, '2026-09-01T10:00:00.000Z'); + + expect(variableColumns().map((column) => column.header)).toEqual(['KEY', 'VALUE', 'UPDATED']); + expect(row).toEqual(['API_KEY', REDACTED, '2026-09-01T10:00:00.000Z']); + expect(row.join(' ')).not.toContain('never-printed-abc'); + }); + + it.each([[undefined], [''], [' ']])('renders a placeholder for a key that is %p', (key) => { + const row = cells({ key, value: 'secret' }, '2026-09-01'); + + expect(row).toEqual(['-', REDACTED, '2026-09-01']); + }); + + it.each([[undefined], [''], [' ']])('renders a placeholder for an update time that is %p', (updatedAt) => { + const row = cells({ key: 'API_KEY' }, updatedAt); + + expect(row).toEqual(['API_KEY', REDACTED, '-']); + }); +}); diff --git a/src/variables/variable.presenter.ts b/src/variables/variable.presenter.ts new file mode 100644 index 00000000..6800d01 --- /dev/null +++ b/src/variables/variable.presenter.ts @@ -0,0 +1,16 @@ +import { redactedColumn } from '../core/redact'; +import type { TableColumn } from '../core/render'; +import { isAbsent } from '../core/values'; +import type { EnvironmentVariable } from '../environments/types'; + +function text(value: string | undefined): string { + return value === undefined || isAbsent(value) ? '-' : value; +} + +export function pairColumns(): TableColumn[] { + return [{ header: 'KEY', value: (variable) => text(variable.key) }, redactedColumn('VALUE')]; +} + +export function variableColumns(updatedAt?: string): TableColumn[] { + return [...pairColumns(), { header: 'UPDATED', value: () => text(updatedAt) }]; +} diff --git a/src/variables/variable.rules.test.ts b/src/variables/variable.rules.test.ts new file mode 100644 index 00000000..61fda24 --- /dev/null +++ b/src/variables/variable.rules.test.ts @@ -0,0 +1,30 @@ +import { UsageError } from '../core/errors'; +import { aliasRequiresFromStack } from './variable.rules'; + +describe('variable rules', () => { + it('refuses --alias without --from-stack, naming both', () => { + expect(() => aliasRequiresFromStack({ alias: 'my-prod' }, { alias: 'flag' })).toThrow(UsageError); + expect(() => aliasRequiresFromStack({ alias: 'my-prod' }, { alias: 'flag' })).toThrow( + '--alias is only used with --from-stack.', + ); + }); + + it('refuses --alias when --from-stack is false', () => { + expect(() => aliasRequiresFromStack({ alias: 'my-prod', 'from-stack': false }, {})).toThrow( + '--alias is only used with --from-stack.', + ); + }); + + it('accepts --alias together with --from-stack', () => { + expect(() => aliasRequiresFromStack({ alias: 'my-prod', 'from-stack': true }, {})).not.toThrow(); + }); + + it.each([[undefined], [null], [''], [' ']])('accepts a command that supplied no alias (%p)', (alias) => { + expect(() => aliasRequiresFromStack({ alias, 'from-stack': undefined }, {})).not.toThrow(); + expect(() => aliasRequiresFromStack({ alias }, {})).not.toThrow(); + }); + + it('accepts --from-stack on its own', () => { + expect(() => aliasRequiresFromStack({ 'from-stack': true }, {})).not.toThrow(); + }); +}); diff --git a/src/variables/variable.rules.ts b/src/variables/variable.rules.ts new file mode 100644 index 00000000..4924027 --- /dev/null +++ b/src/variables/variable.rules.ts @@ -0,0 +1,9 @@ +import { UsageError } from '../core/errors'; +import type { Rule } from '../core/rules'; +import { isAbsent } from '../core/values'; + +export const aliasRequiresFromStack: Rule = (resolved) => { + if (!isAbsent(resolved.alias) && resolved['from-stack'] !== true) { + throw new UsageError('--alias is only used with --from-stack.'); + } +}; diff --git a/src/variables/variable.set.test.ts b/src/variables/variable.set.test.ts new file mode 100644 index 00000000..975fb3f --- /dev/null +++ b/src/variables/variable.set.test.ts @@ -0,0 +1,360 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { Environment, EnvironmentUpdate } from '../environments/types'; +import { DEFAULT_VARIABLE_IO } from './variable.io'; +import { VariableSetter } from './variable.set'; +import type { SavedToken } from './variable.stack'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; + +function fakes( + held: Environment = { environmentVariables: [{ key: 'KEEP', value: 'k' }] }, + files: Record = {}, + answers?: Record, +) { + const asked: { message: string; payload: Record }[] = []; + const printed: string[] = []; + const order: string[] = []; + const updates: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: (async (payload: { message: string }) => { + asked.push({ message: payload.message, payload: payload as unknown as Record }); + return (answers ?? {})[payload.message]; + }) as UxLike['inquire'], + }; + const services = { + ux, + isTTY: answers !== undefined, + outputIsTTY: false, + api: { + environments: { + get: async (params: unknown) => { + order.push('get'); + updates.push(params); + return held; + }, + update: async (params: { update: EnvironmentUpdate }) => { + order.push('update'); + updates.push(params); + return { ...held, environmentVariables: params.update.environmentVariables }; + }, + }, + }, + } as unknown as ServiceContext; + const io = { + readFile: async (path: string) => { + order.push(`read:${path}`); + + if (!(path in files)) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + + return files[path]; + }, + exists: (path: string) => path in files, + readStdin: async () => '', + }; + + return { printed, order, updates, asked, setter: new VariableSetter(services, io) }; +} + +describe('variable setter', () => { + it('gathers every source before it reads the environment, then reads, writes and prints the provenance', async () => { + const { setter, order, printed, updates } = fakes(undefined, { 'a.env': 'FROM_FILE=1' }); + + await setter.set({ ...SCOPE, vars: ['TYPED=2'], envFiles: ['a.env'], dataDir: '/work' }); + + expect(order).toEqual(['read:a.env', 'get', 'update']); + expect(updates[1]).toEqual({ + ...SCOPE, + update: { + environmentVariables: [ + { key: 'KEEP', value: 'k' }, + { key: 'FROM_FILE', value: '1' }, + { key: 'TYPED', value: '2' }, + ], + }, + }); + expect(printed).toEqual(['✔ 2 variables set — 1 from a.env, 1 from --var']); + }); + + it('auto-detects a file under the data directory when no source is given and names it', async () => { + const { setter, printed, updates } = fakes(undefined, { '/work/.env.local': 'A=1' }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); + + expect(printed).toEqual(['Using variables from /work/.env.local.', '✔ 1 variable set — 1 from /work/.env.local']); + expect((updates[1] as { update: EnvironmentUpdate }).update.environmentVariables).toEqual([ + { key: 'KEEP', value: 'k' }, + { key: 'A', value: '1' }, + ]); + }); + + it('prints no changes made and never reads the environment when the sources hold nothing', async () => { + const { setter, printed, order } = fakes(undefined, { 'empty.env': '# nothing' }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: ['empty.env'], dataDir: '/work' }); + + expect(printed).toEqual(['No changes made.']); + expect(order).toEqual(['read:empty.env']); + }); + + it('names an override in the summary', async () => { + const { setter, printed } = fakes(undefined, { 'a.env': 'API_URL=file' }); + + await setter.set({ ...SCOPE, vars: ['API_URL=typed'], envFiles: ['a.env'], dataDir: '/work' }); + + expect(printed).toEqual(['✔ 1 variable set — 1 from a.env, API_URL overridden by --var']); + }); + + it('writes nothing and prints no summary when a source fails', async () => { + const { setter, printed, order } = fakes(); + + await expect(setter.set({ ...SCOPE, vars: ['KEY='], envFiles: undefined, dataDir: '/work' })).rejects.toThrow( + 'KEY has an empty value.', + ); + expect(order).toEqual([]); + expect(printed).toEqual([]); + }); + + it('prints no summary when the write fails', async () => { + const printed: string[] = []; + const failing = new VariableSetter( + { + ux: { print: (message: string) => printed.push(message), inquire: async () => undefined as never }, + isTTY: false, + outputIsTTY: false, + api: { + environments: { + get: async () => ({}), + update: async () => Promise.reject(new Error('update failed')), + }, + }, + } as unknown as ServiceContext, + { readFile: async () => '', exists: () => false, readStdin: async () => '' }, + ); + + await expect(failing.set({ ...SCOPE, vars: ['A=1'], envFiles: undefined, dataDir: '/work' })).rejects.toThrow( + 'update failed', + ); + expect(printed).toEqual([]); + }); + + it('never prints a value', async () => { + const { setter, printed } = fakes(undefined, { 'a.env': 'A=never-printed-value' }); + + await setter.set({ ...SCOPE, vars: ['B=another_secret'], envFiles: ['a.env'], dataDir: '/work' }); + + expect(printed.join('\n')).not.toContain('never-printed-value'); + expect(printed.join('\n')).not.toContain('another_secret'); + }); +}); + +describe('variable setter with --from-stack', () => { + function stackSetter(saved: SavedToken | undefined) { + const printed: string[] = []; + const templateAsked: string[] = []; + const aliasAsked: string[] = []; + const updates: unknown[] = []; + const services = { + ux: { print: (message: string) => printed.push(message), inquire: async () => undefined as never }, + isTTY: false, + outputIsTTY: false, + api: { + variables: { + stackTemplate: async (org: string) => { + templateAsked.push(org); + return [{ key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }]; + }, + }, + environments: { + get: async () => ({ environmentVariables: [{ key: 'KEEP', value: 'k' }] }), + update: async (params: unknown) => { + updates.push(params); + return {}; + }, + }, + }, + } as unknown as ServiceContext; + const io = { readFile: async () => '', exists: () => false, readStdin: async () => '' }; + const setter = new VariableSetter(services, io, (alias) => { + aliasAsked.push(alias); + return saved; + }); + + return { setter, printed, templateAsked, aliasAsked, updates }; + } + + it('imports through the saved token under the alias for the same organization, merges it in and names the stack', async () => { + const { setter, printed, templateAsked, aliasAsked, updates } = stackSetter({ + token: 'cs-secret-token', + apiKey: 'blt9', + environment: 'staging', + }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work', fromStack: true, alias: 'my-prod' }); + + expect(aliasAsked).toEqual(['my-prod']); + expect(templateAsked).toEqual(['org1']); + expect((updates[0] as { update: { environmentVariables: unknown } }).update.environmentVariables).toEqual([ + { key: 'KEEP', value: 'k' }, + { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: 'cs-secret-token' }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'staging' }, + ]); + expect(printed).toEqual([ + 'Using the saved delivery token "my-prod" (environment staging).', + 'No value was available for CONTENTSTACK_CDN, so it was not imported.', + '\u2714 4 variables set \u2014 4 from stack', + ]); + expect(printed.join('\n')).not.toContain('cs-secret-token'); + }); + + it('writes nothing when the alias has no saved token', async () => { + const { setter, printed, updates } = stackSetter(undefined); + + await expect( + setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work', fromStack: true, alias: 'gone' }), + ).rejects.toThrow('No delivery token is saved as "gone".'); + expect(updates).toEqual([]); + expect(printed).toEqual([]); + }); + + it('refuses --from-stack without --alias when there is no terminal, writing nothing', async () => { + const { setter, updates } = stackSetter(undefined); + + await expect( + setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work', fromStack: true }), + ).rejects.toThrow("--from-stack requires --alias when there's no TTY."); + expect(updates).toEqual([]); + }); +}); + +describe('default variable io', () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'launch-variable-io-')); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it('reads a file as utf8 text', async () => { + const path = join(directory, 'a.env'); + writeFileSync(path, 'A=caf\u00e9\n'); + + expect(await DEFAULT_VARIABLE_IO.readFile(path)).toBe('A=caf\u00e9\n'); + }); + + it('rejects reading a file that does not exist with the not-found code', async () => { + await expect(DEFAULT_VARIABLE_IO.readFile(join(directory, 'missing.env'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports whether a path exists', () => { + const path = join(directory, 'a.env'); + writeFileSync(path, 'A=1'); + + expect(DEFAULT_VARIABLE_IO.exists(path)).toBe(true); + expect(DEFAULT_VARIABLE_IO.exists(join(directory, 'missing.env'))).toBe(false); + }); + + it('reads everything piped on standard input', async () => { + const original = Object.getOwnPropertyDescriptor(process, 'stdin'); + Object.defineProperty(process, 'stdin', { value: Readable.from(['A=1\n', 'B=2\n']), configurable: true }); + + try { + expect(await DEFAULT_VARIABLE_IO.readStdin()).toBe('A=1\nB=2\n'); + } finally { + Object.defineProperty(process, 'stdin', original as PropertyDescriptor); + } + }); + + describe('asking which sources to use', () => { + const STACK = 'Import variables from a stack'; + const FILE = 'Import variables from a dotenv file'; + const TYPED = 'Type KEY=VALUE pairs'; + + it('offers the three sources, with no Skip, on a terminal when no source flag was given', async () => { + const { setter, asked } = fakes(undefined, {}, { 'Add variables from': [] }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); + + expect(asked).toHaveLength(1); + expect(asked[0].payload).toMatchObject({ + type: 'checkbox', + message: 'Add variables from', + choices: [ + { name: STACK, value: STACK }, + { name: FILE, value: FILE }, + { name: TYPED, value: TYPED }, + ], + }); + }); + + it('names the detected dotenv file in its choice', async () => { + const { setter, asked } = fakes(undefined, { '/work/.env.local': 'A=1' }, { 'Add variables from': [] }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); + + expect(asked[0].payload).toMatchObject({ + choices: [{ name: STACK }, { name: `${FILE} (.env.local found)`, value: FILE }, { name: TYPED }], + }); + }); + + it('says it skipped adding variables, and never reads the environment, when nothing is ticked', async () => { + const { setter, printed, order } = fakes(undefined, {}, { 'Add variables from': [] }); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); + + expect(printed).toEqual(['Skipped adding variables.']); + expect(order).toEqual([]); + }); + + it('sets what the ticked sources supply and prints the provenance', async () => { + const { setter, printed, updates, order } = fakes( + undefined, + { '/work/.env.local': 'FROM_FILE=1' }, + { + 'Add variables from': [FILE, TYPED], + 'Path to the dotenv file': '/work/.env.local', + 'KEY=VALUE pairs separated by spaces': 'TYPED=2', + }, + ); + + await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); + + expect(order).toEqual(['read:/work/.env.local', 'get', 'update']); + expect((updates[1] as { update: EnvironmentUpdate }).update.environmentVariables).toEqual([ + { key: 'KEEP', value: 'k' }, + { key: 'FROM_FILE', value: '1' }, + { key: 'TYPED', value: '2' }, + ]); + expect(printed.at(-1)).toBe('✔ 2 variables set — 1 from /work/.env.local, 1 from --var'); + }); + + it.each([ + [{ vars: ['A=1'] }], + [{ envFiles: ['a.env'] }], + [{ fromStack: true, alias: 'prod' }], + ])('does not ask when a source flag was given (%p)', async (flags) => { + const { setter, asked } = fakes(undefined, { 'a.env': 'FROM_FILE=1' }, {}); + + await setter + .set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work', ...flags }) + .catch(() => undefined); + + expect(asked).toEqual([]); + }); + }); +}); diff --git a/src/variables/variable.set.ts b/src/variables/variable.set.ts new file mode 100644 index 00000000..d1e7cb0 --- /dev/null +++ b/src/variables/variable.set.ts @@ -0,0 +1,70 @@ +import { styled } from '../core/style'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentParams } from '../environments/environments.api'; +import { chooseSources } from './variable.chooser'; +import { DEFAULT_VARIABLE_IO, given } from './variable.io'; +import type { VariableIo } from './variable.io'; +import type { MergedVariables } from './variable.merge'; +import { collectVariables } from './variable.sources'; +import { importFromStack, readSavedToken } from './variable.stack'; +import type { SavedToken } from './variable.stack'; +import { variablesSetLine } from './variable.summary'; +import { setVariables } from './variable.writer'; + +export interface SetVariablesRequest extends EnvironmentParams { + vars: string[] | undefined; + envFiles: string[] | undefined; + dataDir: string; + fromStack?: boolean; + alias?: string; +} + +export const NO_CHANGES_LINE = 'No changes made.'; +export const SKIPPED_LINE = 'Skipped adding variables.'; +export const CHOOSE_SOURCES_MESSAGE = 'Add variables from'; + +export class VariableSetter { + constructor( + private readonly services: ServiceContext, + private readonly io: VariableIo = DEFAULT_VARIABLE_IO, + private readonly savedToken: (alias: string) => SavedToken | undefined = readSavedToken, + ) {} + + async set(request: SetVariablesRequest): Promise { + const { ux } = this.services; + const stack = (alias?: string) => + importFromStack({ org: request.org, alias }, { services: this.services, savedToken: this.savedToken }); + const prompt = { message: CHOOSE_SOURCES_MESSAGE, skippable: false }; + const asked = !given(request.vars) && !given(request.envFiles) && request.fromStack !== true && this.services.isTTY; + const merged: MergedVariables | undefined = asked + ? await chooseSources(request.dataDir, this.services, this.io, stack, prompt) + : await collectVariables( + { + vars: request.vars, + envFiles: request.envFiles, + autoDetect: true, + fromStack: request.fromStack, + alias: request.alias, + }, + { ...this.io, services: this.services, dataDir: request.dataDir, stack }, + ); + + if (merged === undefined) { + ux.print(styled(SKIPPED_LINE, 'yellow', this.services.outputIsTTY === true)); + return; + } + + if (merged.variables.length === 0) { + ux.print(styled(NO_CHANGES_LINE, 'yellow', this.services.outputIsTTY === true)); + return; + } + + await setVariables( + this.services.api.environments, + { org: request.org, project: request.project, environment: request.environment }, + merged.variables, + ); + + ux.print(variablesSetLine(merged, this.services.outputIsTTY === true)); + } +} diff --git a/src/variables/variable.sources.test.ts b/src/variables/variable.sources.test.ts new file mode 100644 index 00000000..1b9b4d7 --- /dev/null +++ b/src/variables/variable.sources.test.ts @@ -0,0 +1,331 @@ +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentVariableInput } from '../environments/types'; +import { collectVariables } from './variable.sources'; + +function fakes( + options: { + files?: Record; + stdin?: string; + answers?: string[]; + isTTY?: boolean; + stack?: EnvironmentVariableInput[] | Error; + } = {}, +) { + const files = options.files ?? {}; + const printed: string[] = []; + const prompts: string[] = []; + const reads: string[] = []; + const events: string[] = []; + const stackAsked: (string | undefined)[] = []; + const stdinReads: number[] = []; + const answers = [...(options.answers ?? [])]; + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: (async (payload: { message: string }) => { + prompts.push(payload.message); + return answers.shift(); + }) as UxLike['inquire'], + }; + + return { + printed, + prompts, + reads, + events, + stackAsked, + stdinReads, + context: { + services: { isTTY: options.isTTY ?? false, ux } as unknown as ServiceContext, + dataDir: '/work', + stack: async (alias?: string) => { + events.push('stack'); + stackAsked.push(alias); + + if (options.stack instanceof Error) { + throw options.stack; + } + + return options.stack ?? []; + }, + readFile: async (path: string) => { + reads.push(path); + events.push(`read:${path}`); + + if (!(path in files)) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + + return files[path]; + }, + exists: (path: string) => path in files, + readStdin: async () => { + stdinReads.push(1); + return options.stdin ?? ''; + }, + }, + }; +} + +describe('variable sources', () => { + it('merges file then --var with --var winning, and names every override in the provenance', async () => { + const { context } = fakes({ files: { 'prod.env': 'API_URL=file\nDEBUG=1' } }); + + const merged = await collectVariables({ vars: ['API_URL=typed', 'EXTRA=2'], envFiles: ['prod.env'] }, context); + + expect(merged.variables).toEqual([ + { key: 'API_URL', value: 'typed' }, + { key: 'DEBUG', value: '1' }, + { key: 'EXTRA', value: '2' }, + ]); + expect(merged.added).toEqual([ + { label: 'prod.env', count: 2 }, + { label: '--var', count: 1 }, + ]); + expect(merged.overrides).toEqual([{ label: '--var', keys: ['API_URL'] }]); + }); + + it('reads nothing from disk and prompts for nothing when only complete --var pairs are given', async () => { + const { context, reads, prompts, stdinReads } = fakes(); + + const merged = await collectVariables({ vars: ['A=1', 'B=2'], envFiles: undefined }, context); + + expect(merged.variables).toEqual([{ key: 'A', value: '1' }, { key: 'B', value: '2' }]); + expect(reads).toEqual([]); + expect(prompts).toEqual([]); + expect(stdinReads).toEqual([]); + }); + + it('does not auto-detect a file when --var is given, even on a command that allows it', async () => { + const { context, reads } = fakes({ files: { '/work/.env': 'X=1' } }); + + await collectVariables({ vars: ['A=1'], envFiles: undefined, autoDetect: true }, context); + + expect(reads).toEqual([]); + }); + + it('does not auto-detect when --dotenv is given', async () => { + const { context, reads } = fakes({ files: { '/work/.env': 'X=1', 'mine.env': 'Y=2' } }); + + await collectVariables({ vars: undefined, envFiles: ['mine.env'], autoDetect: true }, context); + + expect(reads).toEqual(['mine.env']); + }); + + it('auto-detects .env.local first, names the file it chose and reads only that one', async () => { + const { context, reads, printed } = fakes({ files: { '/work/.env.local': 'A=1', '/work/.env': 'B=2' } }); + + const merged = await collectVariables({ vars: undefined, envFiles: undefined, autoDetect: true }, context); + + expect(reads).toEqual(['/work/.env.local']); + expect(printed).toContain('Using variables from /work/.env.local.'); + expect(merged.variables).toEqual([{ key: 'A', value: '1' }]); + expect(merged.added).toEqual([{ label: '/work/.env.local', count: 1 }]); + }); + + it('falls back to .env and names it', async () => { + const { context, printed } = fakes({ files: { '/work/.env': 'B=2' } }); + + await collectVariables({ vars: undefined, envFiles: undefined, autoDetect: true }, context); + + expect(printed).toContain('Using variables from /work/.env.'); + }); + + it('exits as a usage error naming --dotenv and --data-dir when nothing is detected', async () => { + const { context } = fakes(); + + await expect( + collectVariables({ vars: undefined, envFiles: undefined, autoDetect: true }, context), + ).rejects.toThrow(UsageError); + await expect( + collectVariables({ vars: undefined, envFiles: undefined, autoDetect: true }, context), + ).rejects.toThrow(/--dotenv.*--data-dir/); + }); + + it('returns nothing when no source is given and auto-detection is off', async () => { + const { context } = fakes({ files: { '/work/.env': 'A=1' } }); + + expect(await collectVariables({ vars: undefined, envFiles: undefined }, context)).toEqual({ + variables: [], + added: [], + overrides: [], + }); + }); + + it('treats an empty --var list like no --var for auto-detection', async () => { + const { context, reads } = fakes({ files: { '/work/.env': 'A=1' } }); + + await collectVariables({ vars: [], envFiles: [], autoDetect: true }, context); + + expect(reads).toEqual(['/work/.env']); + }); + + it('prompts hidden for a bare key on a terminal and merges the answer', async () => { + const { context, prompts } = fakes({ isTTY: true, answers: ['s3cret'] }); + + const merged = await collectVariables({ vars: ['API_KEY'], envFiles: undefined }, context); + + expect(prompts).toEqual(['Value for API_KEY']); + expect(merged.variables).toEqual([{ key: 'API_KEY', value: 's3cret' }]); + }); + + it('reads a lone bare key from stdin without a terminal', async () => { + const { context } = fakes({ stdin: 'piped\n' }); + + const merged = await collectVariables({ vars: ['API_KEY'], envFiles: undefined }, context); + + expect(merged.variables).toEqual([{ key: 'API_KEY', value: 'piped' }]); + }); + + it('refuses a bare key together with --dotenv - on a terminal too, before reading or asking anything', async () => { + const { context, stdinReads, prompts } = fakes({ stdin: 'A=1', isTTY: true }); + + await expect(collectVariables({ vars: ['API_KEY'], envFiles: ['-'] }, context)).rejects.toThrow( + 'only one of them can read standard input', + ); + expect(stdinReads).toEqual([]); + expect(prompts).toEqual([]); + }); + + it('refuses a bare key together with --dotenv - without a terminal, since both would read stdin', async () => { + const { context, stdinReads } = fakes({ stdin: 'A=1' }); + + await expect(collectVariables({ vars: ['API_KEY'], envFiles: ['-'] }, context)).rejects.toThrow(UsageError); + await expect(collectVariables({ vars: ['API_KEY'], envFiles: ['-'] }, context)).rejects.toThrow( + 'only one of them can read standard input', + ); + expect(stdinReads).toEqual([]); + }); + + it('reports the keys of empty file variables it skipped, never a value', async () => { + const { context, printed } = fakes({ files: { 'a.env': 'A=1\nEMPTY=\nBLANK= ' } }); + + await collectVariables({ vars: undefined, envFiles: ['a.env'] }, context); + + expect(printed).toContain('Skipped 2 variables with an empty value in a.env: EMPTY, BLANK.'); + }); + + it('uses the singular when one empty variable was skipped', async () => { + const { context, printed } = fakes({ files: { 'a.env': 'A=1\nEMPTY=' } }); + + await collectVariables({ vars: undefined, envFiles: ['a.env'] }, context); + + expect(printed).toContain('Skipped 1 variable with an empty value in a.env: EMPTY.'); + }); + + it('flattens --var tokens and rejects a malformed one before reading any file', async () => { + const { context, reads } = fakes({ files: { 'a.env': 'A=1' } }); + + await expect(collectVariables({ vars: ['OK=1', 'KEY='], envFiles: ['a.env'] }, context)).rejects.toThrow( + 'KEY has an empty value.', + ); + expect(reads).toEqual([]); + }); + + it('propagates a missing --dotenv, a cancelled prompt and a failed read', async () => { + await expect(collectVariables({ vars: undefined, envFiles: ['nope.env'] }, fakes().context)).rejects.toThrow( + '--dotenv nope.env was not found.', + ); + await expect( + collectVariables({ vars: ['A'], envFiles: undefined }, fakes({ isTTY: true, answers: [undefined as unknown as string] }).context), + ).rejects.toThrow(CancelledError); + + const failure = new Error('EACCES'); + const { context } = fakes(); + + await expect( + collectVariables({ vars: undefined, envFiles: ['x.env'] }, { ...context, readFile: async () => Promise.reject(failure) }), + ).rejects.toBe(failure); + }); + + it('never prints a value', async () => { + const { context, printed } = fakes({ files: { 'a.env': 'A=never-printed-value\nEMPTY=' } }); + + await collectVariables({ vars: ['B=another_secret'], envFiles: ['a.env'] }, context); + + expect(printed.join('\n')).not.toContain('never-printed-value'); + expect(printed.join('\n')).not.toContain('another_secret'); + }); + + it('merges stack, then file, then --var, naming the stack as a source and every override', async () => { + const { context } = fakes({ + stack: [{ key: 'API_URL', value: 'from-stack' }, { key: 'REGION', value: 'eu' }, { key: 'ONLY_STACK', value: 's' }], + files: { 'a.env': 'REGION=us\nONLY_FILE=f' }, + }); + + const merged = await collectVariables({ vars: ['API_URL=typed'], envFiles: ['a.env'], fromStack: true }, context); + + expect(merged.variables).toEqual([ + { key: 'API_URL', value: 'typed' }, + { key: 'REGION', value: 'us' }, + { key: 'ONLY_STACK', value: 's' }, + { key: 'ONLY_FILE', value: 'f' }, + ]); + expect(merged.added).toEqual([ + { label: 'stack', count: 3 }, + { label: 'a.env', count: 1 }, + ]); + expect(merged.overrides).toEqual([ + { label: 'a.env', keys: ['REGION'] }, + { label: '--var', keys: ['API_URL'] }, + ]); + }); + + it('asks the stack importer once with the alias, after it has read every file', async () => { + const { context, events, stackAsked } = fakes({ stack: [{ key: 'A', value: '1' }], files: { 'a.env': 'B=2' } }); + + await collectVariables({ vars: undefined, envFiles: ['a.env'], fromStack: true, alias: 'my-prod' }, context); + + expect(stackAsked).toEqual(['my-prod']); + expect(events).toEqual(['read:a.env', 'stack']); + }); + + it('fails on a missing dotenv file before it asks the stack anything', async () => { + const { context, events } = fakes({ stack: [{ key: 'A', value: '1' }], files: {} }); + + await expect( + collectVariables({ vars: undefined, envFiles: ['missing.env'], fromStack: true }, context), + ).rejects.toThrow('--dotenv missing.env was not found.'); + expect(events).not.toContain('stack'); + }); + + it('passes no alias to the stack importer when none was given', async () => { + const { context, stackAsked } = fakes({ stack: [{ key: 'A', value: '1' }] }); + + await collectVariables({ vars: undefined, envFiles: undefined, fromStack: true }, context); + + expect(stackAsked).toEqual([undefined]); + }); + + it.each([[undefined], [false]])('never asks the stack importer when fromStack is %p', async (fromStack) => { + const { context, stackAsked } = fakes({ stack: [{ key: 'A', value: '1' }] }); + + const merged = await collectVariables({ vars: ['B=2'], envFiles: undefined, fromStack }, context); + + expect(stackAsked).toEqual([]); + expect(merged.variables).toEqual([{ key: 'B', value: '2' }]); + }); + + it('counts --from-stack as a source, so nothing is auto-detected and a missing .env is not an error', async () => { + const { context, reads } = fakes({ stack: [{ key: 'A', value: '1' }], files: { '/work/.env': 'X=1' } }); + + const merged = await collectVariables({ vars: undefined, envFiles: undefined, autoDetect: true, fromStack: true }, context); + + expect(reads).toEqual([]); + expect(merged.variables).toEqual([{ key: 'A', value: '1' }]); + }); + + it('propagates a failed stack import without printing anything', async () => { + const failure = new Error('stack failed'); + const { context, reads, printed } = fakes({ stack: failure, files: { 'a.env': 'B=2' } }); + + await expect( + collectVariables({ vars: undefined, envFiles: ['a.env'], fromStack: true, alias: 'a' }, context), + ).rejects.toBe(failure); + expect(reads).toEqual(['a.env']); + expect(printed).toEqual([]); + }); +}); diff --git a/src/variables/variable.sources.ts b/src/variables/variable.sources.ts new file mode 100644 index 00000000..9e5e4b6 --- /dev/null +++ b/src/variables/variable.sources.ts @@ -0,0 +1,97 @@ +import { UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentVariableInput } from '../environments/types'; +import { detectEnvFile, readEnvFiles, STDIN_PATH } from './variable.files'; +import type { EnvFileSource } from './variable.files'; +import { mergeSources } from './variable.merge'; +import type { MergedVariables, VariableSource } from './variable.merge'; +import { parseAssignment } from './variable.parse'; +import { completeValues } from './variable.values'; + +export interface VariableRequest { + vars: string[] | undefined; + envFiles: string[] | undefined; + autoDetect?: boolean; + fromStack?: boolean; + alias?: string; +} + +export interface VariableContext { + services: ServiceContext; + dataDir: string; + stack: (alias?: string) => Promise; + readFile(path: string): Promise; + exists(path: string): boolean; + readStdin(): Promise; +} + +const VAR_LABEL = '--var'; +const STACK_LABEL = 'stack'; + +function plural(count: number): string { + return count === 1 ? 'variable' : 'variables'; +} + +export function noteSkipped(source: EnvFileSource, ux: UxLike): void { + if (source.skipped.length > 0) { + const { label, skipped } = source; + + const noun = plural(skipped.length); + + ux.print(`Skipped ${skipped.length} ${noun} with an empty value in ${label}: ${skipped.join(', ')}.`); + } +} + +async function fileSources( + request: VariableRequest, + context: VariableContext, + noneGiven: boolean, +): Promise { + const reader = { readFile: context.readFile, readStdin: context.readStdin }; + const named = request.envFiles ?? []; + + if (named.length > 0 || !noneGiven || request.autoDetect !== true) { + return readEnvFiles(named, reader); + } + + const detected = detectEnvFile(context.dataDir, context.exists); + + if (detected === undefined) { + throw new UsageError( + `No variables were given and no .env.local or .env was found in ${context.dataDir}. ` + + 'Pass --var, --dotenv, or --data-dir to point at the project folder.', + ); + } + + context.services.ux.print(`Using variables from ${detected}.`); + + return readEnvFiles([detected], reader); +} + +export async function collectVariables(request: VariableRequest, context: VariableContext): Promise { + const assignments = (request.vars ?? []).map(parseAssignment); + const hasBare = assignments.some((assignment) => assignment.value === undefined); + + if (hasBare && (request.envFiles ?? []).includes(STDIN_PATH)) { + throw new UsageError( + 'A --var with no value and --dotenv - both need standard input, ' + + 'and only one of them can read standard input.', + ); + } + + const noneGiven = assignments.length === 0 && request.fromStack !== true; + const files = await fileSources(request, context, noneGiven); + const stackVariables = request.fromStack === true ? await context.stack(request.alias) : []; + const typed = await completeValues(assignments, context); + + files.forEach((source) => noteSkipped(source, context.services.ux)); + + const sources: VariableSource[] = [ + { label: STACK_LABEL, variables: stackVariables }, + ...files.map(({ label, variables }) => ({ label, variables })), + { label: VAR_LABEL, variables: typed }, + ]; + + return mergeSources(sources); +} diff --git a/src/variables/variable.stack.test.ts b/src/variables/variable.stack.test.ts new file mode 100644 index 00000000..ff1b2d2 --- /dev/null +++ b/src/variables/variable.stack.test.ts @@ -0,0 +1,391 @@ +import { configHandler } from '@contentstack/cli-utilities'; + +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { DeliveryToken, Stack } from '../stacks/stacks.api'; +import { StackLookupError } from '../stacks/stack.errors'; +import type { StackTemplateEntry } from './variables.api'; +import { importFromStack, readSavedToken } from './variable.stack'; +import type { SavedToken } from './variable.stack'; + +const TEMPLATE: StackTemplateEntry[] = [ + { key: 'CONTENTSTACK_API_HOST', value: 'https://api.example.test' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: undefined }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: undefined }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: undefined }, +]; + +const STACKS: Stack[] = [ + { name: 'Shop', apiKey: 'blt1' }, + { name: 'Blog', apiKey: 'blt2' }, +]; + +const TOKENS: DeliveryToken[] = [ + { name: 'prod-token', token: 'cs-prod-value', environment: 'production' }, + { name: 'dev-token', token: 'cs-dev-value', environment: 'development' }, +]; + +interface Scenario { + isTTY?: boolean; + answers?: Record; + stacks?: Stack[] | Error; + tokens?: DeliveryToken[] | Error; + template?: StackTemplateEntry[] | Error; +} + +function fakes(scenario: Scenario = {}) { + const calls: string[] = []; + const printed: string[] = []; + const prompts: { message: string; payload: Record }[] = []; + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: (async (payload: Record) => { + const message = String(payload.message); + calls.push(`ask:${message}`); + prompts.push({ message, payload }); + + if (!(message in (scenario.answers ?? {}))) { + throw new Error(`Unexpected prompt: ${message}`); + } + + return (scenario.answers ?? {})[message]; + }) as UxLike['inquire'], + }; + const answer = (value: T | Error | undefined, fallback: T): T => { + if (value instanceof Error) { + throw value; + } + + return value ?? fallback; + }; + const services = { + ux, + isTTY: scenario.isTTY ?? true, + api: { + stacks: { + list: async (org: string) => { + calls.push(`stacks:${org}`); + return answer(scenario.stacks, STACKS); + }, + deliveryTokens: async (apiKey: string) => { + calls.push(`tokens:${apiKey}`); + return answer(scenario.tokens, TOKENS); + }, + }, + variables: { + stackTemplate: async (org: string) => { + calls.push(`template:${org}`); + return answer(scenario.template, TEMPLATE); + }, + }, + }, + } as unknown as ServiceContext; + + return { services, calls, printed, prompts }; +} + +const SAVED: SavedToken = { token: 'cs-saved-value', apiKey: 'blt9', environment: 'staging' }; + +function saved(entries: Record) { + const asked: string[] = []; + + return { + asked, + savedToken: (alias: string) => { + asked.push(alias); + return entries[alias]; + }, + }; +} + +const NOTHING_SAVED = () => undefined; + +describe('variable stack import with --alias', () => { + it('builds the five variables from the saved token and the CMS template without asking anything', async () => { + const { services, calls, printed } = fakes(); + const { savedToken, asked } = saved({ 'my-prod': SAVED }); + + const variables = await importFromStack({ org: 'org1', alias: 'my-prod' }, { services, savedToken }); + + expect(variables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: 'cs-saved-value' }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'staging' }, + ]); + expect(asked).toEqual(['my-prod']); + expect(calls).toEqual(['template:org1']); + expect(printed).toEqual(['Using the saved delivery token "my-prod" (environment staging).']); + expect(printed.join('\n')).not.toContain('cs-saved-value'); + }); + + it('omits the host and CDN when the template carries no value for them', async () => { + const { services } = fakes({ template: [{ key: 'CONTENTSTACK_API_HOST', value: undefined }] }); + const { savedToken } = saved({ a: SAVED }); + + const variables = await importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken }); + + expect(variables.map((variable) => variable.key)).toEqual([ + 'CONTENTSTACK_API_KEY', + 'CONTENTSTACK_DELIVERY_TOKEN', + 'CONTENTSTACK_ENVIRONMENT', + ]); + }); + + it('says which host variables it could not import, never a value', async () => { + const { services, printed } = fakes({ template: [{ key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }] }); + const { savedToken } = saved({ a: SAVED }); + + await importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken }); + + expect(printed).toContain('No value was available for CONTENTSTACK_CDN, so it was not imported.'); + expect(printed.join('\n')).not.toContain('cs-saved-value'); + }); + + it('keeps a host that is not a url as it is and reduces a url to its host and port', async () => { + const { services } = fakes({ + template: [ + { key: 'CONTENTSTACK_API_HOST', value: 'http://localhost:8080/v3' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + ], + }); + const { savedToken } = saved({ a: SAVED }); + + const variables = await importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken }); + + expect(variables.slice(0, 2)).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'localhost:8080' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + ]); + }); + + it('keeps a host that only looks like a url but does not parse', async () => { + const { services } = fakes({ template: [{ key: 'CONTENTSTACK_API_HOST', value: 'http://' }] }); + const { savedToken } = saved({ a: SAVED }); + + const variables = await importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken }); + + expect(variables[0]).toEqual({ key: 'CONTENTSTACK_API_HOST', value: 'http://' }); + }); + + it('refuses an alias nothing is saved under, naming how to save one, before any request', async () => { + const { services, calls } = fakes(); + const { savedToken } = saved({}); + + const error = await importFromStack({ org: 'org1', alias: 'gone' }, { services, savedToken }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).message).toBe( + 'No delivery token is saved as "gone". Save one with csdx auth:tokens:add --alias gone --delivery.', + ); + expect(calls).toEqual([]); + }); + + it('refuses a saved token with no environment and says how to save it again', async () => { + const { services, calls } = fakes(); + const { savedToken } = saved({ a: { token: 't', apiKey: 'blt9' } }); + + await expect(importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken })).rejects.toThrow( + 'The saved delivery token "a" has no environment. Save it again with csdx auth:tokens:add --alias a --delivery --environment .', + ); + expect(calls).toEqual([]); + }); + + it('refuses a saved management token', async () => { + const { services } = fakes(); + const { savedToken } = saved({ a: { ...SAVED, type: 'management' } }); + + await expect(importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken })).rejects.toThrow( + '"a" is a management token. --from-stack needs a delivery token.', + ); + }); + + it('accepts a saved token explicitly typed as delivery', async () => { + const { services } = fakes(); + const { savedToken } = saved({ a: { ...SAVED, type: 'delivery' } }); + + await expect(importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken })).resolves.toHaveLength(5); + }); + + it('propagates a failed template request without having printed a success line', async () => { + const failure = new Error('cms failed'); + const { services, printed } = fakes({ template: failure }); + const { savedToken } = saved({ a: SAVED }); + + await expect(importFromStack({ org: 'org1', alias: 'a' }, { services, savedToken })).rejects.toBe(failure); + expect(printed).toEqual([]); + }); +}); + +describe('variable stack import without --alias', () => { + it('exits as a usage error naming --alias when there is no terminal, asking and requesting nothing', async () => { + const { services, calls } = fakes({ isTTY: false }); + const { savedToken, asked } = saved({}); + + const error = await importFromStack({ org: 'org1' }, { services, savedToken }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).message).toBe("--from-stack requires --alias when there's no TTY."); + expect(calls).toEqual([]); + expect(asked).toEqual([]); + }); + + it('asks for the stack, then the delivery token, then reads the template, and builds the variables from the choices', async () => { + const { services, calls, printed, prompts } = fakes({ answers: { Stack: '1', 'Delivery token': '0' } }); + + const variables = await importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED }); + + expect(calls).toEqual(['stacks:org1', 'ask:Stack', 'tokens:blt2', 'ask:Delivery token', 'template:org1']); + expect(variables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt2' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: 'cs-prod-value' }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'production' }, + ]); + expect(prompts[0].payload).toMatchObject({ + type: 'list', + choices: [ + { name: 'Shop', value: '0' }, + { name: 'Blog', value: '1' }, + ], + }); + expect(prompts[1].payload).toMatchObject({ + choices: [ + { name: 'prod-token (production)', value: '0' }, + { name: 'dev-token (development)', value: '1' }, + ], + }); + expect(printed).toEqual(['Using delivery token "prod-token" of stack "Blog" (environment production).']); + expect(JSON.stringify({ printed, prompts })).not.toContain('cs-prod-value'); + }); + + it('offers a token with no environment without the parenthesis and then refuses it', async () => { + const { services, prompts } = fakes({ + answers: { Stack: '0', 'Delivery token': '0' }, + tokens: [{ name: 'loose', token: 'cs-loose', environment: undefined }], + }); + + await expect(importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED })).rejects.toThrow( + 'The delivery token "loose" is not scoped to an environment. Scope it in the stack, or save one with an environment using csdx auth:tokens:add.', + ); + expect(prompts[1].payload).toMatchObject({ choices: [{ name: 'loose', value: '0' }] }); + }); + + it('refuses an organization with no stacks', async () => { + const { services, calls } = fakes({ stacks: [] }); + + await expect(importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED })).rejects.toThrow( + 'No stacks were found in this organization, or you have no access to any.', + ); + expect(calls).toEqual(['stacks:org1']); + }); + + it.each([['9'], ['x']])('refuses a stack answer that is not one of the choices (%p)', async (answer) => { + const { services } = fakes({ answers: { Stack: answer } }); + + await expect(importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED })).rejects.toThrow( + 'That stack is not one of the choices.', + ); + }); + + it('refuses a delivery token answer that is not one of the choices', async () => { + const { services } = fakes({ answers: { Stack: '0', 'Delivery token': '7' } }); + + await expect(importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED })).rejects.toThrow( + 'That delivery token is not one of the choices.', + ); + }); + + it('refuses a stack with no delivery tokens and names the stack', async () => { + const { services } = fakes({ answers: { Stack: '0' }, tokens: [] }); + + await expect(importFromStack({ org: 'org1' }, { services, savedToken: NOTHING_SAVED })).rejects.toThrow( + 'Stack "Shop" has no delivery tokens. Create one in the stack, or pass --alias.', + ); + }); + + it('propagates a cancelled prompt, a failed stack lookup and a failed token lookup', async () => { + const cancelled = fakes({ answers: { Stack: undefined } }); + + await expect(importFromStack({ org: 'org1' }, { services: cancelled.services, savedToken: NOTHING_SAVED })).rejects.toThrow( + CancelledError, + ); + + const failing = new StackLookupError('the stacks of this organization', 'boom'); + const stacks = fakes({ stacks: failing }); + + await expect(importFromStack({ org: 'org1' }, { services: stacks.services, savedToken: NOTHING_SAVED })).rejects.toBe(failing); + + const tokenFailure = new StackLookupError('the delivery tokens of stack blt1', 'boom'); + const tokens = fakes({ answers: { Stack: '0' }, tokens: tokenFailure }); + + await expect(importFromStack({ org: 'org1' }, { services: tokens.services, savedToken: NOTHING_SAVED })).rejects.toBe(tokenFailure); + }); +}); + +describe('readSavedToken', () => { + function configHolding(value: unknown) { + const asked: string[] = []; + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => { + asked.push(key); + return value; + }); + + return asked; + } + + it('reads the token csdx auth:tokens:add saved under the alias', () => { + const asked = configHolding({ 'my-prod': { token: 'cs-x', apiKey: 'blt1', environment: 'production', type: 'delivery' } }); + + expect(readSavedToken('my-prod')).toEqual({ token: 'cs-x', apiKey: 'blt1', environment: 'production', type: 'delivery' }); + expect(asked).toEqual(['tokens']); + }); + + it('leaves the environment and type unset when the entry has none', () => { + configHolding({ a: { token: 'cs-x', apiKey: 'blt1' } }); + + expect(readSavedToken('a')).toEqual({ token: 'cs-x', apiKey: 'blt1', environment: undefined, type: undefined }); + }); + + it.each([ + [undefined], + [null], + ['text'], + [{}], + [{ token: 'cs-x' }], + [{ apiKey: 'blt1' }], + [{ token: '', apiKey: 'blt1' }], + [{ token: 'cs-x', apiKey: ' ' }], + ])('reports no saved token for an entry of %p', (entry) => { + configHolding({ a: entry }); + + expect(readSavedToken('a')).toBeUndefined(); + }); + + it('finds an alias that contains a dot', () => { + configHolding({ 'prod.eu': { token: 'cs-x', apiKey: 'blt1' } }); + + expect(readSavedToken('prod.eu')).toEqual({ token: 'cs-x', apiKey: 'blt1', environment: undefined, type: undefined }); + }); + + it.each([[undefined], [null], ['text'], [{}], [{ other: { token: 'cs-x', apiKey: 'blt1' } }]])( + 'reports no saved token when the tokens config holds %p', + (tokens) => { + configHolding(tokens); + + expect(readSavedToken('a')).toBeUndefined(); + }, + ); + + it('does not read an alias from the prototype of the tokens object', () => { + configHolding({}); + + expect(readSavedToken('constructor')).toBeUndefined(); + }); +}); diff --git a/src/variables/variable.stack.ts b/src/variables/variable.stack.ts new file mode 100644 index 00000000..3933540 --- /dev/null +++ b/src/variables/variable.stack.ts @@ -0,0 +1,199 @@ +import { configHandler } from '@contentstack/cli-utilities'; + +import { UsageError } from '../core/errors'; +import { askOption } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { isRecord } from '../core/values'; +import type { EnvironmentVariableInput } from '../environments/types'; + +export interface SavedToken { + token: string; + apiKey: string; + environment?: string; + type?: string; +} + +export interface StackImportRequest { + org: string; + alias?: string; +} + +export interface StackImportDeps { + services: ServiceContext; + savedToken: (alias: string) => SavedToken | undefined; +} + +export type StackImporter = (alias?: string) => Promise; + +interface Credentials { + apiKey: string; + token: string; + environment: string; + notice: string; +} + +const TEMPLATE_HOST_KEYS = ['CONTENTSTACK_API_HOST', 'CONTENTSTACK_CDN']; + +function textOf(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +export function readSavedToken(alias: string): SavedToken | undefined { + const saved: unknown = configHandler.get('tokens'); + const held = isRecord(saved) && Object.prototype.hasOwnProperty.call(saved, alias); + const entry: unknown = held ? saved[alias] : undefined; + + if (!isRecord(entry)) { + return undefined; + } + + const token = textOf(entry.token); + const apiKey = textOf(entry.apiKey); + + if (token === undefined || apiKey === undefined) { + return undefined; + } + + return { token, apiKey, environment: textOf(entry.environment), type: textOf(entry.type) }; +} + +function hostOf(value: string): string { + if (!/^https?:\/\//i.test(value)) { + return value; + } + + try { + return new URL(value).host; + } catch { + return value; + } +} + +function fromSavedToken(alias: string, deps: StackImportDeps): Credentials { + const saved = deps.savedToken(alias); + + if (saved === undefined) { + throw new UsageError( + `No delivery token is saved as "${alias}". Save one with csdx auth:tokens:add --alias ${alias} --delivery.`, + ); + } + + if (saved.type === 'management') { + throw new UsageError(`"${alias}" is a management token. --from-stack needs a delivery token.`); + } + + if (saved.environment === undefined) { + throw new UsageError( + `The saved delivery token "${alias}" has no environment. ` + + `Save it again with csdx auth:tokens:add --alias ${alias} --delivery --environment .`, + ); + } + + return { + apiKey: saved.apiKey, + token: saved.token, + environment: saved.environment, + notice: `Using the saved delivery token "${alias}" (environment ${saved.environment}).`, + }; +} + +function chosenAt(entries: T[], answer: string, what: string): T { + const entry = entries[Number(answer)]; + + if (entry === undefined) { + throw new UsageError(`That ${what} is not one of the choices.`); + } + + return entry; +} + +async function fromPickers(org: string, deps: StackImportDeps): Promise { + const { ux, api } = deps.services; + const stacks = await api.stacks.list(org); + + if (stacks.length === 0) { + throw new UsageError('No stacks were found in this organization, or you have no access to any.'); + } + + const stack = chosenAt( + stacks, + await askOption( + ux, + 'Stack', + stacks.map((entry, index) => ({ name: entry.name, value: String(index) })), + ), + 'stack', + ); + const tokens = await api.stacks.deliveryTokens(stack.apiKey); + + if (tokens.length === 0) { + throw new UsageError(`Stack "${stack.name}" has no delivery tokens. Create one in the stack, or pass --alias.`); + } + + const chosen = chosenAt( + tokens, + await askOption( + ux, + 'Delivery token', + tokens.map((entry, index) => ({ + name: entry.environment === undefined ? entry.name : `${entry.name} (${entry.environment})`, + value: String(index), + })), + ), + 'delivery token', + ); + + if (chosen.environment === undefined) { + throw new UsageError( + `The delivery token "${chosen.name}" is not scoped to an environment. ` + + 'Scope it in the stack, or save one with an environment using csdx auth:tokens:add.', + ); + } + + return { + apiKey: stack.apiKey, + token: chosen.token, + environment: chosen.environment, + notice: `Using delivery token "${chosen.name}" of stack "${stack.name}" (environment ${chosen.environment}).`, + }; +} + +async function credentialsOf(request: StackImportRequest, deps: StackImportDeps): Promise { + if (request.alias !== undefined) { + return fromSavedToken(request.alias, deps); + } + + if (!deps.services.isTTY) { + throw new UsageError("--from-stack requires --alias when there's no TTY."); + } + + return fromPickers(request.org, deps); +} + +export async function importFromStack( + request: StackImportRequest, + deps: StackImportDeps, +): Promise { + const credentials = await credentialsOf(request, deps); + const template = await deps.services.api.variables.stackTemplate(request.org); + const shared = TEMPLATE_HOST_KEYS.flatMap((key) => { + const value = template.find((entry) => entry.key === key)?.value; + + return value === undefined ? [] : [{ key, value: hostOf(value) }]; + }); + + deps.services.ux.print(credentials.notice); + + const missingHosts = TEMPLATE_HOST_KEYS.filter((key) => !shared.some((entry) => entry.key === key)); + + if (missingHosts.length > 0) { + deps.services.ux.print(`No value was available for ${missingHosts.join(', ')}, so it was not imported.`); + } + + return [ + ...shared, + { key: 'CONTENTSTACK_API_KEY', value: credentials.apiKey }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: credentials.token }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: credentials.environment }, + ]; +} diff --git a/src/variables/variable.summary.test.ts b/src/variables/variable.summary.test.ts new file mode 100644 index 00000000..93fdede --- /dev/null +++ b/src/variables/variable.summary.test.ts @@ -0,0 +1,70 @@ +import { variablesSetLine } from './variable.summary'; + +describe('variable summary', () => { + it('names the total, every source that added keys and every override', () => { + const line = variablesSetLine( + { + variables: new Array(18).fill({ key: 'K', value: 'v' }), + added: [ + { label: 'stack', count: 12 }, + { label: '.env', count: 6 }, + ], + overrides: [{ label: '--var', keys: ['API_URL'] }], + }, + false, + ); + + expect(line).toBe('✔ 18 variables set — 12 from stack, 6 from .env, API_URL overridden by --var'); + }); + + it('uses the singular for one variable', () => { + const line = variablesSetLine( + { variables: [{ key: 'A', value: '1' }], added: [{ label: '--var', count: 1 }], overrides: [] }, + false, + ); + + expect(line).toBe('✔ 1 variable set — 1 from --var'); + }); + + it('lists several overridden keys of one source together and several overriding sources in order', () => { + const line = variablesSetLine( + { + variables: new Array(3).fill({ key: 'K', value: 'v' }), + added: [{ label: 'stack', count: 3 }], + overrides: [ + { label: '.env', keys: ['A', 'B'] }, + { label: '--var', keys: ['C'] }, + ], + }, + false, + ); + + expect(line).toBe('✔ 3 variables set — 3 from stack, A, B overridden by .env, C overridden by --var'); + }); + + it('colours the line green only on a terminal', () => { + const merged = { variables: [{ key: 'A', value: '1' }], added: [{ label: '--var', count: 1 }], overrides: [] }; + + expect(variablesSetLine(merged, true)).not.toBe(variablesSetLine(merged, false)); + expect(variablesSetLine(merged, true)).toContain('1 variable set'); + }); + + it('never contains a value', () => { + const line = variablesSetLine( + { + variables: [{ key: 'API_KEY', value: 'never-printed-value' }], + added: [{ label: '--var', count: 1 }], + overrides: [{ label: '--var', keys: ['API_KEY'] }], + }, + false, + ); + + expect(line).not.toContain('never-printed-value'); + }); + + it('names the state it is given and leaves out the dash when no source added anything', () => { + const merged = { variables: [], added: [], overrides: [] }; + + expect(variablesSetLine(merged, false, 'ready to set')).toBe('✔ 0 variables ready to set'); + }); +}); diff --git a/src/variables/variable.summary.ts b/src/variables/variable.summary.ts new file mode 100644 index 00000000..f6e1470 --- /dev/null +++ b/src/variables/variable.summary.ts @@ -0,0 +1,18 @@ +import { styled } from '../core/style'; +import type { MergedVariables } from './variable.merge'; + +function plural(count: number): string { + return count === 1 ? 'variable' : 'variables'; +} + +export function variablesSetLine(merged: MergedVariables, outputIsTTY: boolean, state = 'set'): string { + const total = merged.variables.length; + const provenance = [ + ...merged.added.map(({ label, count }) => `${count} from ${label}`), + ...merged.overrides.map(({ label, keys }) => `${keys.join(', ')} overridden by ${label}`), + ]; + + const origin = provenance.length > 0 ? ` — ${provenance.join(', ')}` : ''; + + return styled(`✔ ${total} ${plural(total)} ${state}${origin}`, 'green', outputIsTTY); +} diff --git a/src/variables/variable.unset.test.ts b/src/variables/variable.unset.test.ts new file mode 100644 index 00000000..0fc2851 --- /dev/null +++ b/src/variables/variable.unset.test.ts @@ -0,0 +1,144 @@ +import { UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { Environment, EnvironmentUpdate } from '../environments/types'; +import { VariableRemover } from './variable.unset'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; + +function fakes(held: Environment, answer = true) { + const printed: string[] = []; + const order: string[] = []; + const questions: string[] = []; + const updates: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: async () => undefined as never, + }; + const services = { + ux, + isTTY: true, + outputIsTTY: false, + api: { + environments: { + get: async () => { + order.push('get'); + return held; + }, + update: async (params: { update: EnvironmentUpdate }) => { + order.push('update'); + updates.push(params); + return held; + }, + }, + }, + } as unknown as ServiceContext; + const confirm = async (question: string) => { + order.push('confirm'); + questions.push(question); + return answer; + }; + + return { printed, order, questions, updates, services, remover: new VariableRemover(services, confirm) }; +} + +const HELD: Environment = { + name: 'staging', + environmentVariables: [{ key: 'API_KEY', value: 'never-printed-value' }, { key: 'DEBUG', value: 'true' }], +}; + +describe('variable remover', () => { + it('reads, confirms, then reads again and writes the rest, naming the key and the environment', async () => { + const { remover, order, printed, updates, questions } = fakes(HELD); + + const removed = await remover.remove({ ...SCOPE, keys: ['API_KEY'] }); + + expect(removed).toBe(true); + expect(order).toEqual(['get', 'confirm', 'get', 'update']); + expect(questions).toEqual(['Are you sure you want to remove API_KEY from "staging"?']); + expect(updates).toEqual([{ ...SCOPE, update: { environmentVariables: [{ key: 'DEBUG', value: 'true' }] } }]); + expect(printed).toEqual(['✔ API_KEY removed from staging.']); + }); + + it('names several keys together', async () => { + const { remover, printed, questions } = fakes(HELD); + + await remover.remove({ ...SCOPE, keys: ['API_KEY', 'DEBUG'] }); + + expect(questions).toEqual(['Are you sure you want to remove API_KEY, DEBUG from "staging"?']); + expect(printed).toEqual(['✔ API_KEY, DEBUG removed from staging.']); + }); + + it('falls back to the environment reference when the environment carries no name', async () => { + const { remover, printed, questions } = fakes({ environmentVariables: [{ key: 'A', value: '1' }] }); + + await remover.remove({ ...SCOPE, keys: ['A'] }); + + expect(questions).toEqual(['Are you sure you want to remove A from "env1"?']); + expect(printed).toEqual(['✔ A removed from env1.']); + }); + + it('returns false and changes nothing when the user declines', async () => { + const { remover, order, printed, updates } = fakes(HELD, false); + + const removed = await remover.remove({ ...SCOPE, keys: ['API_KEY'] }); + + expect(removed).toBe(false); + expect(order).toEqual(['get', 'confirm']); + expect(updates).toEqual([]); + expect(printed).toEqual([]); + }); + + it('refuses a key that is not set before it asks anything and writes nothing', async () => { + const { remover, order, updates } = fakes(HELD); + + await expect(remover.remove({ ...SCOPE, keys: ['API_KEY', 'MISSING'] })).rejects.toThrow(UsageError); + await expect(remover.remove({ ...SCOPE, keys: ['MISSING'] })).rejects.toThrow('MISSING is not set in this environment.'); + expect(order).not.toContain('confirm'); + expect(updates).toEqual([]); + }); + + it('never puts a value in the question, the summary or an error', async () => { + const { remover, printed, questions } = fakes(HELD); + + await remover.remove({ ...SCOPE, keys: ['API_KEY'] }); + + expect(`${printed.join('\n')}${questions.join('\n')}`).not.toContain('never-printed-value'); + }); + + it('propagates a failed read, a failed confirm and a failed write', async () => { + const reading = new VariableRemover( + { api: { environments: { get: async () => Promise.reject(new Error('get failed')) } } } as unknown as ServiceContext, + async () => true, + ); + + await expect(reading.remove({ ...SCOPE, keys: ['A'] })).rejects.toThrow('get failed'); + + const broken = new VariableRemover(fakes(HELD).services, async () => Promise.reject(new Error('prompt failed'))); + + await expect(broken.remove({ ...SCOPE, keys: ['API_KEY'] })).rejects.toThrow('prompt failed'); + + const writing = new VariableRemover( + { + ux: { print: () => undefined }, + outputIsTTY: false, + api: { environments: { get: async () => HELD, update: async () => Promise.reject(new Error('update failed')) } }, + } as unknown as ServiceContext, + async () => true, + ); + + await expect(writing.remove({ ...SCOPE, keys: ['API_KEY'] })).rejects.toThrow('update failed'); + }); + + it('refuses before asking when the environment holds a variable it could not send back', async () => { + const { remover, order, updates } = fakes({ name: 'staging', environmentVariables: [{ key: 'A', value: '1' }, { key: 'HIDDEN' }] }); + + await expect(remover.remove({ ...SCOPE, keys: ['A'] })).rejects.toThrow( + 'This environment holds variables the API returned without a value (HIDDEN).', + ); + expect(order).toEqual(['get']); + expect(updates).toEqual([]); + }); +}); diff --git a/src/variables/variable.unset.ts b/src/variables/variable.unset.ts new file mode 100644 index 00000000..60b5298 --- /dev/null +++ b/src/variables/variable.unset.ts @@ -0,0 +1,46 @@ +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentParams } from '../environments/environments.api'; +import { styled } from '../core/style'; +import { assertPreservable, missingKeys, notSetError, unsetVariables } from './variable.writer'; + +export interface RemoveVariablesRequest extends EnvironmentParams { + keys: string[]; +} + +export type Confirm = (question: string) => Promise; + +export class VariableRemover { + constructor( + private readonly services: ServiceContext, + private readonly confirm: Confirm, + ) {} + + async remove(request: RemoveVariablesRequest): Promise { + const { org, project, environment } = request; + const scope = { org, project, environment }; + const store = this.services.api.environments; + const held = await store.get(scope); + const missing = missingKeys(held, request.keys); + + if (missing.length > 0) { + throw notSetError(missing); + } + + assertPreservable(held); + + const name = held.name ?? environment; + const listed = [...new Set(request.keys)].join(', '); + + if (!(await this.confirm(`Are you sure you want to remove ${listed} from "${name}"?`))) { + return false; + } + + await unsetVariables(store, scope, request.keys); + + this.services.ux.print( + styled(`✔ ${listed} removed from ${name}.`, 'green', this.services.outputIsTTY === true), + ); + + return true; + } +} diff --git a/src/variables/variable.values.test.ts b/src/variables/variable.values.test.ts new file mode 100644 index 00000000..c99a20b --- /dev/null +++ b/src/variables/variable.values.test.ts @@ -0,0 +1,129 @@ +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { completeValues } from './variable.values'; + +function fakes(answers: string[] = [], stdin = '') { + const prompts: { message: string; type: string }[] = []; + const stdinReads: number[] = []; + const queue = [...answers]; + const ux: UxLike = { + print: () => undefined, + inquire: (async (payload: { message: string; type: string }) => { + prompts.push({ message: payload.message, type: payload.type }); + return queue.shift(); + }) as UxLike['inquire'], + }; + + return { + prompts, + stdinReads, + deps: (isTTY: boolean) => ({ + services: { isTTY, ux } as unknown as ServiceContext, + readStdin: async () => { + stdinReads.push(1); + return stdin; + }, + }), + }; +} + +describe('variable values', () => { + it('passes every assignment that already has a value through without prompting or reading stdin', async () => { + const { deps, prompts, stdinReads } = fakes(); + + const completed = await completeValues([{ key: 'A', value: '1' }, { key: 'B', value: 'x y' }], deps(true)); + + expect(completed).toEqual([{ key: 'A', value: '1' }, { key: 'B', value: 'x y' }]); + expect(prompts).toEqual([]); + expect(stdinReads).toEqual([]); + }); + + it('asks one hidden prompt for a lone bare key on a terminal, with no counter', async () => { + const { deps, prompts } = fakes(['s3cret']); + + const completed = await completeValues([{ key: 'API_KEY', value: undefined }], deps(true)); + + expect(completed).toEqual([{ key: 'API_KEY', value: 's3cret' }]); + expect(prompts).toEqual([{ message: 'Value for API_KEY', type: 'password' }]); + }); + + it('asks in the order the keys were given with an n/m counter when there are several, skipping those with values', async () => { + const { deps, prompts } = fakes(['one', 'two']); + + const completed = await completeValues( + [{ key: 'A', value: undefined }, { key: 'B', value: 'given' }, { key: 'C', value: undefined }], + deps(true), + ); + + expect(completed).toEqual([ + { key: 'A', value: 'one' }, + { key: 'B', value: 'given' }, + { key: 'C', value: 'two' }, + ]); + expect(prompts.map((prompt) => prompt.message)).toEqual(['Value for A (1/2)', 'Value for C (2/2)']); + }); + + it.each([[' tok \n', ' tok '], ['tok\r\n', 'tok'], ['tok', 'tok']])( + 'keeps the whitespace of a piped value apart from its final line break (%p)', + async (piped, expected) => { + const { deps } = fakes([], piped); + + expect(await completeValues([{ key: 'API_KEY', value: undefined }], deps(false))).toEqual([ + { key: 'API_KEY', value: expected }, + ]); + }, + ); + + it('reads a lone bare key from stdin without a terminal and drops only the trailing newline', async () => { + const { deps, prompts, stdinReads } = fakes([], 'piped-value\n'); + + const completed = await completeValues([{ key: 'API_KEY', value: undefined }], deps(false)); + + expect(completed).toEqual([{ key: 'API_KEY', value: 'piped-value' }]); + expect(stdinReads).toHaveLength(1); + expect(prompts).toEqual([]); + }); + + it('exits as a usage error naming --dotenv - for two bare keys without a terminal, reading nothing', async () => { + const { deps, prompts, stdinReads } = fakes([], 'ignored'); + const bare = [{ key: 'A', value: undefined }, { key: 'B', value: undefined }]; + + await expect(completeValues(bare, deps(false))).rejects.toThrow(UsageError); + await expect(completeValues(bare, deps(false))).rejects.toThrow('--dotenv -'); + expect(stdinReads).toEqual([]); + expect(prompts).toEqual([]); + }); + + it.each([[''], ['\n'], [' \n']])('refuses an empty stdin value and names the key (%p)', async (stdin) => { + const { deps } = fakes([], stdin); + + await expect(completeValues([{ key: 'API_KEY', value: undefined }], deps(false))).rejects.toThrow(UsageError); + await expect(completeValues([{ key: 'API_KEY', value: undefined }], deps(false))).rejects.toThrow( + 'No value for API_KEY was piped in.', + ); + }); + + it('propagates a cancelled prompt and a failed stdin read', async () => { + const { deps } = fakes([undefined as unknown as string]); + + await expect(completeValues([{ key: 'A', value: undefined }], deps(true))).rejects.toThrow(CancelledError); + + const failure = new Error('stdin closed'); + + await expect( + completeValues([{ key: 'A', value: undefined }], { + services: fakes().deps(false).services, + readStdin: async () => Promise.reject(failure), + }), + ).rejects.toBe(failure); + }); + + it('never puts a typed value in an error or a prompt message', async () => { + const { deps, prompts } = fakes(['never-printed-value']); + + await completeValues([{ key: 'A', value: undefined }], deps(true)); + + expect(JSON.stringify(prompts)).not.toContain('never-printed-value'); + }); +}); diff --git a/src/variables/variable.values.ts b/src/variables/variable.values.ts new file mode 100644 index 00000000..2a8e4a6 --- /dev/null +++ b/src/variables/variable.values.ts @@ -0,0 +1,57 @@ +import { UsageError } from '../core/errors'; +import { askSecret } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentVariableInput } from '../environments/types'; +import type { Assignment } from './variable.parse'; + +export interface ValueDeps { + services: ServiceContext; + readStdin(): Promise; +} + +function promptMessage(key: string, position: number, total: number): string { + return total > 1 ? `Value for ${key} (${position}/${total})` : `Value for ${key}`; +} + +async function valueFromStdin(key: string, readStdin: () => Promise): Promise { + const value = (await readStdin()).replace(/\r?\n$/, ''); + + if (value.trim() === '') { + throw new UsageError(`No value for ${key} was piped in.`); + } + + return value; +} + +export async function completeValues( + assignments: Assignment[], + deps: ValueDeps, +): Promise { + const bare = assignments.filter((assignment) => assignment.value === undefined); + + if (bare.length > 1 && !deps.services.isTTY) { + throw new UsageError( + `${bare.length} variables have no value and there is no terminal to ask on. ` + + 'Pass --dotenv - and pipe KEY=VALUE lines instead.', + ); + } + + const completed: EnvironmentVariableInput[] = []; + let asked = 0; + + for (const { key, value } of assignments) { + if (value !== undefined) { + completed.push({ key, value }); + continue; + } + + asked += 1; + const answer = deps.services.isTTY + ? await askSecret(deps.services.ux, promptMessage(key, asked, bare.length)) + : await valueFromStdin(key, deps.readStdin); + + completed.push({ key, value: answer }); + } + + return completed; +} diff --git a/src/variables/variable.writer.test.ts b/src/variables/variable.writer.test.ts new file mode 100644 index 00000000..90e1f83 --- /dev/null +++ b/src/variables/variable.writer.test.ts @@ -0,0 +1,210 @@ +import { UsageError } from '../core/errors'; +import type { EnvironmentParams } from '../environments/environments.api'; +import type { Environment, EnvironmentUpdate } from '../environments/types'; +import { UnreadableVariablesError, missingKeys, setVariables, unsetVariables } from './variable.writer'; + +const PARAMS: EnvironmentParams = { org: 'org1', project: 'proj1', environment: 'env1' }; + +function fakeApi(environment: Environment, failOn?: 'get' | 'update') { + const calls: { method: 'get' | 'update'; params: unknown }[] = []; + + return { + calls, + api: { + get: async (params: EnvironmentParams) => { + calls.push({ method: 'get', params }); + + if (failOn === 'get') { + throw new Error('get failed'); + } + + return environment; + }, + update: async (params: EnvironmentParams & { update: EnvironmentUpdate }) => { + calls.push({ method: 'update', params }); + + if (failOn === 'update') { + throw new Error('update failed'); + } + + return { ...environment, environmentVariables: params.update.environmentVariables }; + }, + }, + }; +} + +describe('variable writer set', () => { + it('reads the environment, then sends the complete merged array and nothing else', async () => { + const { api, calls } = fakeApi({ + uid: 'env1', + name: 'staging', + environmentVariables: [{ key: 'KEEP', value: 'k' }, { key: 'CHANGE', value: 'old' }], + }); + + const environment = await setVariables(api, PARAMS, [{ key: 'CHANGE', value: 'new' }, { key: 'ADD', value: 'a' }]); + + expect(calls.map((call) => call.method)).toEqual(['get', 'update']); + expect(calls[0].params).toEqual(PARAMS); + expect(calls[1].params).toEqual({ + ...PARAMS, + update: { + environmentVariables: [ + { key: 'KEEP', value: 'k' }, + { key: 'CHANGE', value: 'new' }, + { key: 'ADD', value: 'a' }, + ], + }, + }); + expect(environment?.name).toBe('staging'); + }); + + it('leaves every other variable intact on a single-key set', async () => { + const { api, calls } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }] }); + + await setVariables(api, PARAMS, [{ key: 'C', value: '3' }]); + + expect((calls[1].params as { update: EnvironmentUpdate }).update.environmentVariables).toEqual([ + { key: 'A', value: '1' }, + { key: 'B', value: '2' }, + { key: 'C', value: '3' }, + ]); + }); + + it.each([[{}], [{ environmentVariables: null }], [{ environmentVariables: [] }]])( + 'sets into an environment that holds no variables yet (%p)', + async (environment) => { + const { api, calls } = fakeApi(environment as Environment); + + await setVariables(api, PARAMS, [{ key: 'A', value: '1' }]); + + expect((calls[1].params as { update: EnvironmentUpdate }).update).toEqual({ + environmentVariables: [{ key: 'A', value: '1' }], + }); + }, + ); + + it.each([ + [{ value: 'orphan' }, ''], + [{ key: 'NOVALUE' }, ' (NOVALUE)'], + [{ key: 'EMPTY', value: '' }, ' (EMPTY)'], + ])('refuses to set when a stored variable cannot be sent back, writing nothing (%p)', async (stored, named) => { + const { api, calls } = fakeApi({ environmentVariables: [{ key: 'GOOD', value: 'g' }, stored] }); + + const error = await setVariables(api, PARAMS, [{ key: 'NEW', value: 'n' }]).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(UnreadableVariablesError); + expect((error as Error).message).toBe( + `This environment holds variables the API returned without a value${named}. Updating the list would delete them, so nothing was changed.`, + ); + expect(calls.map((call) => call.method)).toEqual(['get']); + }); + + it('refuses to unset when a stored variable cannot be sent back, writing nothing', async () => { + const { api, calls } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }, { key: 'HIDDEN' }] }); + + await expect(unsetVariables(api, PARAMS, ['A'])).rejects.toBeInstanceOf(UnreadableVariablesError); + expect(calls.filter((call) => call.method === 'update')).toEqual([]); + }); + + it('sends nothing when there is nothing to set, without even reading', async () => { + const { api, calls } = fakeApi({}); + + expect(await setVariables(api, PARAMS, [])).toBeUndefined(); + expect(calls).toEqual([]); + }); + + it('propagates a failed read without writing and a failed write', async () => { + const reading = fakeApi({}, 'get'); + + await expect(setVariables(reading.api, PARAMS, [{ key: 'A', value: '1' }])).rejects.toThrow('get failed'); + expect(reading.calls.map((call) => call.method)).toEqual(['get']); + + const writing = fakeApi({}, 'update'); + + await expect(setVariables(writing.api, PARAMS, [{ key: 'A', value: '1' }])).rejects.toThrow('update failed'); + }); +}); + +describe('variable writer unset', () => { + it('removes only the named keys and sends the rest as the complete array', async () => { + const { api, calls } = fakeApi({ + environmentVariables: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }, { key: 'C', value: '3' }], + }); + + const removed = await unsetVariables(api, PARAMS, ['B']); + + expect(removed).toEqual(['B']); + expect(calls.map((call) => call.method)).toEqual(['get', 'update']); + expect((calls[1].params as { update: EnvironmentUpdate }).update).toEqual({ + environmentVariables: [{ key: 'A', value: '1' }, { key: 'C', value: '3' }], + }); + }); + + it('sends an empty array when the last variable is removed', async () => { + const { api, calls } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }] }); + + await unsetVariables(api, PARAMS, ['A']); + + expect((calls[1].params as { update: EnvironmentUpdate }).update).toEqual({ environmentVariables: [] }); + }); + + it('removes a key named twice once', async () => { + const { api } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }] }); + + expect(await unsetVariables(api, PARAMS, ['A', 'A'])).toEqual(['A']); + }); + + it('refuses a key that is not set, naming every missing key, and writes nothing', async () => { + const { api, calls } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }] }); + + await expect(unsetVariables(api, PARAMS, ['A', 'X', 'Y'])).rejects.toThrow(UsageError); + await expect(unsetVariables(api, PARAMS, ['A', 'X', 'Y'])).rejects.toThrow('X, Y are not set in this environment.'); + expect(calls.filter((call) => call.method === 'update')).toEqual([]); + }); + + it('uses the singular for a single missing key and matches keys case-sensitively', async () => { + const { api } = fakeApi({ environmentVariables: [{ key: 'api_key', value: '1' }] }); + + await expect(unsetVariables(api, PARAMS, ['API_KEY'])).rejects.toThrow('API_KEY is not set in this environment.'); + }); + + it.each([[{}], [{ environmentVariables: null }]])('refuses every key on an environment with no variables (%p)', async (environment) => { + const { api } = fakeApi(environment as Environment); + + await expect(unsetVariables(api, PARAMS, ['A'])).rejects.toThrow('A is not set in this environment.'); + }); + + it('sends nothing when no key is named, without even reading', async () => { + const { api, calls } = fakeApi({}); + + expect(await unsetVariables(api, PARAMS, [])).toEqual([]); + expect(calls).toEqual([]); + }); + + it('propagates a failed read and a failed write', async () => { + await expect(unsetVariables(fakeApi({}, 'get').api, PARAMS, ['A'])).rejects.toThrow('get failed'); + await expect( + unsetVariables(fakeApi({ environmentVariables: [{ key: 'A', value: '1' }] }, 'update').api, PARAMS, ['A']), + ).rejects.toThrow('update failed'); + }); +}); + +describe('variable writer missing keys', () => { + it('lists the wanted keys the environment does not hold, once each, in the order given', () => { + const environment = { environmentVariables: [{ key: 'A', value: '1' }] }; + + expect(missingKeys(environment, ['X', 'A', 'Y', 'X'])).toEqual(['X', 'Y']); + }); + + it('counts a stored entry with no key or no value as not set', () => { + expect(missingKeys({ environmentVariables: [{ key: 'A' }, { value: 'v' }] }, ['A'])).toEqual(['A']); + }); + + it.each([[{}], [{ environmentVariables: null }]])('treats an environment with no variables as holding none (%p)', (environment) => { + expect(missingKeys(environment as Environment, ['A'])).toEqual(['A']); + }); + + it('returns nothing when every key is held', () => { + expect(missingKeys({ environmentVariables: [{ key: 'A', value: '1' }] }, ['A'])).toEqual([]); + }); +}); diff --git a/src/variables/variable.writer.ts b/src/variables/variable.writer.ts new file mode 100644 index 00000000..d1c9239 --- /dev/null +++ b/src/variables/variable.writer.ts @@ -0,0 +1,118 @@ +import { EXIT_RUNTIME } from '../core/constants'; +import { LaunchError, UsageError } from '../core/errors'; +import type { EnvironmentParams } from '../environments/environments.api'; +import type { + Environment, + EnvironmentUpdate, + EnvironmentVariable, + EnvironmentVariableInput, +} from '../environments/types'; + +export interface VariableStore { + get(params: EnvironmentParams): Promise; + update(params: EnvironmentParams & { update: EnvironmentUpdate }): Promise; +} + +function writable(variable: EnvironmentVariable): variable is EnvironmentVariableInput { + return ( + typeof variable.key === 'string' && + variable.key !== '' && + typeof variable.value === 'string' && + variable.value !== '' + ); +} + +export class UnreadableVariablesError extends LaunchError { + readonly exitCode = EXIT_RUNTIME; + + constructor(keys: string[]) { + const named = keys.length > 0 ? ` (${keys.join(', ')})` : ''; + + super( + `This environment holds variables the API returned without a value${named}. ` + + 'Updating the list would delete them, so nothing was changed.', + ); + this.name = 'UnreadableVariablesError'; + } +} + +function stored(environment: Environment): EnvironmentVariable[] { + return environment.environmentVariables ?? []; +} + +function heldBy(environment: Environment): EnvironmentVariableInput[] { + return stored(environment).filter(writable); +} + +export function assertPreservable(environment: Environment): void { + const lost = stored(environment).filter((variable) => !writable(variable)); + + if (lost.length > 0) { + throw new UnreadableVariablesError( + lost.flatMap(({ key }) => (typeof key === 'string' && key !== '' ? [key] : [])), + ); + } +} + +export function missingKeys(environment: Environment, keys: string[]): string[] { + const heldKeys = new Set(heldBy(environment).map(({ key }) => key)); + + return [...new Set(keys)].filter((key) => !heldKeys.has(key)); +} + +export function notSetError(missing: string[]): UsageError { + return new UsageError(`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set in this environment.`); +} + +export async function setVariables( + store: VariableStore, + params: EnvironmentParams, + incoming: EnvironmentVariableInput[], +): Promise { + if (incoming.length === 0) { + return undefined; + } + + const environment = await store.get(params); + + assertPreservable(environment); + + const merged = new Map(heldBy(environment).map(({ key, value }) => [key, value])); + + incoming.forEach(({ key, value }) => merged.set(key, value)); + + return store.update({ + ...params, + update: { environmentVariables: [...merged].map(([key, value]) => ({ key, value })) }, + }); +} + +export async function unsetVariables( + store: VariableStore, + params: EnvironmentParams, + keys: string[], +): Promise { + const wanted = [...new Set(keys)]; + + if (wanted.length === 0) { + return []; + } + + const environment = await store.get(params); + const missing = missingKeys(environment, wanted); + + if (missing.length > 0) { + throw notSetError(missing); + } + + assertPreservable(environment); + + const held = heldBy(environment); + + await store.update({ + ...params, + update: { environmentVariables: held.filter(({ key }) => !wanted.includes(key)) }, + }); + + return wanted; +} diff --git a/src/variables/variables.api.test.ts b/src/variables/variables.api.test.ts new file mode 100644 index 00000000..bc609f2 --- /dev/null +++ b/src/variables/variables.api.test.ts @@ -0,0 +1,84 @@ +import { MALFORMED_CODE } from '../transport/envelope'; +import { LaunchApiError } from '../transport/errors'; +import type { RestApiClient, RestRequest } from '../transport/rest-client'; +import { VariablesApi } from './variables.api'; + +function fakeRestClient(result: unknown) { + const requests: RestRequest[] = []; + const messages: unknown[] = []; + const client = { + request: async (req: RestRequest, errorMessages: unknown) => { + requests.push(req); + messages.push(errorMessages); + return result; + }, + } as unknown as RestApiClient; + + return { client, requests, messages }; +} + +const TEMPLATE = [ + { keyName: 'CONTENTSTACK_API_HOST', key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { keyName: 'CONTENTSTACK_CDN', key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + { keyName: 'CONTENTSTACK_API_KEY', key: 'CONTENTSTACK_API_KEY' }, +]; + +describe('variables api', () => { + it('reads the stack variable template for an organization from the CMS route and returns key and value', async () => { + const { client, requests, messages } = fakeRestClient(TEMPLATE); + + const entries = await new VariablesApi(client).stackTemplate('org1'); + + expect(requests).toEqual([{ method: 'GET', path: '/cms/environment-variables', orgUid: 'org1' }]); + expect(messages).toHaveLength(1); + expect(entries).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.example.test' }, + { key: 'CONTENTSTACK_API_KEY', value: undefined }, + ]); + }); + + it('skips an entry with no key and treats a blank or non-text value as absent', async () => { + const { client } = fakeRestClient([ + null, + 'text', + { value: 'orphan' }, + { key: ' ', value: 'blank key' }, + { key: 'A', value: ' ' }, + { key: 'B', value: 5 }, + { key: 'C', value: 'ok' }, + ]); + + await expect(new VariablesApi(client).stackTemplate('org1')).resolves.toEqual([ + { key: 'A', value: undefined }, + { key: 'B', value: undefined }, + { key: 'C', value: 'ok' }, + ]); + }); + + it('returns an empty template for an empty array', async () => { + const { client } = fakeRestClient([]); + + await expect(new VariablesApi(client).stackTemplate('org1')).resolves.toEqual([]); + }); + + it.each([[undefined], [null], [{}], [{ environmentVariables: TEMPLATE }], ['text']])( + 'raises a malformed-response error when the body is not an array (%p)', + async (body) => { + const { client } = fakeRestClient(body); + + const error = await new VariablesApi(client).stackTemplate('org1').catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(LaunchApiError); + expect((error as LaunchApiError).code).toBe(MALFORMED_CODE); + expect((error as LaunchApiError).message).toBe('The Launch API returned a stack variable list that is not an array.'); + }, + ); + + it('propagates a failed request', async () => { + const failure = new Error('cms failed'); + const client = { request: async () => Promise.reject(failure) } as unknown as RestApiClient; + + await expect(new VariablesApi(client).stackTemplate('org1')).rejects.toBe(failure); + }); +}); diff --git a/src/variables/variables.api.ts b/src/variables/variables.api.ts new file mode 100644 index 00000000..26508fe --- /dev/null +++ b/src/variables/variables.api.ts @@ -0,0 +1,42 @@ +import { isRecord } from '../core/values'; +import { malformed } from '../transport/envelope'; +import type { ErrorMessages } from '../transport/errors'; +import type { RestApiClient } from '../transport/rest-client'; + +export interface StackTemplateEntry { + key: string; + value: string | undefined; +} + +const VARIABLE_ERROR_MESSAGES: ErrorMessages = {}; + +function textOf(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +function entryOf(value: unknown): StackTemplateEntry[] { + if (!isRecord(value)) { + return []; + } + + const key = textOf(value.key); + + return key === undefined ? [] : [{ key, value: textOf(value.value) }]; +} + +export class VariablesApi { + constructor(private readonly client: RestApiClient) {} + + async stackTemplate(org: string): Promise { + const response = await this.client.request( + { method: 'GET', path: '/cms/environment-variables', orgUid: org }, + VARIABLE_ERROR_MESSAGES, + ); + + if (!Array.isArray(response)) { + throw malformed('The Launch API returned a stack variable list that is not an array.'); + } + + return response.flatMap(entryOf); + } +} diff --git a/test/fixtures/README.md b/test/fixtures/README.md index 63935dc..5bfe9a9 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -22,6 +22,7 @@ These fixtures have two sources, and every file now satisfies both: | `project-get.json` | `paths./projects/{project_uid}.get.responses.200.content.application/json.example` (schema `ProjectResponse`) | | `project-get-fileupload.json` | same path and schema, shaped from the live FileUpload project observed on 2026-09-23 | | `project-not-found.json` | `paths./projects/{project_uid}.get.responses.404.content.application/json.example` | +| `cms-environment-variables.json` | `GET /cms/environment-variables`, which the OpenAPI spec does not publish (the route is `@ApiExcludeEndpoint()`); shaped from the controller source and corroborated by a live call on 2026-10-21: a bare array of `{keyName, key, value?}`, with `value` set only on the two host entries. The hosts are synthetic. | ## What the live run corroborated diff --git a/test/fixtures/cms-environment-variables.json b/test/fixtures/cms-environment-variables.json new file mode 100644 index 00000000..5e742cc --- /dev/null +++ b/test/fixtures/cms-environment-variables.json @@ -0,0 +1,24 @@ +[ + { + "keyName": "CONTENTSTACK_API_HOST", + "key": "CONTENTSTACK_API_HOST", + "value": "api.integration.test" + }, + { + "keyName": "CONTENTSTACK_CDN", + "key": "CONTENTSTACK_CDN", + "value": "cdn.integration.test" + }, + { + "keyName": "CONTENTSTACK_API_KEY", + "key": "CONTENTSTACK_API_KEY" + }, + { + "keyName": "CONTENTSTACK_DELIVERY_TOKEN", + "key": "CONTENTSTACK_DELIVERY_TOKEN" + }, + { + "keyName": "CONTENTSTACK_ENVIRONMENT", + "key": "CONTENTSTACK_ENVIRONMENT" + } +] diff --git a/test/guards/layering.test.ts b/test/guards/layering.test.ts index 5b7f377..b7055a9 100644 --- a/test/guards/layering.test.ts +++ b/test/guards/layering.test.ts @@ -10,13 +10,15 @@ const RESOURCES = readdirSync(SRC, { withFileTypes: true }) .map((entry) => entry.name); const RESOURCE_EDGES: Record = { - projects: ['environments', 'deployments', 'git', 'uploads'], + projects: ['environments', 'deployments', 'git', 'uploads', 'variables'], functions: [], - environments: ['deployments', 'git', 'uploads'], + environments: ['deployments', 'git', 'uploads', 'variables'], deployments: [], git: [], organizations: [], uploads: [], + stacks: [], + variables: ['environments', 'stacks'], }; function sourceFilesIn(layer: string): string[] { @@ -130,8 +132,8 @@ describe('layering between resources', () => { } }); - it('records the edges the projects resource really uses, and no others', () => { - expect(RESOURCE_EDGES.projects).toEqual(['environments', 'deployments', 'git', 'uploads']); + it('records the edges the projects resource and its commands really use, and no others', () => { + expect(RESOURCE_EDGES.projects).toEqual(['environments', 'deployments', 'git', 'uploads', 'variables']); expect([...new Set(sourceFilesIn('projects').flatMap((path) => crossResourceEdges(path)))].sort()).toEqual([ 'deployments', @@ -139,6 +141,15 @@ describe('layering between resources', () => { 'git', 'uploads', ]); + + const commandFiles = filesUnder('commands/launch/projects', true).filter((path) => !path.endsWith('.test.ts')); + const reachedByCommands = commandFiles.flatMap((path) => + importedPaths(path) + .map((specifier) => resourceOf(specifier, directoryOf(path))) + .filter((target): target is string => target !== undefined && target !== 'projects'), + ); + + expect([...new Set(reachedByCommands)].sort()).toEqual(['deployments', 'environments', 'variables']); }); it('reports a disallowed edge rather than passing vacuously', () => { diff --git a/test/integration/environments-create.test.ts b/test/integration/environments-create.test.ts index 1c70c9d..1661290 100644 --- a/test/integration/environments-create.test.ts +++ b/test/integration/environments-create.test.ts @@ -1,4 +1,7 @@ +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { authHandler, configHandler } from '@contentstack/cli-utilities'; import type { Interfaces } from '@oclif/core'; @@ -7,6 +10,7 @@ import nock from 'nock'; import * as watcher from '../../src/deployments/deployment.watcher'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { answerPrompts, onTerminal } from '../support/terminal'; import projectFixture from '../fixtures/project-get.json'; @@ -150,6 +154,138 @@ describe('integration: launch:env:create on the wire', () => { expect(onWire).toEqual([]); }); + describe('picking variable sources at the prompt', () => { + const STACK_TOKEN = randomBytes(16).toString('hex'); + const WITH_AUTH = [...GITHUB_ARGS, '--cs-auth', 'enable', '--server-cmd', 'serve']; + + function stubStackPickers(): nock.Scope[] { + const cma = nock('https://cma.integration.test'); + + return [ + cma + .get('/v3/stacks') + .query({ organization_uid: ORG_UID, limit: '100', skip: '0' }) + .reply(200, { stacks: [{ name: 'Shop', api_key: 'blt9stackkey' }] }), + cma.get('/v3/stacks').query({ organization_uid: ORG_UID, limit: '100', skip: '1' }).reply(200, { stacks: [] }), + cma + .get('/v3/stacks/delivery_tokens') + .query({ limit: '100', skip: '0' }) + .matchHeader('api_key', 'blt9stackkey') + .reply(200, { + tokens: [{ name: 'prod-token', token: STACK_TOKEN, scope: [{ environments: [{ name: 'staging' }] }] }], + }), + cma + .get('/v3/stacks/delivery_tokens') + .query({ limit: '100', skip: '1' }) + .matchHeader('api_key', 'blt9stackkey') + .reply(200, { tokens: [] }), + nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .get('/manage/cms/environment-variables') + .reply(200, [ + { keyName: 'CONTENTSTACK_API_HOST', key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { keyName: 'CONTENTSTACK_API_KEY', key: 'CONTENTSTACK_API_KEY' }, + ]), + ]; + } + + it('merges a dotenv file and typed pairs in that order, the typed pair winning', async () => { + const file = join(DATA_DIR, `env-create-${randomBytes(4).toString('hex')}.env`); + writeFileSync(file, 'API_URL=from-file\nFROM_FILE=1\n'); + const prompts = answerPrompts({ + 'Add environment variables': ['Import variables from a dotenv file', 'Type KEY=VALUE pairs'], + 'Path to the dotenv file': file, + 'KEY=VALUE pairs separated by spaces': 'API_URL=typed DEBUG=true', + }); + const sent: { environmentVariables?: unknown }[] = []; + stubCreate(sent); + stubFollowUp('LIVE'); + + const { error, stdout } = await onTerminal(() => runCommand(WITH_AUTH, config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([ + 'Add environment variables', + 'Path to the dotenv file', + 'KEY=VALUE pairs separated by spaces', + ]); + expect(sent[0].environmentVariables).toEqual([ + { key: 'API_URL', value: 'typed' }, + { key: 'FROM_FILE', value: '1' }, + { key: 'DEBUG', value: 'true' }, + ]); + expect(stdout).toContain('3 variables ready to set'); + expect(stdout).not.toContain('from-file'); + expect(onWire).toEqual([]); + }); + + it('merges all three sources, stack first, then the file, then the typed pairs, without printing the token', async () => { + const file = join(DATA_DIR, `env-create-${randomBytes(4).toString('hex')}.env`); + writeFileSync(file, 'CONTENTSTACK_ENVIRONMENT=from-file\nFROM_FILE=1\n'); + const prompts = answerPrompts({ + 'Add environment variables': [ + 'Import variables from a stack', + 'Import variables from a dotenv file', + 'Type KEY=VALUE pairs', + ], + Stack: '0', + 'Delivery token': '0', + 'Path to the dotenv file': file, + 'KEY=VALUE pairs separated by spaces': 'FROM_FILE=typed EXTRA=1', + }); + const sent: { environmentVariables?: unknown }[] = []; + stubCreate(sent); + const lookups = stubStackPickers(); + stubFollowUp('LIVE'); + + const { error, stdout } = await onTerminal(() => runCommand(WITH_AUTH, config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([ + 'Add environment variables', + 'Stack', + 'Delivery token', + 'Path to the dotenv file', + 'KEY=VALUE pairs separated by spaces', + ]); + expect(lookups.every((scope) => scope.isDone())).toBe(true); + expect(sent[0].environmentVariables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9stackkey' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: STACK_TOKEN }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'from-file' }, + { key: 'FROM_FILE', value: 'typed' }, + { key: 'EXTRA', value: '1' }, + ]); + expect(stdout).toContain('6 variables ready to set'); + expect(stdout).not.toContain(STACK_TOKEN); + expect(onWire).toEqual([]); + }); + + it('creates with no variables when Skip is chosen', async () => { + answerPrompts({ 'Add environment variables': ['Skip adding environment variables'] }); + const sent: { environmentVariables?: unknown }[] = []; + stubCreate(sent); + stubFollowUp('LIVE'); + + const { error } = await onTerminal(() => runCommand(WITH_AUTH, config)); + + expect(error).toBeUndefined(); + expect(sent[0].environmentVariables).toEqual([]); + }); + + it('refuses Skip combined with another source before creating anything', async () => { + answerPrompts({ 'Add environment variables': ['Skip adding environment variables', 'Type KEY=VALUE pairs'] }); + const sent: unknown[] = []; + stubCreate(sent); + + const { error } = await onTerminal(() => runCommand(WITH_AUTH, config)); + + expect(error?.message).toContain("'Skip adding environment variables' cannot be combined with the other options."); + expect(sent).toEqual([]); + }); + }); + it('exits 1 naming the surviving environment when the first deployment fails', async () => { stubCreate([]); stubFollowUp('FAILED'); diff --git a/test/integration/projects-create.test.ts b/test/integration/projects-create.test.ts index cc36180..fce9572 100644 --- a/test/integration/projects-create.test.ts +++ b/test/integration/projects-create.test.ts @@ -522,6 +522,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory': './public', 'Response mode': 'buffered', 'Contentstack Authentication': 'enable', + 'Add environment variables': ['Skip adding environment variables'], }); const organizations = nock('https://cma.integration.test') .get('/v3/organizations') @@ -558,6 +559,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory', 'Response mode', 'Contentstack Authentication', + 'Add environment variables', ]); expect(prompts.payloads[2].default).toBe(basename(dataDir)); expect(onWire).toEqual([]); @@ -600,7 +602,7 @@ describe('integration: launch:projects:create on the wire', () => { expect(process.listenerCount('SIGINT')).toBe(0); }); - it('asks all ten GitHub prompts in the pinned order and submits exactly what was answered', async () => { + it('asks all eleven GitHub prompts in the pinned order and submits exactly what was answered', async () => { let body: unknown; const prompts = answerPrompts({ 'Choose a project type to proceed': 'GitHub', @@ -613,6 +615,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory': '.next', 'Response mode': 'streaming', 'Contentstack Authentication': 'disable', + 'Add environment variables': ['Skip adding environment variables'], }); nock('https://cma.integration.test') .get('/v3/organizations') @@ -651,6 +654,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory', 'Response mode', 'Contentstack Authentication', + 'Add environment variables', ]); expect(prompts.payloads.map((payload) => payload.default)).toEqual([ undefined, @@ -663,6 +667,7 @@ describe('integration: launch:projects:create on the wire', () => { '.next', 'buffered', 'enable', + undefined, ]); expect(body).toEqual({ name: 'My Site', @@ -700,6 +705,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory': '.next', 'Response mode': 'buffered', 'Contentstack Authentication': 'enable', + 'Add environment variables': ['Skip adding environment variables'], }); stubRepositoryLookup(); const branches = hub() @@ -731,6 +737,7 @@ describe('integration: launch:projects:create on the wire', () => { 'Output directory', 'Response mode', 'Contentstack Authentication', + 'Add environment variables', ]); expect([branches.isDone(), create.isDone()]).toEqual([true, true]); expect((body as { repository: unknown }).repository).toEqual({ @@ -743,7 +750,10 @@ describe('integration: launch:projects:create on the wire', () => { }); it('asks on a terminal only for what argv left out, and nothing that a flag supplied', async () => { - const prompts = answerPrompts({ 'Framework preset': 'NextJs' }); + const prompts = answerPrompts({ + 'Framework preset': 'NextJs', + 'Add environment variables': ['Skip adding environment variables'], + }); stubGitLookups(); const create = hub().post('/manage/projects').query({}).reply(201, { project: CREATED_PROJECT }); stubFollowUp('LIVE'); @@ -752,11 +762,238 @@ describe('integration: launch:projects:create on the wire', () => { const { error } = await onTerminal(() => runCommand(args, config)); expect(error).toBeUndefined(); - expect(prompts.messages).toEqual(['Framework preset']); + expect(prompts.messages).toEqual(['Framework preset', 'Add environment variables']); + expect(create.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('sends the variables from --var and --dotenv in the create body and prints no value', async () => { + let body: { environment?: { environmentVariables?: unknown } } | undefined; + stubGitLookups(); + const file = join(dataDir, 'staging.env'); + writeFileSync(file, 'API_URL=from-file\nFROM_FILE=1\n'); + const create = hub() + .post('/manage/projects', (sent: typeof body) => { + body = sent; + return true; + }) + .query({}) + .reply(201, { project: CREATED_PROJECT }); + stubFollowUp('LIVE'); + + const { error, stdout } = await runCommand( + [...gitFlags(), '--dotenv', file, '--var', 'API_URL=never-printed-typed', 'DEBUG=true'], + config, + ); + + expect(error).toBeUndefined(); expect(create.isDone()).toBe(true); + expect(body?.environment?.environmentVariables).toEqual([ + { key: 'API_URL', value: 'never-printed-typed' }, + { key: 'FROM_FILE', value: '1' }, + { key: 'DEBUG', value: 'true' }, + ]); + expect(stdout).toContain('3 variables ready to set'); + expect(stdout).not.toContain('never-printed-typed'); expect(onWire).toEqual([]); }); + it('imports a stack through --from-stack and --alias into the create body without printing the token', async () => { + const savedToken = randomBytes(16).toString('hex'); + CONFIG.tokens = { 'my-prod': { token: savedToken, apiKey: 'blt9stackkey', environment: 'staging', type: 'delivery' } }; + let body: { environment?: { environmentVariables?: unknown } } | undefined; + stubGitLookups(); + const cms = hub() + .get('/manage/cms/environment-variables') + .reply(200, [ + { keyName: 'CONTENTSTACK_API_HOST', key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { keyName: 'CONTENTSTACK_API_KEY', key: 'CONTENTSTACK_API_KEY' }, + ]); + const create = hub() + .post('/manage/projects', (sent: typeof body) => { + body = sent; + return true; + }) + .query({}) + .reply(201, { project: CREATED_PROJECT }); + stubFollowUp('LIVE'); + + try { + const { error, stdout } = await runCommand([...gitFlags(), '--from-stack', '--alias', 'my-prod'], config); + + expect(error).toBeUndefined(); + expect(cms.isDone()).toBe(true); + expect(create.isDone()).toBe(true); + expect(body?.environment?.environmentVariables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9stackkey' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: savedToken }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'staging' }, + ]); + expect(stdout).toContain('4 variables ready to set'); + expect(stdout).not.toContain(savedToken); + } finally { + delete CONFIG.tokens; + } + }); + + describe('picking variable sources at the prompt', () => { + const STACK_TOKEN = randomBytes(16).toString('hex'); + interface CreateBody { + environment?: { environmentVariables?: unknown }; + } + + function stubStackPickers(): nock.Scope[] { + const cma = nock('https://cma.integration.test'); + + return [ + cma + .get('/v3/stacks') + .query({ organization_uid: ORG_UID, limit: '100', skip: '0' }) + .reply(200, { stacks: [{ name: 'Shop', api_key: 'blt9stackkey' }] }), + cma.get('/v3/stacks').query({ organization_uid: ORG_UID, limit: '100', skip: '1' }).reply(200, { stacks: [] }), + cma + .get('/v3/stacks/delivery_tokens') + .query({ limit: '100', skip: '0' }) + .matchHeader('api_key', 'blt9stackkey') + .reply(200, { + tokens: [{ name: 'prod-token', token: STACK_TOKEN, scope: [{ environments: [{ name: 'staging' }] }] }], + }), + cma + .get('/v3/stacks/delivery_tokens') + .query({ limit: '100', skip: '1' }) + .matchHeader('api_key', 'blt9stackkey') + .reply(200, { tokens: [] }), + hub() + .get('/manage/cms/environment-variables') + .reply(200, [ + { keyName: 'CONTENTSTACK_API_HOST', key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { keyName: 'CONTENTSTACK_API_KEY', key: 'CONTENTSTACK_API_KEY' }, + ]), + ]; + } + + function captureBody(): { scope: nock.Scope; body: () => CreateBody | undefined } { + let sent: CreateBody | undefined; + const scope = hub() + .post('/manage/projects', (body: CreateBody) => { + sent = body; + return true; + }) + .query({}) + .reply(201, { project: CREATED_PROJECT }); + + return { scope, body: () => sent }; + } + + it('merges a dotenv file and typed pairs in that order, the typed pair winning, and asks the sub-prompts in order', async () => { + const file = join(dataDir, 'picked.env'); + writeFileSync(file, 'API_URL=from-file\nFROM_FILE=1\n'); + const prompts = answerPrompts({ + 'Add environment variables': ['Import variables from a dotenv file', 'Type KEY=VALUE pairs'], + 'Path to the dotenv file': file, + 'KEY=VALUE pairs separated by spaces': 'API_URL=typed DEBUG=true', + }); + stubGitLookups(); + const create = captureBody(); + stubFollowUp('LIVE'); + + const { error, stdout } = await onTerminal(() => runCommand(gitFlags(), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([ + 'Add environment variables', + 'Path to the dotenv file', + 'KEY=VALUE pairs separated by spaces', + ]); + expect(create.scope.isDone()).toBe(true); + expect(create.body()?.environment?.environmentVariables).toEqual([ + { key: 'API_URL', value: 'typed' }, + { key: 'FROM_FILE', value: '1' }, + { key: 'DEBUG', value: 'true' }, + ]); + expect(stdout).toContain('3 variables ready to set'); + expect(stdout).not.toContain('from-file'); + expect(onWire).toEqual([]); + }); + + it('merges all three sources, stack first, then the file, then the typed pairs, without printing the token', async () => { + const file = join(dataDir, 'picked.env'); + writeFileSync(file, 'CONTENTSTACK_ENVIRONMENT=from-file\nFROM_FILE=1\n'); + const prompts = answerPrompts({ + 'Add environment variables': [ + 'Import variables from a stack', + 'Import variables from a dotenv file', + 'Type KEY=VALUE pairs', + ], + Stack: '0', + 'Delivery token': '0', + 'Path to the dotenv file': file, + 'KEY=VALUE pairs separated by spaces': 'FROM_FILE=typed EXTRA=1', + }); + stubGitLookups(); + const lookups = stubStackPickers(); + const create = captureBody(); + stubFollowUp('LIVE'); + + const { error, stdout } = await onTerminal(() => runCommand(gitFlags(), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([ + 'Add environment variables', + 'Stack', + 'Delivery token', + 'Path to the dotenv file', + 'KEY=VALUE pairs separated by spaces', + ]); + expect(lookups.every((scope) => scope.isDone())).toBe(true); + expect(create.body()?.environment?.environmentVariables).toEqual([ + { key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9stackkey' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: STACK_TOKEN }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'from-file' }, + { key: 'FROM_FILE', value: 'typed' }, + { key: 'EXTRA', value: '1' }, + ]); + expect(stdout).toContain('6 variables ready to set'); + expect(stdout).not.toContain(STACK_TOKEN); + expect(onWire).toEqual([]); + }); + + it('refuses Skip combined with another source before creating anything', async () => { + answerPrompts({ + 'Add environment variables': ['Skip adding environment variables', 'Type KEY=VALUE pairs'], + }); + stubGitLookups(); + const create = captureBody(); + + const { error } = await onTerminal(() => runCommand(gitFlags(), config)); + + expect(error?.message).toContain("'Skip adding environment variables' cannot be combined with the other options."); + expect(create.scope.isDone()).toBe(false); + }); + }); + + it('sends an empty variable list and asks nothing about variables without a terminal and without flags', async () => { + let body: { environment?: { environmentVariables?: unknown } } | undefined; + stubGitLookups(); + const create = hub() + .post('/manage/projects', (sent: typeof body) => { + body = sent; + return true; + }) + .query({}) + .reply(201, { project: CREATED_PROJECT }); + stubFollowUp('LIVE'); + + const { error, stdout } = await runCommand(gitFlags(), config); + + expect(error).toBeUndefined(); + expect(create.isDone()).toBe(true); + expect(body?.environment?.environmentVariables).toEqual([]); + expect(stdout).not.toContain('variables ready to set'); + }); + it('reports a GitHub authorisation failure as a GitHub problem, never as an expired Contentstack login, and posts once', async () => { stubGitLookups(); const create = hub() diff --git a/test/integration/projects-get.test.ts b/test/integration/projects-get.test.ts index f8a8941..640ff4b 100644 --- a/test/integration/projects-get.test.ts +++ b/test/integration/projects-get.test.ts @@ -19,6 +19,12 @@ const UNUSED_CMA: CmaSession = { fetchOrganization: async () => { throw new Error('this test fetches no organization'); }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => undefined, }; diff --git a/test/integration/projects-list.test.ts b/test/integration/projects-list.test.ts index b132868..403c819 100644 --- a/test/integration/projects-list.test.ts +++ b/test/integration/projects-list.test.ts @@ -17,6 +17,12 @@ const UNUSED_CMA: CmaSession = { fetchOrganization: async () => { throw new Error('this test fetches no organization'); }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => undefined, }; diff --git a/test/integration/projects-paging.test.ts b/test/integration/projects-paging.test.ts index 792cda0..2e787c4 100644 --- a/test/integration/projects-paging.test.ts +++ b/test/integration/projects-paging.test.ts @@ -18,6 +18,12 @@ const UNUSED_CMA: CmaSession = { fetchOrganization: async () => { throw new Error('this test fetches no organization'); }, + fetchStacks: async () => { + throw new Error('this test lists no stacks'); + }, + fetchDeliveryTokens: async () => { + throw new Error('this test lists no delivery tokens'); + }, scopedOrganizationUid: () => undefined, }; diff --git a/test/integration/variables-list.test.ts b/test/integration/variables-list.test.ts new file mode 100644 index 00000000..5e931dd --- /dev/null +++ b/test/integration/variables-list.test.ts @@ -0,0 +1,115 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import getFixture from '../fixtures/environment-get.json'; +import notFoundFixture from '../fixtures/environment-not-found.json'; +import { stdoutOfWidth } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; + +function environmentOfProject() { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0') + .get(`/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`) + .reply(200, getFixture); +} + +const LIST = ['launch:variables:list', '--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID, '--data-dir', DATA_DIR]; + +describe('integration: launch:variables:list driven through oclif runCommand', () => { + beforeAll(async () => { + config = await loadCliConfig(); + }); + + beforeEach(() => { + process.exitCode = 0; + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.cleanAll(); + }); + + afterAll(() => { + process.exitCode = 0; + nock.restore(); + }); + + it('reads the variables from the environment endpoint and prints each key with its value masked', async () => { + const restoreWidth = stdoutOfWidth(200); + + try { + const scope = environmentOfProject(); + + const { error, stdout } = await runCommand(LIST, config); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(stdout).toContain('\u2502 key1 \u2502 \u2022\u2022\u2022\u2022\u2022\u2022 \u2502 2025-11-06T07:39:53.582Z \u2502'); + expect(stdout).not.toContain('value1'); + expect(stdout.trimEnd().split('\n').pop()).toBe('Showing 1-1 of 1'); + } finally { + restoreWidth(); + } + }); + + it('exits 1 with the CLI wording and no value when the environment does not exist', async () => { + nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`) + .reply(404, notFoundFixture); + + const { error, stdout } = await runCommand(LIST, config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('No environment found with that name or UID.'); + expect(stdout).not.toContain('value1'); + }); + + it('exits 2 naming --env when it is missing and there is no terminal to prompt on', async () => { + const { error } = await runCommand( + ['launch:variables:list', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Missing required value for --env'); + }); + + it('rejects --limit 0 before any request is sent', async () => { + const scope = environmentOfProject(); + + const { error } = await runCommand([...LIST, '--limit', '0'], config); + + expect(error).toBeDefined(); + expect(scope.isDone()).toBe(false); + }); +}); diff --git a/test/integration/variables-set-unset.test.ts b/test/integration/variables-set-unset.test.ts new file mode 100644 index 00000000..ffc5cba --- /dev/null +++ b/test/integration/variables-set-unset.test.ts @@ -0,0 +1,554 @@ +import { randomBytes } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import cmsFixture from '../fixtures/cms-environment-variables.json'; +import getFixture from '../fixtures/environment-get.json'; +import { answerPrompts, onTerminal } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const ENV_PATH = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`; + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; + +const SCOPE = ['--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID]; + +interface Wire { + order: string[]; + put: unknown[]; +} + +function scopedHub() { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +function environmentEndpoints(wire: Wire, putStatus = 200, putBody: unknown = undefined, reads = 1) { + scopedHub() + .get(ENV_PATH) + .times(reads) + .reply(200, () => { + wire.order.push('get'); + return getFixture; + }); + + return scopedHub() + .put(ENV_PATH, (body) => { + wire.order.push('put'); + wire.put.push(body); + return true; + }) + .reply(putStatus, () => { + if (putBody !== undefined) { + return putBody; + } + + const [stored] = [getFixture.environment]; + const sent = wire.put[wire.put.length - 1] as { environmentVariables: unknown[] }; + + return { environment: { ...stored, environmentVariables: sent.environmentVariables } }; + }); +} + +function cmsEndpoint(sent: Wire) { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .get('/manage/cms/environment-variables') + .reply(200, () => { + sent.order.push('cms'); + return cmsFixture; + }); +} + +function wire(): Wire { + return { order: [], put: [] }; +} + +describe('integration: launch:variables:set and :unset driven through oclif runCommand', () => { + let workDir: string; + + beforeAll(async () => { + config = await loadCliConfig(); + }); + + beforeEach(() => { + process.exitCode = 0; + workDir = mkdtempSync(join(tmpdir(), 'launch-variables-')); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + rmSync(workDir, { recursive: true, force: true }); + nock.cleanAll(); + }); + + afterAll(() => { + process.exitCode = 0; + nock.restore(); + }); + + it('puts the complete merged array built from a real GET and carries nothing else, so no other field can be dropped', async () => { + const sent = wire(); + const scope = environmentEndpoints(sent); + + const { error, stdout } = await runCommand( + ['launch:variables:set', ...SCOPE, '--var', 'API_URL=https://example.test', 'DEBUG=true', '--data-dir', workDir], + config, + ); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(sent.order).toEqual(['get', 'put']); + expect(sent.put).toEqual([ + { + environmentVariables: [ + ...getFixture.environment.environmentVariables, + { key: 'API_URL', value: 'https://example.test' }, + { key: 'DEBUG', value: 'true' }, + ], + }, + ]); + expect(stdout).toContain('\u2714 2 variables set \u2014 2 from --var'); + expect(stdout).not.toContain('https://example.test'); + expect(stdout).not.toContain('value1'); + }); + + it('keeps every other variable when a single key is set and replaces a key that already exists', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'key1=changed', '--data-dir', workDir], config); + + expect(error).toBeUndefined(); + expect(sent.put).toEqual([{ environmentVariables: [{ key: 'key1', value: 'changed' }] }]); + }); + + it('stops --var at the next flag so --project is not swallowed as a value', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand( + ['launch:variables:set', '--org', ORG_UID, '--var', 'A=1', 'B=2', '--project', PROJECT_UID, '--env', ENV_UID, '--data-dir', workDir], + config, + ); + + expect(error).toBeUndefined(); + expect(sent.put).toEqual([ + { environmentVariables: [...getFixture.environment.environmentVariables, { key: 'A', value: '1' }, { key: 'B', value: '2' }] }, + ]); + }); + + it('splits on the first equals sign and keeps a quoted value with a space whole', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'TOKEN=a=b', '"MSG=hello world"', '--data-dir', workDir], config); + + expect(error).toBeUndefined(); + expect(sent.put).toEqual([ + { + environmentVariables: [ + ...getFixture.environment.environmentVariables, + { key: 'TOKEN', value: 'a=b' }, + { key: 'MSG', value: 'hello world' }, + ], + }, + ]); + }); + + it('reads a named dotenv file, lets --var win a collision and names the override', async () => { + const sent = wire(); + environmentEndpoints(sent); + const file = join(workDir, 'staging.env'); + writeFileSync(file, 'API_URL=from-file\nFROM_FILE=1\n'); + + const { error, stdout } = await runCommand( + ['launch:variables:set', ...SCOPE, '--dotenv', file, '--var', 'API_URL=typed', '--data-dir', workDir], + config, + ); + + expect(error).toBeUndefined(); + expect(sent.put).toEqual([ + { + environmentVariables: [ + ...getFixture.environment.environmentVariables, + { key: 'API_URL', value: 'typed' }, + { key: 'FROM_FILE', value: '1' }, + ], + }, + ]); + expect(stdout).toContain(`2 variables set \u2014 2 from ${file}, API_URL overridden by --var`); + }); + + it('auto-detects .env.local under --data-dir with no source flag and names the file it chose', async () => { + const sent = wire(); + environmentEndpoints(sent); + writeFileSync(join(workDir, '.env.local'), 'LOCAL=1\n'); + writeFileSync(join(workDir, '.env'), 'OTHER=2\n'); + + const { error, stdout } = await runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config); + + expect(error).toBeUndefined(); + expect(stdout).toContain(`Using variables from ${join(workDir, '.env.local')}.`); + expect(sent.put).toEqual([{ environmentVariables: [...getFixture.environment.environmentVariables, { key: 'LOCAL', value: '1' }] }]); + }); + + it('exits 2 naming --dotenv and --data-dir when nothing is given and nothing is detected, sending no request', async () => { + const sent = wire(); + const scope = environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toMatch(/--dotenv.*--data-dir/); + expect(sent.order).toEqual([]); + expect(scope.isDone()).toBe(false); + }); + + it('exits 0 with no changes made and sends no request when the file holds nothing', async () => { + const sent = wire(); + environmentEndpoints(sent); + const file = join(workDir, 'empty.env'); + writeFileSync(file, '# nothing\n'); + + const { error, stdout } = await runCommand(['launch:variables:set', ...SCOPE, '--dotenv', file, '--data-dir', workDir], config); + + expect(error).toBeUndefined(); + expect(stdout).toContain('No changes made.'); + expect(sent.order).toEqual([]); + }); + + it('asks which sources to use on a terminal when none was given, then sets what was typed', async () => { + const sent = wire(); + const scope = environmentEndpoints(sent); + const prompts = answerPrompts({ + 'Add variables from': ['Type KEY=VALUE pairs'], + 'KEY=VALUE pairs separated by spaces': 'API_URL=typed-value DEBUG=true', + }); + + const { error, stdout } = await onTerminal(() => + runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config), + ); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual(['Add variables from', 'KEY=VALUE pairs separated by spaces']); + expect(prompts.payloads[0]).toMatchObject({ type: 'checkbox' }); + expect((prompts.payloads[0].choices as { value: string }[]).map((choice) => choice.value)).toEqual([ + 'Import variables from a stack', + 'Import variables from a dotenv file', + 'Type KEY=VALUE pairs', + ]); + expect(scope.isDone()).toBe(true); + expect(sent.put).toEqual([ + { + environmentVariables: [ + ...getFixture.environment.environmentVariables, + { key: 'API_URL', value: 'typed-value' }, + { key: 'DEBUG', value: 'true' }, + ], + }, + ]); + expect(stdout).toContain('2 variables set \u2014 2 from --var'); + expect(stdout).not.toContain('typed-value'); + }); + + it('names the detected dotenv file in the checklist and reads the path it is given', async () => { + const sent = wire(); + environmentEndpoints(sent); + writeFileSync(join(workDir, '.env.local'), 'FROM_FILE=1\n'); + const prompts = answerPrompts({ + 'Add variables from': ['Import variables from a dotenv file'], + 'Path to the dotenv file': join(workDir, '.env.local'), + }); + + const { error } = await onTerminal(() => + runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config), + ); + + expect(error).toBeUndefined(); + expect((prompts.payloads[0].choices as { name: string }[])[1].name).toBe( + 'Import variables from a dotenv file (.env.local found)', + ); + expect(sent.put).toEqual([ + { environmentVariables: [...getFixture.environment.environmentVariables, { key: 'FROM_FILE', value: '1' }] }, + ]); + }); + + it('exits 0 saying it skipped adding variables, and sends no request, when nothing is ticked on a terminal', async () => { + const sent = wire(); + const scope = environmentEndpoints(sent); + answerPrompts({ 'Add variables from': [] }); + + const { error, stdout } = await onTerminal(() => + runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config), + ); + + expect(error).toBeUndefined(); + expect(stdout).toContain('Skipped adding variables.'); + expect(sent.order).toEqual([]); + expect(scope.isDone()).toBe(false); + }); + + it('does not ask on a terminal when a source flag was given', async () => { + const sent = wire(); + environmentEndpoints(sent); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => + runCommand(['launch:variables:set', ...SCOPE, '--var', 'A=1', '--data-dir', workDir], config), + ); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([]); + }); + + it('exits 2 for two bare keys without a terminal, naming --dotenv -, and sends no request', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'A', 'B', '--data-dir', workDir], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--dotenv -'); + expect(sent.order).toEqual([]); + }); + + it('asks a hidden prompt for a bare key on a terminal and never prints what was typed', async () => { + const sent = wire(); + environmentEndpoints(sent); + const prompts = answerPrompts({ 'Value for API_KEY': 'never-printed-typed-value' }); + + const { error, stdout } = await onTerminal(() => + runCommand(['launch:variables:set', ...SCOPE, '--var', 'API_KEY', '--data-dir', workDir], config), + ); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual(['Value for API_KEY']); + expect(prompts.payloads[0]).toMatchObject({ type: 'password', mask: '*' }); + expect(sent.put).toEqual([ + { environmentVariables: [...getFixture.environment.environmentVariables, { key: 'API_KEY', value: 'never-printed-typed-value' }] }, + ]); + expect(stdout).not.toContain('never-printed-typed-value'); + }); + + it('exits 2 for an empty value before any request is sent', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'KEY=', '--data-dir', workDir], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('KEY has an empty value. The Launch API does not accept one.'); + expect(sent.order).toEqual([]); + }); + + it('exits 1 with the CLI wording and echoes neither the request body nor any value when the PUT is refused', async () => { + const sent = wire(); + environmentEndpoints(sent, 400, { + errors: [ + { + code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.BYTE_SIZE_LIMIT_REACHED', + message: 'API_URL=never-printed-value exceeds the size limit', + }, + ], + status: 400, + }); + + const { error, stdout } = await runCommand( + ['launch:variables:set', ...SCOPE, '--var', 'API_URL=never-printed-value', '--data-dir', workDir], + config, + ); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe( + 'The environment variables are larger than Launch allows. Remove some variables or shorten their values.', + ); + expect(`${error?.message}${error?.stack ?? ''}${stdout}`).not.toContain('never-printed-value'); + expect(stdout).not.toContain('variables set'); + }); + + it('removes only the named key with --yes, sending the rest as the complete array', async () => { + const sent = wire(); + environmentEndpoints(sent, 200, undefined, 2); + + const { error, stdout } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1', '--yes'], config); + + expect(error).toBeUndefined(); + expect(sent.order).toEqual(['get', 'get', 'put']); + expect(sent.put).toEqual([{ environmentVariables: [] }]); + expect(stdout).toContain('\u2714 key1 removed from production.'); + expect(stdout).not.toContain('value1'); + }); + + it('exits 2 for a key that is not set and sends no PUT', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'NOPE', '--yes'], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('NOPE is not set in this environment.'); + expect(sent.order).toEqual(['get']); + }); + + it('exits 2 naming --yes without a terminal and sends no PUT', async () => { + const sent = wire(); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1'], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Pass --yes'); + expect(sent.order).toEqual(['get']); + }); + + it('exits 3 and sends no PUT when the user declines', async () => { + const sent = wire(); + environmentEndpoints(sent); + answerPrompts({ 'Are you sure you want to remove key1 from "production"?': false }); + + const { error, stdout } = await onTerminal(() => + runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1'], config), + ); + + expect(error?.oclif?.exit).toBe(3); + expect(stdout).toContain('Variables not removed.'); + expect(sent.order).toEqual(['get']); + }); + + describe('with --from-stack', () => { + const SAVED_TOKEN = randomBytes(16).toString('hex'); + + beforeEach(() => { + CONFIG.tokens = { 'my-prod': { token: SAVED_TOKEN, apiKey: 'blt9stackkey', environment: 'staging', type: 'delivery' } }; + }); + + afterEach(() => { + delete CONFIG.tokens; + }); + + it('imports through the saved alias before it reads the environment, then puts the complete merged array', async () => { + const sent = wire(); + cmsEndpoint(sent); + environmentEndpoints(sent); + + const { error, stdout } = await runCommand( + ['launch:variables:set', ...SCOPE, '--from-stack', '-a', 'my-prod', '--data-dir', workDir], + config, + ); + + expect(error).toBeUndefined(); + expect(sent.order).toEqual(['cms', 'get', 'put']); + expect(sent.put).toEqual([ + { + environmentVariables: [ + ...getFixture.environment.environmentVariables, + { key: 'CONTENTSTACK_API_HOST', value: 'api.integration.test' }, + { key: 'CONTENTSTACK_CDN', value: 'cdn.integration.test' }, + { key: 'CONTENTSTACK_API_KEY', value: 'blt9stackkey' }, + { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: SAVED_TOKEN }, + { key: 'CONTENTSTACK_ENVIRONMENT', value: 'staging' }, + ], + }, + ]); + expect(stdout).toContain('Using the saved delivery token "my-prod" (environment staging).'); + expect(stdout).toContain('5 variables set \u2014 5 from stack'); + expect(stdout).not.toContain(SAVED_TOKEN); + }); + + it('lets a typed --var override a stack value and names the override', async () => { + const sent = wire(); + cmsEndpoint(sent); + environmentEndpoints(sent); + + const { error, stdout } = await runCommand( + ['launch:variables:set', ...SCOPE, '--from-stack', '--alias', 'my-prod', '--var', 'CONTENTSTACK_ENVIRONMENT=production', '--data-dir', workDir], + config, + ); + + expect(error).toBeUndefined(); + expect((sent.put[0] as { environmentVariables: { key: string; value: string }[] }).environmentVariables.at(-1)).toEqual({ + key: 'CONTENTSTACK_ENVIRONMENT', + value: 'production', + }); + expect(stdout).toContain('CONTENTSTACK_ENVIRONMENT overridden by --var'); + }); + + it('exits 2 naming --alias when there is no terminal and no alias, sending no request', async () => { + const sent = wire(); + cmsEndpoint(sent); + environmentEndpoints(sent); + + const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--from-stack', '--data-dir', workDir], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe("--from-stack requires --alias when there's no TTY."); + expect(sent.order).toEqual([]); + }); + + it('exits 2 for an alias nothing is saved under, sending no request', async () => { + const sent = wire(); + cmsEndpoint(sent); + environmentEndpoints(sent); + + const { error } = await runCommand( + ['launch:variables:set', ...SCOPE, '--from-stack', '--alias', 'gone', '--data-dir', workDir], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('No delivery token is saved as "gone".'); + expect(sent.order).toEqual([]); + }); + + it('exits 2 when --alias is given without --from-stack, sending no request', async () => { + const sent = wire(); + cmsEndpoint(sent); + environmentEndpoints(sent); + + const { error } = await runCommand( + ['launch:variables:set', ...SCOPE, '--alias', 'my-prod', '--var', 'A=1', '--data-dir', workDir], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('--alias is only used with --from-stack.'); + expect(sent.order).toEqual([]); + }); + }); + + it('exits 2 naming --key when none is given', async () => { + const { error } = await runCommand(['launch:variables:unset', ...SCOPE, '--yes'], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--key'); + }); +}); From 161993a72b97bf5b5dc2c4b81da64cdc1ef88453 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 21 Oct 2026 17:37:37 +0530 Subject: [PATCH 4/4] docs: describe the launch:variables commands in AGENTS.md and the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the source resolver, the set checklist, --from-stack and --alias, the stacks resource and its layering edges, the read-modify-write race, and the create-time summary wording, and regenerates the README command reference. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah Co-authored-by: Sakshi Koli --- AGENTS.md | 56 ++++++++++++++++++++++++-- README.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 25523cf..412bc44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,13 @@ src/ EnvironmentBuilder that turns them into a CreateEnvironmentInput, and the EnvironmentCreator / EnvironmentUpdater behind launch:env:create / :update. It reaches deployments, git and uploads, for create + stacks/ the Contentstack stack and delivery-token lookups behind --from-stack, through the + CmaSession (no Launch endpoint) + variables/ the shared variable-source resolver (--var, --dotenv, --from-stack, a detected .env file), the + read-modify-write writer behind launch:variables:set / :unset, and the masked + presenter behind :list. It has no repository of its own: it reads and writes through + environments, and reads the stack template through its own VariablesApi. EnvironmentBuilder calls back into it for the create commands, so the + two folders each name the other in the layering allow-list deployments/ the deployment repository, the status classification, the wait/stream loop and LogTail that projects:create, deployments:create and logs:get all drive, and the DeploymentFollower that watches one @@ -314,6 +321,48 @@ the yellow `Project not deleted.` and exits 3 (`EXIT_CANCELLED`) without the `Er `CancelledError` would print; Ctrl-C at the question is still a `CancelledError`. The success line is the green `✔ Project deleted successfully.`, matching `projects:update`. +**Environment variables (`launch:variables:*`).** There is no per-variable endpoint, so `set` and `unset` +are `GET .../environments/:uid`, merge locally, then `PUT` the complete `environmentVariables` array and +nothing else: the update body is partial on the server, so no other environment field can be dropped by it. +Every source is resolved and every value prompted for before the `GET`, so no user interaction sits between +the read and the write; `unset` reads once to validate the keys and name the environment, confirms, then +reads again immediately before the `PUT`. Two writers racing lose one change - the API offers no ETag and no +partial update (CL-7179 is the proposed `PATCH`) - and FR34 asks that this be documented rather than treated +as a defect. + +`collectVariables` in `src/variables/variable.sources.ts` is the one resolver: stack (`--from-stack`), then +`--dotenv`, then `--var`, later wins, and the summary line names every source's new keys and every override. +With no source flag, `set` on a terminal shows the `Add variables from` checklist (`chooseSources` in +`src/variables/variable.chooser.ts`, shared with the create commands, which add a `Skip` choice); ticking nothing +prints a yellow `Skipped adding variables.` and sends nothing (sources that supply no variable print a yellow +`No changes made.`). Without a terminal, `set` auto-detects `.env.local` then `.env` +under `--data-dir`, always printing the file it chose, and fails with exit 2 when there is none. A bare `--var KEY` is a hidden prompt on a terminal, a stdin read for exactly one bare key without one, and +exit 2 naming `--dotenv -` for two or more. The API refuses an empty value, so `KEY=` is a usage error naming +the key, and an empty value inside a dotenv file is skipped with its key reported. No value reaches stdout, a +prompt message, a confirmation or an error: `list` masks through `redactedColumn`, and a failed `PUT` is worded from +`ENVIRONMENT_ERROR_MESSAGES`, never from the API's own text. + +`projects:create` and `env:create` take `--var` and `--dotenv` and reach the same resolver through +`EnvironmentBuilder`, which asks last - after Contentstack Authentication, where V1 asked - with no auto-detection. +With neither flag it offers a checkbox over the sources plus `Skip adding environment variables`, which cannot be +combined with the others; without a terminal it sends an empty list. + +**`--from-stack` and `--alias`.** `--from-stack` is a bare flag on `variables:set`, `projects:create` and +`env:create`; `--alias ` (`-a`) names a delivery token saved with `csdx auth:tokens:add`, and is a usage error +without `--from-stack` (`aliasRequiresFromStack`). With an alias the token is read from the `tokens` config object +(indexed by alias, so a dot in the alias is safe) and must be a delivery token with an environment. Without an alias +a terminal gets a stack picker then a delivery-token picker (names only); without a terminal it is a usage error +naming `--alias`. The five variables come from `GET /cms/environment-variables` (API host and CDN, scheme stripped) +plus the stack's API key, the token and its environment. `src/stacks/` owns the Contentstack stack lookups through +`CmaSession.fetchStacks` / `fetchDeliveryTokens`, which page 100 at a time; `variables` may import `stacks` and +`environments` and nothing else (`RESOURCE_EDGES`). A delivery token value is never printed or logged. + +Two harness traps. `@oclif/test`'s `runCommand` joins argv with spaces and splits it again, so a value containing a +space must be passed pre-quoted there (`'"MSG=hello world"'`); the real parser keeps it whole, which +`src/variables/variable.inputs.test.ts` proves against `Parser.parse`. And a test that lets a bare key reach the +non-terminal path reads the real `process.stdin` and hangs the run, so such a test must answer on a terminal or +fake `readStdin`. + **A prompt never answers with something that is not on its list.** Two mechanisms, chosen by whether the choices need searching. `askOption` in `src/core/prompt.ts` is the default and the only choice helper left; the two prompts that still need a search box build their @@ -740,10 +789,9 @@ not a total one, so a large archive that is still moving is never cut off. It uses `node:http`/`node:https` rather than `fetch` so `nock` can intercept it - nock 13 does not see undici's `fetch`. -**Environment variables and argv.** `environmentVariables` is always `[]` on create: the variable -sources are CL-7169. The shape is built by a function that takes no input at all, so there is no -argv path into it to grow by accident, and a test passes `var`, `env-file` and `from-stack` into the -request to prove the body is unchanged. **No secret may reach argv** (FR30, G16). +**Environment variables and argv.** On create, `environmentVariables` comes only from `variablesForCreate`, called last by +`EnvironmentBuilder`, so the sources are the explicit `--var`, `--dotenv` and `--from-stack` inputs and the prompt +checklist and nothing else; the create summary says `ready to set` because the environment does not exist yet. **No secret may reach argv** (FR30, G16). **Cross-flag rules.** A rule that is pure flag-versus-flag and evaluable from argv alone belongs in oclif's native `exclusive` / `relationships` on the flag definition, where it also shows in `--help` - diff --git a/README.md b/README.md index be8a5e8..9aa41bd 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ with exit code 2 and name their V2 replacements. `launch:functions:serve` is liv * [`csdx launch:projects:list`](#csdx-launchprojectslist) * [`csdx launch:projects:update`](#csdx-launchprojectsupdate) * [`csdx launch:rollback`](#csdx-launchrollback) +* [`csdx launch:variables:list`](#csdx-launchvariableslist) +* [`csdx launch:variables:set`](#csdx-launchvariablesset) +* [`csdx launch:variables:unset`](#csdx-launchvariablesunset) ## `csdx help [COMMAND]` @@ -118,9 +121,12 @@ Create an environment in a Launch project and follow its first deployment USAGE $ csdx launch:env:create [-c ] [-d ] [--org ] [--project ] [--name ] [--description ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd ] - [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] + [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] [--var ...] [--dotenv + ...] [--from-stack] [-a ] FLAGS + -a, --alias= Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a + prompt -c, --config= Path to the local '.cs-launch.json' file -d, --data-dir= Current working directory --auto-deploy= Deploy on every push, GitHub projects only (enable | disable) @@ -128,14 +134,19 @@ FLAGS --build-cmd= Build command --cs-auth= Contentstack Authentication (enable | disable) --description= Environment description (255 characters or fewer) + --dotenv=... Path to a dotenv file, repeatable. Use - to read dotenv content from standard input --framework= Framework preset (Gatsby | NextJs | CRA | CSR | Analog | Angular | Nuxt | Astro | VueJs | Remix | Other) + --from-stack Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a + terminal --name= Environment name (200 characters or fewer) --org= Organization UID --output-dir= Output directory --project= Project name or UID --res-mode= Response mode (buffered | streaming) --server-cmd= Server command + --var=... Environment variable as KEY=VALUE, repeatable. A KEY with no value is asked at a hidden + prompt, or read from standard input when piped DESCRIPTION Create an environment in a Launch project and follow its first deployment @@ -366,9 +377,12 @@ Create a Launch project, its first environment, and its first deployment USAGE $ csdx launch:projects:create [-c ] [-d ] [--org ] [--type ] [--name ] [--description ] [--env-name ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd ] - [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] + [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] [--var ...] [--dotenv + ...] [--from-stack] [-a ] FLAGS + -a, --alias= Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a + prompt -c, --config= Path to the local '.cs-launch.json' file -d, --data-dir= Current working directory --auto-deploy= Deploy on every push, GitHub projects only (enable | disable) @@ -376,15 +390,20 @@ FLAGS --build-cmd= Build command --cs-auth= Contentstack Authentication (enable | disable) --description= Project description (255 characters or fewer) + --dotenv=... Path to a dotenv file, repeatable. Use - to read dotenv content from standard input --env-name= Name of the environment created with the project (200 characters or fewer) --framework= Framework preset (Gatsby | NextJs | CRA | CSR | Analog | Angular | Nuxt | Astro | VueJs | Remix | Other) + --from-stack Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a + terminal --name= Project name (200 characters or fewer) --org= Organization UID --output-dir= Output directory --res-mode= Response mode (buffered | streaming) --server-cmd= Server command --type= Project type (GitHub | FileUpload) + --var=... Environment variable as KEY=VALUE, repeatable. A KEY with no value is asked at a hidden + prompt, or read from standard input when piped DESCRIPTION Create a Launch project, its first environment, and its first deployment @@ -515,6 +534,98 @@ DESCRIPTION ``` _See code: [src/commands/launch/rollback.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/rollback.ts)_ + +## `csdx launch:variables:list` + +List the environment variables of an environment, with their values masked + +``` +USAGE + $ csdx launch:variables:list [-c ] [-d ] [--org ] [--project ] [--env ] [--limit + ] [--skip ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --env= Environment name or UID + --limit= Number of records to fetch (1-1000) + --org= Organization UID + --project= Project name or UID + --skip= Number of records to skip + +DESCRIPTION + List the environment variables of an environment, with their values masked + +EXAMPLES + $ csdx launch:variables:list --org --project --env +``` + +_See code: [src/commands/launch/variables/list.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/variables/list.ts)_ + +## `csdx launch:variables:set` + +Add or change environment variables from --var, --dotenv or a detected .env file; later sources win + +``` +USAGE + $ csdx launch:variables:set [-c ] [-d ] [--org ] [--project ] [--env ] [--var + ...] [--dotenv ...] [--from-stack] [-a ] + +FLAGS + -a, --alias= Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a + prompt + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --dotenv=... Path to a dotenv file, repeatable. Use - to read dotenv content from standard input + --env= Environment name or UID + --from-stack Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a + terminal + --org= Organization UID + --project= Project name or UID + --var=... Environment variable as KEY=VALUE, repeatable. A KEY with no value is asked at a hidden + prompt, or read from standard input when piped + +DESCRIPTION + Add or change environment variables from --var, --dotenv or a detected .env file; later sources win + +EXAMPLES + $ csdx launch:variables:set --org --project --env --var API_URL=https://example.test DEBUG=true + + $ csdx launch:variables:set --org --project --env --dotenv .env.staging + + $ printf '%s' "$TOKEN" | csdx launch:variables:set --org --project --env --var API_TOKEN +``` + +_See code: [src/commands/launch/variables/set.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/variables/set.ts)_ + +## `csdx launch:variables:unset` + +Remove environment variables from an environment, after asking for confirmation + +``` +USAGE + $ csdx launch:variables:unset [-c ] [-d ] [--org ] [--project ] [--env ] [--key + ...] [-y] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + -y, --yes Skip the confirmation prompt + --env= Environment name or UID + --key=... Environment variable key, repeatable + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Remove environment variables from an environment, after asking for confirmation + +EXAMPLES + $ csdx launch:variables:unset --org --project --env --key API_KEY + + $ csdx launch:variables:unset --org --project --env --key API_KEY --key DEBUG --yes +``` + +_See code: [src/commands/launch/variables/unset.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/variables/unset.ts)_ # How to do development Locally?