diff --git a/.talismanrc b/.talismanrc index 918dfb0f..f2f60ddc 100644 --- a/.talismanrc +++ b/.talismanrc @@ -10,7 +10,7 @@ fileignoreconfig: - filename: src/transport/rest-client.test.ts checksum: 3b5751449c61f7a1d71b978ed03b9472f714e12dd1679f691bbd4dd8f84be923 - filename: src/transport/cma-client.ts - checksum: 0e144de22c757e8d3efd31c892d6de56796a49ce2c18d0e41ba16eff8dc3d24e + checksum: 87b8399a9b84ea60fe322802c2e5270772858ba85ac33c436941fe7cf40afc38 - filename: src/transport/auth-strategy.test.ts checksum: a4f3e3ea7db10dbf82abbac0207d2231ee740916f476fcda836a2268505295ef - filename: src/projects/types.ts @@ -87,12 +87,7 @@ fileignoreconfig: - "'SECRET=hunter2'" - "formFieldKey" - filename: test/integration/projects-create.test.ts - allowed_patterns: - - "mockImplementation\\(\\(key: string\\) => CONFIG\\[key\\]\\)" - - "formFieldKey" - - "key: 'x-ms-blob-type', value: 'BlockBlob'" - - "key: 'Content-Type', value: 'application/zip'" - checksum: 7e6d6b5ff8189374ff15a93994764c742ca250b80c7c2d1a84cff0f0785b05c6 + checksum: 6985ba0adee092fcf3dd4a432614298839deba0bef2867bc3bc8d6747946dc66 - filename: test/fixtures/README.md checksum: d801c84c8b7d7f38f967573e9a3a70da4405b7cbcbf10d2a6e5be3d65fb3daa1 allowed_patterns: @@ -233,7 +228,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: 4766a4bf28eab95d90878b825979cef70c83a79a24fb9450a52d617ce0933f78 + checksum: d46db51dcf83e6db92f5588c1649f0ba76599ccb9a65aca540b19dc0a71208a4 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\\)`" @@ -259,7 +254,7 @@ fileignoreconfig: - filename: test/guards/layering.test.ts checksum: 6740eea4e84f088081e31ac0c12c0abb2c402dcebbe0eb0ef454f60873da0194 - filename: src/commands/launch/projects/create.test.ts - checksum: 2c407c366f2a1da2647fc6eb7e6ad1bb8cbe3c7f901090432ab0e27df2e5d4d6 + checksum: 9c40b4b6ed4f7d678ecb921a71f5af7284cd685c1337f47144456ae43a38b1ad - filename: src/commands/launch/projects/delete.test.ts checksum: d67d8a804d14f9b809153d10b0cf9d5e52b70f9e0b65678ebb1663a7d2da22c1 - filename: test/integration/projects-delete.test.ts @@ -303,13 +298,13 @@ fileignoreconfig: - filename: src/functions/function.logger.test.ts checksum: 840b2b2b7f938e0862881871e8d568112631c58e12cf6c8a5be32e7552b8cf9e - filename: src/transport/cma-client.test.ts - checksum: 471361febb8e08a50af8cf7e8f81824adc7a31af6231d78baf0e125823f43686 + checksum: 85d62ef350b450cb09f1a840db79bdfb212d4f5fd650789d60d722915a892a59 - filename: src/core/catalog.test.ts checksum: bf8c203a8efc40a75f5407ceb29a639ea9a6e1fa081d02260def2d7e0fab958a - filename: test/credential-guard.setup.ts checksum: 519ccbeb0552fdb1d5f27308c56477c0258ee6cc0adec8f618e8e5bc047cbf6d - filename: src/environments/types.ts - checksum: 9c2387b8bd819af79b256f8638d54a33c41bccc1ab04f02dfd1cda48c035e6e7 + checksum: 8f57348764a69aeb7c2351faf3a5c80fda483a1a1f009e126ff71a9679d92457 - filename: src/commands/launch/env/get.test.ts checksum: 6e739ef12bc0d887e652e23ee54d9fac30dd6a3505cf464cdaefc0e696cf3e51 - filename: test/integration/environments-delete.test.ts @@ -329,13 +324,13 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: c870bc0f5e1b0c43f8320d2eb9807241353bfc8fab0e1339becc28183067b06d + checksum: b956e3b147ebb3513472f5f28f47f7895d86201d786f1ed80b10c1fd785267e7 allowed_patterns: - "password-protection" - "-p, --password" - "-u, --username" - filename: test/integration/environments-create.test.ts - checksum: 09269b51e499870a04bccfa5e0f2febd287f5246f176c258347c884240678289 + checksum: 898d30a1a16339ffd0e495eb1b22ea7c08b947c9012ad17a3dc61c905b07a091 - filename: src/commands/launch/variables/list.test.ts checksum: 87ccb762f4931326fc50bca1c01aeb4a16d552e4dfa141d00370fdfafa4363be - filename: src/commands/launch/variables/set.test.ts @@ -347,13 +342,13 @@ fileignoreconfig: - filename: src/environments/environment.errors.test.ts checksum: 33ae71db0a46173c903659438a77d1d441797a8296e0591dbaa48512f32a4cab - filename: src/stacks/stacks.api.test.ts - checksum: 67e00bd82b3c3a96c05585f36a3c3ace7f06eff535bc347fe9937033775cf5cc + checksum: c35b1dcb6560b9f525f3429b91b8ebd2c9c1b8bb654b125d57939351b981d470 - filename: src/stacks/stacks.api.ts - checksum: 7241b9c2c6e946c11849bb5fb21842c03eca09f9ed6c901be198fd544ce8f769 + checksum: d59622ca6411b93d5469a1895b08ccde4b9cd83fbb909c82076ea70d74b5e1e4 - filename: src/variables/variable.chooser.ts checksum: 15a6b8a1a974c8d0396ac18d8c9d39c8bc9f70efbf41ce0eef79180ce16b2f04 - filename: src/variables/variable.create.test.ts - checksum: 71dbed4ca09e86e761026955c7865f12e4f168e3afcf4c4ef664d3de319a2697 + checksum: 908f39b56943d8bc343b93057f83cff1b718ecdc0bcbff5ed9b759c010b4029d - filename: src/variables/variable.dotenv.test.ts checksum: c3d5e33189736bb42b7c4fac7375cf0307681b34feca4830b49331f55a130f78 - filename: src/variables/variable.dotenv.ts @@ -363,9 +358,9 @@ fileignoreconfig: - filename: src/variables/variable.files.test.ts checksum: d8b0efd6bb448c4d501ba52c47119b352ef29d6b5b66cec0e7d373a0ce91a36c - filename: src/variables/variable.inputs.test.ts - checksum: 4b3b9a6c353d099a65ef8f522cff8e46e5c0edbf66bcdb59e0d6dd4f87c78c84 + checksum: c434fd7f32ab0bd17db8f214ba54861b7d8ae8d8481dbbc1978f9dab35fd2429 - filename: src/variables/variable.inputs.ts - checksum: 0f572a86e0c0edbe707882df623a3a7f69715572812ae655eb6aa5e4579b496b + checksum: 050b93e158a1ff29f309f1531b085aa9f0327b47a8029038356869b2864531b2 - filename: src/variables/variable.merge.test.ts checksum: 581a5cc5d9d23223643bd461525c6755205ebf16f33ae7388c8e88de19461fbf - filename: src/variables/variable.merge.ts diff --git a/AGENTS.md b/AGENTS.md index 339578d7..cfe4acf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,7 +351,10 @@ than return a truncated list. `projects:create` and `env:create` take `--var` and `--dotenv` and reach the same resolver through `EnvironmentBuilder`, which asks last - after Contentstack Authentication, where V1 asked - with no auto-detection. With neither flag it offers a checkbox over the sources plus `Skip adding environment variables`, which cannot be -combined with the others; without a terminal it sends an empty list. +combined with the others; without a terminal it sends an empty list. `--no-vars` is the flag form of that Skip +choice, so a fully flagged create on a terminal asks nothing: it sends an empty list without the checkbox. Combined +with `--var`, `--dotenv` or `--from-stack` it is a usage error from the `noVarsAlone` rule rather than oclif's +`exclusive`, whose `--help` usage line would show those three sources excluding each other. **`--from-stack` and `--alias`.** `--from-stack` is a bare flag on `variables:set`, `projects:create` and `env:create`; `--alias ` (`-a`) names a delivery token saved with `csdx auth:tokens:add`, and is a usage error @@ -360,7 +363,10 @@ without `--from-stack` (`aliasRequiresFromStack`). With an alias the token is re a terminal gets a stack picker then a delivery-token picker (names only); without a terminal it is a usage error naming `--alias`. The five variables come from `GET /cms/environment-variables` (API host and CDN, scheme stripped) plus the stack's API key, the token and its environment. `src/stacks/` owns the Contentstack stack lookups through -`CmaSession.fetchStacks` / `fetchDeliveryTokens`, which page 100 at a time; `variables` may import `stacks` and +`CmaSession.fetchStacks` / `fetchDeliveryTokens`, which page 100 at a time. `fetchStacks` scopes by organization on the +stack handle (`stack({ organization_uid })`), which the SDK sends as the `organization_uid` header and an `org_uid` +query filter (inside `query()` it would be a plain query parameter), and `StacksApi.list` also drops +any stack whose `org_uid` names another organization; `variables` may import `stacks` and `environments` and nothing else (`RESOURCE_EDGES`). A delivery token value is never printed or logged. Two harness traps. `@oclif/test`'s `runCommand` joins argv with spaces and splits it again, so a value containing a diff --git a/README.md b/README.md index 9aa41bda..116d6fee 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ USAGE $ csdx launch:env:create [-c ] [-d ] [--org ] [--project ] [--name ] [--description ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd ] [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] [--var ...] [--dotenv - ...] [--from-stack] [-a ] + ...] [--from-stack] [-a ] [--no-vars] FLAGS -a, --alias= Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a @@ -140,6 +140,7 @@ FLAGS --from-stack Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a terminal --name= Environment name (200 characters or fewer) + --no-vars Create the environment with no variables, without asking for them on a terminal --org= Organization UID --output-dir= Output directory --project= Project name or UID @@ -378,7 +379,7 @@ USAGE $ csdx launch:projects:create [-c ] [-d ] [--org ] [--type ] [--name ] [--description ] [--env-name ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd ] [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] [--var ...] [--dotenv - ...] [--from-stack] [-a ] + ...] [--from-stack] [-a ] [--no-vars] FLAGS -a, --alias= Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a @@ -397,6 +398,7 @@ FLAGS --from-stack Import the variables of a Contentstack stack. Asks for the stack and a delivery token on a terminal --name= Project name (200 characters or fewer) + --no-vars Create the environment with no variables, without asking for them on a terminal --org= Organization UID --output-dir= Output directory --res-mode= Response mode (buffered | streaming) diff --git a/src/commands/launch/env/create.test.ts b/src/commands/launch/env/create.test.ts index 95fcc68b..55ce6aa0 100644 --- a/src/commands/launch/env/create.test.ts +++ b/src/commands/launch/env/create.test.ts @@ -7,7 +7,7 @@ import { catalog, resolution } from '../../../resources'; import { DEPLOYMENT_WAIT_TIMEOUT_MS } from '../../../deployments/deployment.watcher'; import { EnvironmentCreator } from '../../../environments/environment.create'; import { serverCommandFrameworkGate } from '../../../environments/environment.inputs'; -import { aliasRequiresFromStack } from '../../../variables/variable.rules'; +import { aliasRequiresFromStack, noVarsAlone } from '../../../variables/variable.rules'; import EnvironmentsCreate from './create'; function commandUnderTest(resolved: Record) { @@ -102,9 +102,10 @@ describe('launch:env:create', () => { dotenv: {}, 'from-stack': {}, alias: {}, + 'no-vars': {}, }); expect(Object.keys(EnvironmentsCreate.flags).sort()).toEqual(Object.keys(EnvironmentsCreate.inputs).sort()); - expect(EnvironmentsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack]); + expect(EnvironmentsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack, noVarsAlone]); }); it('describes --name and --description as the environment its own, leaving the project wording alone', () => { diff --git a/src/commands/launch/env/create.ts b/src/commands/launch/env/create.ts index 1c8e9bed..441c0072 100644 --- a/src/commands/launch/env/create.ts +++ b/src/commands/launch/env/create.ts @@ -3,7 +3,7 @@ import { flagsFor, inputs } from '../../../core/inputs'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; import { EnvironmentCreator } from '../../../environments/environment.create'; import { serverCommandFrameworkGate, withEnvironmentWording } from '../../../environments/environment.inputs'; -import { aliasRequiresFromStack } from '../../../variables/variable.rules'; +import { aliasRequiresFromStack, noVarsAlone } from '../../../variables/variable.rules'; const createInputs = inputs({ org: { required: true }, @@ -22,6 +22,7 @@ const createInputs = inputs({ dotenv: {}, 'from-stack': {}, alias: {}, + 'no-vars': {}, }); export default class EnvironmentsCreate extends LaunchCommand { @@ -38,7 +39,7 @@ export default class EnvironmentsCreate extends LaunchCommand { const resolved = this.resolved; @@ -62,6 +63,7 @@ export default class EnvironmentsCreate extends LaunchCommand; @@ -101,7 +101,7 @@ describe('launch:projects:create', () => { }); it('declares only the framework gate as a rule, leaving GitHub-only flags on FileUpload to a confirmation', () => { - expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack]); + expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate, aliasRequiresFromStack, noVarsAlone]); }); it('declares every flag it reads and requires only the organization', () => { @@ -123,6 +123,7 @@ describe('launch:projects:create', () => { 'dotenv', 'from-stack', 'alias', + 'no-vars', ]); expect(Object.keys(ProjectsCreate.flags).sort()).toEqual(Object.keys(ProjectsCreate.inputs).sort()); expect( diff --git a/src/commands/launch/projects/create.ts b/src/commands/launch/projects/create.ts index e4d3613b..47408413 100644 --- a/src/commands/launch/projects/create.ts +++ b/src/commands/launch/projects/create.ts @@ -3,7 +3,7 @@ import { flagsFor, inputs } from '../../../core/inputs'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; import { serverCommandFrameworkGate } from '../../../environments/environment.inputs'; import { ProjectCreator } from '../../../projects/project.create'; -import { aliasRequiresFromStack } from '../../../variables/variable.rules'; +import { aliasRequiresFromStack, noVarsAlone } from '../../../variables/variable.rules'; const createInputs = inputs({ org: { required: true }, @@ -23,6 +23,7 @@ const createInputs = inputs({ dotenv: {}, 'from-stack': {}, alias: {}, + 'no-vars': {}, }); export default class ProjectsCreate extends LaunchCommand { @@ -41,7 +42,7 @@ export default class ProjectsCreate extends LaunchCommand { static flags = flagsFor(createInputs); - static rules = [serverCommandFrameworkGate, aliasRequiresFromStack]; + static rules = [serverCommandFrameworkGate, aliasRequiresFromStack, noVarsAlone]; async run(): Promise { const resolved = this.resolved; @@ -66,6 +67,7 @@ export default class ProjectsCreate extends LaunchCommand { envFiles: resolved['dotenv'], fromStack: resolved['from-stack'], alias: resolved.alias, + noVars: resolved['no-vars'], }); } } diff --git a/src/environments/environment.builder.ts b/src/environments/environment.builder.ts index 5372fe3a..46f72bf2 100644 --- a/src/environments/environment.builder.ts +++ b/src/environments/environment.builder.ts @@ -64,6 +64,7 @@ export class EnvironmentBuilder { envFiles: request.envFiles, fromStack: request.fromStack, alias: request.alias, + noVars: request.noVars, dataDir: request.dataDir ?? '.', }, this.services, diff --git a/src/environments/types.ts b/src/environments/types.ts index aa348e5d..e3d180fe 100644 --- a/src/environments/types.ts +++ b/src/environments/types.ts @@ -101,6 +101,7 @@ export interface EnvironmentRequest { envFiles?: string[]; fromStack?: boolean; alias?: string; + noVars?: boolean; framework?: FrameworkPreset; buildCmd?: string; outputDir?: string; diff --git a/src/stacks/stacks.api.test.ts b/src/stacks/stacks.api.test.ts index 88de2537..6d324e7f 100644 --- a/src/stacks/stacks.api.test.ts +++ b/src/stacks/stacks.api.test.ts @@ -45,6 +45,24 @@ describe('stacks api', () => { expect(asked.stacks).toEqual(['org7']); }); + it('leaves out a stack the Management API says belongs to another organization', async () => { + const { api } = session( + { + items: [ + { name: 'Shop', api_key: 'blt1', org_uid: 'org7' }, + { name: 'Elsewhere', api_key: 'blt2', org_uid: 'org9' }, + { name: 'Unmarked', api_key: 'blt3' }, + ], + }, + {}, + ); + + await expect(api.list('org7')).resolves.toEqual([ + { name: 'Shop', apiKey: 'blt1' }, + { name: 'Unmarked', apiKey: 'blt3' }, + ]); + }); + it('names a stack by its api key when it has no name and skips an entry with no api key', async () => { const { api } = session( { items: [{ api_key: 'blt1' }, { name: 'Nameless key' }, { name: 'Empty', api_key: ' ' }, null, 'text', { name: ' ', api_key: 'blt3' }] }, diff --git a/src/stacks/stacks.api.ts b/src/stacks/stacks.api.ts index bf34a2c4..43a1ad8b 100644 --- a/src/stacks/stacks.api.ts +++ b/src/stacks/stacks.api.ts @@ -29,8 +29,12 @@ function failureReason(error: unknown): string { return textOf(error) ?? 'the Contentstack Management API gave no reason'; } -function stackOf(value: unknown): Stack[] { - if (!isRecord(value)) { +function ofAnotherOrganization(value: Record, organizationUid: string): boolean { + return textOf(value.org_uid) !== undefined && value.org_uid !== organizationUid; +} + +function stackOf(value: unknown, organizationUid: string): Stack[] { + if (!isRecord(value) || ofAnotherOrganization(value, organizationUid)) { return []; } @@ -78,7 +82,7 @@ export class StacksApi { try { const { items } = await this.cma.fetchStacks(organizationUid); - return (items ?? []).flatMap(stackOf); + return (items ?? []).flatMap((item) => stackOf(item, organizationUid)); } catch (error) { throw new StackLookupError('the stacks of this organization', failureReason(error)); } diff --git a/src/transport/cma-client.test.ts b/src/transport/cma-client.test.ts index 2c22e043..cf1a3439 100644 --- a/src/transport/cma-client.test.ts +++ b/src/transport/cma-client.test.ts @@ -125,7 +125,7 @@ describe('createCmaSession', () => { expect(createCmaSession({ analyticsInfo: 'cli/2.0.0' }).scopedOrganizationUid()).toBeUndefined(); }); - it('lists the stacks of one organization through the SDK', async () => { + it('lists the stacks of one organization through the SDK, scoped by the organization on the stack handle', async () => { const recorded: { stack: unknown[][]; query: unknown[] } = { stack: [], query: [] }; const client = { stack: (...args: unknown[]) => { @@ -144,10 +144,10 @@ describe('createCmaSession', () => { await expect(session.fetchStacks('org7')).resolves.toEqual({ items: [{ name: 'Shop', api_key: 'blt1' }] }); - expect(recorded.stack).toEqual([[], []]); + expect(recorded.stack).toEqual([[{ organization_uid: 'org7' }], [{ organization_uid: 'org7' }]]); expect(recorded.query).toEqual([ - { organization_uid: 'org7', limit: 100, skip: 0 }, - { organization_uid: 'org7', limit: 100, skip: 1 }, + { limit: 100, skip: 0 }, + { limit: 100, skip: 1 }, ]); }); @@ -203,9 +203,9 @@ describe('createCmaSession', () => { expect(items).toHaveLength(51); expect(asked).toEqual([ - { organization_uid: 'org7', limit: 100, skip: 0 }, - { organization_uid: 'org7', limit: 100, skip: 50 }, - { organization_uid: 'org7', limit: 100, skip: 51 }, + { limit: 100, skip: 0 }, + { limit: 100, skip: 50 }, + { limit: 100, skip: 51 }, ]); }); diff --git a/src/transport/cma-client.ts b/src/transport/cma-client.ts index 50d418c7..d5c30d88 100644 --- a/src/transport/cma-client.ts +++ b/src/transport/cma-client.ts @@ -85,7 +85,7 @@ export function createCmaSession(options: CmaSessionOptions): CmaSession { fetchStacks: (organizationUid) => allPages( async (skip) => - (await open()).stack().query({ organization_uid: organizationUid, limit: PAGE_SIZE, skip }).find(), + (await open()).stack({ organization_uid: organizationUid }).query({ limit: PAGE_SIZE, skip }).find(), 'stacks', ), fetchDeliveryTokens: (apiKey) => diff --git a/src/variables/variable.create.test.ts b/src/variables/variable.create.test.ts index a9d59d89..5df42720 100644 --- a/src/variables/variable.create.test.ts +++ b/src/variables/variable.create.test.ts @@ -96,6 +96,15 @@ describe('variable create', () => { expect(printed).toEqual([]); }); + it('returns nothing on a terminal with --no-vars, asking nothing, reading nothing and printing nothing', async () => { + const { services, io, asked, reads, printed } = fakes({ files: { '/work/.env': 'X=1' } }); + + expect(await variablesForCreate({ ...NONE, noVars: true }, services, io)).toEqual([]); + expect(asked).toEqual([]); + expect(reads).toEqual([]); + expect(printed).toEqual([]); + }); + it('treats empty flag lists as no flags', async () => { const { services, io, asked } = fakes({ answers: { 'Add environment variables': [SKIP] } }); diff --git a/src/variables/variable.create.ts b/src/variables/variable.create.ts index 537322d0..73eb759e 100644 --- a/src/variables/variable.create.ts +++ b/src/variables/variable.create.ts @@ -15,6 +15,7 @@ export interface CreateVariablesRequest { org: string; fromStack?: boolean; alias?: string; + noVars?: boolean; } const CHOOSE_MESSAGE = 'Add environment variables'; @@ -45,6 +46,10 @@ export async function variablesForCreate( io: VariableIo = DEFAULT_VARIABLE_IO, savedToken: (alias: string) => SavedToken | undefined = readSavedToken, ): Promise { + if (request.noVars === true) { + return []; + } + const stack: StackImporter = (alias) => importFromStack({ org: request.org, alias }, { services, savedToken }); if (given(request.vars) || given(request.envFiles) || request.fromStack === true) { diff --git a/src/variables/variable.inputs.test.ts b/src/variables/variable.inputs.test.ts index a1edce97..7ecfa917 100644 --- a/src/variables/variable.inputs.test.ts +++ b/src/variables/variable.inputs.test.ts @@ -5,14 +5,14 @@ import { variableFlags, variableResolution } from './variable.inputs'; describe('variable inputs', () => { it('names each flag as the Commands Details "All flags" tables name it', () => { - expect(Object.keys(variableFlags)).toEqual(['var', 'dotenv', 'key', 'from-stack', 'alias']); + expect(Object.keys(variableFlags)).toEqual(['var', 'dotenv', 'key', 'from-stack', 'alias', 'no-vars']); }); it.each(['var', 'dotenv', 'key'] as const)('declares %s repeatable so each occurrence and each value is kept', (name) => { expect(variableFlags[name].multiple).toBe(true); }); - it.each(['var', 'dotenv', 'key', 'from-stack', 'alias'] as const)('never marks %s required at parse time', (name) => { + it.each(['var', 'dotenv', 'key', 'from-stack', 'alias', 'no-vars'] as const)('never marks %s required at parse time', (name) => { expect(variableFlags[name].required).toBeFalsy(); }); @@ -40,6 +40,17 @@ describe('variable inputs', () => { expect(parsed.flags.alias).toBe('my-prod'); }); + it('parses --no-vars as a presence-only flag', async () => { + const parsed = await Parser.parse(['--no-vars'], { flags: { 'no-vars': variableFlags['no-vars'] } }); + + expect(variableFlags['no-vars'].type).toBe('boolean'); + expect(parsed.flags['no-vars']).toBe(true); + }); + + it('leaves the sources out of an oclif exclusive group, since --help would show them excluding each other', () => { + expect(variableFlags['no-vars'].exclusive).toBeUndefined(); + }); + it('describes --var without showing a value that looks like a secret', () => { expect(variableFlags.var.description).toContain('KEY=VALUE'); expect(variableFlags.var.description).not.toMatch(/sk_|ghp_|password/i); diff --git a/src/variables/variable.inputs.ts b/src/variables/variable.inputs.ts index 9fdf8e3b..8631914d 100644 --- a/src/variables/variable.inputs.ts +++ b/src/variables/variable.inputs.ts @@ -21,6 +21,9 @@ export const variableFlags = { char: 'a', description: 'Alias of a delivery token saved with csdx auth:tokens:add, used with --from-stack without a prompt', }), + 'no-vars': Flags.boolean({ + description: 'Create the environment with no variables, without asking for them on a terminal', + }), }; export const variableResolution = { @@ -29,4 +32,5 @@ export const variableResolution = { key: {} satisfies ResolutionSpec, 'from-stack': {} satisfies ResolutionSpec, alias: {} satisfies ResolutionSpec, + 'no-vars': {} satisfies ResolutionSpec, }; diff --git a/src/variables/variable.rules.test.ts b/src/variables/variable.rules.test.ts index 61fda24e..6712f1e1 100644 --- a/src/variables/variable.rules.test.ts +++ b/src/variables/variable.rules.test.ts @@ -1,5 +1,5 @@ import { UsageError } from '../core/errors'; -import { aliasRequiresFromStack } from './variable.rules'; +import { aliasRequiresFromStack, noVarsAlone } from './variable.rules'; describe('variable rules', () => { it('refuses --alias without --from-stack, naming both', () => { @@ -27,4 +27,21 @@ describe('variable rules', () => { it('accepts --from-stack on its own', () => { expect(() => aliasRequiresFromStack({ 'from-stack': true }, {})).not.toThrow(); }); + + it.each([[{ var: ['A=1'] }], [{ dotenv: ['a.env'] }], [{ 'from-stack': true }]])( + 'refuses --no-vars with a variable source (%p)', + (source) => { + expect(() => noVarsAlone({ 'no-vars': true, ...source }, {})).toThrow(UsageError); + expect(() => noVarsAlone({ 'no-vars': true, ...source }, {})).toThrow( + '--no-vars cannot be combined with --var, --dotenv or --from-stack.', + ); + }, + ); + + it.each([[{ 'no-vars': true }], [{ 'no-vars': false, var: ['A=1'] }], [{ var: [], dotenv: [], 'from-stack': false, 'no-vars': true }]])( + 'accepts --no-vars alone and the sources without it (%p)', + (resolved) => { + expect(() => noVarsAlone(resolved, {})).not.toThrow(); + }, + ); }); diff --git a/src/variables/variable.rules.ts b/src/variables/variable.rules.ts index 4924027c..a8d11796 100644 --- a/src/variables/variable.rules.ts +++ b/src/variables/variable.rules.ts @@ -7,3 +7,13 @@ export const aliasRequiresFromStack: Rule = (resolved) => { throw new UsageError('--alias is only used with --from-stack.'); } }; + +function hasValues(value: unknown): boolean { + return Array.isArray(value) ? value.length > 0 : value === true; +} + +export const noVarsAlone: Rule = (resolved) => { + if (resolved['no-vars'] === true && [resolved.var, resolved.dotenv, resolved['from-stack']].some(hasValues)) { + throw new UsageError('--no-vars cannot be combined with --var, --dotenv or --from-stack.'); + } +}; diff --git a/test/integration/environments-create.test.ts b/test/integration/environments-create.test.ts index 232d0887..2f7a638d 100644 --- a/test/integration/environments-create.test.ts +++ b/test/integration/environments-create.test.ts @@ -166,9 +166,14 @@ describe('integration: launch:env:create on the wire', () => { return [ cma .get('/v3/stacks') - .query({ organization_uid: ORG_UID, limit: '100', skip: '0' }) + .matchHeader('organization_uid', ORG_UID) + .query({ limit: '100', skip: '0', query: JSON.stringify({ org_uid: ORG_UID }) }) .reply(200, { stacks: [{ name: 'Shop', api_key: 'blt9stackkey' }] }), - cma.get('/v3/stacks').query({ organization_uid: ORG_UID, limit: '100', skip: '1' }).reply(200, { stacks: [] }), + cma + .get('/v3/stacks') + .matchHeader('organization_uid', ORG_UID) + .query({ limit: '100', skip: '1', query: JSON.stringify({ org_uid: ORG_UID }) }) + .reply(200, { stacks: [] }), cma .get('/v3/stacks/delivery_tokens') .query({ limit: '100', skip: '0' }) @@ -276,6 +281,19 @@ describe('integration: launch:env:create on the wire', () => { expect(sent[0].environmentVariables).toEqual([]); }); + it('creates with no variables and asks nothing when --no-vars is given on a terminal', async () => { + const prompts = answerPrompts({}); + const sent: { environmentVariables?: unknown }[] = []; + stubCreate(sent); + stubFollowUp('LIVE'); + + const { error } = await onTerminal(() => runCommand([...WITH_AUTH, '--no-vars'], config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([]); + expect(sent[0].environmentVariables).toEqual([]); + }); + it('refuses Skip combined with another source before creating anything', async () => { answerPrompts({ 'Add environment variables': ['Skip adding environment variables', 'Type KEY=VALUE pairs'] }); const sent: unknown[] = []; diff --git a/test/integration/projects-create.test.ts b/test/integration/projects-create.test.ts index fce95721..c9b297ee 100644 --- a/test/integration/projects-create.test.ts +++ b/test/integration/projects-create.test.ts @@ -767,6 +767,36 @@ describe('integration: launch:projects:create on the wire', () => { expect(onWire).toEqual([]); }); + it('asks nothing on a terminal when every value is a flag and --no-vars says there are no variables', async () => { + let body: { environment?: { environmentVariables?: unknown } } | undefined; + const prompts = answerPrompts({}); + stubGitLookups(); + const create = hub() + .post('/manage/projects', (sent: typeof body) => { + body = sent; + return true; + }) + .query({}) + .reply(201, { project: CREATED_PROJECT }); + stubFollowUp('LIVE'); + + const { error } = await onTerminal(() => runCommand([...gitFlags(), '--no-vars'], config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([]); + expect(create.isDone()).toBe(true); + expect(body?.environment?.environmentVariables).toEqual([]); + expect(onWire).toEqual([]); + }); + + it('refuses --no-vars with --var with exit 2 before any request', async () => { + const { error } = await runCommand([...gitFlags(), '--no-vars', '--var', 'A=1'], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--no-vars'); + expect(onWire).toEqual([]); + }); + it('sends the variables from --var and --dotenv in the create body and prints no value', async () => { let body: { environment?: { environmentVariables?: unknown } } | undefined; stubGitLookups(); @@ -849,9 +879,14 @@ describe('integration: launch:projects:create on the wire', () => { return [ cma .get('/v3/stacks') - .query({ organization_uid: ORG_UID, limit: '100', skip: '0' }) + .matchHeader('organization_uid', ORG_UID) + .query({ limit: '100', skip: '0', query: JSON.stringify({ org_uid: ORG_UID }) }) .reply(200, { stacks: [{ name: 'Shop', api_key: 'blt9stackkey' }] }), - cma.get('/v3/stacks').query({ organization_uid: ORG_UID, limit: '100', skip: '1' }).reply(200, { stacks: [] }), + cma + .get('/v3/stacks') + .matchHeader('organization_uid', ORG_UID) + .query({ limit: '100', skip: '1', query: JSON.stringify({ org_uid: ORG_UID }) }) + .reply(200, { stacks: [] }), cma .get('/v3/stacks/delivery_tokens') .query({ limit: '100', skip: '0' })