From 23b71d1969950257e07151c75e44847327d7a834 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Thu, 22 Oct 2026 12:31:56 +0530 Subject: [PATCH] feat(variables): send variables:set and :unset through the per-variable PATCH endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set and unset now call PATCH .../environments/:uid/variables with only the keys to set or to unset, instead of reading the environment, merging locally and putting the whole array back. A concurrent change to another key can no longer be lost, and the CLI no longer reads the environment to set. unset still reads once before it asks, to name the environment and refuse a key that is not set. A 404 KEY.NOT_FOUND is worded as the not-set usage error, a 409 CONCURRENT_UPDATE tells the user to run the command again, and PATCH_EMPTY and CONFLICTING_KEYS read as a CLI bug. The guard for stored variables returned without a value is removed, since only keys are sent. AGENTS.md describes the new flow. The .talismanrc checksums are refreshed for the changed files. #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 | 26 +- AGENTS.md | 29 ++- src/commands/launch/variables/set.test.ts | 10 +- src/commands/launch/variables/unset.test.ts | 14 +- src/environments/environment.errors.ts | 7 + src/variables/variable.errors.ts | 11 + src/variables/variable.set.test.ts | 99 +++----- src/variables/variable.set.ts | 2 +- src/variables/variable.unset.test.ts | 59 +++-- src/variables/variable.unset.ts | 10 +- src/variables/variable.writer.test.ts | 191 +++------------ src/variables/variable.writer.ts | 99 +------- src/variables/variables.api.test.ts | 81 +++++++ src/variables/variables.api.ts | 31 +++ test/integration/variables-set-unset.test.ts | 243 +++++++++++-------- 15 files changed, 431 insertions(+), 481 deletions(-) create mode 100644 src/variables/variable.errors.ts diff --git a/.talismanrc b/.talismanrc index 9eae385..cef6aa2 100644 --- a/.talismanrc +++ b/.talismanrc @@ -231,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: ace82b5dc26aa4b681378ef313ed3e5eeb2a5d7f3ab47d6305dc9c97e885f244 + checksum: 66b94ec2bcb34947bcda15e6364dceee7edb50bfe851e26e6c737425315e81da 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\\)`" @@ -315,7 +315,7 @@ fileignoreconfig: - filename: src/commands/launch/env/delete.test.ts checksum: 14116789eb5f9000f2a7d6f569cd15aabf2a1ec3b127ed4427afe13fd515f0c2 - filename: src/environments/environment.errors.ts - checksum: 37256331379dfda26ae10a3bc46399a716fb2740151d47b34f0f8eaeeeef48da + checksum: 3e17db3577ba944633f97063d208bdec57c833d4b6c38e203796a9fea43baacc - filename: src/core/prompt.ts checksum: 3285bdbccc31cba5b50eafe8ed8be4750af6339078a3eaee6df1e0630979aa2c - filename: src/core/prompt.test.ts @@ -337,9 +337,9 @@ fileignoreconfig: - filename: src/commands/launch/variables/list.test.ts checksum: 87ccb762f4931326fc50bca1c01aeb4a16d552e4dfa141d00370fdfafa4363be - filename: src/commands/launch/variables/set.test.ts - checksum: 5f8029b2d49410dd59576b6bda5a2ad5cb452703fa1bf6fd4690f2ac356abea5 + checksum: a6fc222527e946aee1c0015e3961bc3dff13aabec40c2162e7d2d3f2bb2d2cde - filename: src/commands/launch/variables/unset.test.ts - checksum: 857da0c6e10243477aa78cd112a126770f679b75b7b67eab929eeb2f76a41c9d + checksum: 709e849b1ef834bdc433936438110ff3f79b8cc0e7bec0f505c56ffd4f67bd8b - filename: src/commands/launch/variables/unset.ts checksum: b25c2b1547a1ed5b216c57b26bc18744ad04746e01efcc40e9469d1a5e472537 - filename: src/environments/environment.errors.test.ts @@ -356,6 +356,8 @@ fileignoreconfig: checksum: c3d5e33189736bb42b7c4fac7375cf0307681b34feca4830b49331f55a130f78 - filename: src/variables/variable.dotenv.ts checksum: 337273d2ce8d987a6e7cf6a0f743df5031c24ac719cbe31e99fff16d7ffefa79 +- filename: src/variables/variable.errors.ts + checksum: f66b960365b83561f70b5fcef521bcc8a00d99b4415fb6a5df5bcf752a91d480 - filename: src/variables/variable.files.test.ts checksum: d8b0efd6bb448c4d501ba52c47119b352ef29d6b5b66cec0e7d373a0ce91a36c - filename: src/variables/variable.inputs.test.ts @@ -377,7 +379,7 @@ fileignoreconfig: - filename: src/variables/variable.presenter.ts checksum: ee8e4dcf75ad7e849d1e8e64ce6a6549d13fc62dc9aa322d96eb2e725d629db5 - filename: src/variables/variable.set.test.ts - checksum: 68bd428a7f0a26e495cd9f79779cbc9fb5017cdee684594e90a983ea14303a62 + checksum: df7d36ff83d1489301afad5418ebe76338adb7bcad51d1e738f989362be46903 - filename: src/variables/variable.sources.test.ts checksum: 63ad95e998cbc6d33fabe4ce76797e8788b17abec3833b4fed2360eab01a6f79 - filename: src/variables/variable.sources.ts @@ -391,25 +393,25 @@ fileignoreconfig: - filename: src/variables/variable.summary.ts checksum: 0da287557a5c4280074d1a26aeff4f7d95b205e4266ff47c18b62665134f5bdc - filename: src/variables/variable.unset.test.ts - checksum: ef31b77f66913a10f1b865b7a6568bef71ff02803c6636971605b840b489671f + checksum: d449ba8483eeb932c155b2b210ce2088785d0961680eaa3efd036553c46579e2 - filename: src/variables/variable.unset.ts - checksum: 250ce39c694d745370717771dc16d20e2f6b9516f43043900d4a40bde7fc04db + checksum: 0d21f5878aa1f05dcafc864dd7dcb23e3155d3d24b97e248e62ad9bf5bcd7745 - 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 + checksum: 7991a4ba0c58f187dfebf01092642459ce89b297f453159ca8e0953a2ee7e2b8 - filename: src/variables/variable.writer.ts - checksum: a6fc266af2bae734ff1bb716c854a154b5c3e858a0ba2798fc17d5bdbdfde1b9 + checksum: 11acfccfad30b66a44954e97127d2a7bcebdb80f92299da6d645ac847c97192a - filename: src/variables/variables.api.test.ts - checksum: 33d41fb8383e0908260ca3df1d126227e614330acde0c6f195b877245e51cfd6 + checksum: e716f85c2d97aadf95088cf7cf49cb46354d2581f8ed6e194cb0af876c65fb01 - filename: src/variables/variables.api.ts - checksum: 2fca1f8fc784d5f859b13a24bd3cd09bda7aa92e7b3d65c0ecc92d4c0de954fa + checksum: 1b5a93e17fc1cc338afd5a3fc24a6ee444f551037c54797453481855e3300807 - 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 + checksum: 25ae86d9a50f02c5209823a91ae0ff1433e5a0bff3cfa27f84e48274d0e9c571 version: "1.0" diff --git a/AGENTS.md b/AGENTS.md index 412bc44..ec8e060 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,10 +144,11 @@ src/ 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 + writer behind launch:variables:set / :unset, and the masked presenter behind :list. Its + VariablesApi owns the per-variable PATCH and the stack template read; the + pre-confirmation read for unset and the list read go through environments. + 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 @@ -321,14 +322,16 @@ 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. +**Environment variables (`launch:variables:*`).** `set` and `unset` call the per-variable +`PATCH .../environments/:uid/variables` (`VariablesApi.patch`, CL-7179) with a body of `{set: [{key, value}]}` +or `{unset: [key]}`, never both, so the CLI no longer reads the environment to merge and cannot overwrite a +concurrent change to another key. The server merges under a compare-and-swap and its response carries keys +only, which the CLI ignores. `unset` still reads the environment once, before it asks, to name it in the +question and to refuse a key that is not set with exit 2 before any prompt; a key that disappears after that +read comes back as 404 `KEY.NOT_FOUND`, which `VariablesApi.patch` rewords to the same not-set usage error +(`variable.errors.ts`). A 409 `CONCURRENT_UPDATE` (the server gave up after its retries) exits 1 with advice to run the +command again; `PATCH_EMPTY` and `CONFLICTING_KEYS` cannot be sent by this CLI and read as a CLI bug. A PUT or +a UI save racing a PATCH is still last-writer-wins on the whole array, by design on the server. `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. @@ -339,7 +342,7 @@ prints a yellow `Skipped adding variables.` and sends nothing (sources that supp 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 +prompt message, a confirmation or an error: `list` masks through `redactedColumn`, and a failed `PATCH` 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 diff --git a/src/commands/launch/variables/set.test.ts b/src/commands/launch/variables/set.test.ts index d1c1180..286e930 100644 --- a/src/commands/launch/variables/set.test.ts +++ b/src/commands/launch/variables/set.test.ts @@ -23,11 +23,9 @@ function commandUnderTest(resolved: Record, files: Record ({ environmentVariables: [] }), - update: async (params: unknown) => { + variables: { + patch: async (params: unknown) => { updates.push(params); - return {}; }, }, }, @@ -38,13 +36,13 @@ function commandUnderTest(resolved: Record, files: Record { - it('sets the --var pairs through the environment endpoints and prints the provenance', async () => { + it('sets the --var pairs through the variables patch 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' }] } }, + { org: 'o', project: 'p', environment: 'e', set: [{ key: 'A', value: '1' }, { key: 'B', value: '2' }] }, ]); expect(lines).toEqual(['✔ 2 variables set — 2 from --var']); }); diff --git a/src/commands/launch/variables/unset.test.ts b/src/commands/launch/variables/unset.test.ts index aa037fd..e30aa84 100644 --- a/src/commands/launch/variables/unset.test.ts +++ b/src/commands/launch/variables/unset.test.ts @@ -47,15 +47,15 @@ function commandUnderTest(harness: Harness) { 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'); + }, + variables: { + patch: async (params: unknown) => { + calls.push('patch'); updates.push(params); if (harness.updateFails) { throw harness.updateFails; } - - return {}; }, }, }, @@ -74,8 +74,8 @@ describe('launch:variables:unset', () => { 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(calls).toEqual(['get', 'patch']); + expect(updates).toEqual([{ ...SCOPE, unset: ['API_KEY'] }]); expect(lines).toEqual(['✔ API_KEY removed from staging.']); expect(lines.join('')).not.toContain('never-printed-value'); }); @@ -86,7 +86,7 @@ describe('launch:variables:unset', () => { await command.run(); expect(inquired).toEqual([]); - expect(calls).toEqual(['get', 'get', 'update']); + expect(calls).toEqual(['get', 'patch']); expect(lines).toEqual(['✔ API_KEY removed from staging.']); }); diff --git a/src/environments/environment.errors.ts b/src/environments/environment.errors.ts index ead07f8..445b77c 100644 --- a/src/environments/environment.errors.ts +++ b/src/environments/environment.errors.ts @@ -1,5 +1,7 @@ import type { ErrorMessages } from '../transport/errors'; +const CLI_BUG_MESSAGE = 'The CLI sent an update the Launch API could not accept. This is a bug in the CLI.'; + export const ENVIRONMENT_ERROR_MESSAGES: ErrorMessages = { 'launch.ENVIRONMENT.NOT_FOUND': 'No environment found with that name or UID.', 'launch.ENVIRONMENT.DUPLICATE_NAME': 'An environment with that name already exists in this project.', @@ -10,6 +12,11 @@ export const ENVIRONMENT_ERROR_MESSAGES: ErrorMessages = { '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.CONCURRENT_UPDATE': + "Another change to this environment's variables landed at the same moment, so nothing was changed. " + + 'Run the command again.', + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.PATCH_EMPTY': CLI_BUG_MESSAGE, + 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.CONFLICTING_KEYS': CLI_BUG_MESSAGE, '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.', diff --git a/src/variables/variable.errors.ts b/src/variables/variable.errors.ts new file mode 100644 index 00000000..9789fb2 --- /dev/null +++ b/src/variables/variable.errors.ts @@ -0,0 +1,11 @@ +import { UsageError } from '../core/errors'; + +export function notSetError(keys: string[]): UsageError { + return new UsageError(`${keys.join(', ')} ${keys.length === 1 ? 'is' : 'are'} not set in this environment.`); +} + +export function someNotSetError(keys: string[]): UsageError { + return keys.length === 1 + ? notSetError(keys) + : new UsageError(`At least one of ${keys.join(', ')} is not set in this environment.`); +} diff --git a/src/variables/variable.set.test.ts b/src/variables/variable.set.test.ts index 9b2aec8..f73ae54 100644 --- a/src/variables/variable.set.test.ts +++ b/src/variables/variable.set.test.ts @@ -6,7 +6,7 @@ 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 type { PatchVariablesParams } from './variables.api'; import { DEFAULT_VARIABLE_IO } from './variable.io'; import { VariableSetter } from './variable.set'; import type { SavedToken } from './variable.stack'; @@ -16,14 +16,13 @@ const SECRET_TOKEN = `cs-${randomUUID()}`; 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 patches: PatchVariablesParams[] = []; const ux: UxLike = { print: (message: string) => { printed.push(message); @@ -38,16 +37,10 @@ function fakes( 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 }; + variables: { + patch: async (params: PatchVariablesParams) => { + order.push('patch'); + patches.push(params); }, }, }, @@ -66,43 +59,31 @@ function fakes( readStdin: async () => '', }; - return { printed, order, updates, asked, setter: new VariableSetter(services, io) }; + return { printed, order, patches, 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' }); + it('gathers every source first, then patches only the variables it gathered and prints the provenance', async () => { + const { setter, order, printed, patches } = fakes({ '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(order).toEqual(['read:a.env', 'patch']); + expect(patches).toEqual([{ ...SCOPE, set: [{ 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' }); + const { setter, printed, patches } = fakes({ '/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' }, - ]); + expect(patches).toEqual([{ ...SCOPE, set: [{ 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' }); + const { setter, printed, order } = fakes({ 'empty.env': '# nothing' }); await setter.set({ ...SCOPE, vars: undefined, envFiles: ['empty.env'], dataDir: '/work' }); @@ -111,7 +92,7 @@ describe('variable setter', () => { }); it('names an override in the summary', async () => { - const { setter, printed } = fakes(undefined, { 'a.env': 'API_URL=file' }); + const { setter, printed } = fakes({ 'a.env': 'API_URL=file' }); await setter.set({ ...SCOPE, vars: ['API_URL=typed'], envFiles: ['a.env'], dataDir: '/work' }); @@ -136,23 +117,20 @@ describe('variable setter', () => { isTTY: false, outputIsTTY: false, api: { - environments: { - get: async () => ({}), - update: async () => Promise.reject(new Error('update failed')), - }, + variables: { patch: async () => Promise.reject(new Error('patch 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', + 'patch failed', ); expect(printed).toEqual([]); }); it('never prints a value', async () => { - const { setter, printed } = fakes(undefined, { 'a.env': 'A=never-printed-value' }); + const { setter, printed } = fakes({ 'a.env': 'A=never-printed-value' }); await setter.set({ ...SCOPE, vars: ['B=another_secret'], envFiles: ['a.env'], dataDir: '/work' }); @@ -166,7 +144,7 @@ describe('variable setter with --from-stack', () => { const printed: string[] = []; const templateAsked: string[] = []; const aliasAsked: string[] = []; - const updates: unknown[] = []; + const patches: PatchVariablesParams[] = []; const services = { ux: { print: (message: string) => printed.push(message), inquire: async () => undefined as never }, isTTY: false, @@ -177,12 +155,8 @@ describe('variable setter with --from-stack', () => { 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 {}; + patch: async (params: PatchVariablesParams) => { + patches.push(params); }, }, }, @@ -193,11 +167,11 @@ describe('variable setter with --from-stack', () => { return saved; }); - return { setter, printed, templateAsked, aliasAsked, updates }; + return { setter, printed, templateAsked, aliasAsked, patches }; } 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({ + const { setter, printed, templateAsked, aliasAsked, patches } = stackSetter({ token: SECRET_TOKEN, apiKey: 'blt9', environment: 'staging', @@ -207,8 +181,7 @@ describe('variable setter with --from-stack', () => { expect(aliasAsked).toEqual(['my-prod']); expect(templateAsked).toEqual(['org1']); - expect((updates[0] as { update: { environmentVariables: unknown } }).update.environmentVariables).toEqual([ - { key: 'KEEP', value: 'k' }, + expect(patches[0].set).toEqual([ { key: 'CONTENTSTACK_API_HOST', value: 'api.example.test' }, { key: 'CONTENTSTACK_API_KEY', value: 'blt9' }, { key: 'CONTENTSTACK_DELIVERY_TOKEN', value: SECRET_TOKEN }, @@ -223,22 +196,22 @@ describe('variable setter with --from-stack', () => { }); it('writes nothing when the alias has no saved token', async () => { - const { setter, printed, updates } = stackSetter(undefined); + const { setter, printed, patches } = 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(patches).toEqual([]); expect(printed).toEqual([]); }); it('refuses --from-stack without --alias when there is no terminal, writing nothing', async () => { - const { setter, updates } = stackSetter(undefined); + const { setter, patches } = 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([]); + expect(patches).toEqual([]); }); }); @@ -289,7 +262,7 @@ describe('default variable io', () => { 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': [] }); + const { setter, asked } = fakes({}, { 'Add variables from': [] }); await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); @@ -306,7 +279,7 @@ describe('default variable io', () => { }); it('names the detected dotenv file in its choice', async () => { - const { setter, asked } = fakes(undefined, { '/work/.env.local': 'A=1' }, { 'Add variables from': [] }); + const { setter, asked } = fakes({ '/work/.env.local': 'A=1' }, { 'Add variables from': [] }); await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); @@ -316,7 +289,7 @@ describe('default variable io', () => { }); 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': [] }); + const { setter, printed, order } = fakes({}, { 'Add variables from': [] }); await setter.set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work' }); @@ -325,8 +298,7 @@ describe('default variable io', () => { }); it('sets what the ticked sources supply and prints the provenance', async () => { - const { setter, printed, updates, order } = fakes( - undefined, + const { setter, printed, patches, order } = fakes( { '/work/.env.local': 'FROM_FILE=1' }, { 'Add variables from': [FILE, TYPED], @@ -337,9 +309,8 @@ describe('default variable io', () => { 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' }, + expect(order).toEqual(['read:/work/.env.local', 'patch']); + expect(patches[0].set).toEqual([ { key: 'FROM_FILE', value: '1' }, { key: 'TYPED', value: '2' }, ]); @@ -351,7 +322,7 @@ describe('default variable io', () => { [{ 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' }, {}); + const { setter, asked } = fakes({ 'a.env': 'FROM_FILE=1' }, {}); await setter .set({ ...SCOPE, vars: undefined, envFiles: undefined, dataDir: '/work', ...flags }) diff --git a/src/variables/variable.set.ts b/src/variables/variable.set.ts index d1e7cb0..3dfd7ca 100644 --- a/src/variables/variable.set.ts +++ b/src/variables/variable.set.ts @@ -60,7 +60,7 @@ export class VariableSetter { } await setVariables( - this.services.api.environments, + this.services.api.variables, { org: request.org, project: request.project, environment: request.environment }, merged.variables, ); diff --git a/src/variables/variable.unset.test.ts b/src/variables/variable.unset.test.ts index 0fc2851..598c6d4 100644 --- a/src/variables/variable.unset.test.ts +++ b/src/variables/variable.unset.test.ts @@ -1,7 +1,8 @@ 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 type { Environment } from '../environments/types'; +import type { PatchVariablesParams } from './variables.api'; import { VariableRemover } from './variable.unset'; const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; @@ -10,7 +11,7 @@ function fakes(held: Environment, answer = true) { const printed: string[] = []; const order: string[] = []; const questions: string[] = []; - const updates: unknown[] = []; + const patches: PatchVariablesParams[] = []; const ux: UxLike = { print: (message: string) => { printed.push(message); @@ -27,10 +28,11 @@ function fakes(held: Environment, answer = true) { order.push('get'); return held; }, - update: async (params: { update: EnvironmentUpdate }) => { - order.push('update'); - updates.push(params); - return held; + }, + variables: { + patch: async (params: PatchVariablesParams) => { + order.push('patch'); + patches.push(params); }, }, }, @@ -41,7 +43,7 @@ function fakes(held: Environment, answer = true) { return answer; }; - return { printed, order, questions, updates, services, remover: new VariableRemover(services, confirm) }; + return { printed, order, questions, patches, services, remover: new VariableRemover(services, confirm) }; } const HELD: Environment = { @@ -50,15 +52,15 @@ const HELD: Environment = { }; 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); + it('reads, confirms, then patches out only the named key, naming the key and the environment', async () => { + const { remover, order, printed, patches, questions } = fakes(HELD); const removed = await remover.remove({ ...SCOPE, keys: ['API_KEY'] }); expect(removed).toBe(true); - expect(order).toEqual(['get', 'confirm', 'get', 'update']); + expect(order).toEqual(['get', 'confirm', 'patch']); 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(patches).toEqual([{ ...SCOPE, unset: ['API_KEY'] }]); expect(printed).toEqual(['✔ API_KEY removed from staging.']); }); @@ -81,23 +83,31 @@ describe('variable remover', () => { }); it('returns false and changes nothing when the user declines', async () => { - const { remover, order, printed, updates } = fakes(HELD, false); + const { remover, order, printed, patches } = 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(patches).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); + const { remover, order, patches } = 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([]); + expect(patches).toEqual([]); + }); + + it('names every missing key together and uses the plural', async () => { + const { remover } = fakes(HELD); + + await expect(remover.remove({ ...SCOPE, keys: ['API_KEY', 'X', 'Y'] })).rejects.toThrow( + 'X, Y are not set in this environment.', + ); }); it('never puts a value in the question, the summary or an error', async () => { @@ -124,21 +134,22 @@ describe('variable remover', () => { { ux: { print: () => undefined }, outputIsTTY: false, - api: { environments: { get: async () => HELD, update: async () => Promise.reject(new Error('update failed')) } }, + api: { + environments: { get: async () => HELD }, + variables: { patch: async () => Promise.reject(new Error('patch failed')) }, + }, } as unknown as ServiceContext, async () => true, ); - await expect(writing.remove({ ...SCOPE, keys: ['API_KEY'] })).rejects.toThrow('update failed'); + await expect(writing.remove({ ...SCOPE, keys: ['API_KEY'] })).rejects.toThrow('patch 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' }] }); + it('removes a key whose value the API did not return, since only the key is sent', async () => { + const { remover, patches } = fakes({ name: 'staging', environmentVariables: [{ 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([]); + await remover.remove({ ...SCOPE, keys: ['HIDDEN'] }); + + expect(patches).toEqual([{ ...SCOPE, unset: ['HIDDEN'] }]); }); }); diff --git a/src/variables/variable.unset.ts b/src/variables/variable.unset.ts index 60b5298..19a82f3 100644 --- a/src/variables/variable.unset.ts +++ b/src/variables/variable.unset.ts @@ -1,7 +1,8 @@ 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'; +import { notSetError } from './variable.errors'; +import { missingKeys, unsetVariables } from './variable.writer'; export interface RemoveVariablesRequest extends EnvironmentParams { keys: string[]; @@ -18,16 +19,13 @@ export class VariableRemover { 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 held = await this.services.api.environments.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(', '); @@ -35,7 +33,7 @@ export class VariableRemover { return false; } - await unsetVariables(store, scope, request.keys); + await unsetVariables(this.services.api.variables, scope, request.keys); this.services.ux.print( styled(`✔ ${listed} removed from ${name}.`, 'green', this.services.outputIsTTY === true), diff --git a/src/variables/variable.writer.test.ts b/src/variables/variable.writer.test.ts index 90e1f83..35ed5ad 100644 --- a/src/variables/variable.writer.test.ts +++ b/src/variables/variable.writer.test.ts @@ -1,191 +1,72 @@ -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'; +import type { Environment } from '../environments/types'; +import type { PatchVariablesParams } from './variables.api'; +import { 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 }[] = []; +function fakeStore(failure?: Error) { + const patches: PatchVariablesParams[] = []; return { - calls, - api: { - get: async (params: EnvironmentParams) => { - calls.push({ method: 'get', params }); + patches, + store: { + patch: async (params: PatchVariablesParams) => { + patches.push(params); - if (failOn === 'get') { - throw new Error('get failed'); + if (failure !== undefined) { + throw failure; } - - 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' }] }); + it('sends only the variables to set, without reading the environment first', async () => { + const { store, patches } = fakeStore(); - await setVariables(api, PARAMS, [{ key: 'C', value: '3' }]); + await setVariables(store, PARAMS, [{ key: 'CHANGE', value: 'new' }, { key: 'ADD', value: 'a' }]); - expect((calls[1].params as { update: EnvironmentUpdate }).update.environmentVariables).toEqual([ - { key: 'A', value: '1' }, - { key: 'B', value: '2' }, - { key: 'C', value: '3' }, - ]); + expect(patches).toEqual([{ ...PARAMS, set: [{ key: 'CHANGE', value: 'new' }, { key: 'ADD', value: 'a' }] }]); }); - 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', async () => { + const { store, patches } = fakeStore(); - it('sends nothing when there is nothing to set, without even reading', async () => { - const { api, calls } = fakeApi({}); + await setVariables(store, PARAMS, []); - expect(await setVariables(api, PARAMS, [])).toBeUndefined(); - expect(calls).toEqual([]); + expect(patches).toEqual([]); }); - it('propagates a failed read without writing and a failed write', async () => { - const reading = fakeApi({}, 'get'); + it('propagates a failed patch', async () => { + const { store } = fakeStore(new Error('patch failed')); - 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'); + await expect(setVariables(store, PARAMS, [{ key: 'A', value: '1' }])).rejects.toThrow('patch 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' }] }); + it('sends only the keys to unset, once each, and returns them', async () => { + const { store, patches } = fakeStore(); - await unsetVariables(api, PARAMS, ['A']); + const removed = await unsetVariables(store, PARAMS, ['B', 'C', 'B']); - expect((calls[1].params as { update: EnvironmentUpdate }).update).toEqual({ environmentVariables: [] }); + expect(removed).toEqual(['B', 'C']); + expect(patches).toEqual([{ ...PARAMS, unset: ['B', 'C'] }]); }); - it('removes a key named twice once', async () => { - const { api } = fakeApi({ environmentVariables: [{ key: 'A', value: '1' }] }); + it('sends nothing when no key is named', async () => { + const { store, patches } = fakeStore(); - expect(await unsetVariables(api, PARAMS, ['A', 'A'])).toEqual(['A']); + expect(await unsetVariables(store, PARAMS, [])).toEqual([]); + expect(patches).toEqual([]); }); - 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 patch', async () => { + const { store } = fakeStore(new Error('patch failed')); - 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'); + await expect(unsetVariables(store, PARAMS, ['A'])).rejects.toThrow('patch failed'); }); }); @@ -196,8 +77,8 @@ describe('variable writer missing keys', () => { 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('counts a stored key as held even when the API gave no value for it', () => { + expect(missingKeys({ environmentVariables: [{ key: 'A' }, { value: 'v' }] }, ['A'])).toEqual([]); }); it.each([[{}], [{ environmentVariables: null }]])('treats an environment with no variables as holding none (%p)', (environment) => { diff --git a/src/variables/variable.writer.ts b/src/variables/variable.writer.ts index d1c9239..ee55cdc 100644 --- a/src/variables/variable.writer.ts +++ b/src/variables/variable.writer.ts @@ -1,90 +1,25 @@ -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'; +import type { Environment, EnvironmentVariableInput } from '../environments/types'; +import type { PatchVariablesParams } from './variables.api'; 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] : [])), - ); - } + patch(params: PatchVariablesParams): Promise; } export function missingKeys(environment: Environment, keys: string[]): string[] { - const heldKeys = new Set(heldBy(environment).map(({ key }) => key)); + const heldKeys = new Set((environment.environmentVariables ?? []).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; +): Promise { + if (incoming.length > 0) { + await store.patch({ ...params, set: incoming }); } - - 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( @@ -94,25 +29,9 @@ export async function unsetVariables( ): 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); + if (wanted.length > 0) { + await store.patch({ ...params, unset: wanted }); } - 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 index bc609f2..f02c317 100644 --- a/src/variables/variables.api.test.ts +++ b/src/variables/variables.api.test.ts @@ -1,8 +1,12 @@ import { MALFORMED_CODE } from '../transport/envelope'; +import { UsageError } from '../core/errors'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; import { VariablesApi } from './variables.api'; +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; +const PATCH_PATH = '/projects/proj1/environments/env1/variables'; + function fakeRestClient(result: unknown) { const requests: RestRequest[] = []; const messages: unknown[] = []; @@ -81,4 +85,81 @@ describe('variables api', () => { await expect(new VariablesApi(client).stackTemplate('org1')).rejects.toBe(failure); }); + + it('patches the variables of one environment, sending only the keys to set and to unset', async () => { + const { client, requests } = fakeRestClient({ environment: { uid: 'env1', environmentVariables: [{ key: 'A' }] } }); + const api = new VariablesApi(client); + + await api.patch({ ...SCOPE, set: [{ key: 'A', value: '1' }] }); + await api.patch({ ...SCOPE, unset: ['B'] }); + await api.patch({ ...SCOPE, set: [{ key: 'A', value: '1' }], unset: ['B'] }); + + expect(requests).toEqual([ + { method: 'PATCH', path: PATCH_PATH, orgUid: 'org1', projectUid: 'proj1', body: { set: [{ key: 'A', value: '1' }] } }, + { method: 'PATCH', path: PATCH_PATH, orgUid: 'org1', projectUid: 'proj1', body: { unset: ['B'] } }, + { + method: 'PATCH', + path: PATCH_PATH, + orgUid: 'org1', + projectUid: 'proj1', + body: { set: [{ key: 'A', value: '1' }], unset: ['B'] }, + }, + ]); + }); + + it.each([ + [['A'], 'A is not set in this environment.'], + [['A', 'B'], 'At least one of A, B is not set in this environment.'], + ])('words a 404 for a key that is not set as a usage error naming the keys asked for (%p)', async (unset, message) => { + const notSet = new LaunchApiError(404, [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_FOUND' }]); + const client = { request: async () => Promise.reject(notSet) } as unknown as RestApiClient; + + const error = await new VariablesApi(client).patch({ ...SCOPE, unset }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).message).toBe(message); + }); + + it.each([ + [400, 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.PATCH_EMPTY'], + [400, 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.CONFLICTING_KEYS'], + ])('says an unreachable patch rejection is an internal error (%p %s)', async (status, code) => { + const { client, messages } = fakeRestClient({}); + + await new VariablesApi(client).patch({ ...SCOPE, unset: ['A'] }); + + const rejection = new LaunchApiError(status, [{ code }], messages[0] as Record); + + expect(rejection.message).toBe('The CLI sent an update the Launch API could not accept. This is a bug in the CLI.'); + }); + + it('tells the user to run the command again when another writer changed the variables first', async () => { + const { client, messages } = fakeRestClient({}); + + await new VariablesApi(client).patch({ ...SCOPE, unset: ['A'] }); + + const conflict = new LaunchApiError( + 409, + [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.CONCURRENT_UPDATE' }], + messages[0] as Record, + ); + + expect(conflict.message).toBe( + "Another change to this environment's variables landed at the same moment, so nothing was changed. Run the command again.", + ); + }); + + it('rethrows any other failure of the patch untouched', async () => { + const failure = new LaunchApiError(500, [{ code: 'launch.ENVIRONMENT.UPDATE_FAILED' }]); + const client = { request: async () => Promise.reject(failure) } as unknown as RestApiClient; + + await expect(new VariablesApi(client).patch({ ...SCOPE, unset: ['A'] })).rejects.toBe(failure); + }); + + it('leaves a 404 for a key that is not set untouched when the patch had no keys to unset', async () => { + const notSet = new LaunchApiError(404, [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_FOUND' }]); + const client = { request: async () => Promise.reject(notSet) } as unknown as RestApiClient; + + await expect(new VariablesApi(client).patch({ ...SCOPE, set: [{ key: 'A', value: '1' }] })).rejects.toBe(notSet); + }); }); diff --git a/src/variables/variables.api.ts b/src/variables/variables.api.ts index 26508fe..a96d6c5 100644 --- a/src/variables/variables.api.ts +++ b/src/variables/variables.api.ts @@ -1,7 +1,12 @@ import { isRecord } from '../core/values'; +import { ENVIRONMENT_ERROR_MESSAGES } from '../environments/environment.errors'; +import type { EnvironmentParams } from '../environments/environments.api'; +import type { EnvironmentVariableInput } from '../environments/types'; import { malformed } from '../transport/envelope'; import type { ErrorMessages } from '../transport/errors'; +import { LaunchApiError } from '../transport/errors'; import type { RestApiClient } from '../transport/rest-client'; +import { someNotSetError } from './variable.errors'; export interface StackTemplateEntry { key: string; @@ -10,6 +15,13 @@ export interface StackTemplateEntry { const VARIABLE_ERROR_MESSAGES: ErrorMessages = {}; +const KEY_NOT_FOUND_CODE = 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_FOUND'; + +export interface PatchVariablesParams extends EnvironmentParams { + set?: EnvironmentVariableInput[]; + unset?: string[]; +} + function textOf(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value : undefined; } @@ -39,4 +51,23 @@ export class VariablesApi { return response.flatMap(entryOf); } + + async patch({ org, project, environment, set, unset }: PatchVariablesParams): Promise { + await this.client + .request( + { + method: 'PATCH', + path: `/projects/${project}/environments/${environment}/variables`, + orgUid: org, + projectUid: project, + body: { ...(set === undefined ? {} : { set }), ...(unset === undefined ? {} : { unset }) }, + }, + ENVIRONMENT_ERROR_MESSAGES, + ) + .catch((error: unknown) => { + throw error instanceof LaunchApiError && error.code === KEY_NOT_FOUND_CODE && unset?.length + ? someNotSetError(unset) + : error; + }); + } } diff --git a/test/integration/variables-set-unset.test.ts b/test/integration/variables-set-unset.test.ts index ffc5cba..133e6ea 100644 --- a/test/integration/variables-set-unset.test.ts +++ b/test/integration/variables-set-unset.test.ts @@ -38,7 +38,7 @@ const SCOPE = ['--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID]; interface Wire { order: string[]; - put: unknown[]; + patch: unknown[]; } function scopedHub() { @@ -48,33 +48,41 @@ function scopedHub() { .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; - }); - +function patchEndpoint(wire: Wire, status = 200, body: unknown = undefined) { return scopedHub() - .put(ENV_PATH, (body) => { - wire.order.push('put'); - wire.put.push(body); + .patch(`${ENV_PATH}/variables`, (sent) => { + wire.order.push('patch'); + wire.patch.push(sent); return true; }) - .reply(putStatus, () => { - if (putBody !== undefined) { - return putBody; + .reply(status, () => { + if (body !== undefined) { + return body; } - const [stored] = [getFixture.environment]; - const sent = wire.put[wire.put.length - 1] as { environmentVariables: unknown[] }; + const sent = wire.patch[wire.patch.length - 1] as { set?: { key: string }[] }; - return { environment: { ...stored, environmentVariables: sent.environmentVariables } }; + return { + environment: { + uid: ENV_UID, + updatedAt: getFixture.environment.updatedAt, + environmentVariables: (sent.set ?? []).map(({ key }) => ({ key })), + }, + }; }); } +function unsetEndpoints(wire: Wire, status = 200, body: unknown = undefined) { + scopedHub() + .get(ENV_PATH) + .reply(200, () => { + wire.order.push('get'); + return getFixture; + }); + + return patchEndpoint(wire, status, body); +} + function cmsEndpoint(sent: Wire) { return nock(LAUNCH_HUB_URL) .matchHeader('x-organization-uid', ORG_UID) @@ -86,7 +94,7 @@ function cmsEndpoint(sent: Wire) { } function wire(): Wire { - return { order: [], put: [] }; + return { order: [], patch: [] }; } describe('integration: launch:variables:set and :unset driven through oclif runCommand', () => { @@ -115,9 +123,9 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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 () => { + it('patches only the variables it was given, without reading the environment first', async () => { const sent = wire(); - const scope = environmentEndpoints(sent); + const scope = patchEndpoint(sent); const { error, stdout } = await runCommand( ['launch:variables:set', ...SCOPE, '--var', 'API_URL=https://example.test', 'DEBUG=true', '--data-dir', workDir], @@ -126,14 +134,11 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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(sent.order).toEqual(['patch']); + expect(sent.patch).toEqual([ + { set: [{ key: 'API_URL', value: 'https://example.test' }, + { key: 'DEBUG', value: 'true' }, + ], }, ]); expect(stdout).toContain('\u2714 2 variables set \u2014 2 from --var'); @@ -143,17 +148,17 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('keeps every other variable when a single key is set and replaces a key that already exists', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(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' }] }]); + expect(sent.patch).toEqual([{ set: [{ 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); + patchEndpoint(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], @@ -161,32 +166,29 @@ describe('integration: launch:variables:set and :unset driven through oclif runC ); expect(error).toBeUndefined(); - expect(sent.put).toEqual([ - { environmentVariables: [...getFixture.environment.environmentVariables, { key: 'A', value: '1' }, { key: 'B', value: '2' }] }, + expect(sent.patch).toEqual([ + { set: [{ 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); + patchEndpoint(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' }, - ], + expect(sent.patch).toEqual([ + { set: [{ 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); + patchEndpoint(sent); const file = join(workDir, 'staging.env'); writeFileSync(file, 'API_URL=from-file\nFROM_FILE=1\n'); @@ -196,13 +198,10 @@ describe('integration: launch:variables:set and :unset driven through oclif runC ); expect(error).toBeUndefined(); - expect(sent.put).toEqual([ - { - environmentVariables: [ - ...getFixture.environment.environmentVariables, - { key: 'API_URL', value: 'typed' }, - { key: 'FROM_FILE', value: '1' }, - ], + expect(sent.patch).toEqual([ + { set: [{ 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`); @@ -210,7 +209,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('auto-detects .env.local under --data-dir with no source flag and names the file it chose', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); writeFileSync(join(workDir, '.env.local'), 'LOCAL=1\n'); writeFileSync(join(workDir, '.env'), 'OTHER=2\n'); @@ -218,12 +217,12 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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' }] }]); + expect(sent.patch).toEqual([{ set: [{ 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 scope = patchEndpoint(sent); const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--data-dir', workDir], config); @@ -235,7 +234,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('exits 0 with no changes made and sends no request when the file holds nothing', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); const file = join(workDir, 'empty.env'); writeFileSync(file, '# nothing\n'); @@ -248,7 +247,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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 scope = patchEndpoint(sent); const prompts = answerPrompts({ 'Add variables from': ['Type KEY=VALUE pairs'], 'KEY=VALUE pairs separated by spaces': 'API_URL=typed-value DEBUG=true', @@ -267,13 +266,10 @@ describe('integration: launch:variables:set and :unset driven through oclif runC '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(sent.patch).toEqual([ + { set: [{ key: 'API_URL', value: 'typed-value' }, + { key: 'DEBUG', value: 'true' }, + ], }, ]); expect(stdout).toContain('2 variables set \u2014 2 from --var'); @@ -282,7 +278,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('names the detected dotenv file in the checklist and reads the path it is given', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); writeFileSync(join(workDir, '.env.local'), 'FROM_FILE=1\n'); const prompts = answerPrompts({ 'Add variables from': ['Import variables from a dotenv file'], @@ -297,14 +293,14 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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' }] }, + expect(sent.patch).toEqual([ + { set: [{ 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); + const scope = patchEndpoint(sent); answerPrompts({ 'Add variables from': [] }); const { error, stdout } = await onTerminal(() => @@ -319,7 +315,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('does not ask on a terminal when a source flag was given', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); const prompts = answerPrompts({}); const { error } = await onTerminal(() => @@ -332,7 +328,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('exits 2 for two bare keys without a terminal, naming --dotenv -, and sends no request', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'A', 'B', '--data-dir', workDir], config); @@ -343,7 +339,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('asks a hidden prompt for a bare key on a terminal and never prints what was typed', async () => { const sent = wire(); - environmentEndpoints(sent); + patchEndpoint(sent); const prompts = answerPrompts({ 'Value for API_KEY': 'never-printed-typed-value' }); const { error, stdout } = await onTerminal(() => @@ -353,15 +349,15 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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(sent.patch).toEqual([ + { set: [{ 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); + patchEndpoint(sent); const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'KEY=', '--data-dir', workDir], config); @@ -370,9 +366,9 @@ describe('integration: launch:variables:set and :unset driven through oclif runC 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 () => { + it('exits 1 with the CLI wording and echoes neither the request body nor any value when the PATCH is refused', async () => { const sent = wire(); - environmentEndpoints(sent, 400, { + patchEndpoint(sent, 400, { errors: [ { code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.BYTE_SIZE_LIMIT_REACHED', @@ -395,22 +391,66 @@ describe('integration: launch:variables:set and :unset driven through oclif runC expect(stdout).not.toContain('variables set'); }); - it('removes only the named key with --yes, sending the rest as the complete array', async () => { + it('exits 1 telling the user to run set again when another writer changed the variables first', async () => { const sent = wire(); - environmentEndpoints(sent, 200, undefined, 2); + patchEndpoint(sent, 409, { + errors: [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.CONCURRENT_UPDATE' }], + status: 409, + }); + + const { error, stdout } = await runCommand(['launch:variables:set', ...SCOPE, '--var', 'A=1', '--data-dir', workDir], config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe( + "Another change to this environment's variables landed at the same moment, so nothing was changed. Run the command again.", + ); + expect(stdout).not.toContain('variables set'); + }); + + it('exits 1 with the same advice when unset loses the race, and does not report the key as removed', async () => { + const sent = wire(); + unsetEndpoints(sent, 409, { + errors: [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.CONCURRENT_UPDATE' }], + status: 409, + }); + + const { error, stdout } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1', '--yes'], config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toContain('Run the command again.'); + expect(stdout).not.toContain('removed'); + }); + + it('exits 2 with the not-set wording when the key is gone by the time the PATCH lands', async () => { + const sent = wire(); + unsetEndpoints(sent, 404, { + errors: [{ code: 'launch.ENVIRONMENT.ENVIRONMENT_VARIABLES.KEY.NOT_FOUND' }], + status: 404, + }); + + const { error, stdout } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1', '--yes'], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('key1 is not set in this environment.'); + expect(stdout).not.toContain('removed'); + }); + + it('removes only the named key with --yes, patching out only that key', async () => { + const sent = wire(); + unsetEndpoints(sent); 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(sent.order).toEqual(['get', 'patch']); + expect(sent.patch).toEqual([{ unset: ['key1'] }]); 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 () => { + it('exits 2 for a key that is not set and sends no PATCH', async () => { const sent = wire(); - environmentEndpoints(sent); + unsetEndpoints(sent); const { error } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'NOPE', '--yes'], config); @@ -419,9 +459,9 @@ describe('integration: launch:variables:set and :unset driven through oclif runC expect(sent.order).toEqual(['get']); }); - it('exits 2 naming --yes without a terminal and sends no PUT', async () => { + it('exits 2 naming --yes without a terminal and sends no PATCH', async () => { const sent = wire(); - environmentEndpoints(sent); + unsetEndpoints(sent); const { error } = await runCommand(['launch:variables:unset', ...SCOPE, '--key', 'key1'], config); @@ -430,9 +470,9 @@ describe('integration: launch:variables:set and :unset driven through oclif runC expect(sent.order).toEqual(['get']); }); - it('exits 3 and sends no PUT when the user declines', async () => { + it('exits 3 and sends no PATCH when the user declines', async () => { const sent = wire(); - environmentEndpoints(sent); + unsetEndpoints(sent); answerPrompts({ 'Are you sure you want to remove key1 from "production"?': false }); const { error, stdout } = await onTerminal(() => @@ -455,10 +495,10 @@ describe('integration: launch:variables:set and :unset driven through oclif runC delete CONFIG.tokens; }); - it('imports through the saved alias before it reads the environment, then puts the complete merged array', async () => { + it('imports through the saved alias, then patches the stack variables in', async () => { const sent = wire(); cmsEndpoint(sent); - environmentEndpoints(sent); + patchEndpoint(sent); const { error, stdout } = await runCommand( ['launch:variables:set', ...SCOPE, '--from-stack', '-a', 'my-prod', '--data-dir', workDir], @@ -466,17 +506,14 @@ describe('integration: launch:variables:set and :unset driven through oclif runC ); 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(sent.order).toEqual(['cms', 'patch']); + expect(sent.patch).toEqual([ + { set: [{ 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).'); @@ -487,7 +524,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('lets a typed --var override a stack value and names the override', async () => { const sent = wire(); cmsEndpoint(sent); - environmentEndpoints(sent); + patchEndpoint(sent); const { error, stdout } = await runCommand( ['launch:variables:set', ...SCOPE, '--from-stack', '--alias', 'my-prod', '--var', 'CONTENTSTACK_ENVIRONMENT=production', '--data-dir', workDir], @@ -495,7 +532,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC ); expect(error).toBeUndefined(); - expect((sent.put[0] as { environmentVariables: { key: string; value: string }[] }).environmentVariables.at(-1)).toEqual({ + expect((sent.patch[0] as { set: { key: string; value: string }[] }).set.at(-1)).toEqual({ key: 'CONTENTSTACK_ENVIRONMENT', value: 'production', }); @@ -505,7 +542,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('exits 2 naming --alias when there is no terminal and no alias, sending no request', async () => { const sent = wire(); cmsEndpoint(sent); - environmentEndpoints(sent); + patchEndpoint(sent); const { error } = await runCommand(['launch:variables:set', ...SCOPE, '--from-stack', '--data-dir', workDir], config); @@ -517,7 +554,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('exits 2 for an alias nothing is saved under, sending no request', async () => { const sent = wire(); cmsEndpoint(sent); - environmentEndpoints(sent); + patchEndpoint(sent); const { error } = await runCommand( ['launch:variables:set', ...SCOPE, '--from-stack', '--alias', 'gone', '--data-dir', workDir], @@ -532,7 +569,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC it('exits 2 when --alias is given without --from-stack, sending no request', async () => { const sent = wire(); cmsEndpoint(sent); - environmentEndpoints(sent); + patchEndpoint(sent); const { error } = await runCommand( ['launch:variables:set', ...SCOPE, '--alias', 'my-prod', '--var', 'A=1', '--data-dir', workDir],