From f56cc6f070626fd89d2d1a0f23275849faef7ce2 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:37 +0530 Subject: [PATCH 1/8] refactor(core): share resource lookup, paging, prompts and deployment lookup across resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the name-to-uid lookup, cursor paging and resource references out of the projects module into core so environments can reuse them. Adds askChoices (a checkbox prompt) and scrolling choice prompts that do not wrap round, show short lists whole, and refuse an empty checklist. Ctrl-C at a list or checkbox prompt now exits 3 rather than 130. Adds readPipedInput for secrets piped on stdin, the deployment lookup that retries the first deployment, and a branch picker that lists the pre-selected branch first. The Talisman checksums for every file the launch:env work touches are recorded here. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- .talismanrc | 111 +++++++++++++++-- src/core/interruptible-ux.test.ts | 16 +++ src/core/interruptible-ux.ts | 6 +- src/core/name-lookup.test.ts | 64 ++++++++++ src/core/name-lookup.ts | 28 +++++ src/core/paging.test.ts | 81 +++++++++++++ src/core/paging.ts | 39 ++++++ src/core/piped-input.test.ts | 13 ++ src/core/piped-input.ts | 9 ++ src/core/prompt.test.ts | 118 ++++++++++++++++++- src/core/prompt.ts | 76 +++++++++++- src/core/resource-ref.test.ts | 26 ++++ src/core/resource-ref.ts | 23 ++++ src/deployments/deployment.lookup.test.ts | 53 +++++++++ src/deployments/deployment.lookup.ts | 20 ++++ src/deployments/deployment.presenter.test.ts | 47 ++++++++ src/deployments/deployment.presenter.ts | 40 +++++++ src/git/git.prompt.test.ts | 25 ++++ src/git/git.prompt.ts | 6 +- test/guards/layering.test.ts | 15 ++- test/guards/tty-streams.test.ts | 19 ++- 21 files changed, 814 insertions(+), 21 deletions(-) create mode 100644 src/core/name-lookup.test.ts create mode 100644 src/core/name-lookup.ts create mode 100644 src/core/paging.test.ts create mode 100644 src/core/paging.ts create mode 100644 src/core/piped-input.test.ts create mode 100644 src/core/piped-input.ts create mode 100644 src/core/resource-ref.test.ts create mode 100644 src/core/resource-ref.ts create mode 100644 src/deployments/deployment.lookup.test.ts create mode 100644 src/deployments/deployment.lookup.ts diff --git a/.talismanrc b/.talismanrc index 38f5ef6..b5b1d9f 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,6 +1,6 @@ fileignoreconfig: - filename: test/integration/retired-commands.test.ts - checksum: 6eab66fd4393aa3e46e94bb7e21252775f4d809d0b3f2613d0146144589b12de + checksum: 15a04dda74e97d9d1d89340b7a90744fb239d9de1fbada08e7d42cfe12cc649e - filename: test/integration/manage-base-path.test.ts checksum: 79eb9ad420ea47d5bee8045d812c45b010bbeeafcaf5f85d11653140b0cd0db1 - filename: src/transport/rest-client.ts @@ -14,7 +14,7 @@ fileignoreconfig: - filename: src/projects/types.ts checksum: 73826a3686cd0793e7813e80c54bfdc78cddb72b085d2861854f20064a23fdc8 - filename: src/projects/projects.api.ts - checksum: 29fb58c181bab1c76b6827608a77f21b9e72ce0e34eff9fd06fe5d74419c5aad + checksum: 59818dd541577d99c12c50c0c6ef4846ef2db614ab6183cb6dd7b71710931eb1 - filename: src/core/service-context.test.ts checksum: 7bc4f6ca3ec8a960f84fb4c07453a287bb2bb0ccc7e06c5805a7f56df160024a - filename: src/core/rules.test.ts @@ -91,7 +91,7 @@ fileignoreconfig: - "key: 'x-ms-blob-type', value: 'BlockBlob'" - "key: 'Content-Type', value: 'application/zip'" - filename: test/fixtures/README.md - checksum: f0d6c28d269fb7c9e441c3b95fd89ba46584462a2309e1cce4035ff7e0d2a5a1 + checksum: 84200663e97129b03a28240a2cb722d4e45c61fff9b0b19cbcef676582cfc1dd allowed_patterns: - "formFieldKey" - "\\{key, value\\}" @@ -112,6 +112,7 @@ fileignoreconfig: allowed_patterns: - "assertArray\\(response, key, `\\$\\{key\\} response`\\)" - filename: src/environments/environment.inputs.ts + checksum: 3fc2802c79457f7c7e93cd5f32c00a7986dc6f686fe3c346de4b8436a66cc0b9 ignore_detectors: - filename - filename: src/environments/environment.builder.ts @@ -120,8 +121,54 @@ fileignoreconfig: - filename: src/environments/environment.builder.test.ts ignore_detectors: - filename +- filename: src/environments/environment.update.ts + checksum: b1e15e174f98a875f97e0683182638f93966ef4c498135d36df620a28338aacb + ignore_detectors: + - filename +- filename: src/environments/environment.update.test.ts + checksum: 5e90c3dd231ede1b890f7c750c7063412116a2e4be536d2f290d56d28205ceae + ignore_detectors: + - filename +- filename: src/environments/environment.create.ts + ignore_detectors: + - filename +- filename: src/environments/environment.create.test.ts + ignore_detectors: + - filename +- filename: src/environments/environment.rules.ts + checksum: 3440409b7104f49a0f05bd816acdab2d1c5139b8a9170a9aded9e396dbd52de0 + ignore_detectors: + - filename +- filename: src/environments/environment.update.prompt.ts + checksum: 12b8bfafabc5f5174559179b2e926131fb04793685c9bba63cf0620d1cb089a2 + ignore_detectors: + - filename +- filename: src/environments/environment.update.prompt.test.ts + checksum: 5b77e234c5c331d52268157241802345006d59b3af4c0476faa9b28825a61d71 + ignore_detectors: + - filename +- filename: src/environments/environment.presenter.ts + checksum: e980e4fc6dcbe721a2a6d4c826273a3a4ddaa9a4e52a6661ee1b599a9ea9c783 + ignore_detectors: + - filename +- filename: src/environments/environment.presenter.test.ts + checksum: 3f497b982f33c721c988c8245f91534fd50b42317c84cc8f1de784da2d13c942 + ignore_detectors: + - filename +- filename: src/environments/environment.resolver.ts + ignore_detectors: + - filename +- filename: src/environments/environment.resolver.test.ts + ignore_detectors: + - filename +- filename: src/environments/environment.prompt.ts + ignore_detectors: + - filename +- filename: src/environments/environment.prompt.test.ts + ignore_detectors: + - filename - filename: src/environments/environment.inputs.test.ts - checksum: ba38924cf2b2b0df4044e8aba4c4cd0bfa7edcb1f09654cf37a7edb06365de06 + checksum: 499a4601152dded32b766abd8e43aa2ddd88e4e62fc5389b00a268d730178565 ignore_detectors: - filename - filename: src/core/inputs.ts @@ -137,6 +184,24 @@ fileignoreconfig: - "a1b2c3d4e5f60718293a4b5c" - "b2c3d4e5f60718293a4b5c6d" - "c3d4e5f60718293a4b5c6d7e" +- filename: test/fixtures/environments-list.json + ignore_detectors: + - filename + allowed_patterns: + - "a1b2c3d4e5f60718293a4b5c" + - "d4e5f60718293a4b5c6d7e8f" + - "e5f60718293a4b5c6d7e8f90" +- filename: test/fixtures/environment-get.json + ignore_detectors: + - filename + allowed_patterns: + - "a1b2c3d4e5f60718293a4b5c" + - "d4e5f60718293a4b5c6d7e8f" +- filename: test/fixtures/environment-not-found.json + ignore_detectors: + - filename +- filename: test/integration/environments-list-command.test.ts + checksum: 87bf2ec14656f2bcf1bf97dd1ea248c8be087e0f15bf1b7e212c8308d8f13cfe - filename: test/fixtures/projects-list.json allowed_patterns: - "a1b2c3d4e5f60718293a4b5c" @@ -162,7 +227,9 @@ fileignoreconfig: - filename: src/resources.test.ts allowed_patterns: - "a project name passed on argv to the project resolver" + - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md + checksum: 7a582c03c5326b0d1c2465a0060da0763944dc295d6f6d7ab1ee4bf5ce047181 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\\)`" @@ -186,7 +253,7 @@ fileignoreconfig: - filename: src/git/local-repository.ts checksum: b9091ed135dfebdf590a5412bbfba2b2f96d4a177cf944a20909b93405268e9f - filename: test/guards/layering.test.ts - checksum: d856282fd15bfc6fa7b5eb32ff419e66eb5fd20ff8dc79af9e82c0eb3afcd4f6 + checksum: 58f43e0ed643b9317538d12e44ba449d046e762dee219ff84f3907bd9dea3531 - filename: src/commands/launch/projects/create.test.ts checksum: 1d5063992d0a7cfb91871ab00f79123bf71806ddc9faac80952cf279d165aec5 - filename: src/commands/launch/projects/delete.test.ts @@ -198,13 +265,15 @@ fileignoreconfig: - filename: src/core/launch-command.test.ts checksum: 930069aeddbf4348097f33713e4e57bd26cb3be1b7970eb60142bb53010e1674 - filename: src/projects/project.inputs.test.ts - checksum: c20f69186e74bc5fe6deb73a9036fa2cb86aeff1bde48670f0c489d9e9121c62 + checksum: eaecaba9eddec128186871550fc5802166fa56a2e89741d72ac955fcfe2f952f - filename: test/integration/projects-command-flows.test.ts checksum: 786a470c71f3a7ce58463d0a08e9bf47e36f936c55a2ca7cd8ef2a058ac01e4a - filename: src/core/redact.test.ts checksum: ae497ea8c3a4c3f12217d2c4c028a1da3d1324e18362d68c355ba8120902bcb3 - filename: src/commands/launch/projects/list.test.ts checksum: d01cbf70469a771425c8258fb1800b70a6ff0b807208fc76f3060b69a2cc05bc +- filename: src/commands/launch/env/list.test.ts + checksum: 36040ec3ae8230a0b9ae28e1609fd9edf5965b9d472bd2f9a1808c060ea897bf - filename: src/commands/launch/projects/get.test.ts checksum: fec6ca189bc3d93aefa109ee6405f8ce74a42318aa18a99045d40c3cb5561b9e - filename: test/integration/projects-list-command.test.ts @@ -215,8 +284,6 @@ fileignoreconfig: checksum: 06db291bb40ec55774179b02e3e1a9644bbaa116ec511c163e8d6012aebb3868 - filename: test/integration/project-upload-proxy.test.ts checksum: 502adf95d5c1b41e58a8500929597741e8f8a178a3aaa69f517451cb7bedac4a -- filename: src/environments/environment.errors.ts - checksum: ac2b4115715433965f87d017f8751db8ffb534ace02e2a4b6c5e5e1d2b0861e3 - filename: test/fixtures/project-get-fileupload.json checksum: 56488abc4785846b61953e3b32abd0537512ce1d4afea977c937d87b21c07623 - filename: src/core/errors.test.ts @@ -237,4 +304,32 @@ fileignoreconfig: checksum: bf8c203a8efc40a75f5407ceb29a639ea9a6e1fa081d02260def2d7e0fab958a - filename: test/credential-guard.setup.ts checksum: 519ccbeb0552fdb1d5f27308c56477c0258ee6cc0adec8f618e8e5bc047cbf6d +- filename: src/environments/types.ts + checksum: a637325f841e8491a4c058998e6a523461abc7b3b150b9eee0c2f7190a80205e +- filename: src/commands/launch/env/get.test.ts + checksum: 26863ca165d20df9a9ea8afac1eda71c9f96be15fe23bddb4fd7ddfcefab2256 +- filename: test/integration/environments-delete.test.ts + checksum: 7df1b364699953cad40b7aea3beeaaae6e2b781c72b7ae8f36ea78ebf50887e1 +- filename: src/commands/launch/env/delete.test.ts + checksum: 14116789eb5f9000f2a7d6f569cd15aabf2a1ec3b127ed4427afe13fd515f0c2 +- filename: src/environments/environment.errors.ts + checksum: 6696c36c85b5b36d6ade7897b196ad554ef52d615f7f5e61faaf825e4f3b3a31 +- filename: src/core/prompt.ts + checksum: 54a94bca9c39545606d51db0966e040ac03b43539315606c61c1cc64d11ec062 +- filename: src/core/prompt.test.ts + checksum: 783aa03b20d8b2b2e7259fc11c2c35dc8318f1b5e0fdd566b0f61693a47bfb14 +- filename: test/integration/environments-update.test.ts + checksum: 650627ec2cc6fed82f000b0e0a368a3725238dbb64ec18a5866c2fa3abb3a27f +- filename: src/commands/launch/env/update.ts + checksum: 06add97108d878eaf415e32160439b8963683e63a0e37ce77a56f66b88383c7a +- filename: src/commands/launch/env/update.test.ts + checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 +- filename: README.md + checksum: 88e66114eb9049a642a7469458b45b182b19890efe91ef2f6220e9b24ee8814a + allowed_patterns: + - "password-protection" + - "-p, --password" + - "-u, --username" +- filename: test/integration/environments-create.test.ts + checksum: 9ffcc5cc8034fff34a19b6f04c737ff02aedbd3d3d919246d51c800aafb17693 version: "1.0" diff --git a/src/core/interruptible-ux.test.ts b/src/core/interruptible-ux.test.ts index 028b435..1100a17 100644 --- a/src/core/interruptible-ux.test.ts +++ b/src/core/interruptible-ux.test.ts @@ -72,6 +72,22 @@ describe('cancelOnInterrupt', () => { expect(loop.events.slice(0, 2)).toEqual(['held', 'released']); }); + it('is still listening while a later SIGINT listener runs, so signal-exit does not re-raise it and exit 130', async () => { + const signals = new EventEmitter(); + const inner = pendingUx(); + const counted: number[] = []; + + const answer = cancelOnInterrupt(inner.ux, { signals, hold: recordingHold().hold }).inquire(QUESTION); + signals.on('SIGINT', () => counted.push(signals.listenerCount('SIGINT'))); + signals.emit('SIGINT'); + const error = await answer.catch((e: unknown) => e); + await new Promise((done) => setImmediate(done)); + + expect(error).toBeInstanceOf(CancelledError); + expect(counted).toEqual([2]); + expect(signals.listenerCount('SIGINT')).toBe(1); + }); + it('propagates the prompt failure unchanged rather than calling it a cancellation, then stops listening', async () => { const signals = new EventEmitter(); const inner = pendingUx(); diff --git a/src/core/interruptible-ux.ts b/src/core/interruptible-ux.ts index b31bedd..1ae8c88 100644 --- a/src/core/interruptible-ux.ts +++ b/src/core/interruptible-ux.ts @@ -2,7 +2,7 @@ import { CancelledError } from './errors'; import type { UxLike } from './prompt'; export interface InterruptSource { - once(signal: 'SIGINT', listener: () => void): unknown; + on(signal: 'SIGINT', listener: () => void): unknown; removeListener(signal: 'SIGINT', listener: () => void): unknown; } @@ -34,7 +34,7 @@ export function cancelOnInterrupt(ux: UxLike, options: InterruptOptions = {}): U signals.removeListener('SIGINT', cancel); }; const cancel = (): void => { - release(); + queueMicrotask(release); reject(new CancelledError()); }; @@ -43,7 +43,7 @@ export function cancelOnInterrupt(ux: UxLike, options: InterruptOptions = {}): U reject(error); }; - signals.once('SIGINT', cancel); + signals.on('SIGINT', cancel); try { ux.inquire(payload).then((answer) => { diff --git a/src/core/name-lookup.test.ts b/src/core/name-lookup.test.ts new file mode 100644 index 00000000..ee031ee --- /dev/null +++ b/src/core/name-lookup.test.ts @@ -0,0 +1,64 @@ +import { UsageError } from './errors'; +import { uidByName } from './name-lookup'; +import type { ResourceRef } from './resource-ref'; + +interface Entry { + uid?: string | null; + name?: string; +} + +function pagesReading(pages: Entry[][]) { + const read: number[] = []; + const opened = jest.fn(async function* () { + for (let index = 0; index < pages.length; index += 1) { + read.push(index); + yield pages[index]; + } + }); + + return { opened, read }; +} + +function lookup(opened: () => AsyncIterable, ref: ResourceRef): Promise { + return uidByName( + opened, + (entries) => entries, + ref, + (name) => new UsageError(`No thing named "${name}".`), + ); +} + +describe('uidByName', () => { + it('returns a uid reference as it is, without opening the pages at all', async () => { + const { opened } = pagesReading([[{ uid: 'x', name: 'a' }]]); + + await expect(lookup(opened, { kind: 'uid', uid: 'u1' })).resolves.toBe('u1'); + expect(opened).not.toHaveBeenCalled(); + }); + + it('matches the name exactly and case-sensitively, stopping at the page that holds it', async () => { + const { opened, read } = pagesReading([ + [ + { uid: 'a', name: 'Site' }, + { uid: 'b', name: ' site' }, + ], + [{ uid: 'c', name: 'site' }], + [{ uid: 'd', name: 'site' }], + ]); + + await expect(lookup(opened, { kind: 'name', name: 'site' })).resolves.toBe('c'); + expect(read).toEqual([0, 1]); + }); + + it.each([[undefined], [null], [''], [' ']])('skips a match whose uid is %p', async (uid) => { + const { opened } = pagesReading([[{ uid, name: 'site' }], [{ uid: 'c', name: 'site' }]]); + + await expect(lookup(opened, { kind: 'name', name: 'site' })).resolves.toBe('c'); + }); + + it('raises the caller its not-found error naming the name when nothing matches', async () => { + const { opened } = pagesReading([[{ uid: null, name: 'site' }]]); + + await expect(lookup(opened, { kind: 'name', name: 'site' })).rejects.toThrow(new UsageError('No thing named "site".')); + }); +}); diff --git a/src/core/name-lookup.ts b/src/core/name-lookup.ts new file mode 100644 index 00000000..2264bc7 --- /dev/null +++ b/src/core/name-lookup.ts @@ -0,0 +1,28 @@ +import { hasUid } from '../transport/envelope'; +import type { ResourceRef } from './resource-ref'; + +interface Named { + uid?: unknown; + name?: unknown; +} + +export async function uidByName( + pages: () => AsyncIterable

, + entriesOf: (page: P) => readonly T[], + ref: ResourceRef, + notFound: (name: string) => Error, +): Promise { + if (ref.kind === 'uid') { + return ref.uid; + } + + for await (const page of pages()) { + const match = entriesOf(page).filter(hasUid).find((entry) => entry.name === ref.name); + + if (match) { + return match.uid; + } + } + + throw notFound(ref.name); +} diff --git a/src/core/paging.test.ts b/src/core/paging.test.ts new file mode 100644 index 00000000..28f4f47 --- /dev/null +++ b/src/core/paging.test.ts @@ -0,0 +1,81 @@ +import { MAX_PAGES } from './constants'; +import { scanPages } from './paging'; + +interface Page { + pagination: { count?: number }; + items: number[]; +} + +function fetcher(pages: { count?: number; size: number }[]) { + const windows: { limit: number; skip: number }[] = []; + let index = 0; + const fetch = async (window: { limit: number; skip: number }): Promise => { + windows.push(window); + const page = pages[Math.min(index++, pages.length - 1)]; + return { pagination: { count: page.count }, items: Array.from({ length: page.size }, (_, n) => n) }; + }; + + return { fetch, windows }; +} + +async function drain(scan: AsyncGenerator): Promise { + const collected: Page[] = []; + for await (const page of scan) { + collected.push(page); + } + return collected; +} + +const LIMIT_ERROR = new Error('scan limit'); +const scan = (fetch: (window: { limit: number; skip: number }) => Promise, pageSize = 2) => + scanPages(fetch, (page) => page.items, pageSize, () => LIMIT_ERROR); + +describe('scanPages', () => { + it('reads one page at a time, advancing skip by the entries returned, until a short page', async () => { + const { fetch, windows } = fetcher([{ size: 2 }, { size: 1 }]); + + const pages = await drain(scan(fetch)); + + expect(pages.map((page) => page.items.length)).toEqual([2, 1]); + expect(windows).toEqual([ + { limit: 2, skip: 0 }, + { limit: 2, skip: 2 }, + ]); + }); + + it('stops on an empty page without yielding it', async () => { + const { fetch, windows } = fetcher([{ count: 900, size: 0 }]); + + await expect(drain(scan(fetch))).resolves.toEqual([]); + expect(windows).toHaveLength(1); + }); + + it('stops once the reported count is reached exactly, and reads on when one more remains', async () => { + const exact = fetcher([{ count: 2, size: 2 }]); + const oneMore = fetcher([{ count: 3, size: 2 }, { count: 3, size: 1 }]); + + await drain(scan(exact.fetch)); + await drain(scan(oneMore.fetch)); + + expect(exact.windows).toHaveLength(1); + expect(oneMore.windows).toHaveLength(2); + }); + + it('raises the caller its scan-limit error after MAX_PAGES full pages', async () => { + const { fetch, windows } = fetcher([{ size: 2 }]); + + await expect(drain(scan(fetch))).rejects.toBe(LIMIT_ERROR); + expect(windows).toHaveLength(MAX_PAGES); + }); + + it('fetches no further page once the reader stops', async () => { + const { fetch, windows } = fetcher([{ count: 10, size: 2 }]); + + for await (const page of scan(fetch)) { + expect(page.items).toHaveLength(2); + break; + } + + expect(windows).toHaveLength(1); + }); +}); diff --git a/src/core/paging.ts b/src/core/paging.ts new file mode 100644 index 00000000..2f74a07 --- /dev/null +++ b/src/core/paging.ts @@ -0,0 +1,39 @@ +import { MAX_PAGES } from './constants'; + +export interface PageWindow { + limit: number; + skip: number; +} + +export async function* scanPages

( + fetch: (window: PageWindow) => Promise

, + entries: (page: P) => readonly unknown[], + pageSize: number, + scanLimit: () => Error, +): AsyncGenerator

{ + let skip = 0; + + for (let fetched = 0; fetched < MAX_PAGES; fetched += 1) { + const page = await fetch({ limit: pageSize, skip }); + const count = entries(page).length; + + if (count === 0) { + return; + } + + yield page; + + if (count < pageSize) { + return; + } + + skip += count; + const total = page.pagination.count; + + if (typeof total === 'number' && skip >= total) { + return; + } + } + + throw scanLimit(); +} diff --git a/src/core/piped-input.test.ts b/src/core/piped-input.test.ts new file mode 100644 index 00000000..306707a --- /dev/null +++ b/src/core/piped-input.test.ts @@ -0,0 +1,13 @@ +import { Readable } from 'node:stream'; + +import { readPipedInput } from './piped-input'; + +describe('readPipedInput', () => { + it('reads every chunk piped in, in order, as text', async () => { + await expect(readPipedInput(Readable.from(['ab', Buffer.from('cd'), 'e\n']))).resolves.toBe('abcde\n'); + }); + + it('reads nothing as an empty string', async () => { + await expect(readPipedInput(Readable.from([]))).resolves.toBe(''); + }); +}); diff --git a/src/core/piped-input.ts b/src/core/piped-input.ts new file mode 100644 index 00000000..8887493 --- /dev/null +++ b/src/core/piped-input.ts @@ -0,0 +1,9 @@ +export async function readPipedInput(stream: AsyncIterable = process.stdin): Promise { + let text = ''; + + for await (const chunk of stream) { + text += String(chunk); + } + + return text; +} diff --git a/src/core/prompt.test.ts b/src/core/prompt.test.ts index ee53f08..a8be3fb 100644 --- a/src/core/prompt.test.ts +++ b/src/core/prompt.test.ts @@ -1,6 +1,7 @@ -import { CancelledError } from './errors'; -import { askOption, askText } from './prompt'; +import { CancelledError, MissingInputError } from './errors'; +import { PROMPT_PAGE_SIZE, askChoices, askOption, askSecret, askText, needInput } from './prompt'; import type { UxLike } from './prompt'; +import type { ServiceContext } from './service-context'; function scriptedUx(answers: unknown[]) { const asked: unknown[] = []; @@ -78,12 +79,125 @@ describe('prompt helpers', () => { message: 'Project type', choices, default: 'GitHub', + loop: false, + pageSize: 2, }); }); + it.each([ + [3, 3], + [15, 15], + [16, 15], + [100, 15], + ])('shows %i choices %i rows at a time, so a short list is shown whole and a long one scrolls', async (count, rows) => { + const { asked, ux } = scriptedUx(['c0']); + const choices = Array.from({ length: count }, (_, index) => ({ name: `C${index}`, value: `c${index}` })); + + await askOption(ux, 'Pick one', choices); + + expect((asked[0] as { pageSize: number }).pageSize).toBe(rows); + expect(PROMPT_PAGE_SIZE).toBe(15); + }); + it('cancels rather than accepting nothing at a list of options', async () => { const { ux } = scriptedUx([undefined]); await expect(askOption(ux, 'Project type', [{ name: 'a', value: 'a' }])).rejects.toThrow(CancelledError); }); }); + +describe('askSecret', () => { + it('asks with a masked password prompt and returns exactly what was typed, spaces included', async () => { + const { asked, ux } = scriptedUx([' s3cret ']); + + await expect(askSecret(ux, 'Password')).resolves.toBe(' s3cret '); + expect(asked).toEqual([ + { type: 'password', name: 'value', message: 'Password', mask: '*', validate: expect.any(Function) }, + ]); + }); + + it('refuses a blank answer at the prompt without echoing anything typed', async () => { + const { asked, ux } = scriptedUx(['s3cret']); + + await askSecret(ux, 'Password'); + const validate = (asked[0] as { validate(value: string): true | string }).validate; + + expect(validate(' ')).toBe("Password can't be empty."); + expect(validate('')).toBe("Password can't be empty."); + expect(validate('x')).toBe(true); + }); + + it('cancels when the prompt is dismissed without an answer', async () => { + const { ux } = scriptedUx([undefined]); + + await expect(askSecret(ux, 'Password')).rejects.toBeInstanceOf(CancelledError); + }); +}); + +const ON_TERMINAL = { isTTY: true } as ServiceContext; +const OFF_TERMINAL = { isTTY: false } as ServiceContext; + +describe('needInput', () => { + it('returns the supplied value without asking, terminal or not', async () => { + const ask = jest.fn(async () => 'asked'); + + await expect(needInput(ON_TERMINAL, 'name', 'given', ask)).resolves.toBe('given'); + await expect(needInput(OFF_TERMINAL, 'name', 'given', ask)).resolves.toBe('given'); + expect(ask).not.toHaveBeenCalled(); + }); + + it('asks on a terminal when nothing was supplied', async () => { + await expect(needInput(ON_TERMINAL, 'name', undefined, async () => 'asked')).resolves.toBe('asked'); + }); + + it('refuses with a missing-input error naming only the terminal remedy when nothing was supplied off a terminal', async () => { + const ask = jest.fn(async () => 'asked'); + + const failure = await needInput(OFF_TERMINAL, 'name', undefined, ask).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MissingInputError); + expect(failure).toEqual(new MissingInputError('name', { config: false, prompt: true })); + expect(ask).not.toHaveBeenCalled(); + }); +}); + +describe('askChoices', () => { + it('asks with a checkbox prompt and returns the values ticked', async () => { + const { asked, ux } = scriptedUx([['b', 'c']]); + const choices = [ + { name: 'A', value: 'a' }, + { name: 'B', value: 'b' }, + { name: 'C', value: 'c' }, + ]; + + await expect(askChoices(ux, 'Which ones?', choices)).resolves.toEqual(['b', 'c']); + expect(asked).toEqual([ + { + type: 'checkbox', + name: 'value', + message: 'Which ones?', + choices, + loop: false, + pageSize: 3, + validate: expect.any(Function), + }, + ]); + }); + + it('refuses to go on with nothing ticked, saying how to tick, and accepts any ticked answer', async () => { + const { asked, ux } = scriptedUx([['a']]); + + await askChoices(ux, 'Which ones?', [{ name: 'A', value: 'a' }]); + const validate = (asked[0] as { validate(value: unknown): true | string }).validate; + + expect(validate([])).toBe('Select at least one with , then press .'); + expect(validate(undefined)).toBe('Select at least one with , then press .'); + expect(validate(['a'])).toBe(true); + }); + + it.each([[[]], [undefined], ['b']])('answers no choice at all when the prompt returns %p', async (answer) => { + const { ux } = scriptedUx([answer]); + + await expect(askChoices(ux, 'Which ones?', [{ name: 'B', value: 'b' }])).resolves.toEqual([]); + }); +}); diff --git a/src/core/prompt.ts b/src/core/prompt.ts index 6a15428..8010999 100644 --- a/src/core/prompt.ts +++ b/src/core/prompt.ts @@ -1,5 +1,6 @@ import type { ApiSurface } from '../resources'; -import { CancelledError } from './errors'; +import { CancelledError, MissingInputError } from './errors'; +import type { ServiceContext } from './service-context'; export interface UxLike { print(message: string): void; @@ -48,21 +49,88 @@ export async function askText(ux: UxLike, message: string, initial?: string, max ).trim(); } -export async function askOptionalText(ux: UxLike, message: string, initial?: string): Promise { - const answer = await ux.inquire({ type: 'input', name: 'value', message, default: initial }); +export async function askSecret(ux: UxLike, message: string): Promise { + const validate = (value: string): true | string => (value.trim() === '' ? `${message} can't be empty.` : true); + + return answered( + await ux.inquire({ type: 'password', name: 'value', message, mask: '*', validate }), + ); +} + +export async function askOptionalText( + ux: UxLike, + message: string, + initial?: string, + max?: number, +): Promise { + const limit = max === undefined ? {} : { validate: (value: string) => checkLength(message, value, max) }; + const answer = await ux.inquire({ + type: 'input', + name: 'value', + message, + default: initial, + ...limit, + }); const text = typeof answer === 'string' ? answer.trim() : ''; return text === '' ? undefined : text; } +export const PROMPT_PAGE_SIZE = 15; + +function scrolling(choices: Choice[]): { loop: false; pageSize: number } { + return { loop: false, pageSize: Math.min(choices.length, PROMPT_PAGE_SIZE) }; +} + export async function askOption(ux: UxLike, message: string, choices: Choice[], initial?: string): Promise { return answered( - await ux.inquire({ type: 'list', name: 'value', message, choices, default: initial }), + await ux.inquire({ + type: 'list', + name: 'value', + message, + choices, + default: initial, + ...scrolling(choices), + }), ); } +export async function askChoices(ux: UxLike, message: string, choices: Choice[]): Promise { + const validate = (picked: unknown): true | string => + Array.isArray(picked) && picked.length > 0 ? true : 'Select at least one with , then press .'; + const answer = await ux.inquire({ + type: 'checkbox', + name: 'value', + message, + choices, + ...scrolling(choices), + validate, + }); + + return Array.isArray(answer) ? answer.map(String) : []; +} + export function noteTruncation(ux: UxLike, count: number | undefined, shown: number, noun: string, flag: string): void { if (typeof count === 'number' && count > shown) { ux.print(`Showing the first ${shown} of ${count} ${noun}. Use ${flag} to reach any of them.`); } } + +export const CREATE_PROMPT_REMEDIES = { config: false, prompt: true }; + +export async function needInput( + services: ServiceContext, + flag: string, + supplied: string | undefined, + ask: () => Promise, +): Promise { + if (supplied !== undefined) { + return supplied; + } + + if (!services.isTTY) { + throw new MissingInputError(flag, CREATE_PROMPT_REMEDIES); + } + + return ask(); +} diff --git a/src/core/resource-ref.test.ts b/src/core/resource-ref.test.ts new file mode 100644 index 00000000..a340796 --- /dev/null +++ b/src/core/resource-ref.test.ts @@ -0,0 +1,26 @@ +import { randomBytes } from 'node:crypto'; + +import { ResourceRef, UID_PATTERN } from './resource-ref'; + +const UID = randomBytes(12).toString('hex'); + +describe('ResourceRef', () => { + it.each([[UID], [UID.toUpperCase()]])('takes the 24-character hex string %s as a uid', (input) => { + expect(ResourceRef.parse(input)).toEqual({ kind: 'uid', uid: input }); + expect(UID_PATTERN.test(input)).toBe(true); + }); + + it.each([['0'.repeat(23)], ['0'.repeat(25)], ['production'], ['Production'], [''], ['g'.repeat(24)], [' '.repeat(24)]])( + 'takes %p as a name, unchanged', + (input) => { + expect(ResourceRef.parse(input)).toEqual({ kind: 'name', name: input }); + }, + ); + + it.each([['production'], [''], ['0'.repeat(24)]])( + 'takes %p as a uid without sniffing its shape when the caller already knows it is one', + (input) => { + expect(ResourceRef.uid(input)).toEqual({ kind: 'uid', uid: input }); + }, + ); +}); diff --git a/src/core/resource-ref.ts b/src/core/resource-ref.ts new file mode 100644 index 00000000..f7cedce --- /dev/null +++ b/src/core/resource-ref.ts @@ -0,0 +1,23 @@ +export const UID_PATTERN = /^[0-9a-f]{24}$/i; + +export interface UidRef { + readonly kind: 'uid'; + readonly uid: string; +} + +export interface NameRef { + readonly kind: 'name'; + readonly name: string; +} + +export type ResourceRef = UidRef | NameRef; + +function parse(input: string): ResourceRef { + return UID_PATTERN.test(input) ? { kind: 'uid', uid: input } : { kind: 'name', name: input }; +} + +function uid(input: string): UidRef { + return { kind: 'uid', uid: input }; +} + +export const ResourceRef = { parse, uid }; diff --git a/src/deployments/deployment.lookup.test.ts b/src/deployments/deployment.lookup.test.ts new file mode 100644 index 00000000..11c70b8 --- /dev/null +++ b/src/deployments/deployment.lookup.test.ts @@ -0,0 +1,53 @@ +import { FIRST_LOOKUP_ATTEMPTS, untilFound } from './deployment.lookup'; + +function lookupFindingOn(attempt: number | undefined) { + const slept: number[] = []; + let looks = 0; + const timing = { + pollDelayMs: 250, + sleep: async (ms: number) => { + slept.push(ms); + }, + }; + const lookup = async () => { + looks += 1; + return looks === attempt ? 'found' : undefined; + }; + + return { timing, lookup, slept, looks: () => looks }; +} + +describe('untilFound', () => { + it('answers at once, without waiting, when the first look finds it', async () => { + const { timing, lookup, slept, looks } = lookupFindingOn(1); + + await expect(untilFound(lookup, timing)).resolves.toBe('found'); + expect(looks()).toBe(1); + expect(slept).toEqual([]); + }); + + it('waits the poll delay between looks and answers on the last allowed look', async () => { + const { timing, lookup, slept, looks } = lookupFindingOn(FIRST_LOOKUP_ATTEMPTS); + + await expect(untilFound(lookup, timing)).resolves.toBe('found'); + expect(looks()).toBe(3); + expect(slept).toEqual([250, 250]); + }); + + it('gives up with nothing after exactly three looks, never sleeping after the last one', async () => { + const { timing, lookup, slept, looks } = lookupFindingOn(undefined); + + await expect(untilFound(lookup, timing)).resolves.toBeUndefined(); + expect(FIRST_LOOKUP_ATTEMPTS).toBe(3); + expect(looks()).toBe(3); + expect(slept).toEqual([250, 250]); + }); + + it('propagates a failed look without trying again', async () => { + const failure = new Error('lookup refused'); + const { timing, slept } = lookupFindingOn(undefined); + + await expect(untilFound(async () => Promise.reject(failure), timing)).rejects.toBe(failure); + expect(slept).toEqual([]); + }); +}); diff --git a/src/deployments/deployment.lookup.ts b/src/deployments/deployment.lookup.ts new file mode 100644 index 00000000..2b3ce17 --- /dev/null +++ b/src/deployments/deployment.lookup.ts @@ -0,0 +1,20 @@ +import type { WatchTiming } from './deployment.watcher'; + +export const FIRST_LOOKUP_ATTEMPTS = 3; + +export async function untilFound( + lookup: () => Promise, + timing: Pick, +): Promise { + for (let attempt = 1; attempt < FIRST_LOOKUP_ATTEMPTS; attempt += 1) { + const found = await lookup(); + + if (found !== undefined) { + return found; + } + + await timing.sleep(timing.pollDelayMs); + } + + return lookup(); +} diff --git a/src/deployments/deployment.presenter.test.ts b/src/deployments/deployment.presenter.test.ts index e577fd5..476abda 100644 --- a/src/deployments/deployment.presenter.test.ts +++ b/src/deployments/deployment.presenter.test.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import { colouredLogMessage, + firstDeploymentFailureMessage, deploymentLogLine, deploymentLogsUnavailableLine, deploymentUrlLine, @@ -145,3 +146,49 @@ describe('deployment presenter', () => { ); }); }); + +describe('firstDeploymentFailureMessage', () => { + const ENVIRONMENT = { created: 'The environment "staging" (env9)', plural: false }; + const SCOPE = { org: 'org1', project: 'proj1', environmentUid: 'env9' }; + + it('names the status, what survived and both follow-up commands for a failed deployment', () => { + expect(firstDeploymentFailureMessage({ ...ENVIRONMENT, ...SCOPE, deploymentUid: 'dep1', status: 'FAILED' })).toBe( + 'The deployment did not succeed; its last status was FAILED. ' + + 'The environment "staging" (env9) was created and has not been rolled back. ' + + 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + + 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see why it did not succeed.', + ); + }); + + it('agrees the verbs with a plural survivor and leaves out the scope it does not know', () => { + expect( + firstDeploymentFailureMessage({ + created: 'The project "site" (p1) and its environment "Default"', + plural: true, + org: 'org1', + project: 'p1', + status: 'NONE', + reason: 'Lookup refused.', + }), + ).toBe( + 'The deployment could not be followed to completion: Lookup refused. ' + + 'The project "site" (p1) and its environment "Default" were created and have not been rolled back. ' + + 'Run csdx launch:deployments:create --org org1 --project p1 to try the deployment again, ' + + 'or csdx launch:logs:get --org org1 --project p1 to see why it did not succeed.', + ); + }); + + it.each([ + [false, 'The environment "staging" (env9) was created.'], + [true, 'The environment "staging" (env9) were created.'], + ])('words a wait that ran out as possibly still finishing (plural %p)', (plural, created) => { + expect( + firstDeploymentFailureMessage({ ...ENVIRONMENT, plural, ...SCOPE, deploymentUid: 'dep1', status: 'DEPLOYING', timedOut: true }), + ).toBe( + 'The deployment was still DEPLOYING when the CLI stopped waiting for it; it may still finish. ' + + `${created} Do not start another deployment yet: ` + + 'run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to see how it ends, ' + + 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to follow it.', + ); + }); +}); diff --git a/src/deployments/deployment.presenter.ts b/src/deployments/deployment.presenter.ts index 04f1fe2..6e695a3 100644 --- a/src/deployments/deployment.presenter.ts +++ b/src/deployments/deployment.presenter.ts @@ -140,3 +140,43 @@ export function deploymentLogsUnavailableLine(error: unknown): string { return ` ! Could not read the deployment logs (${reason}). Still waiting on the deployment.`; } + +export interface FirstDeploymentFailure { + created: string; + plural: boolean; + org: string; + project: string; + status: string; + environmentUid?: string; + deploymentUid?: string; + reason?: string; + timedOut?: boolean; +} + +export function firstDeploymentFailureMessage(failure: FirstDeploymentFailure): string { + const scope = `--org ${failure.org} --project ${failure.project}`; + const environment = failure.environmentUid === undefined ? '' : ` --env ${failure.environmentUid}`; + const deployment = failure.deploymentUid === undefined ? '' : ` --deployment ${failure.deploymentUid}`; + const were = failure.plural ? 'were' : 'was'; + + if (failure.timedOut === true) { + return ( + `The deployment was still ${failure.status} when the CLI stopped waiting for it; it may still finish. ` + + `${failure.created} ${were} created. Do not start another deployment yet: ` + + `run csdx launch:deployments:get ${scope}${environment}${deployment} to see how it ends, ` + + `or csdx launch:logs:get ${scope}${environment}${deployment} to follow it.` + ); + } + + const opening = + failure.reason === undefined + ? `The deployment did not succeed; its last status was ${failure.status}. ` + : `The deployment could not be followed to completion: ${failure.reason} `; + + return ( + opening + + `${failure.created} ${were} created and ${failure.plural ? 'have' : 'has'} not been rolled back. ` + + `Run csdx launch:deployments:create ${scope}${environment} to try the deployment again, ` + + `or csdx launch:logs:get ${scope}${environment}${deployment} to see why it did not succeed.` + ); +} diff --git a/src/git/git.prompt.test.ts b/src/git/git.prompt.test.ts index 9f070ed..c47253d 100644 --- a/src/git/git.prompt.test.ts +++ b/src/git/git.prompt.test.ts @@ -53,9 +53,34 @@ describe('askBranch', () => { { name: 'develop', value: 'develop' }, ], default: 'main', + loop: false, + pageSize: 2, }); }); + it('lists the pre-selected branch first when the first page does not include it, so Enter keeps it', async () => { + const { deps: d, asked } = deps(['release/x'], { + pagination: { count: 340, limit: 100 }, + branches: [{ name: 'main' }, { name: 'develop' }], + }); + + await expect(askBranch(d, { ...GIT, repoName: 'my-org/my-repo' }, 'release/x')).resolves.toBe('release/x'); + expect((asked[0] as { choices: unknown; default: unknown }).choices).toEqual([ + { name: 'release/x', value: 'release/x' }, + { name: 'main', value: 'main' }, + { name: 'develop', value: 'develop' }, + ]); + expect((asked[0] as { default: unknown }).default).toBe('release/x'); + }); + + it.each([[''], [' ']])('adds no blank choice when the pre-selected branch is %p', async (initial) => { + const { deps: d, asked } = deps(['main'], { pagination: { count: 1, limit: 100 }, branches: [{ name: 'main' }] }); + + await askBranch(d, { ...GIT, repoName: 'my-org/my-repo' }, initial); + + expect((asked[0] as { choices: unknown }).choices).toEqual([{ name: 'main', value: 'main' }]); + }); + it('refuses when the repository has no usable branch', async () => { const { deps: d } = deps([], { pagination: { count: 0, limit: 100 }, branches: [{ name: '' }] }); diff --git a/src/git/git.prompt.ts b/src/git/git.prompt.ts index 81990f0..1d05f5e 100644 --- a/src/git/git.prompt.ts +++ b/src/git/git.prompt.ts @@ -2,6 +2,7 @@ import { PICKER_PAGE_SIZE } from '../core/constants'; import { UsageError } from '../core/errors'; import type { PromptDeps } from '../core/prompt'; import { askOption, noteTruncation } from '../core/prompt'; +import { isAbsent } from '../core/values'; import { repositoryLabel } from './git.presenter'; import type { GitRepository } from './types'; @@ -32,10 +33,13 @@ export async function askBranch( noteTruncation(deps.ux, page.pagination.count, named.length, 'branches', '--branch'); + const names = named.map((branch) => branch.name as string); + const listed = initial === undefined || isAbsent(initial) || names.includes(initial) ? names : [initial, ...names]; + return askOption( deps.ux, 'Choose a branch', - named.map((branch) => ({ name: branch.name as string, value: branch.name as string })), + listed.map((name) => ({ name, value: name })), initial, ); } diff --git a/test/guards/layering.test.ts b/test/guards/layering.test.ts index 94219cb..5b7f377 100644 --- a/test/guards/layering.test.ts +++ b/test/guards/layering.test.ts @@ -12,7 +12,7 @@ const RESOURCES = readdirSync(SRC, { withFileTypes: true }) const RESOURCE_EDGES: Record = { projects: ['environments', 'deployments', 'git', 'uploads'], functions: [], - environments: [], + environments: ['deployments', 'git', 'uploads'], deployments: [], git: [], organizations: [], @@ -51,8 +51,11 @@ function crossResourceEdges(relativePath: string): string[] { .filter((target): target is string => target !== undefined && target !== owner); } +const RESOURCE_OF_TOPIC: Record = { env: 'environments' }; + function commandEdgesOf(relativePath: string, specifiers: string[]): string[] { - const owner = relativePath.split('/')[2]; + const topic = relativePath.split('/')[2]; + const owner = RESOURCE_OF_TOPIC[topic] ?? topic; const directory = directoryOf(relativePath); const allowed = RESOURCE_EDGES[owner] ?? []; @@ -176,6 +179,14 @@ describe('layering of the command files', () => { expect(commandEdgesOf('commands/launch/functions/serve.ts', ['../../../projects/project.create'])).toEqual([ 'projects', ]); + + }); + + it('treats a command under the env topic as owned by the environments resource', () => { + expect(commandEdgesOf('commands/launch/env/list.ts', ['../../../environments/environment.presenter'])).toEqual([]); + expect(commandEdgesOf('commands/launch/env/list.ts', ['../../../projects/project.presenter'])).toEqual([ + 'projects', + ]); }); }); diff --git a/test/guards/tty-streams.test.ts b/test/guards/tty-streams.test.ts index 8fd779f..479bf4f 100644 --- a/test/guards/tty-streams.test.ts +++ b/test/guards/tty-streams.test.ts @@ -12,7 +12,7 @@ function stdinReads(source: SourceFile): Occurrence[] { return lines.flatMap((raw, index) => { const text = raw.trim(); - const stdinSide = text.replace(/\bstdout\.isTTY\b/g, ''); + const stdinSide = text.replace(/\bstdout\.isTTY\b/g, '').replace(/\bprocess\.stdin\b(?!\.)/g, ''); if (!/\bisTTY\b|\bstdin\b/.test(stdinSide)) { return []; @@ -124,6 +124,23 @@ describe('guard: output decisions read stdout; only the decision to prompt reads expect(outputDecisionsOnStdin(source)).toEqual([]); }); + it('allows reading piped data from process.stdin as a stream, while its terminal flag is still a read', () => { + const source: SourceFile = { + path: 'core/piped-input.ts', + text: [ + 'export async function readPipedInput(stream: AsyncIterable = process.stdin): Promise {', + ' for await (const chunk of process.stdin) {', + ' }', + '}', + 'const draw = process.stdin.isTTY ? spinner() : undefined;', + ].join('\n'), + }; + + expect(outputDecisionsOnStdin(source).map((violation) => violation.split(' <-')[0])).toEqual([ + 'src/core/piped-input.ts:5 const draw = process.stdin.isTTY ? spinner() : undefined;', + ]); + }); + it('treats a plumbing line as plumbing only inside the plumbing files', () => { const outside: SourceFile = { path: 'deployments/deployment.watcher.ts', text: 'isTTY: options.isTTY,' }; const inside: SourceFile = { path: 'core/service-context.ts', text: 'isTTY: options.isTTY,' }; From 64bd6e7a13191b5c9a4f92abc4215cf2a006975c Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:37 +0530 Subject: [PATCH 2/8] refactor(projects): reuse the shared core helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects now use the shared name lookup, paging, resource reference and deployment lookup, and warn about --branch / --auto-deploy on a FileUpload project through the shared helper. The project-only reference helper is removed. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- src/projects/project-ref.test.ts | 39 -------------------- src/projects/project-ref.ts | 23 ------------ src/projects/project.create.ts | 19 +++------- src/projects/project.follow.ts | 23 +++--------- src/projects/project.inputs.test.ts | 2 ++ src/projects/project.inputs.ts | 28 ++------------- src/projects/project.presenter.ts | 45 ++++++++---------------- src/projects/project.resolver.test.ts | 32 ++++++++--------- src/projects/project.resolver.ts | 25 +++++-------- src/projects/project.source.ts | 2 +- src/projects/projects.api.ts | 34 +++++------------- test/integration/projects-paging.test.ts | 10 +++--- 12 files changed, 66 insertions(+), 216 deletions(-) delete mode 100644 src/projects/project-ref.test.ts delete mode 100644 src/projects/project-ref.ts diff --git a/src/projects/project-ref.test.ts b/src/projects/project-ref.test.ts deleted file mode 100644 index 732b5a6..00000000 --- a/src/projects/project-ref.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import { ProjectRef, parseProjectRef, uidProjectRef } from './project-ref'; - -const UID = randomBytes(12).toString('hex'); - -describe('parseProjectRef', () => { - it('classifies a 24-character hex string as a uid', () => { - const ref = parseProjectRef(UID); - - expect(ref).toEqual({ kind: 'uid', uid: UID }); - }); - - it('classifies an uppercase 24-character hex string as a uid', () => { - const upper = UID.toUpperCase(); - - expect(parseProjectRef(upper)).toEqual({ kind: 'uid', uid: upper }); - }); - - it.each([['0'.repeat(23)], ['0'.repeat(25)], ['marketing-site'], [''], ['g'.repeat(24)], [' '.repeat(24)]])( - 'classifies %p as a name', - (input) => { - expect(parseProjectRef(input)).toEqual({ kind: 'name', name: input }); - }, - ); - - it.each([['blt4d9e2a7c1f6b3085'], ['marketing-site'], [''], ['0'.repeat(24)]])( - 'takes %p as a uid without sniffing its shape when the caller already knows it is one', - (input) => { - expect(uidProjectRef(input)).toEqual({ kind: 'uid', uid: input }); - expect(ProjectRef.uid(input)).toEqual({ kind: 'uid', uid: input }); - }, - ); - - it('is reachable as ProjectRef.parse, the form callers use', () => { - expect(ProjectRef.parse('marketing-site')).toEqual({ kind: 'name', name: 'marketing-site' }); - expect(ProjectRef.parse(UID)).toEqual({ kind: 'uid', uid: UID }); - }); -}); diff --git a/src/projects/project-ref.ts b/src/projects/project-ref.ts deleted file mode 100644 index be97a10..00000000 --- a/src/projects/project-ref.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const PROJECT_UID_PATTERN = /^[0-9a-f]{24}$/i; - -export interface ProjectUidRef { - readonly kind: 'uid'; - readonly uid: string; -} - -export interface ProjectNameRef { - readonly kind: 'name'; - readonly name: string; -} - -export type ProjectRef = ProjectUidRef | ProjectNameRef; - -export function parseProjectRef(input: string): ProjectRef { - return PROJECT_UID_PATTERN.test(input) ? { kind: 'uid', uid: input } : { kind: 'name', name: input }; -} - -export function uidProjectRef(input: string): ProjectUidRef { - return { kind: 'uid', uid: input }; -} - -export const ProjectRef = { parse: parseProjectRef, uid: uidProjectRef }; diff --git a/src/projects/project.create.ts b/src/projects/project.create.ts index e9366e3..bfa114f 100644 --- a/src/projects/project.create.ts +++ b/src/projects/project.create.ts @@ -3,12 +3,13 @@ import { basename, relative, resolve, sep } from 'node:path'; import { LaunchError, UsageError } from '../core/errors'; import type { ProjectConfig } from '../core/project-config'; import { ProjectConfigStore } from '../core/project-config'; -import { askText } from '../core/prompt'; +import { askText, needInput } from '../core/prompt'; import type { ServiceContext } from '../core/service-context'; import { messageOf } from '../core/values'; import type { WatchTiming } from '../deployments/deployment.watcher'; import { EnvironmentBuilder } from '../environments/environment.builder'; import { ENVIRONMENT_NAME_MAX_LENGTH } from '../environments/environment.inputs'; +import { withoutRepositoryFlags } from '../environments/environment.rules'; import { repositoryLabel } from '../git/git.presenter'; import { repositorySearchTerm } from '../git/git.prompt'; import type { LocalGitHubRepository } from '../git/local-repository'; @@ -16,19 +17,16 @@ import { GIT_PROVIDER_GITHUB } from '../git/types'; import { LaunchApiError } from '../transport/errors'; import { DuplicateProjectNameError, ProjectCreateFailedError } from './project.errors'; import { FirstDeploymentFollower } from './project.follow'; -import type { GitOnlyFlag, ProjectTypeChoice } from './project.inputs'; +import type { ProjectTypeChoice } from './project.inputs'; import { - GIT_ONLY_FLAGS, PROJECT_TYPE_BY_CHOICE, askProjectType, - needInput, projectTypeChoiceOf, } from './project.inputs'; import { RENAME_PROJECT_QUESTION, createFailureCauseLine, duplicateProjectNameLine, - gitOnlyFlagLine, projectCreatedLine, projectCreationFailedLine, renameAndRerunLine, @@ -242,15 +240,6 @@ export class ProjectCreator { return; } - const supplied: Record = { - branch: request.branch, - 'auto-deploy': request.autoDeploy, - }; - - for (const flag of GIT_ONLY_FLAGS) { - if (supplied[flag] !== undefined) { - this.services.ux.print(gitOnlyFlagLine(flag, this.services.outputIsTTY === true)); - } - } + withoutRepositoryFlags(request, 'FILEUPLOAD', this.services.ux, this.services.outputIsTTY === true); } } diff --git a/src/projects/project.follow.ts b/src/projects/project.follow.ts index 53ab438..9b63fe4 100644 --- a/src/projects/project.follow.ts +++ b/src/projects/project.follow.ts @@ -1,6 +1,7 @@ import type { ServiceContext } from '../core/service-context'; import { asSentence, messageOf } from '../core/values'; import { DeploymentFollower } from '../deployments/deployment.follower'; +import { untilFound } from '../deployments/deployment.lookup'; import { DeploymentUnsuccessfulError } from '../deployments/deployment.errors'; import type { WatchTiming } from '../deployments/deployment.watcher'; import type { Deployment } from '../deployments/types'; @@ -9,7 +10,6 @@ import { deploymentFailureMessage } from './project.presenter'; import type { IdentifiedProject } from './types'; const NO_DEPLOYMENT_STATUS = 'NONE'; -const FIRST_LOOKUP_ATTEMPTS = 3; interface Survivors { org: string; @@ -31,7 +31,7 @@ export class FirstDeploymentFollower { async follow(org: string, project: IdentifiedProject, envName: string): Promise { const environment = await this.explaining({ org, project, envName }, () => - this.appearing(() => this.services.api.environments.first({ org, project: project.uid })), + untilFound(() => this.services.api.environments.first({ org, project: project.uid }), this.timing), ); if (environment === undefined) { @@ -39,8 +39,9 @@ export class FirstDeploymentFollower { } const deployment = await this.explaining({ org, project, envName, environment }, () => - this.appearing(() => - this.services.api.deployments.latest({ org, project: project.uid, environment: environment.uid }), + untilFound( + () => this.services.api.deployments.latest({ org, project: project.uid, environment: environment.uid }), + this.timing, ), ); @@ -69,20 +70,6 @@ export class FirstDeploymentFollower { }); } - private async appearing(lookup: () => Promise): Promise { - for (let attempt = 1; attempt < FIRST_LOOKUP_ATTEMPTS; attempt += 1) { - const found = await lookup(); - - if (found !== undefined) { - return found; - } - - await this.timing.sleep(this.timing.pollDelayMs); - } - - return lookup(); - } - private async explaining(survivors: Survivors, step: () => Promise): Promise { try { return await step(); diff --git a/src/projects/project.inputs.test.ts b/src/projects/project.inputs.test.ts index 9d21a13..a195645 100644 --- a/src/projects/project.inputs.test.ts +++ b/src/projects/project.inputs.test.ts @@ -122,6 +122,8 @@ describe('the --type flag', () => { { name: 'Continue with FileUpload', value: 'FileUpload' }, ], default: undefined, + loop: false, + pageSize: 2, }, ]); }); diff --git a/src/projects/project.inputs.ts b/src/projects/project.inputs.ts index 4337a54..10cf971 100644 --- a/src/projects/project.inputs.ts +++ b/src/projects/project.inputs.ts @@ -1,12 +1,9 @@ import { Flags } from '@contentstack/cli-utilities'; -import { MissingInputError } from '../core/errors'; -import { CREATE_PROMPT_REMEDIES } from '../environments/environment.inputs'; import type { UxLike } from '../core/prompt'; import type { ResolutionSpec } from '../core/resolution'; -import type { ServiceContext } from '../core/service-context'; import { oneOf, withinLength } from '../core/values'; -import { ProjectRef } from './project-ref'; +import { ResourceRef } from '../core/resource-ref'; import type { ProjectType } from './types'; import { PROJECT_DESCRIPTION_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH } from './types'; import { askOption } from '../core/prompt'; @@ -36,27 +33,6 @@ export function askProjectType(ux: UxLike): Promise { ); } -export async function needInput( - services: ServiceContext, - flag: string, - supplied: string | undefined, - ask: () => Promise, -): Promise { - if (supplied !== undefined) { - return supplied; - } - - if (!services.isTTY) { - throw new MissingInputError(flag, CREATE_PROMPT_REMEDIES); - } - - return ask(); -} - -export const GIT_ONLY_FLAGS = ['branch', 'auto-deploy'] as const; - -export type GitOnlyFlag = (typeof GIT_ONLY_FLAGS)[number]; - export const projectFlags = { project: Flags.string({ description: 'Project name or UID' }), name: Flags.string({ description: `Project name (${PROJECT_NAME_MAX_LENGTH} characters or fewer)` }), @@ -77,7 +53,7 @@ export const projectResolution = { normalize: (value, { services, resolved, source }) => new ProjectResolver(services.api.projects).toUid( resolved.org, - source === 'flag' ? ProjectRef.parse(value) : ProjectRef.uid(value), + source === 'flag' ? ResourceRef.parse(value) : ResourceRef.uid(value), ), } satisfies ResolutionSpec, name: { diff --git a/src/projects/project.presenter.ts b/src/projects/project.presenter.ts index 643ceb4..4d5269b 100644 --- a/src/projects/project.presenter.ts +++ b/src/projects/project.presenter.ts @@ -1,8 +1,8 @@ import type { LaunchError } from '../core/errors'; import type { TableColumn } from '../core/render'; import { styled } from '../core/style'; +import { firstDeploymentFailureMessage } from '../deployments/deployment.presenter'; import { LaunchApiError } from '../transport/errors'; -import type { GitOnlyFlag } from './project.inputs'; import type { Project } from './types'; export const PROJECT_COLUMNS: TableColumn[] = [ @@ -84,10 +84,6 @@ export function renameAndRerunLine(outputIsTTY: boolean): string { return styled('info: Change the project name and re-run the command.', 'green', outputIsTTY); } -export function gitOnlyFlagLine(flag: GitOnlyFlag, outputIsTTY: boolean): string { - return styled(`warn: --${flag} is not supported for FileUpload projects.`, 'yellow', outputIsTTY); -} - export interface PartialCreateFailure { projectName: string; projectUid: string; @@ -101,32 +97,19 @@ export interface PartialCreateFailure { } export function deploymentFailureMessage(failure: PartialCreateFailure): string { - const scope = `--org ${failure.org} --project ${failure.projectUid}`; - const environment = failure.environmentUid === undefined ? '' : ` --env ${failure.environmentUid}`; - const deployment = failure.deploymentUid === undefined ? '' : ` --deployment ${failure.deploymentUid}`; - - if (failure.timedOut === true) { - return ( - `The deployment was still ${failure.status} when the CLI stopped waiting for it; it may still finish. ` + - `The project "${failure.projectName}" (${failure.projectUid}) and its environment ` + - `"${failure.environmentName}" were created. Do not start another deployment yet: ` + - `run csdx launch:deployments:get ${scope}${environment}${deployment} to see how it ends, ` + - `or csdx launch:logs:get ${scope}${environment}${deployment} to follow it.` - ); - } - - const opening = - failure.reason === undefined - ? `The deployment did not succeed; its last status was ${failure.status}. ` - : `The deployment could not be followed to completion: ${failure.reason} `; - - return ( - opening + - `The project "${failure.projectName}" (${failure.projectUid}) and its environment ` + - `"${failure.environmentName}" were created and have not been rolled back. ` + - `Run csdx launch:deployments:create ${scope}${environment} to try the deployment again, ` + - `or csdx launch:logs:get ${scope}${environment}${deployment} to see why it did not succeed.` - ); + return firstDeploymentFailureMessage({ + created: + `The project "${failure.projectName}" (${failure.projectUid}) ` + + `and its environment "${failure.environmentName}"`, + plural: true, + org: failure.org, + project: failure.projectUid, + environmentUid: failure.environmentUid, + deploymentUid: failure.deploymentUid, + status: failure.status, + reason: failure.reason, + timedOut: failure.timedOut, + }); } export const PROJECT_UPDATED = '\u2714 Project updated successfully.'; diff --git a/src/projects/project.resolver.test.ts b/src/projects/project.resolver.test.ts index bf70f8e..0b69fb7 100644 --- a/src/projects/project.resolver.test.ts +++ b/src/projects/project.resolver.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { UsageError } from '../core/errors'; import { LaunchApiError } from '../transport/errors'; -import { ProjectRef } from './project-ref'; +import { ResourceRef } from '../core/resource-ref'; import type { ProjectPages } from './project.resolver'; import { ProjectResolver } from './project.resolver'; import type { ProjectsPage } from './types'; @@ -54,7 +54,7 @@ describe('ProjectResolver', () => { it('returns a uid reference without calling the API at all', async () => { const { projects, pageCalls } = fakePages([]); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse(PRIMARY_UID))).resolves.toBe(PRIMARY_UID); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(PRIMARY_UID))).resolves.toBe(PRIMARY_UID); expect(pageCalls).toEqual([]); }); @@ -62,14 +62,14 @@ describe('ProjectResolver', () => { const { projects, pageCalls } = fakePages([]); const upper = PRIMARY_UID.toUpperCase(); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse(upper))).resolves.toBe(upper); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(upper))).resolves.toBe(upper); expect(pageCalls).toEqual([]); }); it('looks a name up and returns its uid', async () => { const { projects, pageCalls, fetchedPages } = fakePages([[{ uid: PRIMARY_UID, name: 'marketing-site' }]]); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse('marketing-site'))).resolves.toBe( + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse('marketing-site'))).resolves.toBe( PRIMARY_UID, ); expect(pageCalls).toEqual([{ org: 'org1' }]); @@ -80,8 +80,8 @@ describe('ProjectResolver', () => { const { projects, pageCalls } = fakePages([[{ uid: PRIMARY_UID, name: 'marketing-site' }]]); const resolver = new ProjectResolver(projects); - await expect(resolver.toUid('org1', ProjectRef.parse('ghost'))).rejects.toThrow(UsageError); - await expect(resolver.toUid('org1', ProjectRef.parse('ghost'))).rejects.toThrow( + await expect(resolver.toUid('org1', ResourceRef.parse('ghost'))).rejects.toThrow(UsageError); + await expect(resolver.toUid('org1', ResourceRef.parse('ghost'))).rejects.toThrow( 'No project named "ghost" found in this organization.', ); expect(pageCalls).toEqual([{ org: 'org1' }, { org: 'org1' }]); @@ -94,7 +94,7 @@ describe('ProjectResolver', () => { })); const { projects, fetchedPages } = fakePages([firstPage, [{ uid: SECOND_UID, name: 'docs-site' }]], 101); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse('docs-site'))).resolves.toBe(SECOND_UID); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse('docs-site'))).resolves.toBe(SECOND_UID); expect(fetchedPages).toEqual([0, 1]); }); @@ -108,7 +108,7 @@ describe('ProjectResolver', () => { 3, ); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse('ghost'))).rejects.toThrow( + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse('ghost'))).rejects.toThrow( 'No project named "ghost" found in this organization.', ); expect(fetchedPages).toEqual([0, 1, 2]); @@ -124,7 +124,7 @@ describe('ProjectResolver', () => { 3, ); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse('two'))).resolves.toBe('b'.repeat(24)); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse('two'))).resolves.toBe('b'.repeat(24)); expect(fetchedPages).toEqual([0, 1]); }); @@ -132,8 +132,8 @@ describe('ProjectResolver', () => { const { projects, fetchedPages } = fakePages([], 150); const resolver = new ProjectResolver(projects); - await expect(resolver.toUid('org1', ProjectRef.parse('ghost'))).rejects.toThrow(UsageError); - await expect(resolver.toUid('org1', ProjectRef.parse('ghost'))).rejects.toThrow( + await expect(resolver.toUid('org1', ResourceRef.parse('ghost'))).rejects.toThrow(UsageError); + await expect(resolver.toUid('org1', ResourceRef.parse('ghost'))).rejects.toThrow( 'No project named "ghost" found in this organization.', ); expect(fetchedPages).toEqual([]); @@ -142,14 +142,14 @@ describe('ProjectResolver', () => { it.each([['0'.repeat(23)], ['0'.repeat(25)]])('looks the %s-character hex string up as a name', async (value) => { const { projects, pageCalls } = fakePages([[{ uid: PRIMARY_UID, name: value }]]); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse(value))).resolves.toBe(PRIMARY_UID); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(value))).resolves.toBe(PRIMARY_UID); expect(pageCalls).toEqual([{ org: 'org1' }]); }); it('returns a 24-character hex project name unchanged rather than looking it up', async () => { const { projects, pageCalls } = fakePages([[{ uid: PRIMARY_UID, name: HEX_NAME }]]); - await expect(new ProjectResolver(projects).toUid('org1', ProjectRef.parse(HEX_NAME))).resolves.toBe(HEX_NAME); + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(HEX_NAME))).resolves.toBe(HEX_NAME); expect(pageCalls).toEqual([]); }); @@ -157,8 +157,8 @@ describe('ProjectResolver', () => { const { projects } = fakePages([[{ uid: PRIMARY_UID, name: 'Marketing-Site' }]]); const resolver = new ProjectResolver(projects); - await expect(resolver.toUid('org1', ProjectRef.parse('Marketing-Site'))).resolves.toBe(PRIMARY_UID); - await expect(resolver.toUid('org1', ProjectRef.parse('marketing-site'))).rejects.toThrow( + await expect(resolver.toUid('org1', ResourceRef.parse('Marketing-Site'))).resolves.toBe(PRIMARY_UID); + await expect(resolver.toUid('org1', ResourceRef.parse('marketing-site'))).rejects.toThrow( 'No project named "marketing-site" found in this organization.', ); }); @@ -167,7 +167,7 @@ describe('ProjectResolver', () => { const failure = new LaunchApiError(403, [{ code: 'launch.FORBIDDEN', message: 'no access' }]); await expect( - new ProjectResolver(failingPages(failure)).toUid('org1', ProjectRef.parse('marketing-site')), + new ProjectResolver(failingPages(failure)).toUid('org1', ResourceRef.parse('marketing-site')), ).rejects.toBe(failure); }); }); diff --git a/src/projects/project.resolver.ts b/src/projects/project.resolver.ts index 67beeb2..caefc6c 100644 --- a/src/projects/project.resolver.ts +++ b/src/projects/project.resolver.ts @@ -1,6 +1,6 @@ import { UsageError } from '../core/errors'; -import type { ProjectRef } from './project-ref'; -import { hasUid } from '../transport/envelope'; +import { uidByName } from '../core/name-lookup'; +import type { ResourceRef } from '../core/resource-ref'; import type { PageProjectsParams } from './projects.api'; import type { ProjectsPage } from './types'; @@ -11,19 +11,12 @@ export interface ProjectPages { export class ProjectResolver { constructor(private readonly projects: ProjectPages) {} - async toUid(org: string, ref: ProjectRef): Promise { - if (ref.kind === 'uid') { - return ref.uid; - } - - for await (const page of this.projects.pages({ org })) { - const match = page.projects.filter(hasUid).find((project) => project.name === ref.name); - - if (match) { - return match.uid; - } - } - - throw new UsageError(`No project named "${ref.name}" found in this organization.`); + toUid(org: string, ref: ResourceRef): Promise { + return uidByName( + () => this.projects.pages({ org }), + (page) => page.projects, + ref, + (name) => new UsageError(`No project named "${name}" found in this organization.`), + ); } } diff --git a/src/projects/project.source.ts b/src/projects/project.source.ts index fb77c44..6d969a6 100644 --- a/src/projects/project.source.ts +++ b/src/projects/project.source.ts @@ -3,6 +3,7 @@ import { UsageError } from '../core/errors'; import { connectedAccountsUrl } from '../core/region'; import type { ServiceContext } from '../core/service-context'; import { asSentence, messageOf } from '../core/values'; +import { needInput } from '../core/prompt'; import { GitConnectionMissingError, GitNamespaceNotConnectedError, @@ -21,7 +22,6 @@ import { detectGitHubRepository } from '../git/local-repository'; import type { GitNamespacesPage, GitRepository } from '../git/types'; import { GIT_PROVIDER_GITHUB } from '../git/types'; import { LaunchApiError } from '../transport/errors'; -import { needInput } from './project.inputs'; import { FolderUploader } from '../uploads/upload.folder'; import type { EnvironmentSource } from '../environments/types'; import type { CreateRequest } from './types'; diff --git a/src/projects/projects.api.ts b/src/projects/projects.api.ts index 7c5fb9c..a8d4fa7 100644 --- a/src/projects/projects.api.ts +++ b/src/projects/projects.api.ts @@ -1,4 +1,5 @@ import { MAX_PAGES } from '../core/constants'; +import { scanPages } from '../core/paging'; import { UsageError } from '../core/errors'; import { isRecord } from '../core/values'; import { assertPage, hasUid, malformed, unwrap } from '../transport/envelope'; @@ -105,32 +106,13 @@ export class ProjectsApi { return response; } - async *pages(params: PageProjectsParams): AsyncGenerator { - const limit = params.pageSize ?? PROJECT_SCAN_PAGE_SIZE; - let skip = 0; - - for (let fetched = 0; fetched < MAX_PAGES; fetched += 1) { - const page = await this.list({ org: params.org, limit, skip }); - - if (page.projects.length === 0) { - return; - } - - yield page; - - if (page.projects.length < limit) { - return; - } - - skip += page.projects.length; - const total = page.pagination.count; - - if (typeof total === 'number' && skip >= total) { - return; - } - } - - throw new ProjectScanLimitError(); + pages(params: PageProjectsParams): AsyncGenerator { + return scanPages( + (window) => this.list({ org: params.org, ...window }), + (page) => page.projects, + params.pageSize ?? PROJECT_SCAN_PAGE_SIZE, + () => new ProjectScanLimitError(), + ); } async get(params: GetProjectParams): Promise { diff --git a/test/integration/projects-paging.test.ts b/test/integration/projects-paging.test.ts index ca8e338..792cda0 100644 --- a/test/integration/projects-paging.test.ts +++ b/test/integration/projects-paging.test.ts @@ -7,7 +7,7 @@ import { PROJECT_SCAN_PAGE_SIZE } from '../../src/projects/projects.api'; import { UsageError } from '../../src/core/errors'; import { RestApiClient } from '../../src/transport/rest-client'; import type { UxLike } from '../../src/core/prompt'; -import { ProjectRef } from '../../src/projects/project-ref'; +import { ResourceRef } from '../../src/core/resource-ref'; import { ProjectResolver } from '../../src/projects/project.resolver'; import type { CmaSession } from '../../src/transport/cma-client'; @@ -70,7 +70,7 @@ describe('integration: paging GET /projects for name resolution', () => { .query({ limit: String(PROJECT_SCAN_PAGE_SIZE), skip: String(PROJECT_SCAN_PAGE_SIZE) }) .reply(200, pageOf([{ uid: TARGET_UID, name: 'docs-site' }], 101)); - await expect(new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ProjectRef.parse('docs-site'))).resolves.toBe(TARGET_UID); + await expect(new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ResourceRef.parse('docs-site'))).resolves.toBe(TARGET_UID); expect(first.isDone()).toBe(true); expect(second.isDone()).toBe(true); @@ -87,7 +87,7 @@ describe('integration: paging GET /projects for name resolution', () => { .query({ limit: String(PROJECT_SCAN_PAGE_SIZE), skip: String(PROJECT_SCAN_PAGE_SIZE) }) .reply(200, pageOf(projectsOfSize(PROJECT_SCAN_PAGE_SIZE, 'b'), 500)); - await expect(new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ProjectRef.parse('docs-site'))).resolves.toBe(TARGET_UID); + await expect(new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ResourceRef.parse('docs-site'))).resolves.toBe(TARGET_UID); expect(first.isDone()).toBe(true); expect(second.isDone()).toBe(false); @@ -107,7 +107,7 @@ describe('integration: paging GET /projects for name resolution', () => { .query({ limit: String(PROJECT_SCAN_PAGE_SIZE), skip: '150' }) .reply(200, pageOf([], 150)); - const rejection = await new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ProjectRef.parse('ghost')).catch((error: unknown) => error); + const rejection = await new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ResourceRef.parse('ghost')).catch((error: unknown) => error); expect(rejection).toBeInstanceOf(UsageError); expect((rejection as UsageError).message).toBe('No project named "ghost" found in this organization.'); @@ -126,7 +126,7 @@ describe('integration: paging GET /projects for name resolution', () => { .query({ limit: String(PROJECT_SCAN_PAGE_SIZE), skip: '0' }) .reply(200, pageOf([], 900)); - const rejection = await new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ProjectRef.parse('ghost')).catch((error: unknown) => error); + const rejection = await new ProjectResolver(buildDeps().api.projects).toUid(ORG_UID, ResourceRef.parse('ghost')).catch((error: unknown) => error); expect(rejection).toBeInstanceOf(UsageError); expect(first.isDone()).toBe(true); From d87cc0ed36f7e6749552c078a2221c5d56307107 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:37 +0530 Subject: [PATCH 3/8] feat(environments): add the environments API, --env input, resolver, picker and presenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnvironmentsApi gains get, paged list, create, update and delete. Adds the --env input with its name-to-uid resolver and picker, the environment presenter (the agreed env:get rows, the list columns with absent-value placeholders), the repository-only rules and the controller-shaped fixtures. An empty 409 from update is read as a taken name only when the name was changed. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- src/environments/environment.builder.ts | 6 +- src/environments/environment.errors.ts | 3 + src/environments/environment.inputs.test.ts | 124 +++++++ src/environments/environment.inputs.ts | 60 +++- .../environment.presenter.test.ts | 153 +++++++++ src/environments/environment.presenter.ts | 121 +++++++ src/environments/environment.prompt.test.ts | 122 +++++++ src/environments/environment.prompt.ts | 25 ++ src/environments/environment.resolver.test.ts | 109 ++++++ src/environments/environment.resolver.ts | 22 ++ src/environments/environment.rules.ts | 68 ++++ src/environments/environments.api.test.ts | 320 +++++++++++++++++- src/environments/environments.api.ts | 128 ++++++- src/environments/types.ts | 40 +++ src/resources.test.ts | 8 +- src/resources.ts | 10 +- test/fixtures/README.md | 20 ++ test/fixtures/environment-get.json | 37 ++ test/fixtures/environment-not-found.json | 9 + test/fixtures/environments-list.json | 79 +++++ 20 files changed, 1451 insertions(+), 13 deletions(-) create mode 100644 src/environments/environment.presenter.test.ts create mode 100644 src/environments/environment.presenter.ts create mode 100644 src/environments/environment.prompt.test.ts create mode 100644 src/environments/environment.prompt.ts create mode 100644 src/environments/environment.resolver.test.ts create mode 100644 src/environments/environment.resolver.ts create mode 100644 src/environments/environment.rules.ts create mode 100644 test/fixtures/environment-get.json create mode 100644 test/fixtures/environment-not-found.json create mode 100644 test/fixtures/environments-list.json diff --git a/src/environments/environment.builder.ts b/src/environments/environment.builder.ts index eb57bc4..8a18f15 100644 --- a/src/environments/environment.builder.ts +++ b/src/environments/environment.builder.ts @@ -1,9 +1,9 @@ import { MissingInputError } from '../core/errors'; -import { askOption, askOptionalText, askText } from '../core/prompt'; +import { CREATE_PROMPT_REMEDIES, askOption, askOptionalText, askText } from '../core/prompt'; import { requireValueOf } from '../core/rules'; import type { ServiceContext } from '../core/service-context'; import type { ResponseMode, ToggleValue } from './environment.inputs'; -import { CREATE_PROMPT_REMEDIES, RESPONSE_MODES, TOGGLE_VALUES, frameworkPresetOf } from './environment.inputs'; +import { RESPONSE_MODES, TOGGLE_VALUES, frameworkPresetOf } from './environment.inputs'; import type { FrameworkPreset } from './frameworks'; import { FRAMEWORK_CHOICES, @@ -17,7 +17,7 @@ function emptyEnvironmentVariables(): [] { return []; } -function frameworkLabelOf(detected: unknown): string | undefined { +export function frameworkLabelOf(detected: unknown): string | undefined { if (typeof detected !== 'string') { return undefined; } diff --git a/src/environments/environment.errors.ts b/src/environments/environment.errors.ts index c7d6e5f..fde50ac 100644 --- a/src/environments/environment.errors.ts +++ b/src/environments/environment.errors.ts @@ -2,6 +2,9 @@ import type { ErrorMessages } from '../transport/errors'; 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.', 'launch.ENVIRONMENT.LIMIT_REACHED': 'This project has reached its environment limit.', 'launch.ENVIRONMENT.CREATE_FAILED': 'The Launch API could not create that environment.', }; + +export const DUPLICATE_NAME_CODE = 'launch.ENVIRONMENT.DUPLICATE_NAME'; diff --git a/src/environments/environment.inputs.test.ts b/src/environments/environment.inputs.test.ts index bab4517..cb78aa3 100644 --- a/src/environments/environment.inputs.test.ts +++ b/src/environments/environment.inputs.test.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import { UsageError } from '../core/errors'; import type { ResolveServices } from '../core/resolution'; import type { ApiSurface } from '../resources'; @@ -30,6 +32,7 @@ const FROM_FLAGS = { 'server-cmd': 'flag', framework: 'flag' } as const; describe('environment inputs', () => { it('names each flag as the Commands Details "All flags" tables name it', () => { expect(Object.keys(environmentFlags)).toEqual([ + 'env', 'env-name', 'branch', 'framework', @@ -39,6 +42,10 @@ describe('environment inputs', () => { 'res-mode', 'auto-deploy', 'cs-auth', + 'cache-priming', + 'password-protection', + 'username', + 'password', ]); }); @@ -136,3 +143,120 @@ describe('environment inputs', () => { ); }); }); + +const ENV_UID = randomBytes(12).toString('hex'); + +function envServices(pages: { uid?: string; name?: string }[][]) { + const scanned: unknown[] = []; + const listed: unknown[] = []; + const asked: unknown[] = []; + const ux: UxLike = { + print: () => undefined, + inquire: async (payload: unknown) => { + asked.push(payload); + return 'e9' as never; + }, + }; + const api = { + environments: { + pages: async function* (params: unknown) { + scanned.push(params); + for (const environments of pages) { + yield { pagination: { count: 0, limit: 100 }, environments }; + } + }, + list: async (params: unknown) => { + listed.push(params); + return { pagination: { count: 1, limit: 100 }, environments: [{ uid: 'e9', name: 'production' }] }; + }, + }, + } as unknown as ApiSurface; + + return { services: { api, ux, isTTY: true }, scanned, listed, asked }; +} + +const RESOLVED_SCOPE = { org: 'org1', project: 'proj1' }; + +describe('the --env input', () => { + it('describes the flag as taking a name or a uid, with no short form', () => { + expect(environmentFlags.env.description).toBe('Environment name or UID'); + expect((environmentFlags.env as { char?: string }).char).toBeUndefined(); + }); + + it('resolves after the organization and the project and is never read from the config file', () => { + expect(environmentResolution.env.dependsOn).toEqual(['org', 'project']); + expect((environmentResolution.env as { configPath?: string }).configPath).toBeUndefined(); + }); + + it('resolves a name given on the command line to the uid of the environment with exactly that name', async () => { + const { services: s, scanned } = envServices([[{ uid: 'e1', name: 'Production' }, { uid: 'e2', name: 'production' }]]); + + await expect( + environmentResolution.env.normalize('production', { services: s, resolved: RESOLVED_SCOPE, source: 'flag' }), + ).resolves.toBe('e2'); + expect(scanned).toEqual([RESOLVED_SCOPE]); + }); + + it('takes a 24-character hex value on the command line as a uid without scanning', async () => { + const { services: s, scanned } = envServices([]); + + await expect( + environmentResolution.env.normalize(ENV_UID, { services: s, resolved: RESOLVED_SCOPE, source: 'flag' }), + ).resolves.toBe(ENV_UID); + expect(scanned).toEqual([]); + }); + + it('takes the picker answer as a uid without scanning, whatever it looks like', async () => { + const { services: s, scanned } = envServices([[{ uid: 'e2', name: 'e9' }]]); + + await expect( + environmentResolution.env.normalize('e9', { services: s, resolved: RESOLVED_SCOPE, source: 'prompt' }), + ).resolves.toBe('e9'); + expect(scanned).toEqual([]); + }); + + it('refuses a name on the command line that no environment in the project carries', async () => { + const { services: s } = envServices([[{ uid: 'e1', name: 'Production' }]]); + + await expect( + environmentResolution.env.normalize('production', { services: s, resolved: RESOLVED_SCOPE, source: 'flag' }), + ).rejects.toThrow(new UsageError('No environment named "production" found in this project.')); + }); + + it('prompts with the environment picker scoped to the resolved organization and project', async () => { + const { services: s, listed, asked } = envServices([]); + + await expect(environmentResolution.env.prompt({ services: s, resolved: RESOLVED_SCOPE })).resolves.toBe('e9'); + expect(listed).toEqual([{ ...RESOLVED_SCOPE, limit: 100, skip: 0 }]); + expect((asked[0] as { message: string }).message).toBe('Choose an environment'); + }); +}); + +describe('the update-only toggles and credentials', () => { + it.each([['cache-priming'], ['password-protection']] as const)( + 'takes --%s as enable or disable however it is cased, and refuses anything else', + async (flag) => { + await expect(normalize(flag, ' Disable ')).resolves.toBe('disable'); + await expect(normalize(flag, 'enable')).resolves.toBe('enable'); + await expect(normalize(flag, 'yes')).rejects.toThrow(UsageError); + }, + ); + + it('gives --username the short form -u and --password the short form -p', () => { + expect(environmentFlags.username.char).toBe('u'); + expect(environmentFlags.password.char).toBe('p'); + }); + + it('takes --password as presence only, so a password can never be passed on argv', () => { + expect(environmentFlags.password.type).toBe('boolean'); + expect(environmentResolution.password.default).toBe(false); + }); + + it('trims a --username', async () => { + await expect(normalize('username', ' admin ')).resolves.toBe('admin'); + }); + + it('refuses a --username containing a colon, which basic authentication cannot carry', async () => { + await expect(normalize('username', 'ad:min')).rejects.toThrow(new UsageError('--username must not contain ":".')); + }); +}); diff --git a/src/environments/environment.inputs.ts b/src/environments/environment.inputs.ts index f9a64cb..5c48246 100644 --- a/src/environments/environment.inputs.ts +++ b/src/environments/environment.inputs.ts @@ -1,14 +1,32 @@ import { Flags } from '@contentstack/cli-utilities'; +import { UsageError } from '../core/errors'; import type { ResolutionSpec } from '../core/resolution'; import { onlyWithValueOf } from '../core/rules'; import { oneOf, withinLength } from '../core/values'; +import { ResourceRef } from '../core/resource-ref'; +import { promptForEnvironment } from './environment.prompt'; +import { EnvironmentResolver } from './environment.resolver'; import type { FrameworkPreset } from './frameworks'; import { FRAMEWORK_CHOICES, FRAMEWORK_PRESET_BY_LABEL, SERVER_COMMAND_FRAMEWORKS } from './frameworks'; export const ENVIRONMENT_NAME_MAX_LENGTH = 200; -export const CREATE_PROMPT_REMEDIES = { config: false, prompt: true }; +export const ENVIRONMENT_DESCRIPTION_MAX_LENGTH = 255; + +const ENVIRONMENT_WORDING: Record = { + name: `Environment name (${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer)`, + description: `Environment description (${ENVIRONMENT_DESCRIPTION_MAX_LENGTH} characters or fewer)`, +}; + +export function withEnvironmentWording>(flags: F): F { + return Object.fromEntries( + Object.entries(flags).map(([key, flag]) => [ + key, + key in ENVIRONMENT_WORDING ? { ...flag, description: ENVIRONMENT_WORDING[key] } : flag, + ]), + ) as F; +} export const RESPONSE_MODES = ['buffered', 'streaming'] as const; @@ -22,7 +40,10 @@ export function frameworkPresetOf(value: string): FrameworkPreset { return FRAMEWORK_PRESET_BY_LABEL[oneOf('framework', value, FRAMEWORK_CHOICES).toLowerCase()]; } +export const ENVIRONMENT_DEPENDENCIES = { env: ['org', 'project'] } as const; + export const environmentFlags = { + env: Flags.string({ description: 'Environment name or UID' }), 'env-name': Flags.string({ description: 'Name of the environment created with the project ' + @@ -38,11 +59,40 @@ export const environmentFlags = { description: `Deploy on every push, GitHub projects only (${TOGGLE_VALUES.join(' | ')})`, }), 'cs-auth': Flags.string({ description: `Contentstack Authentication (${TOGGLE_VALUES.join(' | ')})` }), + 'cache-priming': Flags.string({ description: `Cache priming (${TOGGLE_VALUES.join(' | ')})` }), + 'password-protection': Flags.string({ description: `Password protection (${TOGGLE_VALUES.join(' | ')})` }), + username: Flags.string({ char: 'u', description: 'Password protection username, with --password-protection enable' }), + password: Flags.boolean({ + char: 'p', + description: + 'With --password-protection enable: read the password from standard input when there is no terminal ' + + '(on a terminal it is asked at a hidden prompt)', + }), }; +export async function usernameOf(value: string): Promise { + const username = value.trim(); + + if (username.includes(':')) { + throw new UsageError('--username must not contain ":".'); + } + + return username; +} + export const serverCommandFrameworkGate = onlyWithValueOf('server-cmd', 'framework', SERVER_COMMAND_FRAMEWORKS); export const environmentResolution = { + env: { + dependsOn: ENVIRONMENT_DEPENDENCIES.env, + prompt: ({ services, resolved }) => + promptForEnvironment(services, { org: resolved.org, project: resolved.project }), + normalize: (value, { services, resolved, source }) => + new EnvironmentResolver(services.api.environments).toUid( + { org: resolved.org, project: resolved.project }, + source === 'flag' ? ResourceRef.parse(value) : ResourceRef.uid(value), + ), + } satisfies ResolutionSpec, 'env-name': { normalize: (value) => withinLength('env-name', value, ENVIRONMENT_NAME_MAX_LENGTH), } satisfies ResolutionSpec, @@ -62,4 +112,12 @@ export const environmentResolution = { 'cs-auth': { normalize: async (value) => oneOf('cs-auth', value, TOGGLE_VALUES), } satisfies ResolutionSpec, + 'cache-priming': { + normalize: async (value) => oneOf('cache-priming', value, TOGGLE_VALUES), + } satisfies ResolutionSpec, + 'password-protection': { + normalize: async (value) => oneOf('password-protection', value, TOGGLE_VALUES), + } satisfies ResolutionSpec, + username: { normalize: usernameOf } satisfies ResolutionSpec, + password: { default: false } satisfies ResolutionSpec, }; diff --git a/src/environments/environment.presenter.test.ts b/src/environments/environment.presenter.test.ts new file mode 100644 index 00000000..ab2efc7 --- /dev/null +++ b/src/environments/environment.presenter.test.ts @@ -0,0 +1,153 @@ +import { + ENVIRONMENT_COLUMNS, + ENVIRONMENT_DELETE_QUESTION, + ENVIRONMENT_DETAIL_COLUMNS, + ENVIRONMENT_VALUE_COLUMN, + environmentDeletedLine, + environmentDetailRows, + environmentNoChangesLine, + environmentNotDeletedLine, +} from './environment.presenter'; + +const FULL = { + uid: 'e1', + name: 'production', + description: 'the live site', + domains: [{ url: 'https://a.io' }, {}, { url: ' ' }, { url: 'https://b.io' }], + gitBranch: 'main', + frameworkPreset: 'NEXTJS', + buildCommand: 'npm run build', + outputDirectory: './.next', + serverCommand: 'npm start', + autoDeployOnPush: true, + isContentstackAuthenticationEnabled: false, + isCachePrimingEnabled: true, + passwordProtection: { isEnabled: false }, + environmentVariables: [ + { key: 'API_KEY', value: 'never-shown' }, + { key: 'REGION', value: 'eu' }, + ], + organizationUid: 'blt0rg', + project: 'p1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', +}; + +describe('ENVIRONMENT_COLUMNS', () => { + it('renders every list column of a full environment', () => { + expect(ENVIRONMENT_COLUMNS.map((column) => column.value(FULL))).toEqual([ + 'production', + 'main', + 'https://a.io', + '2026-09-01T00:00:00.000Z', + 'e1', + ]); + }); + + it('renders a placeholder for a list column that is missing, empty or blank', () => { + const blank = { uid: ' ', name: '', gitBranch: ' ', updatedAt: '', domains: [{ url: '' }] }; + + expect(ENVIRONMENT_COLUMNS.map((column) => column.value({}))).toEqual(['-', '-', '-', '-', '-']); + expect(ENVIRONMENT_COLUMNS.map((column) => column.value(blank))).toEqual(['-', '-', '-', '-', '-']); + }); +}); + +describe('environmentDetailRows', () => { + it('lists every field in the agreed order, booleans as enabled or disabled and variables as a bare count', () => { + expect(environmentDetailRows(FULL)).toEqual([ + { field: 'UID', value: 'e1' }, + { field: 'NAME', value: 'production' }, + { field: 'DESCRIPTION', value: 'the live site' }, + { field: 'BRANCH', value: 'main' }, + { field: 'FRAMEWORK', value: 'NEXTJS' }, + { field: 'BUILD COMMAND', value: 'npm run build' }, + { field: 'OUTPUT DIRECTORY', value: './.next' }, + { field: 'SERVER COMMAND', value: 'npm start' }, + { field: 'AUTO DEPLOY', value: 'enabled' }, + { field: 'VARIABLES', value: '2' }, + { field: 'CACHE PRIMING', value: 'enabled' }, + { field: 'CONTENTSTACK AUTHENTICATION', value: 'disabled' }, + { field: 'PASSWORD PROTECTION', value: 'disabled' }, + { field: 'URL', value: 'https://a.io, https://b.io' }, + { field: 'ORGANIZATION', value: 'blt0rg' }, + { field: 'PROJECT', value: 'p1' }, + { field: 'CREATED AT', value: '2026-08-01T00:00:00.000Z' }, + { field: 'UPDATED AT', value: '2026-09-01T00:00:00.000Z' }, + ]); + }); + + it('renders a placeholder for every field an environment arrives without, and a zero variable count', () => { + expect(environmentDetailRows({}).map((row) => row.value)).toEqual([ + '-', + '-', + '-', + '-', + '-', + '-', + '-', + '-', + '-', + '0', + '-', + '-', + '-', + '-', + '-', + '-', + '-', + '-', + ]); + }); + + it.each([ + [{ domains: [] }, 'URL', '-'], + [{ domains: [{ url: '' }] }, 'URL', '-'], + [{ passwordProtection: {} }, 'PASSWORD PROTECTION', '-'], + [{ passwordProtection: { isEnabled: true } }, 'PASSWORD PROTECTION', 'enabled'], + [{ environmentVariables: [] }, 'VARIABLES', '0'], + [{ name: ' ' }, 'NAME', '-'], + ])('renders %p as %s = %p', (environment, field, value) => { + expect(environmentDetailRows(environment).find((row) => row.field === field)?.value).toBe(value); + }); + + it('never renders a variable key or value', () => { + const rendered = JSON.stringify(environmentDetailRows(FULL)); + + expect(rendered).not.toContain('API_KEY'); + expect(rendered).not.toContain('never-shown'); + }); +}); + +describe('ENVIRONMENT_DETAIL_COLUMNS', () => { + it('draws a FIELD and a VALUE column and lets the VALUE column flex', () => { + expect(ENVIRONMENT_DETAIL_COLUMNS.map((column) => column.header)).toEqual(['FIELD', 'VALUE']); + expect(ENVIRONMENT_DETAIL_COLUMNS.map((column) => column.value({ field: 'NAME', value: 'production' }))).toEqual([ + 'NAME', + 'production', + ]); + expect(ENVIRONMENT_VALUE_COLUMN).toBe(1); + }); +}); + +describe('environment delete wording', () => { + it('asks the question the Contentstack CLI asks for its own deletes', () => { + expect(ENVIRONMENT_DELETE_QUESTION).toBe('Are you sure you want to delete this environment?'); + }); + + it('says the environment was deleted in green on a terminal and plain otherwise', () => { + expect(environmentDeletedLine(true)).toBe('\u001b[32m\u2714 Environment deleted successfully.\u001b[39m'); + expect(environmentDeletedLine(false)).toBe('\u2714 Environment deleted successfully.'); + }); + + it('says the environment was not deleted in yellow on a terminal and plain otherwise', () => { + expect(environmentNotDeletedLine(true)).toBe('\u001b[33mEnvironment not deleted.\u001b[39m'); + expect(environmentNotDeletedLine(false)).toBe('Environment not deleted.'); + }); +}); + +describe('environmentNoChangesLine', () => { + it('says nothing was changed in yellow on a terminal and plain otherwise', () => { + expect(environmentNoChangesLine(true)).toBe('\u001b[33mEnvironment not updated. No changes were entered.\u001b[39m'); + expect(environmentNoChangesLine(false)).toBe('Environment not updated. No changes were entered.'); + }); +}); diff --git a/src/environments/environment.presenter.ts b/src/environments/environment.presenter.ts new file mode 100644 index 00000000..04eae82 --- /dev/null +++ b/src/environments/environment.presenter.ts @@ -0,0 +1,121 @@ +import type { TableColumn } from '../core/render'; +import { styled } from '../core/style'; +import { firstDeploymentFailureMessage } from '../deployments/deployment.presenter'; +import { isAbsent } from '../core/values'; +import type { Environment } from './types'; + +export interface DetailRow { + field: string; + value: string; +} + +function text(value: string | undefined): string { + return value === undefined || isAbsent(value) ? '-' : value; +} + +function toggle(value: boolean | undefined): string { + if (value === undefined) { + return '-'; + } + + return value ? 'enabled' : 'disabled'; +} + +function urlsOf(environment: Environment): string[] { + return (environment.domains ?? []) + .map((domain) => domain.url) + .filter((url): url is string => !isAbsent(url)); +} + +export const ENVIRONMENT_COLUMNS: TableColumn[] = [ + { header: 'NAME', value: (environment) => text(environment.name) }, + { header: 'BRANCH', value: (environment) => text(environment.gitBranch) }, + { header: 'URL', value: (environment) => text(urlsOf(environment)[0]) }, + { header: 'UPDATED', value: (environment) => text(environment.updatedAt) }, + { header: 'UID', value: (environment) => text(environment.uid) }, +]; + +export const ENVIRONMENT_DETAIL_COLUMNS: TableColumn[] = [ + { header: 'FIELD', value: (row) => row.field }, + { header: 'VALUE', value: (row) => row.value }, +]; + +export const ENVIRONMENT_VALUE_COLUMN = 1; + +export function environmentDetailRows(environment: Environment): DetailRow[] { + return [ + { field: 'UID', value: text(environment.uid) }, + { field: 'NAME', value: text(environment.name) }, + { field: 'DESCRIPTION', value: text(environment.description) }, + { field: 'BRANCH', value: text(environment.gitBranch) }, + { field: 'FRAMEWORK', value: text(environment.frameworkPreset) }, + { field: 'BUILD COMMAND', value: text(environment.buildCommand) }, + { field: 'OUTPUT DIRECTORY', value: text(environment.outputDirectory) }, + { field: 'SERVER COMMAND', value: text(environment.serverCommand) }, + { field: 'AUTO DEPLOY', value: toggle(environment.autoDeployOnPush) }, + { field: 'VARIABLES', value: String(environment.environmentVariables?.length ?? 0) }, + { field: 'CACHE PRIMING', value: toggle(environment.isCachePrimingEnabled) }, + { field: 'CONTENTSTACK AUTHENTICATION', value: toggle(environment.isContentstackAuthenticationEnabled) }, + { field: 'PASSWORD PROTECTION', value: toggle(environment.passwordProtection?.isEnabled) }, + { field: 'URL', value: text(urlsOf(environment).join(', ')) }, + { field: 'ORGANIZATION', value: text(environment.organizationUid) }, + { field: 'PROJECT', value: text(environment.project) }, + { field: 'CREATED AT', value: text(environment.createdAt) }, + { field: 'UPDATED AT', value: text(environment.updatedAt) }, + ]; +} + +export const ENVIRONMENT_DELETE_QUESTION = 'Are you sure you want to delete this environment?'; + +export function environmentDeletedLine(outputIsTTY: boolean): string { + return styled('\u2714 Environment deleted successfully.', 'green', outputIsTTY); +} + +export function environmentNotDeletedLine(outputIsTTY: boolean): string { + return styled('Environment not deleted.', 'yellow', outputIsTTY); +} + +export function environmentUpdatedLine(outputIsTTY: boolean): string { + return styled('\u2714 Environment updated successfully.', 'green', outputIsTTY); +} + +export function environmentNotUpdatedLine(outputIsTTY: boolean): string { + return styled('Environment not updated.', 'yellow', outputIsTTY); +} + +export function disableQuestion(feature: string): string { + return `Are you sure you want to disable ${feature} for this environment?`; +} + +export function environmentCreatedLine(outputIsTTY: boolean): string { + return styled('\u2714 Environment created successfully.', 'green', outputIsTTY); +} + +export interface EnvironmentDeploymentFailure { + org: string; + project: string; + environmentName: string; + environmentUid: string; + status: string; + deploymentUid?: string; + reason?: string; + timedOut?: boolean; +} + +export function environmentDeploymentFailureMessage(failure: EnvironmentDeploymentFailure): string { + return firstDeploymentFailureMessage({ + created: `The environment "${failure.environmentName}" (${failure.environmentUid})`, + plural: false, + org: failure.org, + project: failure.project, + environmentUid: failure.environmentUid, + deploymentUid: failure.deploymentUid, + status: failure.status, + reason: failure.reason, + timedOut: failure.timedOut, + }); +} + +export function environmentNoChangesLine(outputIsTTY: boolean): string { + return styled('Environment not updated. No changes were entered.', 'yellow', outputIsTTY); +} diff --git a/src/environments/environment.prompt.test.ts b/src/environments/environment.prompt.test.ts new file mode 100644 index 00000000..54a5a84 --- /dev/null +++ b/src/environments/environment.prompt.test.ts @@ -0,0 +1,122 @@ +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ApiSurface } from '../resources'; +import { promptForEnvironment } from './environment.prompt'; + +const SCOPE = { org: 'org1', project: 'proj1' }; + +function deps(answers: unknown[], page: unknown) { + const asked: unknown[] = []; + const printed: string[] = []; + const requested: unknown[] = []; + let index = 0; + + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: async (payload: unknown) => { + asked.push(payload); + const answer = answers[index]; + index += 1; + return answer as never; + }, + }; + + const api = { + environments: { + list: async (params: unknown) => { + requested.push(params); + return page; + }, + }, + } as unknown as ApiSurface; + + return { deps: { api, ux }, asked, printed, requested }; +} + +describe('promptForEnvironment', () => { + it('offers one page of the project environments as a list labelled by name and answers with the uid', async () => { + const { deps: d, asked, printed, requested } = deps(['e2'], { + pagination: { count: 2, limit: 100 }, + environments: [ + { uid: 'e1', name: 'staging' }, + { uid: 'e2', name: 'production' }, + ], + }); + + await expect(promptForEnvironment(d, SCOPE)).resolves.toBe('e2'); + expect(requested).toEqual([{ ...SCOPE, limit: 100, skip: 0 }]); + expect(asked).toEqual([ + { + type: 'list', + name: 'value', + message: 'Choose an environment', + choices: [ + { name: 'staging', value: 'e1' }, + { name: 'production', value: 'e2' }, + ], + default: undefined, + loop: false, + pageSize: 2, + }, + ]); + expect(printed).toEqual([]); + }); + + it.each([[undefined], [''], [' ']])( + 'labels an environment whose name is %p by its uid', + async (name) => { + const { deps: d, asked } = deps(['e1'], { pagination: { count: 1, limit: 100 }, environments: [{ uid: 'e1', name }] }); + + await promptForEnvironment(d, SCOPE); + + expect((asked[0] as { choices: unknown }).choices).toEqual([{ name: 'e1', value: 'e1' }]); + }, + ); + + it('leaves out an environment that carries no uid, since choosing it could address nothing', async () => { + const { deps: d, asked } = deps(['e2'], { + pagination: { count: 3, limit: 100 }, + environments: [{ name: 'orphan' }, { uid: ' ', name: 'blank' }, { uid: 'e2', name: 'production' }], + }); + + await promptForEnvironment(d, SCOPE); + + expect((asked[0] as { choices: unknown }).choices).toEqual([{ name: 'production', value: 'e2' }]); + }); + + it('refuses with a usage error, asking nothing, when the project has no choosable environment', async () => { + const { deps: d, asked } = deps([], { pagination: { count: 1, limit: 100 }, environments: [{ name: 'orphan' }] }); + + const failure = await promptForEnvironment(d, SCOPE).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe('No environments found in this project.'); + expect(asked).toEqual([]); + }); + + it('says how to reach an environment the first page did not show', async () => { + const { deps: d, printed } = deps(['e1'], { + pagination: { count: 101, limit: 100 }, + environments: [{ uid: 'e1', name: 'production' }], + }); + + await promptForEnvironment(d, SCOPE); + + expect(printed).toEqual(['Showing the first 1 of 101 environments. Use --env to reach any of them.']); + }); + + it('cancels when the picker is dismissed without an answer', async () => { + const { deps: d } = deps([undefined], { pagination: { count: 1, limit: 100 }, environments: [{ uid: 'e1', name: 'production' }] }); + + await expect(promptForEnvironment(d, SCOPE)).rejects.toBeInstanceOf(CancelledError); + }); + + it('propagates a failed environment list', async () => { + const failure = new Error('list failed'); + const api = { environments: { list: async () => Promise.reject(failure) } } as unknown as ApiSurface; + + await expect(promptForEnvironment({ api, ux: deps([], undefined).deps.ux }, SCOPE)).rejects.toBe(failure); + }); +}); diff --git a/src/environments/environment.prompt.ts b/src/environments/environment.prompt.ts new file mode 100644 index 00000000..eb36f5a --- /dev/null +++ b/src/environments/environment.prompt.ts @@ -0,0 +1,25 @@ +import { PICKER_PAGE_SIZE } from '../core/constants'; +import { UsageError } from '../core/errors'; +import type { PromptDeps } from '../core/prompt'; +import { askOption, noteTruncation } from '../core/prompt'; +import { hasUid } from '../transport/envelope'; + +export async function promptForEnvironment(deps: PromptDeps, scope: { org: string; project: string }): Promise { + const page = await deps.api.environments.list({ ...scope, limit: PICKER_PAGE_SIZE, skip: 0 }); + const choosable = page.environments.filter(hasUid); + + if (choosable.length === 0) { + throw new UsageError('No environments found in this project.'); + } + + noteTruncation(deps.ux, page.pagination.count, page.environments.length, 'environments', '--env'); + + return askOption( + deps.ux, + 'Choose an environment', + choosable.map((environment) => ({ + name: environment.name?.trim() ? environment.name : environment.uid, + value: environment.uid, + })), + ); +} diff --git a/src/environments/environment.resolver.test.ts b/src/environments/environment.resolver.test.ts new file mode 100644 index 00000000..bd3a9b7 --- /dev/null +++ b/src/environments/environment.resolver.test.ts @@ -0,0 +1,109 @@ +import { randomBytes } from 'node:crypto'; + +import { EXIT_USAGE } from '../core/constants'; +import { UsageError } from '../core/errors'; +import { ResourceRef } from '../core/resource-ref'; +import type { EnvironmentPages } from './environment.resolver'; +import { EnvironmentResolver } from './environment.resolver'; +import type { EnvironmentsPage } from './types'; + +interface FakeEnvironment { + uid?: string | null; + name?: string; +} + +const SCOPE = { org: 'org1', project: 'proj1' }; +const FIRST_UID = randomBytes(12).toString('hex'); +const SECOND_UID = randomBytes(12).toString('hex'); + +function fakePages(pages: FakeEnvironment[][]) { + const pageCalls: unknown[] = []; + const fetchedPages: number[] = []; + const environments: EnvironmentPages = { + pages: async function* (params: unknown) { + pageCalls.push(params); + for (let index = 0; index < pages.length; index += 1) { + fetchedPages.push(index); + yield { pagination: { count: 0, limit: 100, skip: null }, environments: pages[index] } as EnvironmentsPage; + } + }, + }; + + return { environments, pageCalls, fetchedPages }; +} + +describe('EnvironmentResolver', () => { + it('returns a uid reference without calling the API at all', async () => { + const { environments, pageCalls } = fakePages([]); + + await expect(new EnvironmentResolver(environments).toUid(SCOPE, ResourceRef.parse(FIRST_UID))).resolves.toBe( + FIRST_UID, + ); + expect(pageCalls).toEqual([]); + }); + + it('scans the project for the name and stops reading pages once it matches', async () => { + const { environments, pageCalls, fetchedPages } = fakePages([ + [{ uid: FIRST_UID, name: 'staging' }], + [{ uid: SECOND_UID, name: 'production' }], + [{ uid: FIRST_UID, name: 'never-read' }], + ]); + + await expect( + new EnvironmentResolver(environments).toUid(SCOPE, { kind: 'name', name: 'production' }), + ).resolves.toBe(SECOND_UID); + expect(pageCalls).toEqual([SCOPE]); + expect(fetchedPages).toEqual([0, 1]); + }); + + it('matches the name exactly and case-sensitively', async () => { + const { environments } = fakePages([ + [ + { uid: FIRST_UID, name: 'Production' }, + { uid: FIRST_UID, name: ' production' }, + { uid: SECOND_UID, name: 'production' }, + ], + ]); + + await expect( + new EnvironmentResolver(environments).toUid(SCOPE, { kind: 'name', name: 'production' }), + ).resolves.toBe(SECOND_UID); + }); + + it.each([[undefined], [null], [''], [' ']])( + 'skips an environment of the wanted name whose uid is %p and resolves the next one that has one', + async (uid) => { + const { environments } = fakePages([[{ uid, name: 'production' }], [{ uid: SECOND_UID, name: 'production' }]]); + + await expect( + new EnvironmentResolver(environments).toUid(SCOPE, { kind: 'name', name: 'production' }), + ).resolves.toBe(SECOND_UID); + }, + ); + + it('refuses with a usage error naming the environment when no environment carries that name', async () => { + const { environments } = fakePages([[{ uid: FIRST_UID, name: 'Production' }], [{ uid: null, name: 'production' }]]); + + const failure = await new EnvironmentResolver(environments) + .toUid(SCOPE, { kind: 'name', name: 'production' }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(EXIT_USAGE); + expect((failure as Error).message).toBe('No environment named "production" found in this project.'); + }); + + it('propagates a failed page rather than reporting no match', async () => { + const failure = new Error('page failed'); + const environments: EnvironmentPages = { + pages: () => + ({ + [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(failure) }), + }) as unknown as AsyncGenerator, + }; + + await expect( + new EnvironmentResolver(environments).toUid(SCOPE, { kind: 'name', name: 'production' }), + ).rejects.toBe(failure); + }); +}); diff --git a/src/environments/environment.resolver.ts b/src/environments/environment.resolver.ts new file mode 100644 index 00000000..5296f1d --- /dev/null +++ b/src/environments/environment.resolver.ts @@ -0,0 +1,22 @@ +import { UsageError } from '../core/errors'; +import { uidByName } from '../core/name-lookup'; +import type { ResourceRef } from '../core/resource-ref'; +import type { PageEnvironmentsParams } from './environments.api'; +import type { EnvironmentsPage } from './types'; + +export interface EnvironmentPages { + pages(params: PageEnvironmentsParams): AsyncGenerator; +} + +export class EnvironmentResolver { + constructor(private readonly environments: EnvironmentPages) {} + + toUid(scope: PageEnvironmentsParams, ref: ResourceRef): Promise { + return uidByName( + () => this.environments.pages(scope), + (page) => page.environments, + ref, + (name) => new UsageError(`No environment named "${name}" found in this project.`), + ); + } +} diff --git a/src/environments/environment.rules.ts b/src/environments/environment.rules.ts new file mode 100644 index 00000000..27eff56 --- /dev/null +++ b/src/environments/environment.rules.ts @@ -0,0 +1,68 @@ +import type { UxLike } from '../core/prompt'; +import { styled } from '../core/style'; +import { isAbsent } from '../core/values'; +import { GIT_PROVIDER_GITHUB } from '../git/types'; +import { malformed } from '../transport/envelope'; +import type { ToggleValue } from './environment.inputs'; + +export const REPOSITORY_ONLY_FLAGS = ['branch', 'auto-deploy'] as const; + +export type RepositoryOnlyFlag = (typeof REPOSITORY_ONLY_FLAGS)[number]; + +export function repositoryOnlyFlagLine(flag: RepositoryOnlyFlag, outputIsTTY: boolean): string { + return styled(`warn: --${flag} is not supported for FileUpload projects.`, 'yellow', outputIsTTY); +} + +interface RepositoryFlags { + branch?: string; + autoDeploy?: ToggleValue; +} + +const FIELD_OF: Record = { branch: 'branch', 'auto-deploy': 'autoDeploy' }; + +export function withoutRepositoryFlags( + request: T, + projectType: string | undefined, + ux: UxLike, + outputIsTTY: boolean, +): T { + if (projectType !== 'FILEUPLOAD') { + return request; + } + + const kept = { ...request }; + + for (const flag of REPOSITORY_ONLY_FLAGS) { + if (kept[FIELD_OF[flag]] !== undefined) { + ux.print(repositoryOnlyFlagLine(flag, outputIsTTY)); + delete kept[FIELD_OF[flag]]; + } + } + + return kept; +} + +export interface GitScope { + org: string; + provider: string; + namespace: string; + repoName: string; +} + +export interface ProjectRepository { + gitProvider?: string; + repositoryName?: string; + username?: string; +} + +export function gitScopeOf(org: string, repository: ProjectRepository = {}): GitScope { + const { repositoryName, username } = repository; + + if (repositoryName === undefined || username === undefined || isAbsent(repositoryName) || isAbsent(username)) { + throw malformed('The Launch API returned a GitHub project without its repository name and owner.'); + } + + const provider = repository.gitProvider ?? GIT_PROVIDER_GITHUB; + + return { org, provider, namespace: username, repoName: repositoryName }; +} diff --git a/src/environments/environments.api.test.ts b/src/environments/environments.api.test.ts index 4bffa22..06a2a6e 100644 --- a/src/environments/environments.api.test.ts +++ b/src/environments/environments.api.test.ts @@ -1,9 +1,11 @@ import { randomBytes } from 'node:crypto'; +import { EXIT_USAGE, MAX_PAGES } from '../core/constants'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; import { ENVIRONMENT_ERROR_MESSAGES } from './environment.errors'; -import { EnvironmentsApi } from './environments.api'; +import type { EnvironmentsPage } from './environments.api'; +import { ENVIRONMENT_SCAN_PAGE_SIZE, EnvironmentScanLimitError, EnvironmentsApi } from './environments.api'; const ORG = randomBytes(9).toString('hex'); const PROJECT = randomBytes(12).toString('hex'); @@ -25,6 +27,31 @@ function fakeRestClient(result: unknown) { return { client, requests, messages }; } +function pagingRestClient(pages: { count?: number; size: number }[]) { + const requests: RestRequest[] = []; + let index = 0; + const client = { + request: async (req: RestRequest) => { + requests.push(req); + const page = pages[Math.min(index++, pages.length - 1)]; + return { + pagination: { count: page.count, limit: ENVIRONMENT_SCAN_PAGE_SIZE, skip: null }, + environments: Array.from({ length: page.size }, (_, n) => ({ uid: `e${index}-${n}`, name: `env-${n}` })), + }; + }, + } as unknown as RestApiClient; + + return { client, requests }; +} + +async function drain(iterator: AsyncGenerator): Promise { + const collected: EnvironmentsPage[] = []; + for await (const page of iterator) { + collected.push(page); + } + return collected; +} + describe('EnvironmentsApi', () => { it('lists environments as a project-scoped GET carrying limit and skip', async () => { const page = { pagination: { count: 1, limit: 10, skip: 0 }, environments: [{ uid: ENVIRONMENT, name: 'Default' }] }; @@ -90,6 +117,297 @@ describe('EnvironmentsApi', () => { }, ); + it('gets one environment as a project-scoped GET and unwraps the environment envelope', async () => { + const environment = { uid: ENVIRONMENT, name: 'production' }; + const { client, requests, messages } = fakeRestClient({ environment }); + + const result = await new EnvironmentsApi(client).get({ ...SCOPE, environment: ENVIRONMENT }); + + expect(result).toBe(environment); + expect(requests).toEqual([ + { + method: 'GET', + path: `/projects/${PROJECT}/environments/${ENVIRONMENT}`, + orgUid: ORG, + projectUid: PROJECT, + }, + ]); + expect(messages[0]).toBe(ENVIRONMENT_ERROR_MESSAGES); + }); + + it.each([[{}], [{ environment: null }], [{ environment: 'production' }], ['']])( + 'raises a malformed-response error when the get body %p carries no environment object', + async (body) => { + const { client } = fakeRestClient(body); + + const failure = await new EnvironmentsApi(client) + .get({ ...SCOPE, environment: ENVIRONMENT }) + .catch((error: Error) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as Error).message).toBe('The Launch API returned an environment response without an environment.'); + }, + ); + + it('pages the project one ENVIRONMENT_SCAN_PAGE_SIZE page at a time, advancing skip by the environments returned', async () => { + const { client, requests } = pagingRestClient([ + { count: 150, size: ENVIRONMENT_SCAN_PAGE_SIZE }, + { count: 150, size: 50 }, + ]); + + const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(collected.map((page) => page.environments.length)).toEqual([ENVIRONMENT_SCAN_PAGE_SIZE, 50]); + expect(ENVIRONMENT_SCAN_PAGE_SIZE).toBe(100); + expect(requests).toEqual([ + { + method: 'GET', + path: `/projects/${PROJECT}/environments`, + orgUid: ORG, + projectUid: PROJECT, + query: { limit: ENVIRONMENT_SCAN_PAGE_SIZE, skip: 0 }, + }, + { + method: 'GET', + path: `/projects/${PROJECT}/environments`, + orgUid: ORG, + projectUid: PROJECT, + query: { limit: ENVIRONMENT_SCAN_PAGE_SIZE, skip: ENVIRONMENT_SCAN_PAGE_SIZE }, + }, + ]); + }); + + it('keeps paging past a full page when the API reports no total, and stops on the short page', async () => { + const { client, requests } = pagingRestClient([{ size: ENVIRONMENT_SCAN_PAGE_SIZE }, { size: 1 }]); + + const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(collected).toHaveLength(2); + expect(requests.map((request) => request.query)).toEqual([ + { limit: ENVIRONMENT_SCAN_PAGE_SIZE, skip: 0 }, + { limit: ENVIRONMENT_SCAN_PAGE_SIZE, skip: ENVIRONMENT_SCAN_PAGE_SIZE }, + ]); + }); + + it('stops after a page one shorter than the page size rather than asking for another', async () => { + const { client, requests } = pagingRestClient([{ count: 900, size: ENVIRONMENT_SCAN_PAGE_SIZE - 1 }]); + + const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(collected).toHaveLength(1); + expect(requests).toHaveLength(1); + }); + + it('stops on an empty page without yielding it, even when the count is never satisfied', async () => { + const { client, requests } = pagingRestClient([{ count: 900, size: 0 }]); + + const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(collected).toEqual([]); + expect(requests).toHaveLength(1); + }); + + it('stops once the reported count is reached exactly, even though the last page was full', async () => { + const { client, requests } = pagingRestClient([{ count: ENVIRONMENT_SCAN_PAGE_SIZE, size: ENVIRONMENT_SCAN_PAGE_SIZE }]); + + const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(collected).toHaveLength(1); + expect(requests).toHaveLength(1); + }); + + it('asks for the next page when the reported count is one more than what was read', async () => { + const { client, requests } = pagingRestClient([ + { count: ENVIRONMENT_SCAN_PAGE_SIZE + 1, size: ENVIRONMENT_SCAN_PAGE_SIZE }, + { count: ENVIRONMENT_SCAN_PAGE_SIZE + 1, size: 1 }, + ]); + + await drain(new EnvironmentsApi(client).pages(SCOPE)); + + expect(requests).toHaveLength(2); + }); + + it('raises the page ceiling by name after MAX_PAGES full pages rather than reporting the scan as complete', async () => { + const { client, requests } = pagingRestClient([{ count: Number.NaN, size: ENVIRONMENT_SCAN_PAGE_SIZE }]); + + const rejection = await drain(new EnvironmentsApi(client).pages(SCOPE)).catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(EnvironmentScanLimitError); + expect((rejection as Error).message).toBe( + `Stopped after scanning ${MAX_PAGES} pages of environments without reaching the end of the project. ` + + 'Pass --env with the environment uid instead of its name.', + ); + expect((rejection as EnvironmentScanLimitError).exitCode).toBe(EXIT_USAGE); + expect((rejection as Error).name).toBe('EnvironmentScanLimitError'); + expect(requests).toHaveLength(MAX_PAGES); + }); + + it('fetches no further page once the consumer stops reading', async () => { + const { client, requests } = pagingRestClient([ + { count: 150, size: ENVIRONMENT_SCAN_PAGE_SIZE }, + { count: 150, size: 50 }, + ]); + + for await (const page of new EnvironmentsApi(client).pages(SCOPE)) { + expect(page.environments).toHaveLength(ENVIRONMENT_SCAN_PAGE_SIZE); + break; + } + + expect(requests).toHaveLength(1); + }); + + it('propagates a failed page request instead of swallowing it mid-walk', async () => { + const failure = new LaunchApiError(403, [{ code: 'launch.FORBIDDEN', message: 'no access' }]); + const client = { + request: async () => { + throw failure; + }, + } as unknown as RestApiClient; + + await expect(drain(new EnvironmentsApi(client).pages(SCOPE))).rejects.toBe(failure); + await expect(new EnvironmentsApi(client).get({ ...SCOPE, environment: ENVIRONMENT })).rejects.toBe(failure); + }); + + it('deletes one environment as a bodyless project-scoped DELETE', async () => { + const { client, requests, messages } = fakeRestClient(undefined); + + await expect(new EnvironmentsApi(client).delete({ ...SCOPE, environment: ENVIRONMENT })).resolves.toBeUndefined(); + expect(requests).toEqual([ + { + method: 'DELETE', + path: `/projects/${PROJECT}/environments/${ENVIRONMENT}`, + orgUid: ORG, + projectUid: PROJECT, + }, + ]); + expect(messages[0]).toBe(ENVIRONMENT_ERROR_MESSAGES); + }); + + it('updates one environment with a project-scoped PUT carrying only the given fields', async () => { + const environment = { uid: ENVIRONMENT, name: 'renamed' }; + const { client, requests, messages } = fakeRestClient({ environment }); + const update = { name: 'renamed', isCachePrimingEnabled: false }; + + const result = await new EnvironmentsApi(client).update({ ...SCOPE, environment: ENVIRONMENT, update }); + + expect(result).toBe(environment); + expect(requests).toEqual([ + { + method: 'PUT', + path: `/projects/${PROJECT}/environments/${ENVIRONMENT}`, + orgUid: ORG, + projectUid: PROJECT, + body: { name: 'renamed', isCachePrimingEnabled: false }, + }, + ]); + expect(messages[0]).toBe(ENVIRONMENT_ERROR_MESSAGES); + }); + + it('raises a malformed-response error when the update body carries no environment', async () => { + const { client } = fakeRestClient({}); + + await expect( + new EnvironmentsApi(client).update({ ...SCOPE, environment: ENVIRONMENT, update: { name: 'x' } }), + ).rejects.toThrow('The Launch API returned an environment response without an environment.'); + }); + + it.each([ + [[{ message: 'launch.ENVIRONMENT.DUPLICATE_NAME' }]], + [[{ code: 'launch.ENVIRONMENT.DUPLICATE_NAME', message: 'Environment name already exists.' }]], + [[{}]], + [[]], + ])('words a 409 from the update as a taken name, however the service shaped it (%p)', async (errors) => { + const client = { + request: async () => { + throw new LaunchApiError(409, errors); + }, + } as unknown as RestApiClient; + + const failure = await new EnvironmentsApi(client) + .update({ ...SCOPE, environment: ENVIRONMENT, update: { name: 'taken' } }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as LaunchApiError).status).toBe(409); + expect((failure as Error).message).toBe('An environment with that name already exists in this project.'); + }); + + it.each([ + [[{ code: 'launch.ENVIRONMENT.SOMETHING_ELSE', message: 'Another conflict.' }]], + [[{ message: 'Another conflict.' }]], + ])('leaves a 409 that names some other conflict alone (%p)', async (errors) => { + const conflict = new LaunchApiError(409, errors); + const client = { + request: async () => { + throw conflict; + }, + } as unknown as RestApiClient; + + await expect( + new EnvironmentsApi(client).update({ ...SCOPE, environment: ENVIRONMENT, update: { name: 'x' } }), + ).rejects.toBe(conflict); + }); + + it.each([[[{}]], [[]]])('leaves an empty 409 alone when the update did not change the name (%p)', async (errors) => { + const conflict = new LaunchApiError(409, errors); + const client = { + request: async () => { + throw conflict; + }, + } as unknown as RestApiClient; + + await expect( + new EnvironmentsApi(client).update({ ...SCOPE, environment: ENVIRONMENT, update: { buildCommand: 'make' } }), + ).rejects.toBe(conflict); + }); + + it('propagates any other update failure unchanged', async () => { + const failure = new LaunchApiError(403, [{ code: 'launch.FORBIDDEN_RESOURCE', message: 'not in plan' }]); + const client = { + request: async () => { + throw failure; + }, + } as unknown as RestApiClient; + + await expect( + new EnvironmentsApi(client).update({ ...SCOPE, environment: ENVIRONMENT, update: { name: 'x' } }), + ).rejects.toBe(failure); + }); + + it('words the duplicate-name code the create endpoint sends', () => { + expect(ENVIRONMENT_ERROR_MESSAGES['launch.ENVIRONMENT.DUPLICATE_NAME']).toBe( + 'An environment with that name already exists in this project.', + ); + }); + + it('creates an environment with a project-scoped POST and unwraps the identified environment', async () => { + const environment = { uid: ENVIRONMENT, name: 'staging' }; + const { client, requests, messages } = fakeRestClient({ environment }); + const input = { + name: 'staging', + gitBranch: 'develop', + outputDirectory: './.next', + frameworkPreset: 'NEXTJS' as const, + environmentVariables: [], + }; + + await expect(new EnvironmentsApi(client).create({ ...SCOPE, input })).resolves.toBe(environment); + expect(requests).toEqual([ + { method: 'POST', path: `/projects/${PROJECT}/environments`, orgUid: ORG, projectUid: PROJECT, body: input }, + ]); + expect(messages[0]).toBe(ENVIRONMENT_ERROR_MESSAGES); + }); + + it.each([[{}], [{ environment: { name: 'staging' } }], [{ environment: { uid: ' ' } }]])( + 'refuses a create response %p that carries no addressable environment', + async (body) => { + const { client } = fakeRestClient(body); + const input = { name: 'x', outputDirectory: './out', frameworkPreset: 'OTHER' as const, environmentVariables: [] }; + + await expect(new EnvironmentsApi(client).create({ ...SCOPE, input })).rejects.toBeInstanceOf(LaunchApiError); + }, + ); + it('reports no first environment when the project has none', async () => { const { client } = fakeRestClient({ pagination: { count: 0, limit: 1 }, environments: [] }); diff --git a/src/environments/environments.api.ts b/src/environments/environments.api.ts index c010540..118581c 100644 --- a/src/environments/environments.api.ts +++ b/src/environments/environments.api.ts @@ -1,7 +1,17 @@ -import { assertPage, hasUid, malformed } from '../transport/envelope'; +import { MAX_PAGES } from '../core/constants'; +import { scanPages } from '../core/paging'; +import { UsageError } from '../core/errors'; +import { assertPage, hasUid, malformed, unwrap } from '../transport/envelope'; +import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; -import { ENVIRONMENT_ERROR_MESSAGES } from './environment.errors'; -import type { EnvironmentsPage, IdentifiedEnvironment } from './types'; +import { DUPLICATE_NAME_CODE, ENVIRONMENT_ERROR_MESSAGES } from './environment.errors'; +import type { + CreateEnvironmentInput, + Environment, + EnvironmentUpdate, + EnvironmentsPage, + IdentifiedEnvironment, +} from './types'; export * from './types'; @@ -12,6 +22,58 @@ export interface ListEnvironmentsParams { skip?: number; } +export const ENVIRONMENT_SCAN_PAGE_SIZE = 100; + +export interface PageEnvironmentsParams { + org: string; + project: string; +} + +export class EnvironmentScanLimitError extends UsageError { + constructor() { + super( + `Stopped after scanning ${MAX_PAGES} pages of environments without reaching the end of the project. ` + + 'Pass --env with the environment uid instead of its name.', + ); + this.name = 'EnvironmentScanLimitError'; + } +} + +export interface EnvironmentParams { + org: string; + project: string; + environment: string; +} + +export interface CreateEnvironmentParams { + org: string; + project: string; + input: CreateEnvironmentInput; +} + +export interface UpdateEnvironmentParams extends EnvironmentParams { + update: EnvironmentUpdate; +} + +function asTakenName(error: unknown, renaming: boolean): unknown { + if (!renaming || !(error instanceof LaunchApiError) || error.status !== 409) { + return error; + } + + const namesAnotherConflict = error.errors.some( + (entry) => + (entry.code !== undefined || entry.message !== undefined) && + entry.code !== DUPLICATE_NAME_CODE && + entry.message !== DUPLICATE_NAME_CODE, + ); + + if (namesAnotherConflict) { + return error; + } + + return new LaunchApiError(409, [{ code: DUPLICATE_NAME_CODE }], ENVIRONMENT_ERROR_MESSAGES); +} + export class EnvironmentsApi { constructor(private readonly client: RestApiClient) {} @@ -33,6 +95,66 @@ export class EnvironmentsApi { return response; } + pages(params: PageEnvironmentsParams): AsyncGenerator { + return scanPages( + (window) => this.list({ ...params, ...window }), + (page) => page.environments, + ENVIRONMENT_SCAN_PAGE_SIZE, + () => new EnvironmentScanLimitError(), + ); + } + + async get(params: EnvironmentParams): Promise { + const response = await this.request({ + method: 'GET', + path: `/projects/${params.project}/environments/${params.environment}`, + orgUid: params.org, + projectUid: params.project, + }); + + return unwrap(response, 'environment', 'environment response'); + } + + async create(params: CreateEnvironmentParams): Promise { + const response = await this.request({ + method: 'POST', + path: `/projects/${params.project}/environments`, + orgUid: params.org, + projectUid: params.project, + body: params.input, + }); + const environment = unwrap(response, 'environment', 'environment response'); + + if (!hasUid(environment)) { + throw malformed('The Launch API returned an environment response without an environment uid.'); + } + + return environment; + } + + async update(params: UpdateEnvironmentParams): Promise { + const response = await this.request({ + method: 'PUT', + path: `/projects/${params.project}/environments/${params.environment}`, + orgUid: params.org, + projectUid: params.project, + body: params.update, + }).catch((error: unknown) => { + throw asTakenName(error, params.update.name !== undefined); + }); + + return unwrap(response, 'environment', 'environment response'); + } + + async delete(params: EnvironmentParams): Promise { + await this.request({ + method: 'DELETE', + path: `/projects/${params.project}/environments/${params.environment}`, + orgUid: params.org, + projectUid: params.project, + }); + } + async first(params: { org: string; project: string }): Promise { const page = await this.list({ ...params, limit: 1, skip: 0 }); const [environment] = page.environments; diff --git a/src/environments/types.ts b/src/environments/types.ts index 709f8b4..9b5196d 100644 --- a/src/environments/types.ts +++ b/src/environments/types.ts @@ -13,15 +13,55 @@ export interface EnvironmentDomain { url?: string; } +export interface EnvironmentPasswordProtection { + isEnabled?: boolean; +} + +export interface EnvironmentVariable { + key?: string; + value?: string; +} + export interface Environment { uid?: string; name?: string; + description?: string; gitBranch?: string; frameworkPreset?: string; buildCommand?: string; outputDirectory?: string; serverCommand?: string; domains?: EnvironmentDomain[]; + autoDeployOnPush?: boolean; + isContentstackAuthenticationEnabled?: boolean; + isCachePrimingEnabled?: boolean; + passwordProtection?: EnvironmentPasswordProtection; + environmentVariables?: EnvironmentVariable[]; + organizationUid?: string; + project?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface PasswordProtectionUpdate { + isEnabled: boolean; + username?: string; + password?: string; +} + +export interface EnvironmentUpdate { + name?: string; + description?: string; + gitBranch?: string; + frameworkPreset?: string; + buildCommand?: string; + serverCommand?: string; + outputDirectory?: string; + isStreamingEnabled?: boolean; + autoDeployOnPush?: boolean; + isContentstackAuthenticationEnabled?: boolean; + isCachePrimingEnabled?: boolean; + passwordProtection?: PasswordProtectionUpdate; } export type IdentifiedEnvironment = Environment & { uid: string }; diff --git a/src/resources.test.ts b/src/resources.test.ts index 6ed8042..589835c 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -139,13 +139,15 @@ describe('resolution', () => { expect(table.project.dependsOn).toBe(DEPENDENCIES.project); }); - it('declares no dependency for any flag other than project', () => { + it('declares no dependency for any flag other than project and env', () => { const withDependencies = Object.entries(table) .filter(([, spec]) => spec.dependsOn !== undefined) .map(([key]) => key); - expect(withDependencies).toEqual(['project']); - expect(Object.keys(DEPENDENCIES)).toEqual(['project']); + expect(withDependencies).toEqual(['project', 'env']); + expect(Object.keys(DEPENDENCIES)).toEqual(['project', 'env']); + expect(table.env.dependsOn).toBe(DEPENDENCIES.env); + expect(DEPENDENCIES.env).toEqual(['org', 'project']); }); it('delegates the org prompt to the organization picker and normalises nothing', async () => { diff --git a/src/resources.ts b/src/resources.ts index bdebdec..f76893d 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -2,7 +2,11 @@ import { coreFlags } from './core/catalog'; import { coreResolution, type AnyResolutionSpec } from './core/resolution'; import { organizationFlags, organizationResolution } from './organizations/organization.inputs'; import { PROJECT_DEPENDENCIES, projectFlags, projectResolution } from './projects/project.inputs'; -import { environmentFlags, environmentResolution } from './environments/environment.inputs'; +import { + ENVIRONMENT_DEPENDENCIES, + environmentFlags, + environmentResolution, +} from './environments/environment.inputs'; import { ProjectsApi } from './projects/projects.api'; import { EnvironmentsApi } from './environments/environments.api'; import { DeploymentsApi } from './deployments/deployments.api'; @@ -29,7 +33,9 @@ export type Resolution = typeof resolution; export const resolutionTable: Record = resolution; -export const DEPENDENCIES = { ...PROJECT_DEPENDENCIES } satisfies Partial>; +export const DEPENDENCIES = { ...PROJECT_DEPENDENCIES, ...ENVIRONMENT_DEPENDENCIES } satisfies Partial< + Record +>; export type DependenciesOf = K extends keyof typeof DEPENDENCIES ? (typeof DEPENDENCIES)[K][number] diff --git a/test/fixtures/README.md b/test/fixtures/README.md index 34e5a0e..63935dc 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -75,3 +75,23 @@ now follow `contentfly-management-service` and a live dev11 response: (`src/projects/controllers/projects.controller.ts:484-485`), and the Fastify server (`src/app/main.ts:67`) refuses a bodyless request declared `application/json` with `400`. The delete interceptors therefore refuse a request that carries a `content-type`. + +## Environment fixtures (2026-10-12, from the service source) + +`environments-list.json`, `environment-get.json` and `environment-not-found.json` were written from +`contentfly-management-service` `origin/main`, **not yet corroborated against a live environment**: + +| File | Source | +|---|---| +| `environments-list.json` | `src/environments/api-responses/environment.example.response.ts` `getEnvironmentsSummaryResponse`, with the bare `{pagination, environments}` envelope of `environment.controller.ts` `environmentsV1` | +| `environment-get.json` | the same example environment, wrapped as `{environment}` by `environmentV1` | +| `environment-not-found.json` | `getEnvironmentNotFoundResponse` in the same file | + +Two fields the published example omits are added because the controller and DTO return them: +`domains` (`[{url, createdAt, updatedAt}]`, attached to every environment by both `environmentsV1` +and `environmentV1`) and `isContentstackAuthenticationEnabled` (flattened from +`contentstackAuthentication.isEnabled` in `models/rest/environments.rest.dto.ts`). Identifiers are +substituted as above; environment uids are 24-character lowercase hex, and the environment names are +the placeholders `production` and `staging`. The list's `pagination.skip` is the `0` the controller +echoes from the query; replace it with whatever a live response shows once one is taken. + diff --git a/test/fixtures/environment-get.json b/test/fixtures/environment-get.json new file mode 100644 index 00000000..0c311d3 --- /dev/null +++ b/test/fixtures/environment-get.json @@ -0,0 +1,37 @@ +{ + "environment": { + "uid": "d4e5f60718293a4b5c6d7e8f", + "name": "production", + "description": "It is a test environment.", + "project": "a1b2c3d4e5f60718293a4b5c", + "gitBranch": "main", + "buildCommand": "npx @11ty/eleventy", + "outputDirectory": "./_site", + "frameworkPreset": "OTHER", + "environmentVariables": [ + { + "key": "key1", + "value": "value1" + } + ], + "autoDeployOnPush": true, + "nodeVersion": "22", + "organizationUid": "blt4d9e2a7c1f6b3085", + "createdAt": "2025-11-06T07:39:19.230Z", + "updatedAt": "2025-11-06T07:39:53.582Z", + "createdBy": "blt8c3f1b6d4a9e2075", + "updatedBy": "blt8c3f1b6d4a9e2075", + "isCachePrimingEnabled": false, + "isContentstackAuthenticationEnabled": true, + "passwordProtection": { + "isEnabled": false + }, + "domains": [ + { + "url": "https://production-sample-project.contentstackapps.com", + "createdAt": "2025-11-06T07:39:19.512Z", + "updatedAt": "2025-11-06T07:39:19.512Z" + } + ] + } +} diff --git a/test/fixtures/environment-not-found.json b/test/fixtures/environment-not-found.json new file mode 100644 index 00000000..b3a07bb --- /dev/null +++ b/test/fixtures/environment-not-found.json @@ -0,0 +1,9 @@ +{ + "errors": [ + { + "code": "launch.ENVIRONMENT.NOT_FOUND", + "message": "Environment not found." + } + ], + "status": 404 +} diff --git a/test/fixtures/environments-list.json b/test/fixtures/environments-list.json new file mode 100644 index 00000000..cd15981 --- /dev/null +++ b/test/fixtures/environments-list.json @@ -0,0 +1,79 @@ +{ + "pagination": { + "count": 2, + "limit": 100, + "skip": 0 + }, + "environments": [ + { + "uid": "d4e5f60718293a4b5c6d7e8f", + "name": "production", + "description": "It is a test environment.", + "project": "a1b2c3d4e5f60718293a4b5c", + "gitBranch": "main", + "buildCommand": "npx @11ty/eleventy", + "outputDirectory": "./_site", + "frameworkPreset": "OTHER", + "environmentVariables": [ + { + "key": "key1", + "value": "value1" + } + ], + "autoDeployOnPush": true, + "nodeVersion": "22", + "organizationUid": "blt4d9e2a7c1f6b3085", + "createdAt": "2025-11-06T07:39:19.230Z", + "updatedAt": "2025-11-06T07:39:53.582Z", + "createdBy": "blt8c3f1b6d4a9e2075", + "updatedBy": "blt8c3f1b6d4a9e2075", + "isCachePrimingEnabled": false, + "isContentstackAuthenticationEnabled": true, + "passwordProtection": { + "isEnabled": false + }, + "domains": [ + { + "url": "https://production-sample-project.contentstackapps.com", + "createdAt": "2025-11-06T07:39:19.512Z", + "updatedAt": "2025-11-06T07:39:19.512Z" + } + ] + }, + { + "uid": "e5f60718293a4b5c6d7e8f90", + "name": "staging", + "description": "It is a staging environment.", + "project": "a1b2c3d4e5f60718293a4b5c", + "gitBranch": "develop", + "buildCommand": "npx @11ty/eleventy", + "outputDirectory": "./_site", + "frameworkPreset": "OTHER", + "environmentVariables": [ + { + "key": "key1", + "value": "value1" + } + ], + "autoDeployOnPush": false, + "nodeVersion": "22", + "organizationUid": "blt4d9e2a7c1f6b3085", + "createdAt": "2025-11-07T08:00:00.000Z", + "updatedAt": "2025-11-07T08:10:00.000Z", + "createdBy": "blt8c3f1b6d4a9e2075", + "updatedBy": "blt8c3f1b6d4a9e2075", + "isCachePrimingEnabled": false, + "isContentstackAuthenticationEnabled": true, + "passwordProtection": { + "isEnabled": false + }, + "domains": [ + { + "url": "https://staging-sample-project.contentstackapps.com", + "createdAt": "2025-11-07T08:00:00.000Z", + "updatedAt": "2025-11-07T08:00:00.000Z" + } + ] + } + ] +} From 0e152f342b03473964d387fb94bce58560059d8f Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:37 +0530 Subject: [PATCH 4/8] feat(environments): add launch:env:list and launch:env:get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists a project's environments and shows one environment. Registers the launch:env topic and command ids, describes the topic and points the retired environment commands at their replacements. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- package.json | 8 + src/commands/launch/env/get.test.ts | 71 +++++++ src/commands/launch/env/get.ts | 37 ++++ src/commands/launch/env/list.test.ts | 125 ++++++++++++ src/commands/launch/env/list.ts | 35 ++++ src/commands/launch/environments/index.ts | 4 +- src/commands/launch/index.ts | 2 +- .../environments-list-command.test.ts | 192 ++++++++++++++++++ test/integration/oclif-manifest.test.ts | 4 + test/integration/retired-commands.test.ts | 6 +- 10 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/commands/launch/env/get.test.ts create mode 100644 src/commands/launch/env/get.ts create mode 100644 src/commands/launch/env/list.test.ts create mode 100644 src/commands/launch/env/list.ts create mode 100644 test/integration/environments-list-command.test.ts diff --git a/package.json b/package.json index d3bf5d7..f16da24 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,9 @@ "launch:projects": { "description": "Manage Launch projects" }, + "launch:env": { + "description": "Manage the environments of a Launch project" + }, "launch:functions": { "description": "Run Launch cloud functions locally" } @@ -141,6 +144,11 @@ "launch": "LNCH", "launch:deployments": "LNCH-DPLMNT", "launch:environments": "LNCH-ENV", + "launch:env:create": "LNCH-ENV-CRT", + "launch:env:delete": "LNCH-ENV-DEL", + "launch:env:get": "LNCH-ENV-GET", + "launch:env:list": "LNCH-ENV-LS", + "launch:env:update": "LNCH-ENV-UPD", "launch:functions": "LNCH-FN", "launch:functions:serve": "LNCH-FN-SRV", "launch:logs": "LNCH-LGS", diff --git a/src/commands/launch/env/get.test.ts b/src/commands/launch/env/get.test.ts new file mode 100644 index 00000000..3257f9d --- /dev/null +++ b/src/commands/launch/env/get.test.ts @@ -0,0 +1,71 @@ +import type { UxLike } from '../../../core/prompt'; +import EnvironmentsGet from './get'; + +function commandUnderTest(answer: () => Promise) { + const lines: string[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const fetched: unknown[] = []; + const command = Object.create(EnvironmentsGet.prototype) as EnvironmentsGet; + + Object.assign(command, { + ux, + resolved: { org: 'org1', project: 'proj1', env: 'env1' }, + services: { + ux, + isTTY: false, + outputIsTTY: false, + api: { + environments: { + get: async (params: unknown) => { + fetched.push(params); + return answer(); + }, + }, + }, + }, + }); + + return { command, lines, fetched }; +} + +describe('launch:env:get', () => { + it('fetches the resolved environment and prints its fields as a FIELD | VALUE table', async () => { + const { command, lines, fetched } = commandUnderTest(async () => ({ uid: 'env1', name: 'production' })); + + await command.run(); + + expect(fetched).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); + expect(lines[1]).toBe('│ FIELD │ VALUE │'); + expect(lines[3]).toBe('│ UID │ env1 │'); + expect(lines[5]).toBe('│ NAME │ production │'); + expect(lines).toHaveLength(39); + }); + + it('propagates a failed lookup', async () => { + const failure = new Error('not found'); + const { command, lines } = commandUnderTest(async () => Promise.reject(failure)); + + await expect(command.run()).rejects.toBe(failure); + expect(lines).toEqual([]); + }); + + it('declares org, project and env as required inputs', () => { + expect(EnvironmentsGet.inputs).toEqual({ org: { required: true }, project: { required: true }, env: { required: true } }); + expect(Object.keys(EnvironmentsGet.flags).sort()).toEqual(['env', 'org', 'project']); + }); + + it('shows the project it resolved in the PROJECT row, not the project field the API returns', async () => { + const { command, lines } = commandUnderTest(async () => ({ uid: 'env1', name: 'production', project: 'regenerated' })); + + await command.run(); + + const project = lines.find((line) => line.startsWith('\u2502 PROJECT ')); + expect(project).toContain('proj1'); + expect(project).not.toContain('regenerated'); + }); +}); diff --git a/src/commands/launch/env/get.ts b/src/commands/launch/env/get.ts new file mode 100644 index 00000000..d28f926 --- /dev/null +++ b/src/commands/launch/env/get.ts @@ -0,0 +1,37 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { renderTable } from '../../../core/render'; +import { + ENVIRONMENT_DETAIL_COLUMNS, + ENVIRONMENT_VALUE_COLUMN, + environmentDetailRows, +} from '../../../environments/environment.presenter'; + +const getInputs = inputs({ org: { required: true }, project: { required: true }, env: { required: true } }); + +export default class EnvironmentsGet extends LaunchCommand { + static description = 'Show a single environment of a Launch project'; + + static examples = ['$ csdx launch:env:get --org --project --env ']; + + static inputs = getInputs; + + static flags = flagsFor(getInputs); + + async run(): Promise { + const environment = await this.services.api.environments.get({ + org: this.resolved.org, + project: this.resolved.project, + environment: this.resolved.env, + }); + + renderTable( + this.ux, + ENVIRONMENT_DETAIL_COLUMNS, + environmentDetailRows({ ...environment, project: this.resolved.project }), + process.stdout.columns, + ENVIRONMENT_VALUE_COLUMN, + this.services.outputIsTTY, + ); + } +} diff --git a/src/commands/launch/env/list.test.ts b/src/commands/launch/env/list.test.ts new file mode 100644 index 00000000..7db32d8 --- /dev/null +++ b/src/commands/launch/env/list.test.ts @@ -0,0 +1,125 @@ +import type { UxLike } from '../../../core/prompt'; +import EnvironmentsList from './list'; + +function commandUnderTest( + page: unknown, + resolved: Record = { org: 'org1', project: 'proj1', limit: 50, skip: 0 }, + answer: () => Promise = async () => page, +) { + const lines: string[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const listed: unknown[] = []; + const command = Object.create(EnvironmentsList.prototype) as EnvironmentsList; + + Object.assign(command, { + ux, + resolved, + services: { + ux, + isTTY: false, + api: { + environments: { + list: async (params: unknown) => { + listed.push(params); + return answer(); + }, + }, + }, + }, + }); + + return { command, lines, listed }; +} + +describe('launch:env:list', () => { + it('lists the project environments as a table with a pagination footer', async () => { + const { command, lines, listed } = commandUnderTest({ + pagination: { count: 1, limit: 50, skip: 0 }, + environments: [ + { + uid: 'e1', + name: 'production', + gitBranch: 'main', + domains: [{ url: 'https://a.io' }], + updatedAt: '2026-09-01', + }, + ], + }); + + await command.run(); + + expect(listed).toEqual([{ org: 'org1', project: 'proj1', limit: 50, skip: 0 }]); + expect(lines[1]).toBe('│ NAME │ BRANCH │ URL │ UPDATED │ UID │'); + expect(lines[3]).toBe('│ production │ main │ https://a.io │ 2026-09-01 │ e1 │'); + expect(lines[5]).toBe('Showing 1-1 of 1'); + }); + + it('shows the first domain that carries a url, skipping domains without one', async () => { + const { command, lines } = commandUnderTest({ + pagination: { count: 1, limit: 50, skip: 0 }, + environments: [ + { + uid: 'e1', + name: 'production', + gitBranch: 'main', + domains: [{}, { url: '' }, { url: 'https://a.io' }, { url: 'https://b.io' }], + updatedAt: '2026-09-01', + }, + ], + }); + + await command.run(); + + expect(lines[3]).toBe('│ production │ main │ https://a.io │ 2026-09-01 │ e1 │'); + }); + + it.each([[{}], [{ domains: [] }], [{ domains: [{ url: ' ' }] }]])( + 'renders a placeholder in every cell of an environment row carrying none of its fields (%p)', + async (environment) => { + const { command, lines } = commandUnderTest({ pagination: { count: 1, limit: 50, skip: 0 }, environments: [environment] }); + + await command.run(); + + expect(lines[1]).toBe('│ NAME │ BRANCH │ URL │ UPDATED │ UID │'); + expect(lines[3]).toBe('│ - │ - │ - │ - │ - │'); + }, + ); + + it('prints only the empty-table placeholder and no pagination footer for an empty page', async () => { + const { command, lines } = commandUnderTest({ pagination: { count: 0, limit: 50, skip: 0 }, environments: [] }); + + await command.run(); + + expect(lines).toEqual(['No records found.']); + }); + + it('passes a non-default limit and skip through to the api and numbers the footer from the requested skip', async () => { + const { command, lines, listed } = commandUnderTest( + { pagination: { count: 120, limit: 10, skip: null }, environments: [{ uid: 'e1', name: 'production' }] }, + { org: 'org1', project: 'proj1', limit: 10, skip: 20 }, + ); + + await command.run(); + + expect(listed).toEqual([{ org: 'org1', project: 'proj1', limit: 10, skip: 20 }]); + expect(lines[lines.length - 1]).toBe('Showing 21-21 of 120'); + }); + + it('propagates a failed list request', async () => { + const failure = new Error('list failed'); + const { command } = commandUnderTest(undefined, undefined, async () => Promise.reject(failure)); + + await expect(command.run()).rejects.toBe(failure); + }); + + it('declares org and project as required and limit and skip as optional', () => { + expect(EnvironmentsList.inputs).toEqual({ org: { required: true }, project: { required: true }, limit: {}, skip: {} }); + expect(Object.keys(EnvironmentsList.flags).sort()).toEqual(['limit', 'org', 'project', 'skip']); + expect(Object.keys(EnvironmentsList.flags).sort()).toEqual(Object.keys(EnvironmentsList.inputs).sort()); + }); +}); diff --git a/src/commands/launch/env/list.ts b/src/commands/launch/env/list.ts new file mode 100644 index 00000000..0b0e72b --- /dev/null +++ b/src/commands/launch/env/list.ts @@ -0,0 +1,35 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { renderPagination, renderTable } from '../../../core/render'; +import { ENVIRONMENT_COLUMNS } from '../../../environments/environment.presenter'; + +const listInputs = inputs({ org: { required: true }, project: { required: true }, limit: {}, skip: {} }); + +export default class EnvironmentsList extends LaunchCommand { + static description = 'List the environments of a Launch project'; + + static examples = ['$ csdx launch:env:list --org --project ']; + + static inputs = listInputs; + + static flags = flagsFor(listInputs); + + async run(): Promise { + const page = await this.services.api.environments.list({ + org: this.resolved.org, + project: this.resolved.project, + limit: this.resolved.limit, + skip: this.resolved.skip, + }); + + renderTable( + this.ux, + ENVIRONMENT_COLUMNS, + page.environments, + process.stdout.columns, + undefined, + this.services.outputIsTTY, + ); + renderPagination(this.ux, { ...page.pagination, skip: this.resolved.skip }, page.environments.length); + } +} diff --git a/src/commands/launch/environments/index.ts b/src/commands/launch/environments/index.ts index 4b03f62..61cf6e4 100644 --- a/src/commands/launch/environments/index.ts +++ b/src/commands/launch/environments/index.ts @@ -1,9 +1,9 @@ import { RetiredCommand } from '../../../core/retired-command'; export default class EnvironmentsRetired extends RetiredCommand { - static description = 'Removed in v2 - list environments with launch:environments:list'; + static description = 'Removed in v2 - list environments with launch:env:list'; static retiredName = 'launch:environments'; - static replacements = ['launch:environments:list']; + static replacements = ['launch:env:list']; } diff --git a/src/commands/launch/index.ts b/src/commands/launch/index.ts index 40bf27e..9fb179a 100644 --- a/src/commands/launch/index.ts +++ b/src/commands/launch/index.ts @@ -5,5 +5,5 @@ export default class LaunchRetired extends RetiredCommand { static retiredName = 'launch'; - static replacements = ['launch:projects:create', 'launch:environments:create', 'launch:deployments:create']; + static replacements = ['launch:projects:create', 'launch:env:create', 'launch:deployments:create']; } diff --git a/test/integration/environments-list-command.test.ts b/test/integration/environments-list-command.test.ts new file mode 100644 index 00000000..ec64fc5 --- /dev/null +++ b/test/integration/environments-list-command.test.ts @@ -0,0 +1,192 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import getFixture from '../fixtures/environment-get.json'; +import listFixture from '../fixtures/environments-list.json'; +import notFoundFixture from '../fixtures/environment-not-found.json'; +import projectsFixture from '../fixtures/projects-list.json'; +import { stdoutOfWidth } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; + +function environmentsOfProject() { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0') + .get(`/manage/projects/${PROJECT_UID}/environments`) + .query({ limit: '100', skip: '0' }) + .reply(200, listFixture); +} + +function valueOf(stdout: string, field: string): string | undefined { + const row = stdout.split('\n').find((line) => line.startsWith(`\u2502 ${field} `)); + + return row?.split('\u2502')[2].trim(); +} + +describe('integration: launch:env commands driven through oclif runCommand', () => { + beforeAll(async () => { + config = await loadCliConfig(); + }); + + beforeEach(() => { + process.exitCode = 0; + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.cleanAll(); + }); + + afterAll(() => { + process.exitCode = 0; + nock.restore(); + }); + + it('lists the environments of the project scoped by organization and project headers', async () => { + const restoreWidth = stdoutOfWidth(200); + + try { + const scope = environmentsOfProject(); + + const { error, stdout } = await runCommand( + ['launch:env:list', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR], + config, + ); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(stdout).toContain( + '│ production │ main │ https://production-sample-project.contentstackapps.com │' + + ' 2025-11-06T07:39:53.582Z │ d4e5f60718293a4b5c6d7e8f │', + ); + expect(stdout).toContain('│ staging │ develop │'); + expect(stdout.trimEnd().split('\n').pop()).toBe('Showing 1-2 of 2'); + } finally { + restoreWidth(); + } + }); + + it('resolves a project name to its uid before listing its environments', async () => { + const lookup = nock(LAUNCH_HUB_URL) + .get('/manage/projects') + .query({ limit: '100', skip: '0' }) + .reply(200, projectsFixture); + const scope = environmentsOfProject(); + + const { error } = await runCommand( + ['launch:env:list', '--org', ORG_UID, '--project', 'sample-project', '--data-dir', DATA_DIR], + config, + ); + + expect(error).toBeUndefined(); + expect(lookup.isDone()).toBe(true); + expect(scope.isDone()).toBe(true); + }); + + it('exits 2 naming --project when it is missing and there is no terminal to prompt on', async () => { + const { error } = await runCommand(['launch:env:list', '--org', ORG_UID, '--data-dir', DATA_DIR], config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Missing required value for --project'); + }); + + it('resolves --env by name, fetches it and prints its fields with only a count of its variables', async () => { + const restoreWidth = stdoutOfWidth(200); + + try { + const scan = environmentsOfProject(); + const fetch = nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .get(`/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`) + .reply(200, getFixture); + + const { error, stdout } = await runCommand( + ['launch:env:get', '--org', ORG_UID, '--project', PROJECT_UID, '--env', 'production', '--data-dir', DATA_DIR], + config, + ); + + expect(error).toBeUndefined(); + expect(scan.isDone()).toBe(true); + expect(fetch.isDone()).toBe(true); + expect(valueOf(stdout, 'NAME')).toBe('production'); + expect(valueOf(stdout, 'CONTENTSTACK AUTHENTICATION')).toBe('enabled'); + expect(valueOf(stdout, 'VARIABLES')).toBe('1'); + expect(valueOf(stdout, 'ORGANIZATION')).toBe(ORG_UID); + expect(valueOf(stdout, 'PROJECT')).toBe(PROJECT_UID); + expect(valueOf(stdout, 'CREATED AT')).toBe('2025-11-06T07:39:19.230Z'); + expect(stdout).not.toContain('value1'); + expect(stdout).not.toContain('key1'); + } finally { + restoreWidth(); + } + }); + + it('exits 1 with the CLI wording when the environment does not exist', async () => { + nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`) + .reply(404, notFoundFixture); + + const { error } = await runCommand( + ['launch:env:get', '--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID, '--data-dir', DATA_DIR], + config, + ); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('No environment found with that name or UID.'); + }); + + it('exits 2 when no environment of the project carries the --env name, sending no environment GET', async () => { + const scan = environmentsOfProject(); + const fetch = nock(LAUNCH_HUB_URL).get(new RegExp(`/environments/[0-9a-f]{24}$`)).reply(200, getFixture); + + const { error } = await runCommand( + ['launch:env:get', '--org', ORG_UID, '--project', PROJECT_UID, '--env', 'Production', '--data-dir', DATA_DIR], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('No environment named "Production" found in this project.'); + expect(scan.isDone()).toBe(true); + expect(fetch.isDone()).toBe(false); + }); + + it('exits 2 naming --env when it is missing and there is no terminal to prompt on', async () => { + const { error } = await runCommand( + ['launch:env:get', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Missing required value for --env'); + }); +}); diff --git a/test/integration/oclif-manifest.test.ts b/test/integration/oclif-manifest.test.ts index 5ddedb9..d38166c 100644 --- a/test/integration/oclif-manifest.test.ts +++ b/test/integration/oclif-manifest.test.ts @@ -40,6 +40,10 @@ describe('integration: the oclif manifest in package.json', () => { expect(manifest.oclif.topics?.['launch:functions']?.description).toBe('Run Launch cloud functions locally'); }); + it('describes the launch:env subtopic', () => { + expect(manifest.oclif.topics?.['launch:env']?.description).toBe('Manage the environments of a Launch project'); + }); + it('declares a description for every topic it declares', () => { const topics = manifest.oclif.topics ?? {}; diff --git a/test/integration/retired-commands.test.ts b/test/integration/retired-commands.test.ts index 37c766d..b7ad754 100644 --- a/test/integration/retired-commands.test.ts +++ b/test/integration/retired-commands.test.ts @@ -29,8 +29,6 @@ const CONFIG: Record = { let config: Interfaces.Config; const PLANNED_COMMANDS: Record = { - 'launch:environments:create': 'CL-7168', - 'launch:environments:list': 'CL-7168', 'launch:deployments:create': 'CL-7170', 'launch:deployments:get': 'CL-7170', 'launch:deployments:list': 'CL-7170', @@ -68,7 +66,7 @@ describe('integration: retired V1 command names', () => { expect(error?.oclif?.exit).toBe(2); expect(error?.message).toBe( 'csdx launch was removed in Launch CLI v2. Use csdx launch:projects:create, ' + - 'csdx launch:environments:create or csdx launch:deployments:create instead.', + 'csdx launch:env:create or csdx launch:deployments:create instead.', ); }); @@ -79,7 +77,7 @@ describe('integration: retired V1 command names', () => { ], [ 'launch:environments', - 'csdx launch:environments was removed in Launch CLI v2. Use csdx launch:environments:list instead.', + 'csdx launch:environments was removed in Launch CLI v2. Use csdx launch:env:list instead.', ], ['launch:open', 'csdx launch:open was removed in Launch CLI v2. Use csdx launch:site:open instead.'], ['launch:logs', 'csdx launch:logs was removed in Launch CLI v2. Use csdx launch:logs:get instead.'], From ba93ded9786716afcf20e19ada67484c90f6f590 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:37 +0530 Subject: [PATCH 5/8] feat(environments): add launch:env:create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates an environment and follows its first deployment, ending on the live announcement without a detail table. --help states the same name and description limits the flags enforce. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- src/commands/launch/env/create.test.ts | 121 +++++ src/commands/launch/env/create.ts | 58 +++ src/environments/environment.create.test.ts | 440 +++++++++++++++++++ src/environments/environment.create.ts | 196 +++++++++ test/integration/environments-create.test.ts | 166 +++++++ 5 files changed, 981 insertions(+) create mode 100644 src/commands/launch/env/create.test.ts create mode 100644 src/commands/launch/env/create.ts create mode 100644 src/environments/environment.create.test.ts create mode 100644 src/environments/environment.create.ts create mode 100644 test/integration/environments-create.test.ts diff --git a/src/commands/launch/env/create.test.ts b/src/commands/launch/env/create.test.ts new file mode 100644 index 00000000..90d8359 --- /dev/null +++ b/src/commands/launch/env/create.test.ts @@ -0,0 +1,121 @@ +import { UsageError } from '../../../core/errors'; +import { + ENVIRONMENT_DESCRIPTION_MAX_LENGTH, + ENVIRONMENT_NAME_MAX_LENGTH, +} from '../../../environments/environment.inputs'; +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 EnvironmentsCreate from './create'; + +function commandUnderTest(resolved: Record) { + const command = Object.create(EnvironmentsCreate.prototype) as EnvironmentsCreate; + + Object.assign(command, { + resolved: { org: 'org1', project: 'proj1', ...resolved }, + services: { api: {}, ux: { print: () => undefined, inquire: async () => undefined }, isTTY: false }, + dataDir: '/tmp/site', + configPath: '/tmp/site/.cs-launch.json', + }); + + return command; +} + +describe('launch:env:create', () => { + it('hands every resolved flag to the EnvironmentCreator with the data dir, config path and wait timing', async () => { + const requests: unknown[] = []; + const timings: unknown[] = []; + jest.spyOn(EnvironmentCreator.prototype, 'create').mockImplementation(async function ( + this: EnvironmentCreator, + request, + ) { + requests.push(request); + timings.push((this as unknown as { timing: { timeoutMs: number } }).timing.timeoutMs); + }); + + await commandUnderTest({ + name: 'staging', + description: 'pre-release', + branch: 'develop', + framework: 'NEXTJS', + 'build-cmd': 'npm run build', + 'server-cmd': undefined, + 'output-dir': '.next', + 'res-mode': 'buffered', + 'auto-deploy': 'enable', + 'cs-auth': 'disable', + }).run(); + + expect(requests).toEqual([ + { + org: 'org1', + project: 'proj1', + dataDir: '/tmp/site', + configPath: '/tmp/site/.cs-launch.json', + name: 'staging', + description: 'pre-release', + branch: 'develop', + framework: 'NEXTJS', + buildCmd: 'npm run build', + serverCmd: undefined, + outputDir: '.next', + resMode: 'buffered', + autoDeploy: 'enable', + csAuth: 'disable', + }, + ]); + expect(timings).toEqual([DEPLOYMENT_WAIT_TIMEOUT_MS]); + }); + + it('propagates a failed create', async () => { + const failure = new Error('create failed'); + jest.spyOn(EnvironmentCreator.prototype, 'create').mockRejectedValue(failure); + + await expect(commandUnderTest({}).run()).rejects.toBe(failure); + }); + + it('declares org and project as required, every build setting as optional, and the server-command rule', () => { + expect(EnvironmentsCreate.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + name: {}, + description: {}, + branch: {}, + framework: {}, + 'build-cmd': {}, + 'server-cmd': {}, + 'output-dir': {}, + 'res-mode': {}, + 'auto-deploy': {}, + 'cs-auth': {}, + }); + expect(Object.keys(EnvironmentsCreate.flags).sort()).toEqual(Object.keys(EnvironmentsCreate.inputs).sort()); + expect(EnvironmentsCreate.rules).toEqual([serverCommandFrameworkGate]); + }); + + it('describes --name and --description as the environment its own, leaving the project wording alone', () => { + expect(EnvironmentsCreate.flags.name.description).toBe('Environment name (200 characters or fewer)'); + expect(EnvironmentsCreate.flags.description.description).toBe('Environment description (255 characters or fewer)'); + expect(catalog.name.description).toBe('Project name (200 characters or fewer)'); + expect(catalog.description.description).toBe('Project description (255 characters or fewer)'); + expect(EnvironmentsCreate.flags.org).toBe(catalog.org); + }); + + it('states in --help the same limits the shared --name and --description specs enforce', async () => { + const nameSpec = resolution.name as { normalize(value: string): Promise }; + const descriptionSpec = resolution.description as { normalize(value: string): Promise }; + + await expect(nameSpec.normalize('n'.repeat(ENVIRONMENT_NAME_MAX_LENGTH))).resolves.toHaveLength( + ENVIRONMENT_NAME_MAX_LENGTH, + ); + await expect(nameSpec.normalize('n'.repeat(ENVIRONMENT_NAME_MAX_LENGTH + 1))).rejects.toThrow(UsageError); + await expect(descriptionSpec.normalize('d'.repeat(ENVIRONMENT_DESCRIPTION_MAX_LENGTH))).resolves.toHaveLength( + ENVIRONMENT_DESCRIPTION_MAX_LENGTH, + ); + await expect(descriptionSpec.normalize('d'.repeat(ENVIRONMENT_DESCRIPTION_MAX_LENGTH + 1))).rejects.toThrow( + UsageError, + ); + }); +}); + diff --git a/src/commands/launch/env/create.ts b/src/commands/launch/env/create.ts new file mode 100644 index 00000000..eef801f --- /dev/null +++ b/src/commands/launch/env/create.ts @@ -0,0 +1,58 @@ +import { LaunchCommand } from '../../../core/launch-command'; +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'; + +const createInputs = inputs({ + org: { required: true }, + project: { required: true }, + name: {}, + description: {}, + branch: {}, + framework: {}, + 'build-cmd': {}, + 'server-cmd': {}, + 'output-dir': {}, + 'res-mode': {}, + 'auto-deploy': {}, + 'cs-auth': {}, +}); + +export default class EnvironmentsCreate extends LaunchCommand { + static description = 'Create an environment in a Launch project and follow its first deployment'; + + static examples = [ + '$ csdx launch:env:create --org --project --name staging --branch develop ' + + '--framework NextJs --build-cmd "npm run build" --output-dir .next --res-mode buffered', + '$ csdx launch:env:create --org --project --name staging --data-dir ./site ' + + '# a FileUpload project uploads the folder', + ]; + + static inputs = createInputs; + + static flags = withEnvironmentWording(flagsFor(createInputs)); + + static rules = [serverCommandFrameworkGate]; + + async run(): Promise { + const resolved = this.resolved; + + await new EnvironmentCreator(this.services, defaultWatchTiming()).create({ + org: resolved.org, + project: resolved.project, + dataDir: this.dataDir, + configPath: this.configPath, + name: resolved.name, + description: resolved.description, + branch: resolved.branch, + framework: resolved.framework, + buildCmd: resolved['build-cmd'], + serverCmd: resolved['server-cmd'], + outputDir: resolved['output-dir'], + resMode: resolved['res-mode'], + autoDeploy: resolved['auto-deploy'], + csAuth: resolved['cs-auth'], + }); + } +} diff --git a/src/environments/environment.create.test.ts b/src/environments/environment.create.test.ts new file mode 100644 index 00000000..7f0bd1c --- /dev/null +++ b/src/environments/environment.create.test.ts @@ -0,0 +1,440 @@ +import { MissingInputError } from '../core/errors'; +import type { ServiceContext } from '../core/service-context'; +import { DeploymentUnsuccessfulError } from '../deployments/deployment.errors'; +import { DeploymentFollower } from '../deployments/deployment.follower'; +import type { WatchTiming } from '../deployments/deployment.watcher'; +import { LaunchApiError } from '../transport/errors'; +import { FolderUploader } from '../uploads/upload.folder'; +import type { EnvironmentCreateRequest } from './environment.create'; +import { EnvironmentCreator } from './environment.create'; + +const TIMING: WatchTiming = { sleep: async () => undefined, now: () => 0, pollDelayMs: 1, maxBackoffSteps: 1, timeoutMs: 10 }; + +const GITHUB_PROJECT = { + uid: 'proj1', + projectType: 'GITPROVIDER', + repository: { repositoryName: 'octo-user/site', username: 'octo-user', gitProvider: 'GitHub' }, +}; + +const UPLOAD_PROJECT = { uid: 'proj1', projectType: 'FILEUPLOAD' }; + +interface Harness { + isTTY?: boolean; + project?: unknown; + outcome?: { kind: 'success' | 'failure' | 'timed-out'; status: string }; + answers?: Record; + detectFails?: Error; + noDeployment?: boolean; + deploymentAfterLooks?: number; + latestFails?: Error; + watchFails?: Error; + createFails?: Error; + createdEnvironment?: unknown; +} + +function creatorUnderTest(harness: Harness = {}) { + const calls: string[] = []; + const lines: string[] = []; + const asked: string[] = []; + const created: unknown[] = []; + const slept: number[] = []; + let looks = 0; + + const services = { + isTTY: harness.isTTY ?? false, + outputIsTTY: false, + ux: { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: { message: string }) => { + asked.push(payload.message); + return harness.answers?.[payload.message]; + }, + }, + api: { + projects: { + get: async (params: unknown) => { + calls.push(`project ${JSON.stringify(params)}`); + return harness.project ?? GITHUB_PROJECT; + }, + gitFramework: async (params: unknown) => { + calls.push(`gitFramework ${JSON.stringify(params)}`); + if (harness.detectFails) { + throw harness.detectFails; + } + return { framework: 'NEXTJS', buildCommand: 'npm run build', outputDirectory: './.next' }; + }, + fileFramework: async (params: unknown) => { + calls.push(`fileFramework ${JSON.stringify(params)}`); + if (harness.detectFails) { + throw harness.detectFails; + } + return { framework: 'GATSBY', buildCommand: 'gatsby build', outputDirectory: './public' }; + }, + }, + git: { + branches: async (params: unknown) => { + calls.push(`branches ${JSON.stringify(params)}`); + return { pagination: { count: 2 }, branches: [{ name: 'main' }, { name: 'develop' }] }; + }, + }, + environments: { + create: async (params: unknown) => { + calls.push('create'); + created.push(params); + if (harness.createFails) { + throw harness.createFails; + } + return harness.createdEnvironment ?? { uid: 'env9', name: 'staging' }; + }, + get: async (params: unknown) => { + calls.push(`get ${JSON.stringify(params)}`); + return { uid: 'env9', name: 'staging', domains: [{ url: 'https://staging.example.test' }] }; + }, + }, + deployments: { + latest: async (params: unknown) => { + calls.push(`latest ${JSON.stringify(params)}`); + if (harness.latestFails) { + throw harness.latestFails; + } + looks += 1; + if (harness.noDeployment || looks <= (harness.deploymentAfterLooks ?? 0)) { + return undefined; + } + return { uid: 'dep1', status: 'QUEUED' }; + }, + }, + }, + } as unknown as ServiceContext; + + jest.spyOn(FolderUploader.prototype, 'upload').mockImplementation(async (scope, dataDir, excluded) => { + calls.push(`upload ${JSON.stringify({ scope, dataDir, excluded })}`); + return 'upl1'; + }); + jest.spyOn(DeploymentFollower.prototype, 'watch').mockImplementation(async (scope) => { + calls.push(`watch ${JSON.stringify(scope)}`); + if (harness.watchFails) { + throw harness.watchFails; + } + const outcome = harness.outcome ?? { kind: 'success', status: 'LIVE' }; + return { ...outcome, deployment: { uid: 'dep1', status: outcome.status } }; + }); + jest.spyOn(DeploymentFollower.prototype, 'announceLive').mockImplementation(async (deployment, domains) => { + calls.push(`announce ${JSON.stringify({ deployment, domains })}`); + }); + + const timing: WatchTiming = { + ...TIMING, + pollDelayMs: 250, + sleep: async (ms: number) => { + slept.push(ms); + }, + }; + + return { creator: new EnvironmentCreator(services, timing), calls, lines, asked, created, slept }; +} + +function request(fields: Partial = {}): EnvironmentCreateRequest { + return { + org: 'org1', + project: 'proj1', + dataDir: '/work/site', + configPath: '/work/site/.cs-launch.json', + name: 'staging', + branch: 'develop', + resMode: 'buffered', + ...fields, + }; +} + +describe('EnvironmentCreator', () => { + it('creates a GitHub environment and follows its first deployment, ending on the live announcement', async () => { + const { creator, calls, lines, created } = creatorUnderTest(); + + await creator.create(request({ description: 'pre-release', csAuth: 'disable' })); + + expect(calls).toEqual([ + 'project {"org":"org1","project":"proj1"}', + 'gitFramework {"org":"org1","provider":"GitHub","repoName":"octo-user/site","branchName":"develop","namespace":"octo-user"}', + 'create', + 'latest {"org":"org1","project":"proj1","environment":"env9"}', + 'watch {"org":"org1","project":"proj1","environment":"env9","deployment":"dep1"}', + 'announce {"deployment":{"uid":"dep1","status":"LIVE"}}', + ]); + expect(created).toEqual([ + { + org: 'org1', + project: 'proj1', + input: { + name: 'staging', + description: 'pre-release', + gitBranch: 'develop', + buildCommand: 'npm run build', + outputDirectory: './.next', + frameworkPreset: 'NEXTJS', + environmentVariables: [], + isStreamingEnabled: false, + isContentstackAuthenticationEnabled: false, + }, + }, + ]); + expect(lines[lines.length - 1]).toBe('✔ Environment created successfully.'); + expect(lines.some((line) => line.includes('│'))).toBe(false); + }); + + it('uploads the data dir without the config file and creates a FileUpload environment from the upload', async () => { + const { creator, calls, created } = creatorUnderTest({ project: UPLOAD_PROJECT }); + + await creator.create(request({ branch: undefined })); + + expect(calls.slice(0, 4)).toEqual([ + 'project {"org":"org1","project":"proj1"}', + 'upload {"scope":{"org":"org1","project":"proj1"},"dataDir":"/work/site","excluded":["/work/site/.cs-launch.json"]}', + 'fileFramework {"org":"org1","uploadUid":"upl1"}', + 'create', + ]); + expect(created).toEqual([ + { + org: 'org1', + project: 'proj1', + input: { + name: 'staging', + uploadUid: 'upl1', + buildCommand: 'gatsby build', + outputDirectory: './public', + frameworkPreset: 'GATSBY', + environmentVariables: [], + isStreamingEnabled: false, + }, + }, + ]); + }); + + it.each([ + [{ branch: 'develop' }, '--branch'], + [{ branch: undefined, autoDeploy: 'enable' as const }, '--auto-deploy'], + ])('warns that %p does not apply to a FileUpload project, before uploading, and creates without it', async (fields, flag) => { + const { creator, calls, lines, created } = creatorUnderTest({ project: UPLOAD_PROJECT }); + + await creator.create(request(fields)); + + expect(lines[0]).toBe(`warn: ${flag} is not supported for FileUpload projects.`); + expect(calls[1]).toMatch(/^upload /); + expect(created).toEqual([ + expect.objectContaining({ + input: expect.not.objectContaining({ gitBranch: expect.anything(), autoDeployOnPush: expect.anything() }), + }), + ]); + }); + + it.each([ + [{ name: undefined }, 'name'], + [{ branch: undefined }, 'branch'], + ])('refuses %p off a terminal naming the flag, before detecting or creating anything', async (fields, flag) => { + const { creator, calls } = creatorUnderTest(); + + const failure = await creator.create(request(fields)).catch((error: unknown) => error); + + expect(failure).toEqual(new MissingInputError(flag, { config: false, prompt: true })); + expect(calls).toEqual(['project {"org":"org1","project":"proj1"}']); + }); + + it('asks in the pinned order on a terminal: name, branch, then the build settings', async () => { + const { creator, calls, asked, created } = creatorUnderTest({ + isTTY: true, + answers: { + 'Environment name': 'staging', + 'Choose a branch': 'develop', + 'Framework preset': 'NextJs', + 'Build command': 'npm run build', + 'Output directory': './.next', + 'Response mode': 'streaming', + 'Contentstack Authentication': 'enable', + }, + }); + + await creator.create(request({ name: undefined, branch: undefined, resMode: undefined })); + + expect(asked).toEqual([ + 'Environment name', + 'Choose a branch', + 'Framework preset', + 'Build command', + 'Output directory', + 'Response mode', + 'Contentstack Authentication', + ]); + expect(calls[1]).toBe( + 'branches {"org":"org1","provider":"GitHub","namespace":"octo-user","repoName":"octo-user/site","limit":100,"skip":0}', + ); + expect(created).toEqual([ + expect.objectContaining({ + input: expect.objectContaining({ name: 'staging', gitBranch: 'develop', isStreamingEnabled: true }), + }), + ]); + }); + + it('asks the FileUpload name only after the folder has been uploaded', async () => { + const { creator, calls, asked } = creatorUnderTest({ + project: UPLOAD_PROJECT, + isTTY: true, + answers: { 'Environment name': 'staging', 'Contentstack Authentication': 'enable' }, + }); + + await creator.create(request({ name: undefined, branch: undefined, framework: 'GATSBY', buildCmd: 'b', outputDir: 'o' })); + + expect(calls[1]).toMatch(/^upload /); + expect(asked).toEqual(['Environment name', 'Contentstack Authentication']); + }); + + it.each([ + [{ uid: 'proj1', projectType: 'GITPROVIDER' }], + [{ uid: 'proj1', projectType: 'GITPROVIDER', repository: { username: 'octo-user' } }], + [{ uid: 'proj1', projectType: 'GITPROVIDER', repository: { repositoryName: 'octo-user/site', username: ' ' } }], + ])('reports a GitHub project %p without its repository as a malformed response, asking nothing', async (project) => { + const { creator, asked, calls } = creatorUnderTest({ project, isTTY: true, answers: { 'Environment name': 'x' } }); + + const failure = await creator.create(request({ branch: undefined })).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as Error).message).toBe( + 'The Launch API returned a GitHub project without its repository name and owner.', + ); + expect(asked).toEqual([]); + expect(calls).toEqual(['project {"org":"org1","project":"proj1"}']); + }); + + it('takes the provider as GitHub when the project does not name one', async () => { + const { creator, calls } = creatorUnderTest({ + project: { uid: 'proj1', projectType: 'GITPROVIDER', repository: { repositoryName: 'octo-user/site', username: 'octo-user' } }, + }); + + await creator.create(request()); + + expect(calls[1]).toContain('"provider":"GitHub"'); + }); + + it.each([[GITHUB_PROJECT], [UPLOAD_PROJECT]])( + 'treats a 404 from framework detection as nothing detected and builds from the flags (%p)', + async (project) => { + const detectFails = new LaunchApiError(404, []); + const { creator, created } = creatorUnderTest({ project, detectFails }); + + await creator.create( + request({ branch: project === UPLOAD_PROJECT ? undefined : 'develop', framework: 'OTHER', outputDir: './out' }), + ); + + expect(created).toEqual([ + expect.objectContaining({ input: expect.objectContaining({ frameworkPreset: 'OTHER', outputDirectory: './out' }) }), + ]); + }, + ); + + it('refuses off a terminal naming --framework when detection found nothing and no framework was given', async () => { + const { creator, created } = creatorUnderTest({ detectFails: new LaunchApiError(404, []) }); + + const failure = await creator.create(request()).catch((error: unknown) => error); + + expect(failure).toEqual(new MissingInputError('framework', { config: false, prompt: true })); + expect(created).toEqual([]); + }); + + it('propagates any other detection failure', async () => { + const detectFails = new LaunchApiError(500, []); + const { creator, created } = creatorUnderTest({ detectFails }); + + await expect(creator.create(request({ framework: 'OTHER' }))).rejects.toBe(detectFails); + expect(created).toEqual([]); + }); + + it('reports a failed first deployment as a partial success naming the surviving environment, exit 1', async () => { + const { creator, calls, lines } = creatorUnderTest({ outcome: { kind: 'failure', status: 'FAILED' } }); + + const failure = await creator.create(request()).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1); + expect((failure as Error).message).toBe( + 'The deployment did not succeed; its last status was FAILED. ' + + 'The environment "staging" (env9) was created and has not been rolled back. ' + + 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + + 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see why it did not succeed.', + ); + expect(lines).toContain('\u2714 Environment created successfully.'); + expect(calls.some((call) => call.startsWith('announce'))).toBe(false); + }); + + it('words a wait that ran out as possibly still finishing, naming deployments:get instead of a retry', async () => { + const { creator } = creatorUnderTest({ outcome: { kind: 'timed-out', status: 'DEPLOYING' } }); + + await expect(creator.create(request())).rejects.toThrow( + new DeploymentUnsuccessfulError( + 'The deployment was still DEPLOYING when the CLI stopped waiting for it; it may still finish. ' + + 'The environment "staging" (env9) was created. Do not start another deployment yet: ' + + 'run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to see how it ends, ' + + 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to follow it.', + ), + ); + }); + + it('looks for the first deployment again, after the poll delay, when it is not listed yet', async () => { + const { creator, calls, slept } = creatorUnderTest({ deploymentAfterLooks: 2 }); + + await creator.create(request()); + + expect(calls.filter((call) => call.startsWith('latest'))).toHaveLength(3); + expect(slept).toEqual([250, 250]); + expect(calls).toContain('watch {"org":"org1","project":"proj1","environment":"env9","deployment":"dep1"}'); + }); + + it('reports a created environment with no deployment to follow after three looks, without naming a deployment', async () => { + const { creator, calls, slept } = creatorUnderTest({ noDeployment: true }); + + await expect(creator.create(request())).rejects.toThrow( + new DeploymentUnsuccessfulError( + 'The deployment did not succeed; its last status was NONE. ' + + 'The environment "staging" (env9) was created and has not been rolled back. ' + + 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + + 'or csdx launch:logs:get --org org1 --project proj1 --env env9 to see why it did not succeed.', + ), + ); + expect(calls.filter((call) => call.startsWith('latest'))).toHaveLength(3); + expect(slept).toEqual([250, 250]); + }); + + it.each([ + [{ latestFails: new Error('lookup refused') }, ''], + [{ watchFails: new Error('watch refused') }, ' --deployment dep1'], + ])('words a failed step after the create the same partial-success way (%p)', async (fails, deployment) => { + const { creator } = creatorUnderTest(fails); + + const failure = await creator.create(request()).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as Error).message).toMatch(/^The deployment could not be followed to completion: \w+ refused\. /); + expect((failure as Error).message).toContain('The environment "staging" (env9) was created and has not been rolled back.'); + expect((failure as Error).message).toContain(`csdx launch:logs:get --org org1 --project proj1 --env env9${deployment} to see`); + }); + + it('propagates a refused create without saying the environment was created', async () => { + const createFails = new LaunchApiError(409, [{ code: 'launch.ENVIRONMENT.DUPLICATE_NAME' }]); + const { creator, lines, calls } = creatorUnderTest({ createFails }); + + await expect(creator.create(request())).rejects.toBe(createFails); + expect(lines).not.toContain('\u2714 Environment created successfully.'); + expect(calls[calls.length - 1]).toBe('create'); + }); + + it('names the environment by the name it asked for when the create response carries none', async () => { + const { creator } = creatorUnderTest({ + createdEnvironment: { uid: 'env9' }, + outcome: { kind: 'failure', status: 'FAILED' }, + }); + + await expect(creator.create(request({ name: 'pre-release' }))).rejects.toThrow( + 'The environment "pre-release" (env9) was created and has not been rolled back.', + ); + }); +}); diff --git a/src/environments/environment.create.ts b/src/environments/environment.create.ts new file mode 100644 index 00000000..4e4bd4f --- /dev/null +++ b/src/environments/environment.create.ts @@ -0,0 +1,196 @@ +import { askText, needInput } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { DeploymentUnsuccessfulError } from '../deployments/deployment.errors'; +import { DeploymentFollower } from '../deployments/deployment.follower'; +import { untilFound } from '../deployments/deployment.lookup'; +import type { WatchTiming } from '../deployments/deployment.watcher'; +import { asSentence, messageOf } from '../core/values'; +import { askBranch } from '../git/git.prompt'; +import { LaunchApiError } from '../transport/errors'; +import { FolderUploader } from '../uploads/upload.folder'; +import { EnvironmentBuilder } from './environment.builder'; +import type { GitScope } from './environment.rules'; +import { gitScopeOf, withoutRepositoryFlags } from './environment.rules'; +import type { ToggleValue } from './environment.inputs'; +import { ENVIRONMENT_NAME_MAX_LENGTH } from './environment.inputs'; +import { environmentCreatedLine, environmentDeploymentFailureMessage } from './environment.presenter'; +import type { + DetectedFramework, + EnvironmentRequest, + EnvironmentSource, + IdentifiedEnvironment, +} from './types'; + +export interface EnvironmentCreateRequest extends EnvironmentRequest { + org: string; + project: string; + dataDir: string; + configPath: string; + name?: string; + description?: string; + branch?: string; + autoDeploy?: ToggleValue; +} + +interface NamedSource { + name: string; + source: EnvironmentSource; +} + +async function detection(detect: () => Promise): Promise { + try { + return await detect(); + } catch (error) { + if (error instanceof LaunchApiError && error.status === 404) { + return {}; + } + + throw error; + } +} + +const NO_DEPLOYMENT_STATUS = 'NONE'; + +interface Followed { + scope: { org: string; project: string; environment: IdentifiedEnvironment; name: string }; + deploymentUid?: string; +} + +export class EnvironmentCreator { + constructor( + private readonly services: ServiceContext, + private readonly timing: WatchTiming, + ) {} + + async create(given: EnvironmentCreateRequest): Promise { + const { org, project } = given; + const current = await this.services.api.projects.get({ org, project }); + const request = withoutRepositoryFlags( + given, + current.projectType, + this.services.ux, + this.services.outputIsTTY === true, + ); + + const { name, source } = + current.projectType === 'FILEUPLOAD' + ? await this.fromUpload(request) + : await this.fromRepository(request, gitScopeOf(org, current.repository)); + + const input = await new EnvironmentBuilder(this.services).build(request, name, source, request.autoDeploy); + + if (request.description !== undefined) { + input.description = request.description; + } + + const environment = await this.services.api.environments.create({ org, project, input }); + + this.services.ux.print(environmentCreatedLine(this.services.outputIsTTY === true)); + + await this.follow(request, environment, name); + } + + private async fromUpload(request: EnvironmentCreateRequest): Promise { + const source = await this.uploadSource(request); + + return { name: await this.environmentName(request), source }; + } + + private async fromRepository(request: EnvironmentCreateRequest, git: GitScope): Promise { + const name = await this.environmentName(request); + + return { name, source: await this.gitSource(request, git) }; + } + + private environmentName(request: EnvironmentCreateRequest): Promise { + return needInput(this.services, 'name', request.name, () => + askText(this.services.ux, 'Environment name', undefined, ENVIRONMENT_NAME_MAX_LENGTH), + ); + } + + private async uploadSource(request: EnvironmentCreateRequest): Promise { + const { org, project } = request; + const uploadUid = await new FolderUploader(this.services).upload({ org, project }, request.dataDir, [ + request.configPath, + ]); + const detected = await detection(() => this.services.api.projects.fileFramework({ org, uploadUid })); + + return { detected, uploadUid }; + } + + private async gitSource( + request: EnvironmentCreateRequest, + git: GitScope, + ): Promise { + const branch = await needInput(this.services, 'branch', request.branch, () => askBranch(this.services, git)); + const detected = await detection(() => + this.services.api.projects.gitFramework({ + org: git.org, + provider: git.provider, + repoName: git.repoName, + branchName: branch, + namespace: git.namespace, + }), + ); + + return { detected, branch }; + } + + private async follow( + request: EnvironmentCreateRequest, + environment: IdentifiedEnvironment, + name: string, + ): Promise { + const { org, project } = request; + const followed: Followed = { scope: { org, project, environment, name } }; + const scope = { org, project, environment: environment.uid }; + const deployment = await this.explaining(followed, () => + untilFound(() => this.services.api.deployments.latest(scope), this.timing), + ); + + if (deployment === undefined) { + throw this.unsuccessful(followed, NO_DEPLOYMENT_STATUS); + } + + followed.deploymentUid = deployment.uid; + + const follower = new DeploymentFollower(this.services, this.timing); + const outcome = await this.explaining(followed, () => follower.watch({ ...scope, deployment: deployment.uid })); + + if (outcome.kind !== 'success') { + throw this.unsuccessful(followed, outcome.status, undefined, outcome.kind === 'timed-out'); + } + + await follower.announceLive(outcome.deployment, environment.domains); + } + + private async explaining(followed: Followed, step: () => Promise): Promise { + try { + return await step(); + } catch (error) { + throw this.unsuccessful(followed, NO_DEPLOYMENT_STATUS, asSentence(messageOf(error))); + } + } + + private unsuccessful( + followed: Followed, + status: string, + reason?: string, + timedOut?: boolean, + ): DeploymentUnsuccessfulError { + const { org, project, environment, name } = followed.scope; + + return new DeploymentUnsuccessfulError( + environmentDeploymentFailureMessage({ + org, + project, + environmentName: environment.name ?? name, + environmentUid: environment.uid, + deploymentUid: followed.deploymentUid, + status, + reason, + timedOut, + }), + ); + } +} diff --git a/test/integration/environments-create.test.ts b/test/integration/environments-create.test.ts new file mode 100644 index 00000000..1c70c9d --- /dev/null +++ b/test/integration/environments-create.test.ts @@ -0,0 +1,166 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import * as watcher from '../../src/deployments/deployment.watcher'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +import projectFixture from '../fixtures/project-get.json'; + +jest.mock('open', () => jest.fn().mockResolvedValue(undefined)); + +const realWatchTiming = watcher.defaultWatchTiming; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const DEPLOYMENT_UID = 'f60718293a4b5c6d7e8f9012'; +const ENV_PATH = `/manage/projects/${PROJECT_UID}/environments`; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +const GITHUB_ARGS = [ + 'launch:env:create', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--name', + 'production', + '--branch', + 'main', + '--framework', + 'Other', + '--build-cmd', + 'eleventy', + '--output-dir', + './_site', + '--res-mode', + 'buffered', + '--data-dir', + DATA_DIR, +]; + +let config: Interfaces.Config; +let onWire: string[]; + +function hub(): nock.Scope { + return nock(LAUNCH_HUB_URL).matchHeader('x-organization-uid', ORG_UID).matchHeader('x-project-uid', PROJECT_UID); +} + +function stubCreate(sent: unknown[]): void { + hub().get(`/manage/projects/${PROJECT_UID}`).reply(200, projectFixture); + nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .get('/manage/projects/framework') + .query(true) + .reply(200, { framework: 'OTHER', buildCommand: 'npx @11ty/eleventy', outputDirectory: './_site' }); + hub() + .post(ENV_PATH, (body: unknown) => { + sent.push(body); + return true; + }) + .reply(201, { environment: { uid: ENV_UID, name: 'production' } }); +} + +function stubFollowUp(status: string): void { + hub() + .get(`${ENV_PATH}/${ENV_UID}/deployments`) + .query({ limit: 1, skip: 0 }) + .reply(200, { pagination: { count: 1, limit: 1, skip: null }, deployments: [{ uid: DEPLOYMENT_UID, status: 'QUEUED' }] }); + hub() + .get(`${ENV_PATH}/${ENV_UID}/deployments/${DEPLOYMENT_UID}`) + .reply(200, { deployment: { uid: DEPLOYMENT_UID, status, deploymentUrl: 'production.example.test' } }); + hub() + .get(`/logs/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments/${DEPLOYMENT_UID}/logs/deployment-logs`) + .query(true) + .reply(200, { deploymentLogs: [] }); +} + +describe('integration: launch:env:create on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => undefined, + })); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('creates a GitHub environment and follows its first deployment, without a terminal or a closing read', async () => { + const sent: unknown[] = []; + stubCreate(sent); + stubFollowUp('LIVE'); + + const { error, stdout } = await runCommand(GITHUB_ARGS, config); + + expect(error).toBeUndefined(); + expect(sent).toEqual([ + { + name: 'production', + gitBranch: 'main', + buildCommand: 'eleventy', + outputDirectory: './_site', + frameworkPreset: 'OTHER', + environmentVariables: [], + isStreamingEnabled: false, + }, + ]); + expect(stdout).toContain('✔ Environment created successfully.'); + expect(stdout).toContain('production.example.test'); + expect(stdout).not.toContain('│'); + expect(onWire).toEqual([]); + }); + + it('exits 1 naming the surviving environment when the first deployment fails', async () => { + stubCreate([]); + stubFollowUp('FAILED'); + + const { error, stdout } = await runCommand(GITHUB_ARGS, config); + + expect(error?.oclif?.exit).toBe(1); + expect(stdout).toContain('✔ Environment created successfully.'); + expect(error?.message).toContain( + `The environment "production" (${ENV_UID}) was created and has not been rolled back. ` + + `Run csdx launch:deployments:create --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID}`, + ); + }); +}); From 8df352b33fc0a22cc9806a4687495f0e6685c40b Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:38 +0530 Subject: [PATCH 6/8] feat(environments): add launch:env:update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates an environment from flags, or from a settings checklist when no flag is given on a terminal. Refuses -u / -p without --password-protection enable before any request, and without a terminal reads the password for -p from standard input. Warns about and ignores --branch / --auto-deploy on a FileUpload project. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- src/commands/launch/env/update.test.ts | 135 +++++ src/commands/launch/env/update.ts | 75 +++ .../environment.update.prompt.test.ts | 246 +++++++++ src/environments/environment.update.prompt.ts | 192 +++++++ src/environments/environment.update.test.ts | 514 ++++++++++++++++++ src/environments/environment.update.ts | 235 ++++++++ test/integration/environments-update.test.ts | 210 +++++++ 7 files changed, 1607 insertions(+) create mode 100644 src/commands/launch/env/update.test.ts create mode 100644 src/commands/launch/env/update.ts create mode 100644 src/environments/environment.update.prompt.test.ts create mode 100644 src/environments/environment.update.prompt.ts create mode 100644 src/environments/environment.update.test.ts create mode 100644 src/environments/environment.update.ts create mode 100644 test/integration/environments-update.test.ts diff --git a/src/commands/launch/env/update.test.ts b/src/commands/launch/env/update.test.ts new file mode 100644 index 00000000..a7e65b2 --- /dev/null +++ b/src/commands/launch/env/update.test.ts @@ -0,0 +1,135 @@ +import { catalog } from '../../../resources'; +import { serverCommandFrameworkGate } from '../../../environments/environment.inputs'; +import type { UpdateOutcome } from '../../../environments/environment.update'; +import { EnvironmentUpdater } from '../../../environments/environment.update'; +import EnvironmentsUpdate from './update'; + +const RESOLVED = { + org: 'org1', + project: 'proj1', + env: 'env1', + name: 'renamed', + description: 'the live site', + branch: 'release', + framework: 'NUXT', + 'build-cmd': 'npm run build', + 'server-cmd': 'npm start', + 'output-dir': '.output', + 'res-mode': 'streaming', + 'auto-deploy': 'enable', + 'cs-auth': 'disable', + 'cache-priming': 'disable', + 'password-protection': 'enable', + username: 'admin', + password: true, + yes: true, +}; + +function commandUnderTest(outcome: UpdateOutcome) { + const command = Object.create(EnvironmentsUpdate.prototype) as EnvironmentsUpdate; + const services = { isTTY: false }; + const requests: unknown[] = []; + let confirmHandedIn: ((question: string) => Promise) | undefined; + + Object.assign(command, { resolved: RESOLVED, services }); + jest.spyOn(EnvironmentUpdater.prototype, 'update').mockImplementation(async function ( + this: EnvironmentUpdater, + request, + ) { + requests.push(request); + confirmHandedIn = (this as unknown as { confirm: (question: string) => Promise }).confirm; + return outcome; + }); + + return { command, requests, confirm: () => confirmHandedIn }; +} + +describe('launch:env:update', () => { + it('hands every resolved flag to the EnvironmentUpdater under its request name', async () => { + const { command, requests } = commandUnderTest('updated'); + + await command.run(); + + expect(requests).toEqual([ + { + org: 'org1', + project: 'proj1', + environment: 'env1', + name: 'renamed', + description: 'the live site', + branch: 'release', + framework: 'NUXT', + buildCmd: 'npm run build', + serverCmd: 'npm start', + outputDir: '.output', + resMode: 'streaming', + autoDeploy: 'enable', + csAuth: 'disable', + cachePriming: 'disable', + passwordProtection: 'enable', + username: 'admin', + password: true, + }, + ]); + }); + + it('hands the updater the command confirm gate, so --yes answers it', async () => { + const { command, confirm } = commandUnderTest('updated'); + + await command.run(); + + await expect(confirm()?.('Disable it?')).resolves.toBe(true); + }); + + it('exits 0 without an error when the updater reports nothing was changed', async () => { + const { command } = commandUnderTest('unchanged'); + + await expect(command.run()).resolves.toBeUndefined(); + }); + + it('exits 3 when the updater reports the user declined', async () => { + const { command } = commandUnderTest('cancelled'); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as { oclif?: { exit?: number } }; + + expect(error.oclif?.exit).toBe(3); + }); + + it('declares its inputs, the confirm gate and the server-command framework rule', () => { + expect(Object.keys(EnvironmentsUpdate.inputs).sort()).toEqual( + [ + 'org', + 'project', + 'env', + 'name', + 'description', + 'branch', + 'framework', + 'build-cmd', + 'server-cmd', + 'output-dir', + 'res-mode', + 'auto-deploy', + 'cs-auth', + 'cache-priming', + 'password-protection', + 'username', + 'password', + 'yes', + ].sort(), + ); + expect(EnvironmentsUpdate.inputs.org).toEqual({ required: true }); + expect(EnvironmentsUpdate.inputs.project).toEqual({ required: true }); + expect(EnvironmentsUpdate.inputs.env).toEqual({ required: true }); + expect(Object.keys(EnvironmentsUpdate.flags).sort()).toEqual(Object.keys(EnvironmentsUpdate.inputs).sort()); + expect(EnvironmentsUpdate.rules).toEqual([serverCommandFrameworkGate]); + }); + + it('describes --name and --description as the environment its own, leaving the project wording alone', () => { + expect(EnvironmentsUpdate.flags.name.description).toBe('Environment name (200 characters or fewer)'); + expect(EnvironmentsUpdate.flags.description.description).toBe('Environment description (255 characters or fewer)'); + expect(catalog.name.description).toBe('Project name (200 characters or fewer)'); + expect(catalog.description.description).toBe('Project description (255 characters or fewer)'); + expect(EnvironmentsUpdate.flags.org).toBe(catalog.org); + }); +}); diff --git a/src/commands/launch/env/update.ts b/src/commands/launch/env/update.ts new file mode 100644 index 00000000..1da321e --- /dev/null +++ b/src/commands/launch/env/update.ts @@ -0,0 +1,75 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { EXIT_CANCELLED } from '../../../core/constants'; +import { serverCommandFrameworkGate, withEnvironmentWording } from '../../../environments/environment.inputs'; +import { EnvironmentUpdater } from '../../../environments/environment.update'; + +const updateInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + name: {}, + description: {}, + branch: {}, + framework: {}, + 'build-cmd': {}, + 'server-cmd': {}, + 'output-dir': {}, + 'res-mode': {}, + 'auto-deploy': {}, + 'cs-auth': {}, + 'cache-priming': {}, + 'password-protection': {}, + username: {}, + password: {}, + yes: {}, +}); + +export default class EnvironmentsUpdate extends LaunchCommand { + static description = 'Update an environment of a Launch project'; + + static examples = [ + '$ csdx launch:env:update --org --project --env --build-cmd "npm run build"', + '$ csdx launch:env:update --org --project --env ' + + '--cache-priming disable --yes', + '$ csdx launch:env:update --org --project --env ' + + '--password-protection enable -u -p', + '$ printf \'%s\' "$PASSWORD" | csdx launch:env:update --org --project ' + + '--env --password-protection enable -u -p', + ]; + + static inputs = updateInputs; + + static flags = withEnvironmentWording(flagsFor(updateInputs)); + + static rules = [serverCommandFrameworkGate]; + + async run(): Promise { + const resolved = this.resolved; + const updater = new EnvironmentUpdater(this.services, (question) => this.confirm(question)); + + const outcome = await updater.update({ + org: resolved.org, + project: resolved.project, + environment: resolved.env, + name: resolved.name, + description: resolved.description, + branch: resolved.branch, + framework: resolved.framework, + buildCmd: resolved['build-cmd'], + serverCmd: resolved['server-cmd'], + outputDir: resolved['output-dir'], + resMode: resolved['res-mode'], + autoDeploy: resolved['auto-deploy'], + csAuth: resolved['cs-auth'], + cachePriming: resolved['cache-priming'], + passwordProtection: resolved['password-protection'], + username: resolved.username, + password: resolved.password, + }); + + if (outcome === 'cancelled') { + this.exit(EXIT_CANCELLED); + } + } +} diff --git a/src/environments/environment.update.prompt.test.ts b/src/environments/environment.update.prompt.test.ts new file mode 100644 index 00000000..490afe4 --- /dev/null +++ b/src/environments/environment.update.prompt.test.ts @@ -0,0 +1,246 @@ +import type { ServiceContext } from '../core/service-context'; +import type { Environment } from './types'; +import { SETTINGS_QUESTION, promptForEnvironmentUpdate } from './environment.update.prompt'; + +const CURRENT: Environment = { + uid: 'env1', + name: 'production', + description: 'the live site', + gitBranch: 'main', + frameworkPreset: 'NEXTJS', + buildCommand: 'npm run build', + outputDirectory: './.next', + autoDeployOnPush: true, + isContentstackAuthenticationEnabled: false, + isCachePrimingEnabled: false, + passwordProtection: { isEnabled: false }, +}; + +const REPOSITORY = { repositoryName: 'octo-user/site', username: 'octo-user', gitProvider: 'GitHub' }; + +function servicesAnswering(answers: Record) { + const asked: { message: string; default?: unknown; choices?: unknown; validate?: (value: string) => true | string }[] = []; + const lines: string[] = []; + const branchLookups: unknown[] = []; + const services = { + isTTY: true, + outputIsTTY: false, + ux: { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: { message: string; default?: unknown; choices?: unknown; validate?: (value: string) => true | string }) => { + asked.push(payload); + return answers[payload.message]; + }, + }, + api: { + git: { + branches: async (params: unknown) => { + branchLookups.push(params); + return { pagination: { count: 2 }, branches: [{ name: 'main' }, { name: 'release' }] }; + }, + }, + }, + } as unknown as ServiceContext; + + return { services, asked, lines, branchLookups }; +} + +function githubContext(current: Environment = CURRENT) { + return { org: 'org1', projectType: 'GITPROVIDER', repository: REPOSITORY, current }; +} + +describe('promptForEnvironmentUpdate', () => { + it('offers every setting as a checklist, in a fixed order', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: [] }); + + await promptForEnvironmentUpdate(services, githubContext()); + + expect(SETTINGS_QUESTION).toBe('Which settings do you want to change?'); + expect(asked[0]).toEqual({ + type: 'checkbox', + name: 'value', + message: SETTINGS_QUESTION, + choices: [ + { name: 'Name', value: 'name' }, + { name: 'Description', value: 'description' }, + { name: 'Branch', value: 'branch' }, + { name: 'Framework preset', value: 'framework' }, + { name: 'Build command', value: 'buildCmd' }, + { name: 'Server command', value: 'serverCmd' }, + { name: 'Output directory', value: 'outputDir' }, + { name: 'Response mode', value: 'resMode' }, + { name: 'Auto deploy', value: 'autoDeploy' }, + { name: 'Contentstack Authentication', value: 'csAuth' }, + { name: 'Cache priming', value: 'cachePriming' }, + { name: 'Password protection', value: 'passwordProtection' }, + ], + loop: false, + pageSize: 12, + validate: expect.any(Function), + }); + }); + + it('leaves Branch and Auto deploy off the checklist for a FileUpload project', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: [] }); + + await promptForEnvironmentUpdate(services, { org: 'org1', projectType: 'FILEUPLOAD', current: CURRENT }); + + const offered = (asked[0].choices as { value: string }[]).map((choice) => choice.value); + expect(offered).not.toContain('branch'); + expect(offered).not.toContain('autoDeploy'); + expect(offered).toHaveLength(10); + }); + + it('asks nothing more and changes nothing when no setting is ticked', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: [] }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({}); + expect(asked).toHaveLength(1); + }); + + it('asks only the ticked settings, each offering its current value, and keeps only what changed', async () => { + const { services, asked } = servicesAnswering({ + [SETTINGS_QUESTION]: ['cachePriming', 'name', 'buildCmd', 'description'], + Name: 'production', + Description: ' pre-release ', + 'Build command': 'npm run build:prod', + 'Cache priming': 'enable', + }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ + description: 'pre-release', + buildCmd: 'npm run build:prod', + cachePriming: 'enable', + }); + expect(asked.slice(1).map((question) => [question.message, question.default])).toEqual([ + ['Name', 'production'], + ['Description', 'the live site'], + ['Build command', 'npm run build'], + ['Cache priming', 'disable'], + ]); + }); + + it('keeps a toggle answered with its current state out of the update', async () => { + const { services } = servicesAnswering({ + [SETTINGS_QUESTION]: ['autoDeploy', 'csAuth', 'passwordProtection'], + 'Auto deploy': 'enable', + 'Contentstack Authentication': 'enable', + 'Password protection': 'disable', + }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ csAuth: 'enable' }); + }); + + it('sends a toggle whose current state the API did not report, whatever was answered', async () => { + const { services } = servicesAnswering({ [SETTINGS_QUESTION]: ['cachePriming'], 'Cache priming': 'disable' }); + + await expect( + promptForEnvironmentUpdate(services, githubContext({ ...CURRENT, isCachePrimingEnabled: undefined })), + ).resolves.toEqual({ cachePriming: 'disable' }); + }); + + it('picks the branch from the repository, pre-selecting the current one', async () => { + const { services, asked, branchLookups } = servicesAnswering({ + [SETTINGS_QUESTION]: ['branch'], + 'Choose a branch': 'release', + }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ branch: 'release' }); + expect(branchLookups).toEqual([ + { org: 'org1', provider: 'GitHub', namespace: 'octo-user', repoName: 'octo-user/site', limit: 100, skip: 0 }, + ]); + expect(asked[1].default).toBe('main'); + }); + + it('pre-selects the current framework and maps the label picked onto its preset', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: ['framework'], 'Framework preset': 'Nuxt' }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ framework: 'NUXT' }); + expect(asked[1].default).toBe('NextJs'); + }); + + it('asks the response mode and always sends it, since the API never reports the current one', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: ['resMode'], 'Response mode': 'buffered' }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ resMode: 'buffered' }); + expect(asked[1].default).toBe('buffered'); + }); + + it('skips the server command, saying why, when the framework it would run under takes none', async () => { + const { services, asked, lines } = servicesAnswering({ [SETTINGS_QUESTION]: ['serverCmd'] }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({}); + expect(asked).toHaveLength(1); + expect(lines).toEqual(['Server command skipped: the NEXTJS framework runs no server command.']); + }); + + it('asks the server command when the framework chosen in the same session takes one', async () => { + const { services } = servicesAnswering({ + [SETTINGS_QUESTION]: ['framework', 'serverCmd'], + 'Framework preset': 'Nuxt', + 'Server command': 'node .output/server/index.mjs', + }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ + framework: 'NUXT', + serverCmd: 'node .output/server/index.mjs', + }); + }); + + it('leaves out a blank answer to an optional text setting', async () => { + const { services } = servicesAnswering({ [SETTINGS_QUESTION]: ['description', 'buildCmd'], Description: ' ' }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({}); + }); + + it('skips the server command when the environment reports no framework at all', async () => { + const { services, lines } = servicesAnswering({ [SETTINGS_QUESTION]: ['serverCmd'] }); + + await expect( + promptForEnvironmentUpdate(services, githubContext({ ...CURRENT, frameworkPreset: undefined })), + ).resolves.toEqual({}); + expect(lines).toEqual(['Server command skipped: this environment has no framework that runs a server command.']); + }); + + it('asks the output directory offering the current one and keeps a new answer', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: ['outputDir'], 'Output directory': './out' }); + + await expect(promptForEnvironmentUpdate(services, githubContext())).resolves.toEqual({ outputDir: './out' }); + expect(asked[1].default).toBe('./.next'); + }); + + it('keeps enable for password protection that is already on, so the credentials can be changed', async () => { + const { services } = servicesAnswering({ + [SETTINGS_QUESTION]: ['passwordProtection'], + 'Password protection': 'enable', + }); + + await expect( + promptForEnvironmentUpdate(services, githubContext({ ...CURRENT, passwordProtection: { isEnabled: true } })), + ).resolves.toEqual({ passwordProtection: 'enable' }); + }); + + it('offers password protection with nothing pre-selected, and keeps the answer, when the API reports no state', async () => { + const { services, asked } = servicesAnswering({ + [SETTINGS_QUESTION]: ['passwordProtection'], + 'Password protection': 'disable', + }); + + await expect( + promptForEnvironmentUpdate(services, githubContext({ ...CURRENT, passwordProtection: undefined })), + ).resolves.toEqual({ passwordProtection: 'disable' }); + expect(asked[1].default).toBeUndefined(); + }); + + it('refuses a description over 255 characters at the prompt, so it is asked again rather than sent', async () => { + const { services, asked } = servicesAnswering({ [SETTINGS_QUESTION]: ['description'], Description: 'short' }); + + await promptForEnvironmentUpdate(services, githubContext()); + + const validate = asked[1].validate as (value: string) => true | string; + expect(validate('d'.repeat(255))).toBe(true); + expect(validate('d'.repeat(256))).toBe('Description must be 255 characters or fewer; that value is 256 characters.'); + }); +}); diff --git a/src/environments/environment.update.prompt.ts b/src/environments/environment.update.prompt.ts new file mode 100644 index 00000000..5875151 --- /dev/null +++ b/src/environments/environment.update.prompt.ts @@ -0,0 +1,192 @@ +import { askChoices, askOption, askOptionalText, askText } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { askBranch } from '../git/git.prompt'; +import { frameworkLabelOf } from './environment.builder'; +import type { ToggleValue } from './environment.inputs'; +import { + ENVIRONMENT_DESCRIPTION_MAX_LENGTH, + ENVIRONMENT_NAME_MAX_LENGTH, + RESPONSE_MODES, + TOGGLE_VALUES, + frameworkPresetOf, +} from './environment.inputs'; +import type { ProjectRepository } from './environment.rules'; +import { gitScopeOf } from './environment.rules'; +import type { EnvironmentUpdateRequest } from './environment.update'; +import { FRAMEWORK_CHOICES, SERVER_COMMAND_FRAMEWORKS } from './frameworks'; +import type { Environment } from './types'; + +export const SETTINGS_QUESTION = 'Which settings do you want to change?'; + +export type EnvironmentChanges = Partial< + Pick< + EnvironmentUpdateRequest, + | 'name' + | 'description' + | 'branch' + | 'framework' + | 'buildCmd' + | 'serverCmd' + | 'outputDir' + | 'resMode' + | 'autoDeploy' + | 'csAuth' + | 'cachePriming' + | 'passwordProtection' + > +>; + +type Setting = keyof EnvironmentChanges; + +export interface UpdatePromptContext { + org: string; + projectType?: string; + repository?: ProjectRepository; + current: Environment; +} + +const SETTINGS: { setting: Setting; label: string; repositoryOnly?: boolean }[] = [ + { setting: 'name', label: 'Name' }, + { setting: 'description', label: 'Description' }, + { setting: 'branch', label: 'Branch', repositoryOnly: true }, + { setting: 'framework', label: 'Framework preset' }, + { setting: 'buildCmd', label: 'Build command' }, + { setting: 'serverCmd', label: 'Server command' }, + { setting: 'outputDir', label: 'Output directory' }, + { setting: 'resMode', label: 'Response mode' }, + { setting: 'autoDeploy', label: 'Auto deploy', repositoryOnly: true }, + { setting: 'csAuth', label: 'Contentstack Authentication' }, + { setting: 'cachePriming', label: 'Cache priming' }, + { setting: 'passwordProtection', label: 'Password protection' }, +]; + +function toggleOf(value: boolean | undefined): ToggleValue | undefined { + if (value === undefined) { + return undefined; + } + + return value ? 'enable' : 'disable'; +} + +function changed(answer: T | undefined, current: T | undefined): T | undefined { + return answer === current ? undefined : answer; +} + +export async function promptForEnvironmentUpdate( + services: ServiceContext, + context: UpdatePromptContext, +): Promise { + const offered = SETTINGS.filter((entry) => !(entry.repositoryOnly && context.projectType === 'FILEUPLOAD')); + const picked = await askChoices( + services.ux, + SETTINGS_QUESTION, + offered.map((entry) => ({ name: entry.label, value: entry.setting })), + ); + const asker = new SettingAsker(services, context); + const changes: EnvironmentChanges = {}; + + for (const { setting } of offered.filter((entry) => picked.includes(entry.setting))) { + const value = await asker.ask(setting, changes); + + if (value !== undefined) { + Object.assign(changes, { [setting]: value }); + } + } + + return changes; +} + +class SettingAsker { + constructor( + private readonly services: ServiceContext, + private readonly context: UpdatePromptContext, + ) {} + + ask(setting: Setting, changes: EnvironmentChanges): Promise { + const current = this.context.current; + const ux = this.services.ux; + + switch (setting) { + case 'name': + return this.text(askText(ux, 'Name', current.name, ENVIRONMENT_NAME_MAX_LENGTH), current.name); + case 'description': + return this.text( + askOptionalText(ux, 'Description', current.description, ENVIRONMENT_DESCRIPTION_MAX_LENGTH), + current.description, + ); + case 'branch': + return this.text( + askBranch(this.services, gitScopeOf(this.context.org, this.context.repository), current.gitBranch), + current.gitBranch, + ); + case 'framework': + return this.framework(); + case 'buildCmd': + return this.text(askOptionalText(ux, 'Build command', current.buildCommand), current.buildCommand); + case 'serverCmd': + return this.serverCommand(changes); + case 'outputDir': + return this.text(askText(ux, 'Output directory', current.outputDirectory), current.outputDirectory); + case 'resMode': + return askOption(ux, 'Response mode', RESPONSE_MODES.map((value) => ({ name: value, value })), 'buffered'); + case 'autoDeploy': + return this.toggle('Auto deploy', current.autoDeployOnPush); + case 'csAuth': + return this.toggle('Contentstack Authentication', current.isContentstackAuthenticationEnabled); + case 'cachePriming': + return this.toggle('Cache priming', current.isCachePrimingEnabled); + default: + return this.passwordProtection(current.passwordProtection?.isEnabled); + } + } + + private async text(answer: Promise, current: string | undefined): Promise { + return changed(await answer, current); + } + + private async framework(): Promise { + const current = this.context.current.frameworkPreset; + const label = await askOption( + this.services.ux, + 'Framework preset', + FRAMEWORK_CHOICES.map((choice) => ({ name: choice, value: choice })), + frameworkLabelOf(current), + ); + + return changed(frameworkPresetOf(label), current); + } + + private async serverCommand(changes: EnvironmentChanges): Promise { + const current = this.context.current; + const framework = changes.framework ?? current.frameworkPreset; + + if (!SERVER_COMMAND_FRAMEWORKS.some((preset) => preset === framework)) { + this.services.ux.print( + framework === undefined + ? 'Server command skipped: this environment has no framework that runs a server command.' + : `Server command skipped: the ${framework} framework runs no server command.`, + ); + return undefined; + } + + return this.text(askOptionalText(this.services.ux, 'Server command', current.serverCommand), current.serverCommand); + } + + private async passwordProtection(current: boolean | undefined): Promise { + const answer = await this.toggle('Password protection', current); + + return answer ?? (current === true ? 'enable' : undefined); + } + + private async toggle(message: string, current: boolean | undefined): Promise { + const state = toggleOf(current); + const answer = await askOption( + this.services.ux, + message, + TOGGLE_VALUES.map((value) => ({ name: value, value })), + state, + ); + + return changed(answer, state); + } +} diff --git a/src/environments/environment.update.test.ts b/src/environments/environment.update.test.ts new file mode 100644 index 00000000..435eb8e --- /dev/null +++ b/src/environments/environment.update.test.ts @@ -0,0 +1,514 @@ +import { randomUUID } from 'node:crypto'; + +import { UsageError } from '../core/errors'; +import type { ServiceContext } from '../core/service-context'; +import type { EnvironmentUpdateRequest } from './environment.update'; +import { EnvironmentUpdater } from './environment.update'; +import { SETTINGS_QUESTION } from './environment.update.prompt'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; +const FAILURE = new Error('request failed'); +const TYPED_PASSWORD = randomUUID(); +const NEW_PASSWORD = randomUUID(); +const PIPED_PASSWORD = randomUUID(); + +interface Harness { + isTTY?: boolean; + projectType?: string; + frameworkPreset?: string; + answers?: Record; + confirmations?: boolean[]; + fails?: 'project' | 'environment' | 'update'; + passwordProtected?: boolean; + piped?: string; +} + +function updaterUnderTest(harness: Harness = {}) { + const calls: string[] = []; + const lines: string[] = []; + const asked: string[] = []; + const questions: string[] = []; + const sent: unknown[] = []; + let confirmIndex = 0; + + const services = { + isTTY: harness.isTTY ?? true, + outputIsTTY: false, + ux: { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: { message: string }) => { + asked.push(payload.message); + return harness.answers?.[payload.message]; + }, + }, + api: { + projects: { + get: async (params: unknown) => { + calls.push(`project ${JSON.stringify(params)}`); + if (harness.fails === 'project') { + throw FAILURE; + } + return { uid: 'proj1', projectType: harness.projectType ?? 'GITPROVIDER' }; + }, + }, + environments: { + get: async (params: unknown) => { + calls.push(`environment ${JSON.stringify(params)}`); + if (harness.fails === 'environment') { + throw FAILURE; + } + return { + uid: 'env1', + frameworkPreset: 'frameworkPreset' in harness ? harness.frameworkPreset : 'NEXTJS', + passwordProtection: { isEnabled: harness.passwordProtected ?? false }, + }; + }, + update: async (params: unknown) => { + calls.push('update'); + sent.push(params); + if (harness.fails === 'update') { + throw FAILURE; + } + return { uid: 'env1' }; + }, + }, + }, + } as unknown as ServiceContext; + + const confirm = async (question: string): Promise => { + questions.push(question); + const answer = harness.confirmations?.[confirmIndex] ?? true; + confirmIndex += 1; + return answer; + }; + + const readSecret = async (): Promise => { + calls.push('stdin'); + return harness.piped ?? ''; + }; + const updater = new EnvironmentUpdater(services, confirm, readSecret); + + return { updater, calls, lines, asked, questions, sent }; +} + +function request(fields: Partial): EnvironmentUpdateRequest { + return { ...SCOPE, password: false, ...fields }; +} + +describe('EnvironmentUpdater', () => { + it('maps every supplied flag onto its body field and sends one PUT', async () => { + const { updater, sent, lines } = updaterUnderTest(); + + await expect( + updater.update( + request({ + name: 'renamed', + description: 'the live site', + branch: 'release', + framework: 'NEXTJS', + buildCmd: 'npm run build', + serverCmd: 'npm start', + outputDir: './.next', + resMode: 'streaming', + autoDeploy: 'disable', + csAuth: 'enable', + cachePriming: 'enable', + }), + ), + ).resolves.toBe('updated'); + + expect(sent).toEqual([ + { + ...SCOPE, + update: { + name: 'renamed', + description: 'the live site', + gitBranch: 'release', + frameworkPreset: 'NEXTJS', + buildCommand: 'npm run build', + serverCommand: 'npm start', + outputDirectory: './.next', + isStreamingEnabled: true, + autoDeployOnPush: false, + isContentstackAuthenticationEnabled: true, + isCachePrimingEnabled: true, + }, + }, + ]); + expect(lines).toEqual(['✔ Environment updated successfully.']); + }); + + it.each([[{}]])( + 'refuses %p off a terminal with a usage error naming every updatable flag, before reading or sending anything', + async (fields) => { + const { updater, calls } = updaterUnderTest({ isTTY: false }); + + const failure = await updater.update(request(fields)).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe( + 'Pass at least one of --name, --description, --branch, --framework, --build-cmd, --server-cmd, ' + + '--output-dir, --res-mode, --auto-deploy, --cs-auth, --cache-priming, --password-protection; ' + + 'none was supplied.', + ); + expect(calls).toEqual([]); + }, + ); + + it.each([ + [{ branch: 'release' }, '--branch'], + [{ autoDeploy: 'enable' as const }, '--auto-deploy'], + ])('warns that %p does not apply to a FileUpload project and, with nothing else to change, sends nothing', async (fields, flag) => { + const { updater, calls, lines } = updaterUnderTest({ projectType: 'FILEUPLOAD' }); + + await expect(updater.update(request(fields))).resolves.toBe('unchanged'); + + expect(lines).toEqual([ + `warn: ${flag} is not supported for FileUpload projects.`, + 'Environment not updated. No changes were entered.', + ]); + expect(calls).not.toContain('update'); + }); + + it('drops the repository-only flags of a FileUpload project and sends the rest', async () => { + const { updater, sent, lines } = updaterUnderTest({ projectType: 'FILEUPLOAD' }); + + await updater.update(request({ name: 'renamed', branch: 'release', autoDeploy: 'enable' })); + + expect(lines).toEqual([ + 'warn: --branch is not supported for FileUpload projects.', + 'warn: --auto-deploy is not supported for FileUpload projects.', + '\u2714 Environment updated successfully.', + ]); + expect(sent).toEqual([{ ...SCOPE, update: { name: 'renamed' } }]); + }); + + it('reads the project and the environment before sending the update', async () => { + const { updater, calls } = updaterUnderTest({ projectType: 'FILEUPLOAD' }); + + await updater.update(request({ name: 'renamed' })); + + expect(calls).toEqual([ + 'project {"org":"org1","project":"proj1"}', + 'environment {"org":"org1","project":"proj1","environment":"env1"}', + 'update', + ]); + }); + + it('refuses --server-cmd without --framework when the current framework takes no server command', async () => { + const { updater, calls } = updaterUnderTest({ frameworkPreset: 'GATSBY' }); + + const failure = await updater.update(request({ serverCmd: 'npm start' })).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe( + '--server-cmd is only supported when --framework is one of ANALOG, ANGULAR, NUXT, ASTRO, REMIX, OTHER; ' + + '--framework is GATSBY.', + ); + expect(calls).not.toContain('update'); + }); + + it('refuses --server-cmd without --framework when the environment reports no framework at all', async () => { + const { updater, calls } = updaterUnderTest({ frameworkPreset: undefined }); + + await expect(updater.update(request({ serverCmd: 'npm start' }))).rejects.toThrow( + '--server-cmd is only supported when --framework is one of ANALOG, ANGULAR, NUXT, ASTRO, REMIX, OTHER; ' + + '--framework is not set.', + ); + expect(calls).not.toContain('update'); + }); + + it.each([ + [{ serverCmd: 'npm start' }, 'NUXT'], + [{ serverCmd: 'npm start', framework: 'NUXT' as const }, 'GATSBY'], + ])('sends %p when the framework it will run under is %s-compatible', async (fields, current) => { + const { updater, calls } = updaterUnderTest({ frameworkPreset: current }); + + await updater.update(request(fields)); + + expect(calls).toContain('update'); + }); + + it.each([ + [{ name: 'renamed', username: 'admin' }, '--username'], + [{ name: 'renamed', password: true }, '--password'], + [{ passwordProtection: 'disable' as const, username: 'admin' }, '--username'], + [{ passwordProtection: 'disable' as const, password: true }, '--password'], + ])('refuses %p, because credentials only apply when enabling password protection', async (fields, flag) => { + const { updater, calls, questions } = updaterUnderTest(); + + const failure = await updater.update(request(fields)).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe(`${flag} is only supported with --password-protection enable.`); + expect(questions).toEqual([]); + expect(calls).not.toContain('update'); + }); + + it('asks before disabling cache priming and sends the toggle once confirmed', async () => { + const { updater, questions, sent } = updaterUnderTest(); + + await expect(updater.update(request({ cachePriming: 'disable' }))).resolves.toBe('updated'); + + expect(questions).toEqual(['Are you sure you want to disable cache priming for this environment?']); + expect(sent).toEqual([{ ...SCOPE, update: { isCachePrimingEnabled: false } }]); + }); + + it('asks before disabling password protection and sends only isEnabled false once confirmed', async () => { + const { updater, questions, sent } = updaterUnderTest(); + + await updater.update(request({ passwordProtection: 'disable' })); + + expect(questions).toEqual(['Are you sure you want to disable password protection for this environment?']); + expect(sent).toEqual([{ ...SCOPE, update: { passwordProtection: { isEnabled: false } } }]); + }); + + it('asks both questions, cache priming first, when both are being disabled', async () => { + const { updater, questions } = updaterUnderTest(); + + await updater.update(request({ cachePriming: 'disable', passwordProtection: 'disable' })); + + expect(questions).toEqual([ + 'Are you sure you want to disable cache priming for this environment?', + 'Are you sure you want to disable password protection for this environment?', + ]); + }); + + it.each([[[false]], [[true, false]]])( + 'says the environment was not updated and sends nothing when an answer is no (%p)', + async (confirmations) => { + const { updater, calls, lines } = updaterUnderTest({ confirmations }); + + await expect( + updater.update(request({ cachePriming: 'disable', passwordProtection: 'disable' })), + ).resolves.toBe('cancelled'); + + expect(calls).not.toContain('update'); + expect(lines).toEqual(['Environment not updated.']); + }, + ); + + it('asks nothing when enabling cache priming', async () => { + const { updater, questions } = updaterUnderTest(); + + await updater.update(request({ cachePriming: 'enable' })); + + expect(questions).toEqual([]); + }); + + it('enables password protection with the --username given and a password from the hidden prompt', async () => { + const { updater, asked, sent, lines } = updaterUnderTest({ answers: { Password: TYPED_PASSWORD } }); + + await updater.update(request({ passwordProtection: 'enable', username: 'admin', password: true })); + + expect(asked).toEqual(['Password']); + expect(sent).toEqual([ + { ...SCOPE, update: { passwordProtection: { isEnabled: true, username: 'admin', password: TYPED_PASSWORD } } }, + ]); + expect(lines.join('\n')).not.toContain(TYPED_PASSWORD); + }); + + it('asks for the username, then the password, when --username was not given', async () => { + const { updater, asked, sent } = updaterUnderTest({ answers: { Username: ' admin ', Password: TYPED_PASSWORD } }); + + await updater.update(request({ passwordProtection: 'enable' })); + + expect(asked).toEqual(['Username', 'Password']); + expect(sent).toEqual([ + { ...SCOPE, update: { passwordProtection: { isEnabled: true, username: 'admin', password: TYPED_PASSWORD } } }, + ]); + }); + + it('refuses a prompted username containing a colon, sending nothing', async () => { + const { updater, calls } = updaterUnderTest({ answers: { Username: 'ad:min', Password: TYPED_PASSWORD } }); + + await expect(updater.update(request({ passwordProtection: 'enable' }))).rejects.toThrow( + new UsageError('--username must not contain ":".'), + ); + expect(calls).not.toContain('update'); + }); + + it('enables password protection without a terminal from -u and a password piped to -p, asking nothing', async () => { + const { updater, asked, sent, calls } = updaterUnderTest({ isTTY: false, piped: `${PIPED_PASSWORD}\n` }); + + await updater.update(request({ passwordProtection: 'enable', username: 'admin', password: true })); + + expect(asked).toEqual([]); + expect(calls).toContain('stdin'); + expect(sent).toEqual([ + { ...SCOPE, update: { passwordProtection: { isEnabled: true, username: 'admin', password: PIPED_PASSWORD } } }, + ]); + }); + + it.each([ + [ + { username: 'admin' }, + 'Without a terminal, --password-protection enable reads the password from standard input: pass -p and pipe it in, ' + + 'e.g. printf \'%s\' "$PASSWORD" | csdx launch:env:update ... -u -p', + ], + [{ password: true }, 'Without a terminal, --password-protection enable needs --username (-u).'], + ])('refuses %p off a terminal before reading or sending anything', async (fields, message) => { + const { updater, calls } = updaterUnderTest({ isTTY: false, piped: PIPED_PASSWORD }); + + const failure = await updater + .update(request({ passwordProtection: 'enable', ...fields })) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe(message); + expect(calls).toEqual([]); + }); + + it.each([[''], ['\n'], [' \r\n']])('refuses an empty password piped to -p (%p), sending nothing', async (piped) => { + const { updater, calls } = updaterUnderTest({ isTTY: false, piped }); + + await expect( + updater.update(request({ passwordProtection: 'enable', username: 'admin', password: true })), + ).rejects.toThrow(new UsageError('No password was read from standard input; pipe a non-empty password into -p.')); + expect(calls).not.toContain('update'); + }); + + it('asks the hidden prompt on a terminal and never reads stdin, -p or not', async () => { + const { updater, calls, asked } = updaterUnderTest({ answers: { Password: TYPED_PASSWORD } }); + + await updater.update(request({ passwordProtection: 'enable', username: 'admin', password: true })); + + expect(asked).toEqual(['Password']); + expect(calls).not.toContain('stdin'); + }); + + it('asks for credentials only after the disable question has been answered yes', async () => { + const { updater, asked, questions } = updaterUnderTest({ + confirmations: [false], + answers: { Username: 'admin', Password: TYPED_PASSWORD }, + }); + + await updater.update(request({ cachePriming: 'disable', passwordProtection: 'enable' })); + + expect(questions).toHaveLength(1); + expect(asked).toEqual([]); + }); + + it.each([['project' as const], ['environment' as const], ['update' as const]])( + 'propagates a failed %s request without reporting a success', + async (fails) => { + const { updater, lines } = updaterUnderTest({ fails }); + + await expect(updater.update(request({ name: 'renamed' }))).rejects.toBe(FAILURE); + expect(lines).toEqual([]); + }, + ); + + it('asks which settings to change on a terminal when no update flag was given, then sends what was picked', async () => { + const { updater, calls, asked, questions, sent, lines } = updaterUnderTest({ + answers: { [SETTINGS_QUESTION]: ['buildCmd', 'cachePriming'], 'Build command': 'npm run build:prod', 'Cache priming': 'disable' }, + }); + + await expect(updater.update(request({}))).resolves.toBe('updated'); + + expect(calls.slice(0, 2)).toEqual([ + 'project {"org":"org1","project":"proj1"}', + 'environment {"org":"org1","project":"proj1","environment":"env1"}', + ]); + expect(asked).toEqual([SETTINGS_QUESTION, 'Build command', 'Cache priming']); + expect(questions).toEqual(['Are you sure you want to disable cache priming for this environment?']); + expect(sent).toEqual([{ ...SCOPE, update: { buildCommand: 'npm run build:prod', isCachePrimingEnabled: false } }]); + expect(lines).toEqual(['\u2714 Environment updated successfully.']); + }); + + it.each([[[]], [['serverCmd']]])( + 'says nothing changed and sends nothing when the checklist answer %p changes no setting', + async (picked) => { + const { updater, calls, lines } = updaterUnderTest({ answers: { [SETTINGS_QUESTION]: picked } }); + + await expect(updater.update(request({}))).resolves.toBe('unchanged'); + + expect(calls).not.toContain('update'); + expect(lines[lines.length - 1]).toBe('Environment not updated. No changes were entered.'); + }, + ); + + it('enables password protection picked from the checklist, asking the credentials after it', async () => { + const { updater, asked, sent } = updaterUnderTest({ + answers: { + [SETTINGS_QUESTION]: ['passwordProtection'], + 'Password protection': 'enable', + Username: 'admin', + Password: TYPED_PASSWORD, + }, + }); + + await updater.update(request({})); + + expect(asked).toEqual([SETTINGS_QUESTION, 'Password protection', 'Username', 'Password']); + expect(sent).toEqual([ + { ...SCOPE, update: { passwordProtection: { isEnabled: true, username: 'admin', password: TYPED_PASSWORD } } }, + ]); + }); + + it('asks no checklist when any update flag was given', async () => { + const { updater, asked } = updaterUnderTest(); + + await updater.update(request({ name: 'renamed' })); + + expect(asked).toEqual([]); + }); + + it('asks new credentials when password protection is picked and enabled again, so they can be rotated', async () => { + const { updater, asked, sent } = updaterUnderTest({ + answers: { + [SETTINGS_QUESTION]: ['passwordProtection'], + 'Password protection': 'enable', + Username: 'admin2', + Password: NEW_PASSWORD, + }, + passwordProtected: true, + }); + + await expect(updater.update(request({}))).resolves.toBe('updated'); + + expect(asked).toEqual([SETTINGS_QUESTION, 'Password protection', 'Username', 'Password']); + expect(sent).toEqual([ + { ...SCOPE, update: { passwordProtection: { isEnabled: true, username: 'admin2', password: NEW_PASSWORD } } }, + ]); + }); + + it.each([[{ username: 'bob' }], [{ password: true }], [{ name: 'renamed', username: 'bob' }]])( + 'refuses %p on a terminal before reading anything or showing the checklist', + async (fields) => { + const { updater, calls, asked } = updaterUnderTest(); + + const failure = await updater.update(request(fields)).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toMatch(/^--(username|password) is only supported with --password-protection enable\.$/); + expect(calls).toEqual([]); + expect(asked).toEqual([]); + }, + ); + + it('reads the project and the environment at the same time rather than one after the other', async () => { + let environmentStarted!: () => void; + const started = new Promise((resolve) => { + environmentStarted = resolve; + }); + const { updater, sent } = updaterUnderTest(); + const services = (updater as unknown as { services: { api: Record> } }).services; + const getProject = services.api.projects.get as (params: unknown) => Promise; + const getEnvironment = services.api.environments.get as (params: unknown) => Promise; + services.api.projects.get = async (params: unknown) => { + await started; + return getProject(params); + }; + services.api.environments.get = async (params: unknown) => { + environmentStarted(); + return getEnvironment(params); + }; + + await updater.update(request({ name: 'renamed' })); + + expect(sent).toHaveLength(1); + }, 1000); +}); diff --git a/src/environments/environment.update.ts b/src/environments/environment.update.ts new file mode 100644 index 00000000..6234d3b --- /dev/null +++ b/src/environments/environment.update.ts @@ -0,0 +1,235 @@ +import { UsageError } from '../core/errors'; +import { askSecret, askText } from '../core/prompt'; +import { readPipedInput } from '../core/piped-input'; +import { requireValueOf } from '../core/rules'; +import type { ServiceContext } from '../core/service-context'; +import type { ResponseMode, ToggleValue } from './environment.inputs'; +import { usernameOf } from './environment.inputs'; +import { withoutRepositoryFlags } from './environment.rules'; +import { + disableQuestion, + environmentNoChangesLine, + environmentNotUpdatedLine, + environmentUpdatedLine, +} from './environment.presenter'; +import { promptForEnvironmentUpdate } from './environment.update.prompt'; +import type { FrameworkPreset } from './frameworks'; +import { SERVER_COMMAND_FRAMEWORKS } from './frameworks'; +import type { EnvironmentUpdate, PasswordProtectionUpdate } from './types'; + +export interface EnvironmentUpdateRequest { + org: string; + project: string; + environment: string; + name?: string; + description?: string; + branch?: string; + framework?: FrameworkPreset; + buildCmd?: string; + serverCmd?: string; + outputDir?: string; + resMode?: ResponseMode; + autoDeploy?: ToggleValue; + csAuth?: ToggleValue; + cachePriming?: ToggleValue; + passwordProtection?: ToggleValue; + username?: string; + password: boolean; +} + +export type Confirm = (question: string) => Promise; + +const PIPE_THE_PASSWORD = + 'Without a terminal, --password-protection enable reads the password from standard input: pass -p and pipe it in, ' + + 'e.g. printf \'%s\' "$PASSWORD" | csdx launch:env:update ... -u -p'; + +const USERNAME_WITHOUT_A_TERMINAL = 'Without a terminal, --password-protection enable needs --username (-u).'; + +const EMPTY_PIPED_PASSWORD = 'No password was read from standard input; pipe a non-empty password into -p.'; + +export type ReadSecret = () => Promise; + +function pipedPassword(text: string): string { + const password = text.replace(/\r?\n$/, ''); + + if (password.trim() === '') { + throw new UsageError(EMPTY_PIPED_PASSWORD); + } + + return password; +} + +const UPDATABLE_FLAGS: [keyof EnvironmentUpdateRequest, string][] = [ + ['name', 'name'], + ['description', 'description'], + ['branch', 'branch'], + ['framework', 'framework'], + ['buildCmd', 'build-cmd'], + ['serverCmd', 'server-cmd'], + ['outputDir', 'output-dir'], + ['resMode', 'res-mode'], + ['autoDeploy', 'auto-deploy'], + ['csAuth', 'cs-auth'], + ['cachePriming', 'cache-priming'], + ['passwordProtection', 'password-protection'], +]; + +export type UpdateOutcome = 'updated' | 'cancelled' | 'unchanged'; + +function hasChanges(request: EnvironmentUpdateRequest): boolean { + return UPDATABLE_FLAGS.some(([field]) => request[field] !== undefined); +} + +function emptyUpdateMessage(): string { + const flags = UPDATABLE_FLAGS.map(([, flag]) => `--${flag}`).join(', '); + + return `Pass at least one of ${flags}; none was supplied.`; +} + +function enabled(toggle: ToggleValue | undefined): boolean | undefined { + return toggle === undefined ? undefined : toggle === 'enable'; +} + +function bodyOf( + request: EnvironmentUpdateRequest, + passwordProtection: PasswordProtectionUpdate | undefined, +): EnvironmentUpdate { + const fields: EnvironmentUpdate = { + name: request.name, + description: request.description, + gitBranch: request.branch, + frameworkPreset: request.framework, + buildCommand: request.buildCmd, + serverCommand: request.serverCmd, + outputDirectory: request.outputDir, + isStreamingEnabled: request.resMode === undefined ? undefined : request.resMode === 'streaming', + autoDeployOnPush: enabled(request.autoDeploy), + isContentstackAuthenticationEnabled: enabled(request.csAuth), + isCachePrimingEnabled: enabled(request.cachePriming), + passwordProtection, + }; + + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as EnvironmentUpdate; +} + +function refuseCredentialsWithoutEnabling(request: EnvironmentUpdateRequest): void { + if (request.passwordProtection === 'enable') { + return; + } + + if (request.username !== undefined) { + throw new UsageError('--username is only supported with --password-protection enable.'); + } + + if (request.password) { + throw new UsageError('--password is only supported with --password-protection enable.'); + } +} + +export class EnvironmentUpdater { + constructor( + private readonly services: ServiceContext, + private readonly confirm: Confirm, + private readonly readSecret: ReadSecret = readPipedInput, + ) {} + + async update(given: EnvironmentUpdateRequest): Promise { + const { org, project, environment } = given; + const asking = !hasChanges(given); + + refuseCredentialsWithoutEnabling(given); + + if (asking && !this.services.isTTY) { + throw new UsageError(emptyUpdateMessage()); + } + + if (given.passwordProtection === 'enable' && !this.services.isTTY && !given.password) { + throw new UsageError(PIPE_THE_PASSWORD); + } + + if (given.passwordProtection === 'enable' && !this.services.isTTY && given.username === undefined) { + throw new UsageError(USERNAME_WITHOUT_A_TERMINAL); + } + + const [current, existing] = await Promise.all([ + this.services.api.projects.get({ org, project }), + this.services.api.environments.get({ org, project, environment }), + ]); + const answered = asking + ? { + ...given, + ...(await promptForEnvironmentUpdate(this.services, { + org, + projectType: current.projectType, + repository: current.repository, + current: existing, + })), + } + : given; + const request = withoutRepositoryFlags( + answered, + current.projectType, + this.services.ux, + this.services.outputIsTTY === true, + ); + + if (!hasChanges(request)) { + this.services.ux.print(environmentNoChangesLine(this.services.outputIsTTY === true)); + return 'unchanged'; + } + + if (request.serverCmd !== undefined && request.framework === undefined) { + requireValueOf('server-cmd', 'framework', SERVER_COMMAND_FRAMEWORKS, existing.frameworkPreset ?? 'not set'); + } + + if (!(await this.confirmDisabling(request))) { + this.services.ux.print(environmentNotUpdatedLine(this.services.outputIsTTY === true)); + return 'cancelled'; + } + + const passwordProtection = await this.passwordProtectionOf(request); + + await this.services.api.environments.update({ + org, + project, + environment, + update: bodyOf(request, passwordProtection), + }); + + this.services.ux.print(environmentUpdatedLine(this.services.outputIsTTY === true)); + + return 'updated'; + } + + private async passwordProtectionOf(request: EnvironmentUpdateRequest): Promise { + if (request.passwordProtection === 'disable') { + return { isEnabled: false }; + } + + if (request.passwordProtection !== 'enable') { + return undefined; + } + + const username = request.username ?? (await usernameOf(await askText(this.services.ux, 'Username'))); + const password = this.services.isTTY + ? await askSecret(this.services.ux, 'Password') + : pipedPassword(await this.readSecret()); + + return { isEnabled: true, username, password }; + } + + private async confirmDisabling(request: EnvironmentUpdateRequest): Promise { + const disabling = [ + request.cachePriming === 'disable' ? 'cache priming' : undefined, + request.passwordProtection === 'disable' ? 'password protection' : undefined, + ].filter((feature): feature is string => feature !== undefined); + + for (const feature of disabling) { + if (!(await this.confirm(disableQuestion(feature)))) { + return false; + } + } + + return true; + } +} diff --git a/test/integration/environments-update.test.ts b/test/integration/environments-update.test.ts new file mode 100644 index 00000000..a61ae79 --- /dev/null +++ b/test/integration/environments-update.test.ts @@ -0,0 +1,210 @@ +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import { answerPrompts, onTerminal } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +import getFixture from '../fixtures/environment-get.json'; +import projectFixture from '../fixtures/project-get.json'; +import uploadProjectFixture from '../fixtures/project-get-fileupload.json'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const ENV_PATH = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; +let onWire: string[]; + +function updateArgs(...extra: string[]): string[] { + return ['launch:env:update', '--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID, '--data-dir', DATA_DIR, ...extra]; +} + +function reads(project: unknown = projectFixture) { + return [ + nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).reply(200, project as nock.Body), + nock(LAUNCH_HUB_URL).get(ENV_PATH).reply(200, getFixture), + ]; +} + +function update(sent: unknown[], status = 200, reply: unknown = getFixture) { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('content-type', 'application/json') + .put(ENV_PATH, (body: unknown) => { + sent.push(body); + return true; + }) + .reply(status, reply as nock.Body); +} + +describe('integration: launch:env:update on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('reads the project and environment, then PUTs only the supplied fields', async () => { + const sent: unknown[] = []; + const [project, environment] = reads(); + const put = update(sent); + + const { error, stdout } = await runCommand( + updateArgs('--name', 'renamed', '--res-mode', 'streaming', '--cs-auth', 'disable'), + config, + ); + + expect(error).toBeUndefined(); + expect([project.isDone(), environment.isDone(), put.isDone()]).toEqual([true, true, true]); + expect(sent).toEqual([{ name: 'renamed', isStreamingEnabled: true, isContentstackAuthenticationEnabled: false }]); + expect(stdout).toBe('✔ Environment updated successfully.\n'); + expect(onWire).toEqual([]); + }); + + it('refuses a password value on the command line with exit 2 before any request', async () => { + const { error } = await runCommand(updateArgs('--password-protection', 'enable', '-p', 'on-argv'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming the updatable flags and sends nothing when none was supplied', async () => { + const { error } = await runCommand(updateArgs(), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Pass at least one of --name,'); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming --yes, and never sends the PUT, when disabling cache priming without a terminal', async () => { + const sent: unknown[] = []; + reads(); + const put = update(sent); + + const { error } = await runCommand(updateArgs('--cache-priming', 'disable'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe( + 'Are you sure you want to disable cache priming for this environment? ' + + 'Pass --yes to confirm without an interactive terminal.', + ); + expect(put.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('enables password protection with a hidden password prompt, and never prints the password', async () => { + const password = randomBytes(8).toString('hex'); + const sent: unknown[] = []; + reads(); + const put = update(sent); + const prompts = answerPrompts({ Password: password }); + + const { error, stdout } = await onTerminal(() => + runCommand(updateArgs('--password-protection', 'enable', '-u', 'admin', '-p'), config), + ); + + expect(error).toBeUndefined(); + expect(put.isDone()).toBe(true); + expect(prompts.payloads).toEqual([ + { type: 'password', name: 'value', message: 'Password', mask: '*', validate: expect.any(Function) }, + ]); + expect(sent).toEqual([{ passwordProtection: { isEnabled: true, username: 'admin', password } }]); + expect(stdout).not.toContain(password); + }); + + it('words the empty 409 the update endpoint sends for a taken name in the CLI its own words', async () => { + reads(); + update([], 409, { errors: [{}], status: 409 }); + + const { error } = await runCommand(updateArgs('--name', 'staging'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('An environment with that name already exists in this project.'); + }); + + it('warns that --branch does not apply to a FileUpload project and sends the other fields without it', async () => { + const sent: unknown[] = []; + reads(uploadProjectFixture); + const put = update(sent); + + const { error, stdout } = await runCommand(updateArgs('--branch', 'release', '--name', 'renamed'), config); + + expect(error).toBeUndefined(); + expect(stdout).toBe( + 'warn: --branch is not supported for FileUpload projects.\n\u2714 Environment updated successfully.\n', + ); + expect(put.isDone()).toBe(true); + expect(sent).toEqual([{ name: 'renamed' }]); + }); + + it('asks which settings to change on a terminal when no flag was given, and sends only the answers', async () => { + const sent: unknown[] = []; + reads(); + const put = update(sent); + const prompts = answerPrompts({ 'Which settings do you want to change?': ['name'], Name: 'renamed' }); + + const { error, stdout } = await onTerminal(() => runCommand(updateArgs(), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual(['Which settings do you want to change?', 'Name']); + expect(prompts.payloads[0].type).toBe('checkbox'); + expect(put.isDone()).toBe(true); + expect(sent).toEqual([{ name: 'renamed' }]); + expect(stdout).toBe('\u2714 Environment updated successfully.\n'); + }); + + it('says nothing changed, exits 0 and sends no PUT when every ticked setting keeps its value', async () => { + reads(); + const put = update([]); + answerPrompts({ 'Which settings do you want to change?': ['name'], Name: 'production' }); + + const { error, stdout } = await onTerminal(() => runCommand(updateArgs(), config)); + + expect(error).toBeUndefined(); + expect(stdout).toBe('Environment not updated. No changes were entered.\n'); + expect(put.isDone()).toBe(false); + }); +}); From 2a33716b82d3e83d8e5a63a48515c3e61a638cbd Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:38 +0530 Subject: [PATCH 7/8] feat(environments): add launch:env:delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes an environment after a confirmation. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- src/commands/launch/env/delete.test.ts | 151 +++++++++++++++++++ src/commands/launch/env/delete.ts | 44 ++++++ test/integration/environments-delete.test.ts | 145 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 src/commands/launch/env/delete.test.ts create mode 100644 src/commands/launch/env/delete.ts create mode 100644 test/integration/environments-delete.test.ts diff --git a/src/commands/launch/env/delete.test.ts b/src/commands/launch/env/delete.test.ts new file mode 100644 index 00000000..0cf082e --- /dev/null +++ b/src/commands/launch/env/delete.test.ts @@ -0,0 +1,151 @@ +import { UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import EnvironmentsDelete from './delete'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; + +interface Harness { + answer?: boolean; + isTTY?: boolean; + yes?: boolean; + getFails?: Error; + deleteFails?: Error; +} + +function commandUnderTest(harness: Harness) { + const lines: string[] = []; + const inquired: unknown[] = []; + const calls: string[] = []; + const got: unknown[] = []; + const deleted: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: unknown) => { + inquired.push(payload); + return harness.answer as never; + }, + }; + const command = Object.create(EnvironmentsDelete.prototype) as EnvironmentsDelete; + + Object.assign(command, { + ux, + resolved: { org: 'org1', project: 'proj1', env: 'env1', yes: harness.yes ?? false }, + services: { + ux, + isTTY: harness.isTTY ?? true, + api: { + environments: { + get: async (params: unknown) => { + calls.push('get'); + got.push(params); + + if (harness.getFails) { + throw harness.getFails; + } + + return { uid: 'env1', name: 'production' }; + }, + delete: async (params: unknown) => { + calls.push('delete'); + deleted.push(params); + + if (harness.deleteFails) { + throw harness.deleteFails; + } + }, + }, + }, + }, + }); + + return { command, lines, inquired, calls, got, deleted }; +} + +describe('launch:env:delete', () => { + it('fetches the environment, confirms, deletes it and says so', async () => { + const { command, lines, inquired, calls, got, deleted } = commandUnderTest({ answer: true }); + + await command.run(); + + expect(inquired).toEqual([ + { + type: 'confirm', + name: 'confirm', + message: 'Are you sure you want to delete this environment?', + default: false, + }, + ]); + expect(calls).toEqual(['get', 'delete']); + expect(got).toEqual([SCOPE]); + expect(deleted).toEqual([SCOPE]); + expect(lines).toEqual(['✔ Environment deleted successfully.']); + }); + + it('skips the question entirely when --yes was passed', async () => { + const { command, lines, inquired, calls } = commandUnderTest({ yes: true }); + + await command.run(); + + expect(inquired).toEqual([]); + expect(calls).toEqual(['get', 'delete']); + expect(lines).toEqual(['✔ Environment deleted successfully.']); + }); + + it('says the environment was not deleted and exits 3 without sending the delete when the user declines', async () => { + const { command, lines, calls, deleted } = commandUnderTest({ answer: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as { oclif?: { exit?: number } }; + + expect(error.oclif?.exit).toBe(3); + expect(calls).toEqual(['get']); + expect(deleted).toEqual([]); + expect(lines).toEqual(['Environment not deleted.']); + }); + + it('exits 2 naming --yes without asking or sending the delete when there is no terminal', async () => { + const { command, lines, inquired, calls, deleted } = commandUnderTest({ isTTY: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe( + 'Are you sure you want to delete this environment? Pass --yes to confirm without an interactive terminal.', + ); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(deleted).toEqual([]); + expect(lines).toEqual([]); + }); + + it('propagates a failed lookup without asking or deleting anything', async () => { + const failure = new Error('lookup exploded'); + const { command, lines, calls, inquired } = commandUnderTest({ answer: true, getFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(calls).toEqual(['get']); + expect(inquired).toEqual([]); + expect(lines).toEqual([]); + }); + + it('propagates a failed delete rather than reporting a success', async () => { + const failure = new Error('delete exploded'); + const { command, lines, calls } = commandUnderTest({ yes: true, deleteFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(calls).toEqual(['get', 'delete']); + expect(lines).toEqual([]); + }); + + it('declares org, project and env as required and opts into the confirm gate', () => { + expect(EnvironmentsDelete.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + yes: {}, + }); + expect(Object.keys(EnvironmentsDelete.flags).sort()).toEqual(['env', 'org', 'project', 'yes']); + }); +}); diff --git a/src/commands/launch/env/delete.ts b/src/commands/launch/env/delete.ts new file mode 100644 index 00000000..df09096 --- /dev/null +++ b/src/commands/launch/env/delete.ts @@ -0,0 +1,44 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { EXIT_CANCELLED } from '../../../core/constants'; +import { + ENVIRONMENT_DELETE_QUESTION, + environmentDeletedLine, + environmentNotDeletedLine, +} from '../../../environments/environment.presenter'; + +const deleteInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + yes: {}, +}); + +export default class EnvironmentsDelete extends LaunchCommand { + static description = 'Delete an environment of a Launch project'; + + static examples = [ + '$ csdx launch:env:delete --org --project --env ', + '$ csdx launch:env:delete --org --project --env --yes', + ]; + + static inputs = deleteInputs; + + static flags = flagsFor(deleteInputs); + + async run(): Promise { + const scope = { org: this.resolved.org, project: this.resolved.project, environment: this.resolved.env }; + const outputIsTTY = this.services.outputIsTTY === true; + + await this.services.api.environments.get(scope); + + if (!(await this.confirm(ENVIRONMENT_DELETE_QUESTION))) { + this.ux.print(environmentNotDeletedLine(outputIsTTY)); + this.exit(EXIT_CANCELLED); + } + + await this.services.api.environments.delete(scope); + + this.ux.print(environmentDeletedLine(outputIsTTY)); + } +} diff --git a/test/integration/environments-delete.test.ts b/test/integration/environments-delete.test.ts new file mode 100644 index 00000000..3b6b770 --- /dev/null +++ b/test/integration/environments-delete.test.ts @@ -0,0 +1,145 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import { answerPrompts, onTerminal } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; + +import getFixture from '../fixtures/environment-get.json'; +import listFixture from '../fixtures/environments-list.json'; +import notFoundFixture from '../fixtures/environment-not-found.json'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const ENV_PATH = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`; +const DATA_DIR = tmpdir(); +const BODYLESS = { badheaders: ['content-type'] }; +const QUESTION = 'Are you sure you want to delete this environment?'; + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; +let onWire: string[]; + +function deleteArgs(...extra: string[]): string[] { + return ['launch:env:delete', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR, ...extra]; +} + +function lookupAndRemoval(status = 200, body: unknown = getFixture) { + const lookup = nock(LAUNCH_HUB_URL) + .matchHeader('x-project-uid', PROJECT_UID) + .get(ENV_PATH) + .reply(status, body as nock.Body); + const removal = nock(LAUNCH_HUB_URL, BODYLESS) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .delete(ENV_PATH) + .reply(204); + + return { lookup, removal }; +} + +describe('integration: launch:env:delete on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('deletes the environment with a bodyless DELETE and says so when --yes is given', async () => { + const { lookup, removal } = lookupAndRemoval(); + + const { error, stdout } = await runCommand(deleteArgs('--env', ENV_UID, '--yes'), config); + + expect(error).toBeUndefined(); + expect([lookup.isDone(), removal.isDone()]).toEqual([true, true]); + expect(stdout).toBe('✔ Environment deleted successfully.\n'); + expect(onWire).toEqual([]); + }); + + it('resolves an environment name and deletes the uid it resolves to', async () => { + const scan = nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments`) + .query({ limit: '100', skip: '0' }) + .reply(200, listFixture); + const { lookup, removal } = lookupAndRemoval(); + + const { error } = await runCommand(deleteArgs('--env', 'production', '--yes'), config); + + expect(error).toBeUndefined(); + expect([scan.isDone(), lookup.isDone(), removal.isDone()]).toEqual([true, true, true]); + }); + + it('exits 2 naming --yes, and never sends the delete, without a terminal and without --yes', async () => { + const { lookup, removal } = lookupAndRemoval(); + + const { error } = await runCommand(deleteArgs('--env', ENV_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${QUESTION} Pass --yes to confirm without an interactive terminal.`); + expect([lookup.isDone(), removal.isDone()]).toEqual([true, false]); + expect(onWire).toEqual([]); + }); + + it('says the environment was not deleted, exits 3 and never sends the delete when the user declines', async () => { + const { lookup, removal } = lookupAndRemoval(); + const prompts = answerPrompts({ [QUESTION]: false }); + + const { error, stdout } = await onTerminal(() => runCommand(deleteArgs('--env', ENV_UID), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(stdout).toBe('Environment not deleted.\n'); + expect(prompts.messages).toEqual([QUESTION]); + expect([lookup.isDone(), removal.isDone()]).toEqual([true, false]); + expect(onWire).toEqual([]); + }); + + it('exits 1 before asking anything when the environment does not exist', async () => { + const { lookup, removal } = lookupAndRemoval(404, notFoundFixture); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(deleteArgs('--env', ENV_UID), config)); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('No environment found with that name or UID.'); + expect(prompts.messages).toEqual([]); + expect([lookup.isDone(), removal.isDone()]).toEqual([true, false]); + }); +}); From ee8a9a5144ce043ca55f5bc8614e46c17a564179 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Fri, 16 Oct 2026 12:09:38 +0530 Subject: [PATCH 8/8] docs: describe the launch:env commands in AGENTS.md and the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the launch:env commands, the stdin -p, and the SIGINT handling, and regenerates the README command reference. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Harshi Shah --- AGENTS.md | 98 +++++++++++++++++++++++------- README.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0fc59fe..25523cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ the gate as a floor, never as evidence of correctness. it was handed, and nothing read the documented `now() + delay >= deadline` boundary. **A defect the live harness finds is closed only with two tests.** When the live harness -(`docs/launch-cli-manual-tests`) finds a defect, it gets (1) a regression test that fails on the old +(`scripts/launch-cli-manual-tests` in the developer workspace, not this repository) finds a defect, it gets (1) a regression test that fails on the old code and (2) a class-level test where one is feasible, before it is closed. The regression test pins that bug; the class test fails on the *next* bug of the same kind, in code that does not exist yet. Before writing a new class test, check the guards below: if the defect belongs to one of their @@ -59,7 +59,7 @@ file and line that broke its rule, and each was proven red by reintroducing its | `test/integration/transport-content-type.test.ts` | For every method in `HTTP_METHODS`, a bodyless request carries no content type and a request with a body carries `application/json`, asserted on the wire. `withoutDefaultContentType` suppresses the utility client's default, so a bodyless POST, PUT or PATCH (for example `deployments:cancel`) no longer goes out as `application/x-www-form-urlencoded`. Only `utility-http-client.ts` builds an `HttpClient` and only `rest-client.ts` sets the JSON type. | D5: every DELETE sent `Content-Type: application/json` | | `test/guards/tty-streams.test.ts` | `stdin.isTTY`, and the `isTTY` it becomes on the service context, are read only to decide whether the CLI may prompt (a read next to a prompt or a refusal to prompt) or on the plumbing lines that carry it there. Anything deciding what to draw reads `outputIsTTY` / `process.stdout.isTTY`. | D6: heartbeat drawn into a redirected file | | `test/integration/retired-commands.test.ts` (named commands) | Every `launch:…` command named anywhere in `src` - a retirement message, a failure hint, an example - is either a registered command or on `PLANNED_COMMANDS` with the ticket that builds it, and a command drops off that list once it ships. | Review: retirement messages and hints pointed at commands the beta does not have | -| `test/guards/prompt-funnel.test.ts` | Every prompt goes through `LaunchCommand`'s `ux`, built once as `cancelOnInterrupt(cliux)`, so Ctrl-C at any prompt exits 3. No source calls `cliux.inquire` / `prompt` / `confirm` under any name, hands `cliux` on as a value, or imports a prompt library; `search-list.ts` may register a type but never prompt. | D2: Ctrl-C exited 130 | +| `test/guards/prompt-funnel.test.ts` | Every prompt goes through `LaunchCommand`'s `ux`, built once as `cancelOnInterrupt(cliux)`, so Ctrl-C at any prompt exits 3. The wrapper stays attached to `SIGINT` until the signal has reached every listener (it detaches in a microtask): inquirer's `cli-cursor` loads `signal-exit`, which re-raises `SIGINT` - exit 130 - when it finds itself the only listener, so a `once` listener made every list and checkbox prompt exit 130 (`src/core/interruptible-ux.test.ts` pins it). No source calls `cliux.inquire` / `prompt` / `confirm` under any name, hands `cliux` on as a value, or imports a prompt library; `search-list.ts` may register a type but never prompt. | D2: Ctrl-C exited 130 | A new prompt, request, render path or command is covered by these guards without being listed in them. Do not add an exemption to a guard to make a new file pass: if a guard is wrong about a file, @@ -136,9 +136,11 @@ src/ and LaunchApiError / LaunchNetworkError - no CLI wording lives here projects/ one resource: repository, value object, domain service, presenter, error wording, prompt adapter, and the flags it contributes - environments/ the environment DTOs, the framework preset table, the environment - flags every create command contributes, and the EnvironmentBuilder that - turns them into a CreateEnvironmentInput + environments/ the environment repository, DTOs, the framework preset table, the + environment flags (including --env and its resolver and picker), the + EnvironmentBuilder that turns them into a CreateEnvironmentInput, and the + EnvironmentCreator / EnvironmentUpdater behind launch:env:create / :update. + It reaches deployments, git and uploads, for create 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 @@ -176,7 +178,11 @@ That is why `Pagination` lives in `src/core/render.ts` beside `renderPagination` lives under `src/projects/`. `src/commands/` mirrors the oclif topic path and nothing else: `launch:projects:list` -is `src/commands/launch/projects/list.ts`, and that path is the public contract. Topic +is `src/commands/launch/projects/list.ts`, and that path is the public contract. One topic is +named differently from its resource folder: the environment commands are `launch:env:*` +(`src/commands/launch/env/`), owned by `src/environments/`, and `RESOURCE_OF_TOPIC` in +`test/guards/layering.test.ts` maps the one to the other. The V1 name `launch:environments` stays a +retired command pointing at `launch:env:list`. Topic descriptions are declared in `package.json` under `oclif.topics`; without them oclif shows a leaf command's description for the whole topic on every `csdx --help`. @@ -231,10 +237,14 @@ A new resource - environments, variables, deployments, logs, cache - is a folder `extends` clause. Anything with domain behaviour - "is this a uid or a name?", "which uid does this name -have?" - is a value object and a domain service in the resource folder, not something -inlined into a prompt module or a resolution spec. `projects/project-ref.ts` and -`projects/project.resolver.ts` are the worked example: the prompt module is a UI adapter -that renders choices and nothing more. +have?" - is a value object and a domain service, not something inlined into a prompt module or a +resolution spec. The generic parts live once in `core`: `ResourceRef` (`core/resource-ref.ts`, a +24-hex value is a uid, anything else a name), `scanPages` (`core/paging.ts`, the page-at-a-time +walk with its stop rules and the `MAX_PAGES` ceiling) and `uidByName` (`core/name-lookup.ts`, an +exact, case-sensitive name match that skips entries without a uid). A resource contributes only its +wording and its page shape: `projects/project.resolver.ts` and `environments/environment.resolver.ts` +are the worked examples, and a new resource (variables, deployments) follows them rather than copying +the loop. The prompt module is a UI adapter that renders choices and nothing more. **Typed resolved values.** `this.resolved.` carries the flag's own type, derived from the catalog entry: `Flags.string` gives `string`, `Flags.integer` gives `number`, @@ -311,7 +321,10 @@ payload inline. *Almost every prompt takes no typed text at all.* `askOption` asks `type: 'list'`, which moves only on an arrow or a digit and ignores every letter, so text can never become the answer and nothing -needs validating. Project type, branch, Framework preset, Response mode and Contentstack +needs validating. Every choice prompt (`askOption`, and the checkbox `askChoices`) passes `loop: false`, so the +arrow stops at the last entry instead of wrapping to the first, and a `pageSize` of the choice count +capped at `PROMPT_PAGE_SIZE` (15), so a short list shows whole and a 100-entry picker scrolls in a +fixed window. `askChoices` refuses an empty selection at the prompt, naming . Project type, branch, Framework preset, Response mode and Contentstack Authentication are all asked this way. Prefer it: the `❯` always shows what Enter will take, where a search box hides the list the moment the filter matches nothing. The branch picker is on this side despite fetching `PICKER_PAGE_SIZE` branches, because `noteTruncation` already prints the escape @@ -525,8 +538,9 @@ there is **no server-side maximum at all**. | `PICKER_PAGE_SIZE` | 100 | the page the interactive project picker fetches. It is separate from `CLIENT_MAX_LIMIT` on purpose: raising the client cap must never dump 1000 choices into a prompt | | `MAX_PAGES` | 100 | how many pages a name-to-uid scan will walk before raising `ProjectScanLimitError` | -`PROJECT_SCAN_PAGE_SIZE` (100) lives in `src/projects/projects.api.ts` beside the `pages()` -generator it sizes, because it is that repository's paging decision and not a CLI-wide one. +`PROJECT_SCAN_PAGE_SIZE` and `ENVIRONMENT_SCAN_PAGE_SIZE` (100 each) live beside the `pages()` +each repository builds on `scanPages`, because the page size is that repository's decision and not a +CLI-wide one; the scan-limit error each raises carries its own wording. **The deployment wait/stream loop.** `src/deployments/deployment.watcher.ts` polls a deployment to a terminal state and is the single place any command waits on one. `POST /projects` creates the @@ -625,8 +639,9 @@ at either means none, and the field is left out of the body. What the specs do o exactly like one from argv. The framework gate is the declarative rule `onlyWithValueOf('server-cmd', 'framework', ...)`, -exported as `serverCommandFrameworkGate` and declared in `static rules` with `gitOnlyFlagRules` -(`--branch`, `--namespace`, `--repo` only with `--type GitHub`). A rule judges its gate only when the +exported as `serverCommandFrameworkGate` and declared in `static rules`. (`--branch` and `--auto-deploy` +on a FileUpload create are not a rule: create warns about each one with `repositoryOnlyFlagLine` and ignores +it; `--namespace` and `--repo` no longer exist, the repository comes from the local clone.) A rule judges its gate only when the user supplied the gate: with `--framework` on argv, a bad pairing costs exit 2 and nothing on the wire. When the framework is prompted or detected later, `EnvironmentBuilder` in `src/environments/environment.builder.ts` applies the same check @@ -747,13 +762,12 @@ gate value `onlyWithValueOf` reads is a value, not a "was it supplied?" question wherever it came from. Write the next rule when a command needs it; a rule kept alive only by its own test proves nothing. -**Built ahead of use.** Five pieces of production code have no caller yet, on purpose, each held +**Built ahead of use.** Four pieces of production code have no caller yet, on purpose, each held for a named ticket whose shape it already fits. Nothing else in `src` is uncalled; add to this list rather than leave an orphan unexplained. | Code | Held for | Why its API fits | |---|---|---| -| `EnvironmentsApi.list` | CL-7168 (`environments:*`) | `{org, project, limit, skip}` -> `EnvironmentsPage`, the paging every list command renders | | `DeploymentsApi.list` | CL-7170 (`deployments:*`) | a `DeploymentScope` plus paging -> `DeploymentsPage` | | `src/core/redact.ts` | CL-7169 (variables) | see below | | `exactlyOneOf` | CL-7172 (`cache:purge`) | "purge these paths, or everything, not both" - and it judges what the user supplied, not defaults | @@ -768,15 +782,59 @@ debug logger and an in-flight secret registry to redact against, neither of whic one does, a command handling variable values must redact them itself before they reach `confirm()` or an error message. +**The environment commands (CL-7168).** `launch:env:list`, `:get`, `:create`, `:update` and +`:delete` - `launch:env:*` by product decision, not the `launch:environments:*` the Commands Details +page shows; `launch:environments` stays a retired V1 name pointing at `launch:env:list`. + +- `--env ` depends on `--org` and `--project` and is never read from `.cs-launch.json`. + From argv a 24-hex value is a uid and anything else is matched exactly and case-sensitively by + `EnvironmentResolver` over `EnvironmentsApi.pages` (exit 2 on no match, `EnvironmentScanLimitError` + past `MAX_PAGES`); a picker answer is already a uid. The picker is a plain `list`, not a search list. + CL-7169 to CL-7172 reuse it; do not build a second one. +- `env:get`'s PROJECT row shows the project the command resolved, never the response's `project` + field: the service declares it `project: Types.ObjectId` with `@Expose()` and no transform + (`environments.rest.dto.ts:96`), and class-transformer answers with a freshly generated ObjectId on + every call. Found by the live harness on dev11. +- `env:get` shows a FIELD | VALUE table; `VARIABLES` is a **count**, never a key or a value, and + password protection shows only enabled/disabled. The response mode cannot be shown: the API does + not return `isStreamingEnabled`. +- `env:delete` mirrors `projects:delete`: GET first, then the confirm gate, then a bodyless DELETE. +- `env:update` with no updatable flag asks on a terminal: a checklist of the settings + (`askChoices`, a checkbox prompt in `src/core/prompt.ts`; Branch and Auto deploy are left off for + FileUpload), then only the ticked ones, each offering the current value, keeping only what changed + (`promptForEnvironmentUpdate` in `environment.update.prompt.ts`). The response mode is always sent + when ticked, because the API never reports it. Nothing changed prints the yellow "Environment not + updated. No changes were entered." and exits 0. Without a terminal it is exit 2 naming every flag - + the API would answer `{}` with 200 and change nothing. Disabling cache priming or password protection is confirm-gated. + `--password` (`-p`) takes no value, so a password never reaches argv: on a terminal the password is + asked through `askSecret` (a masked prompt in `src/core/prompt.ts`); without one, `-p` reads it from + standard input (`readPipedInput`, `src/core/piped-input.ts`; one trailing newline dropped, an empty one + refused): `printf '%s' "$PASSWORD" | csdx launch:env:update ... --password-protection enable -u admin -p`. + Without a terminal, enabling needs both `-u` and `-p`, checked before any request. `--username` and + `--password` are refused without `--password-protection enable`. A taken name comes back from the + update endpoint as a 409 whose body carries the code as its *message* and no `code` field + (`environment.controller.ts:801`), so `EnvironmentsApi.update` rewords a 409 whose code *or message* + is `launch.ENVIRONMENT.DUPLICATE_NAME`; any other 409 keeps the API's wording. +- `--branch` and `--auto-deploy` on a FileUpload project are warned about and dropped, never refused, + in `projects:create`, `env:create` and `env:update` alike: one warning line and one flag list + (`repositoryOnlyFlagLine`, `REPOSITORY_ONLY_FLAGS`, `withoutRepositoryFlags` in + `environments/environment.rules.ts`). An `env:update` left with nothing else says nothing changed. +- `env:create` follows `projects:create`: the FileUpload warnings come before any upload or prompt, + the order is (upload) -> name -> (branch) -> framework -> build -> output -> server -> response mode + -> Contentstack Authentication, the green "Environment created successfully." prints as soon as the + POST succeeds, and every later failure is a partial success (exit 1) naming the environment. A 404 + from framework detection means nothing was detected: both endpoints throw a bare + `NotFoundException` whenever detection fails. + **Value helpers.** `src/core/values.ts` holds `withinLength` and `oneOf`, the two checks a resolution spec's `normalize` reaches for. `oneOf` is case- and whitespace-insensitive and returns the **canonical** option, which is what makes a doc label map cleanly onto a service enum. Both resources use it rather than each writing their own. **One copy of a shared helper.** A predicate or a wording rule that two resources need lives in the -layer they both import, never copied beside each one. `isRecord` is in `src/transport/envelope.ts` -beside `hasUid`; `sentence` - trim a reason and give it a full stop, so a reason can be sewn into a -longer message - is in `src/core/errors.ts` beside `advice`. Both had two identical copies, which is +layer they both import, never copied beside each one. `isRecord` and `asSentence` - trim a +reason and give it a full stop, so a reason can be sewn into a longer message - are in +`src/core/values.ts`, beside `isAbsent` and `messageOf`. Both had two identical copies, which is the state just before they drift: one gets a fix and the other keeps punctuating a reason its own way, so the same failure reads differently from one command to the next. diff --git a/README.md b/README.md index fcf98fa..be8a5e8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,11 @@ with exit code 2 and name their V2 replacements. `launch:functions:serve` is liv * [`csdx help [COMMAND]`](#csdx-help-command) * [`csdx launch`](#csdx-launch) * [`csdx launch:deployments`](#csdx-launchdeployments) +* [`csdx launch:env:create`](#csdx-launchenvcreate) +* [`csdx launch:env:delete`](#csdx-launchenvdelete) +* [`csdx launch:env:get`](#csdx-launchenvget) +* [`csdx launch:env:list`](#csdx-launchenvlist) +* [`csdx launch:env:update`](#csdx-launchenvupdate) * [`csdx launch:environments`](#csdx-launchenvironments) * [`csdx launch:functions`](#csdx-launchfunctions) * [`csdx launch:functions:serve`](#csdx-launchfunctionsserve) @@ -105,16 +110,181 @@ DESCRIPTION _See code: [src/commands/launch/deployments/index.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/index.ts)_ +## `csdx launch:env:create` + +Create an environment in a Launch project and follow its first deployment + +``` +USAGE + $ csdx launch:env:create [-c ] [-d ] [--org ] [--project ] [--name ] + [--description ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd ] + [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --auto-deploy= Deploy on every push, GitHub projects only (enable | disable) + --branch= Git branch name, GitHub projects only + --build-cmd= Build command + --cs-auth= Contentstack Authentication (enable | disable) + --description= Environment description (255 characters or fewer) + --framework= Framework preset (Gatsby | NextJs | CRA | CSR | Analog | Angular | Nuxt | Astro | VueJs | + Remix | Other) + --name= Environment name (200 characters or fewer) + --org= Organization UID + --output-dir= Output directory + --project= Project name or UID + --res-mode= Response mode (buffered | streaming) + --server-cmd= Server command + +DESCRIPTION + Create an environment in a Launch project and follow its first deployment + +EXAMPLES + $ csdx launch:env:create --org --project --name staging --branch develop --framework NextJs --build-cmd "npm run build" --output-dir .next --res-mode buffered + + $ csdx launch:env:create --org --project --name staging --data-dir ./site # a FileUpload project uploads the folder +``` + +_See code: [src/commands/launch/env/create.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/env/create.ts)_ + +## `csdx launch:env:delete` + +Delete an environment of a Launch project + +``` +USAGE + $ csdx launch:env:delete [-c ] [-d ] [--org ] [--project ] [--env ] [-y] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + -y, --yes Skip the confirmation prompt + --env= Environment name or UID + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Delete an environment of a Launch project + +EXAMPLES + $ csdx launch:env:delete --org --project --env + + $ csdx launch:env:delete --org --project --env --yes +``` + +_See code: [src/commands/launch/env/delete.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/env/delete.ts)_ + +## `csdx launch:env:get` + +Show a single environment of a Launch project + +``` +USAGE + $ csdx launch:env:get [-c ] [-d ] [--org ] [--project ] [--env ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --env= Environment name or UID + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Show a single environment of a Launch project + +EXAMPLES + $ csdx launch:env:get --org --project --env +``` + +_See code: [src/commands/launch/env/get.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/env/get.ts)_ + +## `csdx launch:env:list` + +List the environments of a Launch project + +``` +USAGE + $ csdx launch:env:list [-c ] [-d ] [--org ] [--project ] [--limit ] [--skip + ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --limit= Number of records to fetch (1-1000) + --org= Organization UID + --project= Project name or UID + --skip= Number of records to skip + +DESCRIPTION + List the environments of a Launch project + +EXAMPLES + $ csdx launch:env:list --org --project +``` + +_See code: [src/commands/launch/env/list.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/env/list.ts)_ + +## `csdx launch:env:update` + +Update an environment of a Launch project + +``` +USAGE + $ csdx launch:env:update [-c ] [-d ] [--org ] [--project ] [--env ] [--name + ] [--description ] [--branch ] [--framework ] [--build-cmd ] [--server-cmd + ] [--output-dir ] [--res-mode ] [--auto-deploy ] [--cs-auth ] [--cache-priming + ] [--password-protection ] [-u ] [-p] [-y] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + -p, --password With --password-protection enable: read the password from standard input when there + is no terminal (on a terminal it is asked at a hidden prompt) + -u, --username= Password protection username, with --password-protection enable + -y, --yes Skip the confirmation prompt + --auto-deploy= Deploy on every push, GitHub projects only (enable | disable) + --branch= Git branch name, GitHub projects only + --build-cmd= Build command + --cache-priming= Cache priming (enable | disable) + --cs-auth= Contentstack Authentication (enable | disable) + --description= Environment description (255 characters or fewer) + --env= Environment name or UID + --framework= Framework preset (Gatsby | NextJs | CRA | CSR | Analog | Angular | Nuxt | Astro | + VueJs | Remix | Other) + --name= Environment name (200 characters or fewer) + --org= Organization UID + --output-dir= Output directory + --password-protection= Password protection (enable | disable) + --project= Project name or UID + --res-mode= Response mode (buffered | streaming) + --server-cmd= Server command + +DESCRIPTION + Update an environment of a Launch project + +EXAMPLES + $ csdx launch:env:update --org --project --env --build-cmd "npm run build" + + $ csdx launch:env:update --org --project --env --cache-priming disable --yes + + $ csdx launch:env:update --org --project --env --password-protection enable -u -p + + $ printf '%s' "$PASSWORD" | csdx launch:env:update --org --project --env --password-protection enable -u -p +``` + +_See code: [src/commands/launch/env/update.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/env/update.ts)_ + ## `csdx launch:environments` -Removed in v2 - list environments with launch:environments:list +Removed in v2 - list environments with launch:env:list ``` USAGE $ csdx launch:environments DESCRIPTION - Removed in v2 - list environments with launch:environments:list + Removed in v2 - list environments with launch:env:list ``` _See code: [src/commands/launch/environments/index.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/environments/index.ts)_