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],