From 0de01b8eeef2f91f3a0682ba5b053e51f4d22d69 Mon Sep 17 00:00:00 2001 From: SakshiKoli-CS Date: Thu, 22 Oct 2026 15:02:52 +0530 Subject: [PATCH] fix(environments): one error line per failure, an org check on the project, and FileUpload-aware env output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors - Every failure prints a single `error: ` line on stderr, as V1 did, instead of oclif's `Error:` block (src/core/failure.ts). LaunchCommand, RetiredCommand and launch:functions:serve report through it, and bin/run.js and bin/dev.js use it as the last handler so an unknown command gets the same line. - reportFailure passes through an error it has already printed (skipOclifErrorHandling), so the bin's handler no longer prints the line a second time. The manual harness caught the doubled line on every failing env command. - eslint.config.mjs gives bin/*.js its Node globals, and the bins drop disable comments for rules this config does not load, so lint-staged accepts them. Project resolution - A project uid given by flag or config must belong to the selected organization; otherwise the command stops with `No project with UID "…" found in this organization.` A project picked at the prompt is already scoped, so it is not looked up again. Environment and project output - launch:env:list drops the BRANCH column, and launch:env:get the BRANCH row, for a FileUpload project (the project is fetched alongside the environment). - env:get rows read ORGANIZATION UID and PROJECT UID; the project list column reads PROJECT UID. - The env:update checklist asks "Select the fields to update:", and "No records found." is yellow on a terminal. Tests - test/support/project-ownership.ts mocks the ownership lookup; the integration suites, including the variables suites merged from v2-dev, register it. - .talismanrc checksums refreshed for the test files Talisman flags by name (`env`) or by the word "pass" in a test title; none holds a credential. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Harshi Shah Co-Authored-By: Claude --- .talismanrc | 44 +++---- bin/dev.js | 13 +- bin/run.js | 10 +- eslint.config.mjs | 13 ++ src/commands/launch/env/get.test.ts | 14 ++- src/commands/launch/env/get.ts | 12 +- src/commands/launch/env/list.test.ts | 24 +++- src/commands/launch/env/list.ts | 15 ++- src/commands/launch/functions/serve.ts | 5 + src/commands/launch/projects/get.test.ts | 14 +-- src/commands/launch/projects/list.test.ts | 12 +- src/commands/launch/projects/update.test.ts | 3 +- src/core/failure.test.ts | 115 ++++++++++++++++++ src/core/failure.ts | 69 +++++++++++ src/core/launch-command.test.ts | 105 +++++++++------- src/core/launch-command.ts | 14 ++- src/core/render.test.ts | 8 ++ src/core/render.ts | 2 +- src/core/resolve.test.ts | 5 + src/core/retired-command.ts | 5 + .../environment.presenter.test.ts | 33 ++++- src/environments/environment.presenter.ts | 18 ++- .../environment.update.prompt.test.ts | 2 +- src/environments/environment.update.prompt.ts | 2 +- src/projects/project.inputs.ts | 12 +- src/projects/project.presenter.test.ts | 7 ++ src/projects/project.presenter.ts | 2 +- src/projects/project.resolver.test.ts | 47 ++++++- src/projects/project.resolver.ts | 19 ++- src/resources.test.ts | 14 ++- test/integration/environments-create.test.ts | 2 + test/integration/environments-delete.test.ts | 2 + .../environments-list-command.test.ts | 26 ++++ test/integration/environments-update.test.ts | 12 +- test/integration/organization-prompt.test.ts | 4 + .../projects-command-flows.test.ts | 13 +- test/integration/projects-delete.test.ts | 6 + test/integration/projects-get.test.ts | 4 +- .../integration/projects-list-command.test.ts | 7 +- test/integration/projects-list.test.ts | 2 +- test/integration/projects-update.test.ts | 15 ++- test/integration/retired-commands.test.ts | 6 + test/integration/variables-list.test.ts | 2 + test/integration/variables-set-unset.test.ts | 2 + test/support/project-ownership.ts | 11 ++ 45 files changed, 625 insertions(+), 147 deletions(-) create mode 100644 src/core/failure.test.ts create mode 100644 src/core/failure.ts create mode 100644 test/support/project-ownership.ts diff --git a/.talismanrc b/.talismanrc index cef6aa24..69042126 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,6 +1,8 @@ fileignoreconfig: +- filename: src/core/failure.test.ts + checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts - checksum: 15a04dda74e97d9d1d89340b7a90744fb239d9de1fbada08e7d42cfe12cc649e + checksum: 3d2af18ef58effd89e398e6cb523f6680d7385eb34cc660f559be59114ab07f7 - filename: test/integration/manage-base-path.test.ts checksum: 79eb9ad420ea47d5bee8045d812c45b010bbeeafcaf5f85d11653140b0cd0db1 - filename: src/transport/rest-client.ts @@ -142,19 +144,19 @@ fileignoreconfig: ignore_detectors: - filename - filename: src/environments/environment.update.prompt.ts - checksum: 12b8bfafabc5f5174559179b2e926131fb04793685c9bba63cf0620d1cb089a2 + checksum: 5f2da5ae044eda92f9e555c997d23f065b5454cda8a340c12595597795ecfbb9 ignore_detectors: - filename - filename: src/environments/environment.update.prompt.test.ts - checksum: 5b77e234c5c331d52268157241802345006d59b3af4c0476faa9b28825a61d71 + checksum: b83aa8da125e6a45b455cf03c50fe82cc41890343be9be0c2ccf0a01d09bd160 ignore_detectors: - filename - filename: src/environments/environment.presenter.ts - checksum: e980e4fc6dcbe721a2a6d4c826273a3a4ddaa9a4e52a6661ee1b599a9ea9c783 + checksum: 3ca3e566a136fb0100cd794e9e973fce70a80c88224b6f93027d8190ac0b6882 ignore_detectors: - filename - filename: src/environments/environment.presenter.test.ts - checksum: 3f497b982f33c721c988c8245f91534fd50b42317c84cc8f1de784da2d13c942 + checksum: f5f6e5ec469a16ca8e1c9175ec88aa406a24c6318ef95520d534c444fdd3ab88 ignore_detectors: - filename - filename: src/environments/environment.resolver.ts @@ -203,7 +205,7 @@ fileignoreconfig: ignore_detectors: - filename - filename: test/integration/environments-list-command.test.ts - checksum: 87bf2ec14656f2bcf1bf97dd1ea248c8be087e0f15bf1b7e212c8308d8f13cfe + checksum: 34abcf0f1ab6de513fde298243dfe1e6cf2859e29c72ade7ae9f2418de7db074 - filename: test/fixtures/projects-list.json allowed_patterns: - "a1b2c3d4e5f60718293a4b5c" @@ -251,7 +253,7 @@ fileignoreconfig: - filename: src/core/resolve.ts checksum: 5d150db11cb1c976911ecba61336118ddad051942da95f809695c0cf0346de42 - filename: src/core/resolve.test.ts - checksum: 42dfd6c77b752f64f6a927845a2d98626934ba0952c911cf701b3df502cb0443 + checksum: b62b53aedd32ab01212508d79bf4c306dfbd9b0c9788e5fc449886eb770e0377 - filename: src/git/local-repository.ts checksum: b9091ed135dfebdf590a5412bbfba2b2f96d4a177cf944a20909b93405268e9f - filename: test/guards/layering.test.ts @@ -261,25 +263,25 @@ fileignoreconfig: - filename: src/commands/launch/projects/delete.test.ts checksum: d67d8a804d14f9b809153d10b0cf9d5e52b70f9e0b65678ebb1663a7d2da22c1 - filename: test/integration/projects-delete.test.ts - checksum: 78baba5c3e3aa5fd054dec04cee0e72a99e85cb8451b2346f53761f099509191 + checksum: bb59cbe1f4f7b3ac6aa7944067c1ab1214a7c5d20229e9d6577cdaabec56622e - filename: src/core/launch-command.ts - checksum: 85f86ea5f075bfdd78652dc05d20a2f6e89348466c6bd41ae903bcb1b7e8515d + checksum: b14abd1d4cee3e93c4458ab3567b1f252564bda7e12773cfa1e0d678967664a4 - filename: src/core/launch-command.test.ts - checksum: 930069aeddbf4348097f33713e4e57bd26cb3be1b7970eb60142bb53010e1674 + checksum: c4d3785f25377db560bb9daa5d0d080a0131056e6c31e3b45cb4f1f2347305ed - filename: src/projects/project.inputs.test.ts checksum: eaecaba9eddec128186871550fc5802166fa56a2e89741d72ac955fcfe2f952f - filename: test/integration/projects-command-flows.test.ts - checksum: 786a470c71f3a7ce58463d0a08e9bf47e36f936c55a2ca7cd8ef2a058ac01e4a + checksum: 30858bb3dff8a22406efa4ac82d882d1501797cdda24e09555aecc0bcd8dca0b - filename: src/core/redact.test.ts checksum: ae497ea8c3a4c3f12217d2c4c028a1da3d1324e18362d68c355ba8120902bcb3 - filename: src/commands/launch/projects/list.test.ts - checksum: d01cbf70469a771425c8258fb1800b70a6ff0b807208fc76f3060b69a2cc05bc + checksum: ba1876fb42b3031d3ae2eb23fd6591f827d3dc552a5edba765a7983c11fb3ba8 - filename: src/commands/launch/env/list.test.ts - checksum: 36040ec3ae8230a0b9ae28e1609fd9edf5965b9d472bd2f9a1808c060ea897bf + checksum: f5de16e0fd37a85ceb31a7fbc8e9058347ccc072f53c95d4f49ef15e923f4e95 - filename: src/commands/launch/projects/get.test.ts - checksum: fec6ca189bc3d93aefa109ee6405f8ce74a42318aa18a99045d40c3cb5561b9e + checksum: ecc48c6a381de44858eafa57fdfd8c24c087837d2869909c8326db8e77148f29 - filename: test/integration/projects-list-command.test.ts - checksum: d9617f9ae02a32a03d707be711f495350e4a442e61ac1a3514b8bc9190e9e6a2 + checksum: 5f49dc7f384fb9e33c8fb91f1c67b17ffc2fd3bbde3663f90f820056407e98a0 - filename: src/functions/cloud-functions.test.ts checksum: 5faf7b0497efc9f620983220019736a0ec2e8c43f22316f888201912bbcb5d62 - filename: src/transport/proxy.test.ts @@ -309,9 +311,9 @@ fileignoreconfig: - filename: src/environments/types.ts checksum: 9c2387b8bd819af79b256f8638d54a33c41bccc1ab04f02dfd1cda48c035e6e7 - filename: src/commands/launch/env/get.test.ts - checksum: 26863ca165d20df9a9ea8afac1eda71c9f96be15fe23bddb4fd7ddfcefab2256 + checksum: 6e739ef12bc0d887e652e23ee54d9fac30dd6a3505cf464cdaefc0e696cf3e51 - filename: test/integration/environments-delete.test.ts - checksum: 7df1b364699953cad40b7aea3beeaaae6e2b781c72b7ae8f36ea78ebf50887e1 + checksum: a6f002256162971e5f4ace879153454c95199101d2acc853f2a54b39d1d48914 - filename: src/commands/launch/env/delete.test.ts checksum: 14116789eb5f9000f2a7d6f569cd15aabf2a1ec3b127ed4427afe13fd515f0c2 - filename: src/environments/environment.errors.ts @@ -321,7 +323,7 @@ fileignoreconfig: - filename: src/core/prompt.test.ts checksum: 2deacd402e8d7f79458ee6d3917205c6dbe5cd7e6a98a2e5c49e37ac1a9d57c4 - filename: test/integration/environments-update.test.ts - checksum: 650627ec2cc6fed82f000b0e0a368a3725238dbb64ec18a5866c2fa3abb3a27f + checksum: 4cd0106bf1881d373d23f063314627057d85cab0c43b793f013ffc2ca8859036 - filename: src/commands/launch/env/update.ts checksum: 06add97108d878eaf415e32160439b8963683e63a0e37ce77a56f66b88383c7a - filename: src/commands/launch/env/update.test.ts @@ -333,7 +335,7 @@ fileignoreconfig: - "-p, --password" - "-u, --username" - filename: test/integration/environments-create.test.ts - checksum: 9582c39ab4732e6ab530c6d80b5a08bac28920ba5421608527479fe5bace8c95 + checksum: 09269b51e499870a04bccfa5e0f2febd287f5246f176c258347c884240678289 - filename: src/commands/launch/variables/list.test.ts checksum: 87ccb762f4931326fc50bca1c01aeb4a16d552e4dfa141d00370fdfafa4363be - filename: src/commands/launch/variables/set.test.ts @@ -411,7 +413,7 @@ fileignoreconfig: - filename: test/fixtures/cms-environment-variables.json checksum: cb2cbb2d1d858b1cf2115c218a0ecca04ce04baf054fe11fe6123589c1afce27 - filename: test/integration/variables-list.test.ts - checksum: c0ccb0bc394c9013f75b69c4354cc5d52b2f6916a35af902de36bef1cda1648c + checksum: 5ea6a0fcd331496ca19773e90dab81e09eb5290b8f22a1880bffc7916f3778b2 - filename: test/integration/variables-set-unset.test.ts - checksum: 25ae86d9a50f02c5209823a91ae0ff1433e5a0bff3cfa27f84e48274d0e9c571 + checksum: cafc4eed5f80fe9763cb9362acce9ba51663da7a615abf683b23545f5342b913 version: "1.0" diff --git a/bin/dev.js b/bin/dev.js index 5cd97018..7d7de4cd 100755 --- a/bin/dev.js +++ b/bin/dev.js @@ -1,6 +1,13 @@ #!/usr/bin/env node_modules/.bin/ts-node -// eslint-disable-next-line node/shebang, unicorn/prefer-top-level-await (async () => { const oclif = await import('@oclif/core'); - await oclif.execute({ development: true, dir: __dirname }); -})(); \ No newline at end of file + const { handleStandaloneFailure } = require('../dist/core/failure'); + + process.env.NODE_ENV = 'development'; + oclif.settings.debug = true; + + await oclif + .run(process.argv.slice(2), __dirname) + .then(() => oclif.flush()) + .catch(handleStandaloneFailure); +})(); diff --git a/bin/run.js b/bin/run.js index 1024bac3..ff680c6f 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,7 +1,11 @@ #!/usr/bin/env node -// eslint-disable-next-line unicorn/prefer-top-level-await (async () => { const oclif = await import('@oclif/core'); - await oclif.execute({ development: false, dir: __dirname }); -})(); \ No newline at end of file + const { handleStandaloneFailure } = require('../dist/core/failure'); + + await oclif + .run(process.argv.slice(2), __dirname) + .then(() => oclif.flush()) + .catch(handleStandaloneFailure); +})(); diff --git a/eslint.config.mjs b/eslint.config.mjs index af115bfb..d9e92464 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,19 @@ export default tseslint.config( }, }, }, + { + files: ['bin/**/*.js'], + languageOptions: { + globals: { + require: 'readonly', + process: 'readonly', + __dirname: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, { files: ['src/**/*.{js,ts}', 'test/**/*.{js,ts}'], rules: { diff --git a/src/commands/launch/env/get.test.ts b/src/commands/launch/env/get.test.ts index 3257f9db..24a69989 100644 --- a/src/commands/launch/env/get.test.ts +++ b/src/commands/launch/env/get.test.ts @@ -1,7 +1,7 @@ import type { UxLike } from '../../../core/prompt'; import EnvironmentsGet from './get'; -function commandUnderTest(answer: () => Promise) { +function commandUnderTest(answer: () => Promise, projectType = 'GITPROVIDER') { const lines: string[] = []; const ux: UxLike = { print: (message: string) => { @@ -20,6 +20,7 @@ function commandUnderTest(answer: () => Promise) { isTTY: false, outputIsTTY: false, api: { + projects: { get: async () => ({ uid: 'proj1', projectType }) }, environments: { get: async (params: unknown) => { fetched.push(params); @@ -41,11 +42,20 @@ describe('launch:env:get', () => { expect(fetched).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); expect(lines[1]).toBe('│ FIELD │ VALUE │'); - expect(lines[3]).toBe('│ UID │ env1 │'); + expect(lines[3]).toBe('│ ENVIRONMENT UID │ env1 │'); expect(lines[5]).toBe('│ NAME │ production │'); expect(lines).toHaveLength(39); }); + it('leaves the branch row out for an environment of a file-upload project', async () => { + const { command, lines } = commandUnderTest(async () => ({ uid: 'env1', gitBranch: 'main' }), 'FILEUPLOAD'); + + await command.run(); + + expect(lines.some((line) => line.includes('BRANCH'))).toBe(false); + expect(lines).toHaveLength(37); + }); + it('propagates a failed lookup', async () => { const failure = new Error('not found'); const { command, lines } = commandUnderTest(async () => Promise.reject(failure)); diff --git a/src/commands/launch/env/get.ts b/src/commands/launch/env/get.ts index d28f926d..16b500fd 100644 --- a/src/commands/launch/env/get.ts +++ b/src/commands/launch/env/get.ts @@ -19,16 +19,16 @@ export default class EnvironmentsGet extends LaunchCommand { 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, - }); + const scope = { org: this.resolved.org, project: this.resolved.project }; + const [environment, project] = await Promise.all([ + this.services.api.environments.get({ ...scope, environment: this.resolved.env }), + this.services.api.projects.get(scope), + ]); renderTable( this.ux, ENVIRONMENT_DETAIL_COLUMNS, - environmentDetailRows({ ...environment, project: this.resolved.project }), + environmentDetailRows({ ...environment, project: this.resolved.project }, project.projectType !== 'FILEUPLOAD'), 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 index 7db32d83..5672cb08 100644 --- a/src/commands/launch/env/list.test.ts +++ b/src/commands/launch/env/list.test.ts @@ -5,6 +5,7 @@ function commandUnderTest( page: unknown, resolved: Record = { org: 'org1', project: 'proj1', limit: 50, skip: 0 }, answer: () => Promise = async () => page, + projectType = 'GITPROVIDER', ) { const lines: string[] = []; const ux: UxLike = { @@ -23,6 +24,7 @@ function commandUnderTest( ux, isTTY: false, api: { + projects: { get: async () => ({ uid: 'proj1', projectType }) }, environments: { list: async (params: unknown) => { listed.push(params); @@ -54,11 +56,23 @@ describe('launch:env:list', () => { 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[1]).toBe('│ NAME │ BRANCH │ URL │ UPDATED │ ENVIRONMENT UID │'); + expect(lines[3]).toBe('│ production │ main │ https://a.io │ 2026-09-01 │ e1 │'); expect(lines[5]).toBe('Showing 1-1 of 1'); }); + it('leaves the branch column out for a file-upload project', async () => { + const page = { + pagination: { count: 1, limit: 50, skip: 0 }, + environments: [{ uid: 'e1', name: 'production', domains: [{ url: 'https://a.io' }], updatedAt: '2026-09-01' }], + }; + const { command, lines } = commandUnderTest(page, undefined, undefined, 'FILEUPLOAD'); + + await command.run(); + + expect(lines[1]).toBe('│ NAME │ URL │ UPDATED │ ENVIRONMENT UID │'); + }); + 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 }, @@ -75,7 +89,7 @@ describe('launch:env:list', () => { await command.run(); - expect(lines[3]).toBe('│ production │ main │ https://a.io │ 2026-09-01 │ e1 │'); + expect(lines[3]).toBe('│ production │ main │ https://a.io │ 2026-09-01 │ e1 │'); }); it.each([[{}], [{ domains: [] }], [{ domains: [{ url: ' ' }] }]])( @@ -85,8 +99,8 @@ describe('launch:env:list', () => { await command.run(); - expect(lines[1]).toBe('│ NAME │ BRANCH │ URL │ UPDATED │ UID │'); - expect(lines[3]).toBe('│ - │ - │ - │ - │ - │'); + expect(lines[1]).toBe('│ NAME │ BRANCH │ URL │ UPDATED │ ENVIRONMENT UID │'); + expect(lines[3]).toBe('│ - │ - │ - │ - │ - │'); }, ); diff --git a/src/commands/launch/env/list.ts b/src/commands/launch/env/list.ts index 0b0e72b8..ca29ac79 100644 --- a/src/commands/launch/env/list.ts +++ b/src/commands/launch/env/list.ts @@ -1,7 +1,7 @@ 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'; +import { environmentColumns } from '../../../environments/environment.presenter'; const listInputs = inputs({ org: { required: true }, project: { required: true }, limit: {}, skip: {} }); @@ -15,16 +15,15 @@ export default class EnvironmentsList extends LaunchCommand { 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, - }); + const scope = { org: this.resolved.org, project: this.resolved.project }; + const [page, project] = await Promise.all([ + this.services.api.environments.list({ ...scope, limit: this.resolved.limit, skip: this.resolved.skip }), + this.services.api.projects.get(scope), + ]); renderTable( this.ux, - ENVIRONMENT_COLUMNS, + environmentColumns(project.projectType !== 'FILEUPLOAD'), page.environments, process.stdout.columns, undefined, diff --git a/src/commands/launch/functions/serve.ts b/src/commands/launch/functions/serve.ts index 61b96c9a..d01c018b 100755 --- a/src/commands/launch/functions/serve.ts +++ b/src/commands/launch/functions/serve.ts @@ -1,6 +1,7 @@ import { Command } from '@oclif/core'; import { EXIT_RUNTIME, EXIT_USAGE } from '../../../core/constants'; +import { failureOutputOf, reportFailure } from '../../../core/failure'; import Contentfly from '../../../functions'; import { PortInUseError } from '../../../functions/function.errors'; import { isValidPort, serveFlags } from '../../../functions/function.inputs'; @@ -50,4 +51,8 @@ export default class Functions extends Command { throw error; } } + + protected async catch(err: Error): Promise { + return reportFailure(err, failureOutputOf((original) => super.catch(original))); + } } diff --git a/src/commands/launch/projects/get.test.ts b/src/commands/launch/projects/get.test.ts index e73301e7..41b363c0 100644 --- a/src/commands/launch/projects/get.test.ts +++ b/src/commands/launch/projects/get.test.ts @@ -48,7 +48,7 @@ describe('launch:projects:get', () => { expect(fetched).toEqual([{ org: 'org1', project: 'a'.repeat(24) }]); expect(lines).toEqual([ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 site \u2502 FILEUPLOAD \u2502 aaaaaaaaaaaaaaaaaaaaaaaa \u2502 marketing \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', @@ -62,7 +62,7 @@ describe('launch:projects:get', () => { expect(lines).toEqual([ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 site \u2502 - \u2502 aaaaaaaaaaaaaaaaaaaaaaaa \u2502 - \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', @@ -75,11 +75,11 @@ describe('launch:projects:get', () => { await command.run(); expect(lines).toEqual([ - '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', - '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', - '\u2502 - \u2502 - \u2502 - \u2502 - \u2502', - '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', + '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', + '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', + '\u2502 - \u2502 - \u2502 - \u2502 - \u2502', + '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', ]); }); diff --git a/src/commands/launch/projects/list.test.ts b/src/commands/launch/projects/list.test.ts index b0f62069..046828a4 100644 --- a/src/commands/launch/projects/list.test.ts +++ b/src/commands/launch/projects/list.test.ts @@ -42,8 +42,8 @@ describe('launch:projects:list', () => { await command.run(); expect(listed).toEqual([{ org: 'org1', limit: 50, skip: 0 }]); - expect(lines[1]).toBe('\u2502 NAME \u2502 TYPE \u2502 UID \u2502'); - expect(lines[3]).toBe('\u2502 site \u2502 FILEUPLOAD \u2502 p1 \u2502'); + expect(lines[1]).toBe('\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502'); + expect(lines[3]).toBe('\u2502 site \u2502 FILEUPLOAD \u2502 p1 \u2502'); expect(lines[5]).toBe('Showing 1-1 of 1'); }); @@ -55,7 +55,7 @@ describe('launch:projects:list', () => { await command.run(); - expect(lines[3]).toBe('\u2502 site \u2502 - \u2502 p1 \u2502'); + expect(lines[3]).toBe('\u2502 site \u2502 - \u2502 p1 \u2502'); }); it('renders a placeholder for a project row carrying neither a uid nor a name', async () => { @@ -66,8 +66,8 @@ describe('launch:projects:list', () => { await command.run(); - expect(lines[1]).toBe('\u2502 NAME \u2502 TYPE \u2502 UID \u2502'); - expect(lines[3]).toBe('\u2502 - \u2502 - \u2502 - \u2502'); + expect(lines[1]).toBe('\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502'); + expect(lines[3]).toBe('\u2502 - \u2502 - \u2502 - \u2502'); }); it('prints only the empty-table placeholder and no pagination footer for an empty page', async () => { @@ -93,7 +93,7 @@ describe('launch:projects:list', () => { await command.run(); - expect(lines[1]).toBe(`\u2502 NAME \u2502 TYPE \u2502 UID${' '.repeat(23)}\u2502`); + expect(lines[1]).toBe(`\u2502 NAME \u2502 TYPE \u2502 PROJECT UID${' '.repeat(15)}\u2502`); expect(lines[3]).toBe(`\u2502 marketing-site \u2502 GITPROVIDER \u2502 ${'a'.repeat(24)} \u2502`); expect(lines[5]).toBe(`\u2502 docs \u2502 FILEUPLOAD \u2502 ${'b'.repeat(24)} \u2502`); expect(lines[7]).toBe(`\u2502 x \u2502 FILEUPLOAD \u2502 ${'c'.repeat(24)} \u2502`); diff --git a/src/commands/launch/projects/update.test.ts b/src/commands/launch/projects/update.test.ts index 85fa0ce9..1e667988 100644 --- a/src/commands/launch/projects/update.test.ts +++ b/src/commands/launch/projects/update.test.ts @@ -68,7 +68,8 @@ function commandUnderTest( function fakeServices() { const ux: UxLike = { print: () => undefined, inquire: async () => undefined as never }; - return { api: {} as ApiSurface, ux, isTTY: false }; + const projects = { get: async () => ({ uid: PROJECT_UID, organizationUid: 'org1' }) }; + return { api: { projects } as unknown as ApiSurface, ux, isTTY: false }; } describe('launch:projects:update', () => { diff --git a/src/core/failure.test.ts b/src/core/failure.test.ts new file mode 100644 index 00000000..934ba69a --- /dev/null +++ b/src/core/failure.test.ts @@ -0,0 +1,115 @@ +import { Errors } from '@oclif/core'; + +import { handleStandaloneFailure, reportFailure } from './failure'; + +type Reported = Errors.CLIError & { skipOclifErrorHandling?: boolean }; + +function report(err: Error, options: { colour?: boolean; debug?: boolean } = {}) { + const lines: string[] = []; + const fallback = jest.fn(async (original: Error) => { + throw original; + }); + const outcome = reportFailure(err, { + print: (line) => lines.push(line), + colour: options.colour ?? false, + debug: options.debug ?? false, + fallback, + }).catch((failure: unknown) => failure) as Promise; + + return { lines, fallback, outcome }; +} + +describe('reportFailure', () => { + it('prints an oclif error once as a lowercase error line and keeps its exit code', async () => { + const { lines, outcome } = report(new Errors.CLIError('command launch:project:list not found', { exit: 127 })); + const thrown = await outcome; + + expect(lines).toEqual(['error: command launch:project:list not found']); + expect(thrown.message).toBe('command launch:project:list not found'); + expect(thrown.oclif.exit).toBe(127); + expect(thrown.skipOclifErrorHandling).toBe(true); + }); + + it('prints the whole line in red when colour is on', async () => { + const { lines, outcome } = report(new Errors.CLIError('Nonexistent flag: --json', { exit: 2 }), { colour: true }); + await outcome; + + expect(lines).toEqual(['\u001b[31merror: Nonexistent flag: --json\u001b[39m']); + }); + + it('prints an unexpected failure the same way and exits 1', async () => { + const { lines, fallback, outcome } = report(new Error('boom')); + const thrown = await outcome; + + expect(lines).toEqual(['error: boom']); + expect(thrown.oclif.exit).toBe(1); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('hands an unexpected failure to oclif under DEBUG so the stack trace is shown', async () => { + const boom = new Error('boom'); + const { lines, fallback, outcome } = report(boom, { debug: true }); + + await expect(outcome).resolves.toBe(boom); + expect(lines).toEqual([]); + expect(fallback).toHaveBeenCalledWith(boom); + }); + + it('passes an error it has already printed through untouched instead of printing it again', async () => { + const first = report(new Errors.CLIError('No environment found with that name or UID.', { exit: 1 })); + const printed = await first.outcome; + + const second = report(printed); + + await expect(second.outcome).resolves.toBe(printed); + expect(second.lines).toEqual([]); + }); + + it('lets an exit request pass through untouched and prints nothing', async () => { + const exit = new Errors.ExitError(3); + const { lines, outcome } = report(exit); + + await expect(outcome).resolves.toBe(exit); + expect(lines).toEqual([]); + }); +}); + +describe('handleStandaloneFailure', () => { + it('prints an error raised before any command runs, such as an unknown command, as the same error line', async () => { + const written: string[] = []; + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }); + const handle = jest.spyOn(Errors, 'handle').mockResolvedValue(undefined); + + await handleStandaloneFailure(new Errors.CLIError('command launch:project:list not found', { exit: 2 })); + + stderr.mockRestore(); + const [[handed]] = handle.mock.calls as [[Reported]]; + handle.mockRestore(); + expect(written).toEqual(['error: command launch:project:list not found\n']); + expect(handed.oclif.exit).toBe(2); + expect(handed.skipOclifErrorHandling).toBe(true); + }); + + it('does not print a second error line for a failure the command already reported', async () => { + const written: string[] = []; + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }); + const handle = jest.spyOn(Errors, 'handle').mockResolvedValue(undefined); + const reported = Object.assign(new Errors.CLIError('No environment found with that name or UID.', { exit: 1 }), { + skipOclifErrorHandling: true, + }); + + await handleStandaloneFailure(reported); + + stderr.mockRestore(); + const [[handed]] = handle.mock.calls as [[Reported]]; + handle.mockRestore(); + expect(written).toEqual([]); + expect(handed).toBe(reported); + }); +}); diff --git a/src/core/failure.ts b/src/core/failure.ts new file mode 100644 index 00000000..375b92d2 --- /dev/null +++ b/src/core/failure.ts @@ -0,0 +1,69 @@ +import { Errors, settings } from '@oclif/core'; + +import { EXIT_RUNTIME } from './constants'; +import { styled } from './style'; + +export interface FailureOutput { + print: (line: string) => void; + colour: boolean; + debug: boolean; + fallback: (err: Error) => Promise; +} + +/** Prints `error: ` once, as V1 did, then throws an error oclif exits on without printing it again. */ +export function failWith( + message: string, + exit: number | undefined, + output: Pick, +): never { + output.print(styled(`error: ${message}`, 'red', output.colour)); + + const failure = new Errors.CLIError(message, { exit }); + Object.assign(failure, { skipOclifErrorHandling: true }); + throw failure; +} + +export function writeToStderr(line: string): void { + process.stderr.write(`${line}\n`); +} + +export function failureOutputOf(fallback: (err: Error) => Promise): FailureOutput { + return { + print: writeToStderr, + colour: Boolean(process.stdout.isTTY), + debug: Boolean(settings.debug), + fallback, + }; +} + +function alreadyReported(err: Error): boolean { + return (err as Error & { skipOclifErrorHandling?: boolean }).skipOclifErrorHandling === true; +} + +export async function reportFailure(err: Error, output: FailureOutput): Promise { + if (err instanceof Errors.ExitError || alreadyReported(err)) { + throw err; + } + + if (err instanceof Errors.CLIError) { + return failWith(err.message, err.oclif.exit, output); + } + + if (output.debug) { + return output.fallback(err); + } + + return failWith(err.message, EXIT_RUNTIME, output); +} + +/** + * The standalone bin's last handler, in place of oclif's: a failure raised before any command runs (an unknown + * command, say) gets the same `error:` line. Under csdx this never runs, so the parent CLI keeps its own handling. + */ +export async function handleStandaloneFailure(err: Error): Promise { + try { + await reportFailure(err, failureOutputOf(async (original) => Promise.reject(original))); + } catch (reported) { + await Errors.handle(reported as Error); + } +} diff --git a/src/core/launch-command.test.ts b/src/core/launch-command.test.ts index 1d9b8f21..390e0a4c 100644 --- a/src/core/launch-command.test.ts +++ b/src/core/launch-command.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { resolve as resolvePath, join } from 'node:path'; import { HttpClient, authHandler, configHandler } from '@contentstack/cli-utilities'; +import { Errors } from '@oclif/core'; import { EXIT_RUNTIME, PROJECT_CONFIG_FILE } from './constants'; import { ProjectConfigStore } from './project-config'; @@ -60,6 +61,10 @@ function probe(): Probe & { error: jest.Mock } { return instance; } +function stdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); +} + interface Wire { queries: unknown[]; bodies: unknown[]; @@ -103,65 +108,80 @@ function fakeHttpClient( } describe('LaunchCommand.catch', () => { - it('reports a missing input as a usage error', async () => { - const instance = probe(); + const original = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); - await instance['catch'](new MissingInputError('org', { config: true, prompt: true })); + beforeEach(() => stdoutIsTTY(false)); - expect(instance.error).toHaveBeenCalledWith(expect.stringContaining('Missing required value for --org'), { - exit: 2, - }); + afterEach(() => { + if (original) { + Object.defineProperty(process.stdout, 'isTTY', original); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } }); - it('reports a usage error as a usage error', async () => { + async function caught(err: Error) { const instance = probe(); + const lines: string[] = []; + const spy = jest.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + lines.push(String(chunk).replace(/\n$/, '')); + return true; + }); + const thrown = (await instance['catch'](err) + .catch((failure: unknown) => failure) + .finally(() => spy.mockRestore())) as Errors.CLIError & { skipOclifErrorHandling?: boolean }; - await instance['catch'](new UsageError('No project named "ghost" found in this organization.')); + return { instance, lines, thrown }; + } - expect(instance.error).toHaveBeenCalledWith('No project named "ghost" found in this organization.', { - exit: 2, - }); + it.each([ + [new MissingInputError('org', { config: true, prompt: true }), 2], + [new UsageError('No project named "ghost" found in this organization.'), 2], + [new CancelledError(), 3], + [new LaunchApiError(404, [{ code: 'launch.SOMETHING.ELSE', message: 'the api said no' }]), 1], + [new LaunchApiError(500, []), 1], + ])('prints %p once as a lowercase error line and exits %i without oclif printing it again', async (err, exit) => { + const { instance, lines, thrown } = await caught(err); + + expect(lines).toEqual([`error: ${err.message}`]); + expect(thrown).toBeInstanceOf(Errors.CLIError); + expect(thrown.message).toBe(err.message); + expect(thrown.oclif.exit).toBe(exit); + expect(thrown.skipOclifErrorHandling).toBe(true); + expect(instance.error).not.toHaveBeenCalled(); }); - it('reports a declined confirmation with the dedicated cancellation exit code', async () => { - const instance = probe(); + it('prints the whole error line in red when the output is a terminal', async () => { + stdoutIsTTY(true); - await instance['catch'](new CancelledError()); + const { lines } = await caught(new UsageError('No project named "ghost" found in this organization.')); - expect(instance.error).toHaveBeenCalledWith('Cancelled. Nothing was changed.', { exit: 3 }); + expect(lines).toEqual(['\u001b[31merror: No project named "ghost" found in this organization.\u001b[39m']); }); - it('reports an API failure as a runtime error', async () => { - const instance = probe(); + it('prints an oclif error, such as an unknown flag, the same way and keeps its exit code', async () => { + const { lines, thrown } = await caught(new Errors.CLIError('Nonexistent flag: --json', { exit: 2 })); - await instance['catch'](new LaunchApiError(404, [{ code: 'launch.SOMETHING.ELSE', message: 'the api said no' }])); - - expect(instance.error).toHaveBeenCalledWith('the api said no', { exit: 1 }); + expect(lines).toEqual(['error: Nonexistent flag: --json']); + expect(thrown.oclif.exit).toBe(2); + expect(thrown.skipOclifErrorHandling).toBe(true); }); - it('maps a cancelled confirmation, a usage failure and an api failure through one LaunchError branch', async () => { - const instance = probe(); + it('lets an exit request pass through untouched and prints nothing', async () => { + const exit = new Errors.ExitError(3); - await instance['catch'](new CancelledError()); - await instance['catch'](new UsageError('Pass --org.')); - await instance['catch'](new LaunchApiError(500, [])); + const { lines, thrown } = await caught(exit); - expect(instance.error).toHaveBeenNthCalledWith(1, 'Cancelled. Nothing was changed.', { exit: 3 }); - expect(instance.error).toHaveBeenNthCalledWith(2, 'Pass --org.', { exit: 2 }); - expect(instance.error).toHaveBeenNthCalledWith(3, 'Launch API request failed with status 500.', { exit: 1 }); + expect(lines).toEqual([]); + expect(thrown).toBe(exit); }); - it('delegates anything else to oclif, which sets process.exitCode before rethrowing', async () => { - const instance = probe(); - const other = new Error('boom'); - const previousExitCode = process.exitCode; - process.exitCode = undefined; - - await expect(instance['catch'](other)).rejects.toThrow('boom'); + it('prints an unexpected failure as an error line and exits 1, keeping the stack for DEBUG runs', async () => { + const { lines, thrown } = await caught(new Error('boom')); - expect(instance.error).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - process.exitCode = previousExitCode; + expect(lines).toEqual(['error: boom']); + expect(thrown.oclif.exit).toBe(1); + expect(thrown.skipOclifErrorHandling).toBe(true); }); }); @@ -231,15 +251,18 @@ describe('LaunchCommand.confirm', () => { it('fails as a usage error without prompting when there is no TTY and no --yes', async () => { const { instance, inquired } = gated(false, false); + const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const rejection = await instance['confirm']('Delete project "marketing-site"?').catch((err: Error) => err); - await instance['catch'](rejection as Error); + const reported = (await instance['catch'](rejection as Error).catch((err: unknown) => err)) as Errors.CLIError; expect(rejection).toBeInstanceOf(UsageError); expect(inquired).toEqual([]); - expect(instance.error).toHaveBeenCalledWith( + expect(reported.message).toBe( 'Delete project "marketing-site"? Pass --yes to confirm without an interactive terminal.', - { exit: 2 }, ); + expect(reported.oclif.exit).toBe(2); + spy.mockRestore(); }); it('does not accept a truthy non-boolean yes value as a confirmation', async () => { diff --git a/src/core/launch-command.ts b/src/core/launch-command.ts index 8b5d99c9..065576dd 100644 --- a/src/core/launch-command.ts +++ b/src/core/launch-command.ts @@ -17,6 +17,8 @@ import type { Rule } from './rules'; import { cancelOnInterrupt } from './interruptible-ux'; import type { UxLike } from './prompt'; import { registerSearchList } from './search-list'; +import type { FailureOutput } from './failure'; +import { failWith, failureOutputOf, reportFailure } from './failure'; import type { ServiceContext } from './service-context'; import { buildServiceContext } from './service-context'; @@ -190,10 +192,18 @@ export abstract class LaunchCommand extends Com } protected async catch(err: Error & { exitCode?: number }): Promise { + if (err instanceof LaunchError && err.reported) { + return this.exit(err.exitCode); + } + if (err instanceof LaunchError) { - return err.reported ? this.exit(err.exitCode) : this.error(err.message, { exit: err.exitCode }); + return failWith(err.message, err.exitCode, this.failureOutput()); } - return super.catch(err); + return reportFailure(err, this.failureOutput()); + } + + protected failureOutput(): FailureOutput { + return failureOutputOf((original) => super.catch(original)); } } diff --git a/src/core/render.test.ts b/src/core/render.test.ts index a8d1ec9d..6f00cc8d 100644 --- a/src/core/render.test.ts +++ b/src/core/render.test.ts @@ -47,6 +47,14 @@ describe('renderTable', () => { expect(lines).toEqual(['No records found.']); }); + it('prints the no-rows placeholder in yellow when the output is a terminal', () => { + const { ux, lines } = fakeUx(); + + renderTable(ux, [{ header: 'UID', value: (row: { uid: string }) => row.uid }], [], undefined, undefined, true); + + expect(lines).toEqual(['\u001b[33mNo records found.\u001b[39m']); + }); + it('boxes a single column', () => { const { ux, lines } = fakeUx(); diff --git a/src/core/render.ts b/src/core/render.ts index 15db551b..0188d545 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -127,7 +127,7 @@ export function renderTable( outputIsTTY = false, ): void { if (rows.length === 0) { - ux.print('No records found.'); + ux.print(styled('No records found.', 'yellow', outputIsTTY)); return; } diff --git a/src/core/resolve.test.ts b/src/core/resolve.test.ts index 6171d5f7..cc699081 100644 --- a/src/core/resolve.test.ts +++ b/src/core/resolve.test.ts @@ -35,6 +35,7 @@ function recordingServices(seen: unknown[]) { seen.push(params.org); yield { pagination: { count: 1, limit: 1, skip: null }, projects: [{ uid: PROJECT_UID, name: 'Project One' }] }; }, + get: async (params: { org: string; project: string }) => ({ uid: params.project, organizationUid: params.org }), }, } as unknown as ApiSurface; @@ -61,6 +62,7 @@ function services(overrides: Partial<{ isTTY: boolean; answer: unknown }> = {}) projects: [{ uid: PROJECT_UID, name: 'Project One' }], }; }, + get: async (params: { org: string; project: string }) => ({ uid: params.project, organizationUid: params.org }), }, } as unknown as ApiSurface, ux, @@ -341,6 +343,7 @@ describe('resolveInputs', () => { projects: [{ uid: PROJECT_UID, name: 'Project One' }], }; }, + get: async (params: { org: string; project: string }) => ({ uid: params.project, organizationUid: params.org }), }, } as unknown as ApiSurface; @@ -375,6 +378,7 @@ describe('resolveInputs', () => { projects: [{ uid: PROJECT_UID, name: 'Project One' }], }; }, + get: async (params: { org: string; project: string }) => ({ uid: params.project, organizationUid: params.org }), }, } as unknown as ApiSurface; @@ -474,6 +478,7 @@ describe('resolveInputs', () => { projects: [{ uid: PROJECT_UID, name: 'Project One' }], }; }, + get: async (params: { org: string; project: string }) => ({ uid: params.project, organizationUid: params.org }), }, } as unknown as ApiSurface; diff --git a/src/core/retired-command.ts b/src/core/retired-command.ts index d2281548..4b740553 100644 --- a/src/core/retired-command.ts +++ b/src/core/retired-command.ts @@ -1,6 +1,7 @@ import { Command } from '@oclif/core'; import { EXIT_USAGE } from './constants'; +import { failureOutputOf, reportFailure } from './failure'; export function retirementMessage(name: string, replacements: readonly string[]): string { const named = replacements.map((replacement) => `csdx ${replacement}`); @@ -20,4 +21,8 @@ export abstract class RetiredCommand extends Command { this.error(retirementMessage(contract.retiredName, contract.replacements), { exit: EXIT_USAGE }); } + + protected async catch(err: Error): Promise { + return reportFailure(err, failureOutputOf((original) => super.catch(original))); + } } diff --git a/src/environments/environment.presenter.test.ts b/src/environments/environment.presenter.test.ts index ab2efc77..bb276695 100644 --- a/src/environments/environment.presenter.test.ts +++ b/src/environments/environment.presenter.test.ts @@ -3,6 +3,7 @@ import { ENVIRONMENT_DELETE_QUESTION, ENVIRONMENT_DETAIL_COLUMNS, ENVIRONMENT_VALUE_COLUMN, + environmentColumns, environmentDeletedLine, environmentDetailRows, environmentNoChangesLine, @@ -34,6 +35,10 @@ const FULL = { }; describe('ENVIRONMENT_COLUMNS', () => { + it('labels the uid column as the environment uid', () => { + expect(ENVIRONMENT_COLUMNS.map((column) => column.header)).toEqual(['NAME', 'BRANCH', 'URL', 'UPDATED', 'ENVIRONMENT UID']); + }); + it('renders every list column of a full environment', () => { expect(ENVIRONMENT_COLUMNS.map((column) => column.value(FULL))).toEqual([ 'production', @@ -52,10 +57,25 @@ describe('ENVIRONMENT_COLUMNS', () => { }); }); +describe('environmentColumns', () => { + it('keeps every list column when the branch is shown', () => { + expect(environmentColumns(true)).toEqual(ENVIRONMENT_COLUMNS); + }); + + it('drops the branch column when the branch is not shown', () => { + expect(environmentColumns(false).map((column) => column.header)).toEqual([ + 'NAME', + 'URL', + 'UPDATED', + 'ENVIRONMENT UID', + ]); + }); +}); + 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: 'ENVIRONMENT UID', value: 'e1' }, { field: 'NAME', value: 'production' }, { field: 'DESCRIPTION', value: 'the live site' }, { field: 'BRANCH', value: 'main' }, @@ -69,8 +89,8 @@ describe('environmentDetailRows', () => { { 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: 'ORGANIZATION UID', value: 'blt0rg' }, + { field: 'PROJECT UID', value: 'p1' }, { field: 'CREATED AT', value: '2026-08-01T00:00:00.000Z' }, { field: 'UPDATED AT', value: '2026-09-01T00:00:00.000Z' }, ]); @@ -110,6 +130,13 @@ describe('environmentDetailRows', () => { expect(environmentDetailRows(environment).find((row) => row.field === field)?.value).toBe(value); }); + it('leaves the branch out when asked not to show it', () => { + const fields = environmentDetailRows(FULL, false).map((row) => row.field); + + expect(fields).not.toContain('BRANCH'); + expect(fields).toContain('ENVIRONMENT UID'); + }); + it('never renders a variable key or value', () => { const rendered = JSON.stringify(environmentDetailRows(FULL)); diff --git a/src/environments/environment.presenter.ts b/src/environments/environment.presenter.ts index 04eae82b..bed5ba16 100644 --- a/src/environments/environment.presenter.ts +++ b/src/environments/environment.presenter.ts @@ -32,9 +32,13 @@ export const ENVIRONMENT_COLUMNS: TableColumn[] = [ { 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) }, + { header: 'ENVIRONMENT UID', value: (environment) => text(environment.uid) }, ]; +export function environmentColumns(showBranch: boolean): TableColumn[] { + return showBranch ? ENVIRONMENT_COLUMNS : ENVIRONMENT_COLUMNS.filter((column) => column.header !== 'BRANCH'); +} + export const ENVIRONMENT_DETAIL_COLUMNS: TableColumn[] = [ { header: 'FIELD', value: (row) => row.field }, { header: 'VALUE', value: (row) => row.value }, @@ -42,9 +46,9 @@ export const ENVIRONMENT_DETAIL_COLUMNS: TableColumn[] = [ export const ENVIRONMENT_VALUE_COLUMN = 1; -export function environmentDetailRows(environment: Environment): DetailRow[] { - return [ - { field: 'UID', value: text(environment.uid) }, +export function environmentDetailRows(environment: Environment, showBranch = true): DetailRow[] { + const rows: DetailRow[] = [ + { field: 'ENVIRONMENT UID', value: text(environment.uid) }, { field: 'NAME', value: text(environment.name) }, { field: 'DESCRIPTION', value: text(environment.description) }, { field: 'BRANCH', value: text(environment.gitBranch) }, @@ -58,11 +62,13 @@ export function environmentDetailRows(environment: Environment): DetailRow[] { { 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: 'ORGANIZATION UID', value: text(environment.organizationUid) }, + { field: 'PROJECT UID', value: text(environment.project) }, { field: 'CREATED AT', value: text(environment.createdAt) }, { field: 'UPDATED AT', value: text(environment.updatedAt) }, ]; + + return showBranch ? rows : rows.filter((row) => row.field !== 'BRANCH'); } export const ENVIRONMENT_DELETE_QUESTION = 'Are you sure you want to delete this environment?'; diff --git a/src/environments/environment.update.prompt.test.ts b/src/environments/environment.update.prompt.test.ts index 490afe4c..67167332 100644 --- a/src/environments/environment.update.prompt.test.ts +++ b/src/environments/environment.update.prompt.test.ts @@ -57,7 +57,7 @@ describe('promptForEnvironmentUpdate', () => { await promptForEnvironmentUpdate(services, githubContext()); - expect(SETTINGS_QUESTION).toBe('Which settings do you want to change?'); + expect(SETTINGS_QUESTION).toBe('Select the fields to update:'); expect(asked[0]).toEqual({ type: 'checkbox', name: 'value', diff --git a/src/environments/environment.update.prompt.ts b/src/environments/environment.update.prompt.ts index 58751517..56aabaf2 100644 --- a/src/environments/environment.update.prompt.ts +++ b/src/environments/environment.update.prompt.ts @@ -16,7 +16,7 @@ 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 const SETTINGS_QUESTION = 'Select the fields to update:'; export type EnvironmentChanges = Partial< Pick< diff --git a/src/projects/project.inputs.ts b/src/projects/project.inputs.ts index 10cf9717..77f1452a 100644 --- a/src/projects/project.inputs.ts +++ b/src/projects/project.inputs.ts @@ -50,11 +50,13 @@ export const projectResolution = { configLabel: 'project', dependsOn: PROJECT_DEPENDENCIES.project, prompt: ({ services, resolved }) => promptForProject(services, resolved.org), - normalize: (value, { services, resolved, source }) => - new ProjectResolver(services.api.projects).toUid( - resolved.org, - source === 'flag' ? ResourceRef.parse(value) : ResourceRef.uid(value), - ), + normalize: async (value, { services, resolved, source }) => + source === 'prompt' + ? value + : new ProjectResolver(services.api.projects).toUid( + resolved.org, + source === 'flag' ? ResourceRef.parse(value) : ResourceRef.uid(value), + ), } satisfies ResolutionSpec, name: { normalize: (value) => withinLength('name', value, PROJECT_NAME_MAX_LENGTH), diff --git a/src/projects/project.presenter.test.ts b/src/projects/project.presenter.test.ts index 68e7d0a2..83e275ec 100644 --- a/src/projects/project.presenter.test.ts +++ b/src/projects/project.presenter.test.ts @@ -1,4 +1,5 @@ import { + PROJECT_COLUMNS, PROJECT_DELETE_QUESTION, deploymentFailureMessage, projectDeletedLine, @@ -7,6 +8,12 @@ import { projectUpdatedLine, } from './project.presenter'; +describe('PROJECT_COLUMNS', () => { + it('labels the uid column as the project uid', () => { + expect(PROJECT_COLUMNS.map((column) => column.header)).toEqual(['NAME', 'TYPE', 'PROJECT UID']); + }); +}); + describe('PROJECT_DELETE_QUESTION', () => { it('asks for confirmation without naming the project', () => { expect(PROJECT_DELETE_QUESTION).toBe('Are you sure you want to delete this project?'); diff --git a/src/projects/project.presenter.ts b/src/projects/project.presenter.ts index 4d5269bb..689e3bb6 100644 --- a/src/projects/project.presenter.ts +++ b/src/projects/project.presenter.ts @@ -8,7 +8,7 @@ import type { Project } from './types'; export const PROJECT_COLUMNS: TableColumn[] = [ { header: 'NAME', value: (project) => project.name ?? '-' }, { header: 'TYPE', value: (project) => project.projectType ?? '-' }, - { header: 'UID', value: (project) => project.uid ?? '-' }, + { header: 'PROJECT UID', value: (project) => project.uid ?? '-' }, ]; export const PROJECT_DETAIL_COLUMNS: TableColumn[] = [ diff --git a/src/projects/project.resolver.test.ts b/src/projects/project.resolver.test.ts index 0b69fb7a..914609e6 100644 --- a/src/projects/project.resolver.test.ts +++ b/src/projects/project.resolver.test.ts @@ -13,9 +13,14 @@ const PRIMARY_UID = randomBytes(12).toString('hex'); const SECOND_UID = randomBytes(12).toString('hex'); const HEX_NAME = randomBytes(12).toString('hex'); -function fakePages(pages: FakeProject[][], totalCount = pages.reduce((total, page) => total + page.length, 0)) { +function fakePages( + pages: FakeProject[][], + totalCount = pages.reduce((total, page) => total + page.length, 0), + owningOrg = 'org1', +) { const pageCalls: unknown[] = []; const fetchedPages: number[] = []; + const getCalls: unknown[] = []; const projects: ProjectPages = { pages: async function* (params: unknown) { pageCalls.push(params); @@ -24,15 +29,24 @@ function fakePages(pages: FakeProject[][], totalCount = pages.reduce((total, pag yield { pagination: { count: totalCount, limit: 100, skip: null }, projects: pages[index] } as ProjectsPage; } }, + get: async (params) => { + getCalls.push(params); + return { uid: params.project, organizationUid: owningOrg }; + }, }; - return { projects, pageCalls, fetchedPages }; + return { projects, pageCalls, fetchedPages, getCalls }; +} + +function projectIn(organizationUid: string): ProjectPages { + return fakePages([], 0, organizationUid).projects; } function failingPages(failure: Error): ProjectPages { return { pages: () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(failure) }) }) as unknown as AsyncGenerator, + get: () => Promise.reject(failure), }; } @@ -51,14 +65,23 @@ describe('ProjectResolver', () => { ); }); - it('returns a uid reference without calling the API at all', async () => { - const { projects, pageCalls } = fakePages([]); + it('rejects a uid reference whose project belongs to another organization without naming the owner', async () => { + const projects = projectIn('org-owner'); + + await expect(new ProjectResolver(projects).toUid('org-other', ResourceRef.parse(PRIMARY_UID))).rejects.toThrow( + new UsageError(`No project with UID "${PRIMARY_UID}" found in this organization.`), + ); + }); + + it('returns a uid reference once the project is confirmed to belong to the organization, without paging', async () => { + const { projects, pageCalls, getCalls } = fakePages([]); await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(PRIMARY_UID))).resolves.toBe(PRIMARY_UID); + expect(getCalls).toEqual([{ org: 'org1', project: PRIMARY_UID }]); expect(pageCalls).toEqual([]); }); - it('returns an uppercase uid reference unchanged and without calling the API', async () => { + it('returns an uppercase uid reference unchanged and without paging', async () => { const { projects, pageCalls } = fakePages([]); const upper = PRIMARY_UID.toUpperCase(); @@ -163,6 +186,20 @@ describe('ProjectResolver', () => { ); }); + it('propagates an api failure raised while fetching a project by uid', async () => { + const failure = new LaunchApiError(404, [{ code: 'launch.PROJECTS.NOT_FOUND' }]); + + await expect(new ProjectResolver(failingPages(failure)).toUid('org1', ResourceRef.parse(PRIMARY_UID))).rejects.toBe( + failure, + ); + }); + + it('accepts a uid reference when the API does not report the project organization', async () => { + const projects = { ...fakePages([]).projects, get: async () => ({ uid: PRIMARY_UID }) }; + + await expect(new ProjectResolver(projects).toUid('org1', ResourceRef.parse(PRIMARY_UID))).resolves.toBe(PRIMARY_UID); + }); + it('propagates an api failure raised while paging the organization projects', async () => { const failure = new LaunchApiError(403, [{ code: 'launch.FORBIDDEN', message: 'no access' }]); diff --git a/src/projects/project.resolver.ts b/src/projects/project.resolver.ts index caefc6c5..5695d16c 100644 --- a/src/projects/project.resolver.ts +++ b/src/projects/project.resolver.ts @@ -1,17 +1,22 @@ import { UsageError } from '../core/errors'; import { uidByName } from '../core/name-lookup'; import type { ResourceRef } from '../core/resource-ref'; -import type { PageProjectsParams } from './projects.api'; -import type { ProjectsPage } from './types'; +import type { GetProjectParams, PageProjectsParams } from './projects.api'; +import type { Project, ProjectsPage } from './types'; export interface ProjectPages { pages(params: PageProjectsParams): AsyncGenerator; + get(params: GetProjectParams): Promise; } export class ProjectResolver { constructor(private readonly projects: ProjectPages) {} - toUid(org: string, ref: ResourceRef): Promise { + async toUid(org: string, ref: ResourceRef): Promise { + if (ref.kind === 'uid') { + await this.assertBelongsTo(org, ref.uid); + } + return uidByName( () => this.projects.pages({ org }), (page) => page.projects, @@ -19,4 +24,12 @@ export class ProjectResolver { (name) => new UsageError(`No project named "${name}" found in this organization.`), ); } + + private async assertBelongsTo(org: string, uid: string): Promise { + const { organizationUid } = await this.projects.get({ org, project: uid }); + + if (organizationUid !== undefined && organizationUid !== org) { + throw new UsageError(`No project with UID "${uid}" found in this organization.`); + } + } } diff --git a/src/resources.test.ts b/src/resources.test.ts index 1f356b8a..d53f137b 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -127,7 +127,7 @@ describe('resolution', () => { spy.mockRestore(); }); - it.each(['config', 'prompt', 'default'] as const)( + it.each(['config', 'default'] as const)( 'hands the resolver a uid reference for a value that came from %s, whatever its shape', async (source) => { const spy = jest.spyOn(ProjectResolver.prototype, 'toUid').mockResolvedValue(PROJECT_UID); @@ -140,6 +140,18 @@ describe('resolution', () => { }, ); + it('keeps a project the picker returned as it is, without the resolver', async () => { + const spy = jest.spyOn(ProjectResolver.prototype, 'toUid'); + const { services, resolved } = args(); + + await expect(table.project.normalize?.(PROJECT_UID, { services, resolved, source: 'prompt' })).resolves.toBe( + PROJECT_UID, + ); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + it('declares project as depending on org so the order of the literal cannot matter', () => { expect(table.project.dependsOn).toEqual(['org']); expect(table.project.dependsOn).toBe(DEPENDENCIES.project); diff --git a/test/integration/environments-create.test.ts b/test/integration/environments-create.test.ts index 1661290f..232d0887 100644 --- a/test/integration/environments-create.test.ts +++ b/test/integration/environments-create.test.ts @@ -10,6 +10,7 @@ import nock from 'nock'; import * as watcher from '../../src/deployments/deployment.watcher'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; import { answerPrompts, onTerminal } from '../support/terminal'; import projectFixture from '../fixtures/project-get.json'; @@ -69,6 +70,7 @@ function hub(): nock.Scope { } function stubCreate(sent: unknown[]): void { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); hub().get(`/manage/projects/${PROJECT_UID}`).reply(200, projectFixture); nock(LAUNCH_HUB_URL) .matchHeader('x-organization-uid', ORG_UID) diff --git a/test/integration/environments-delete.test.ts b/test/integration/environments-delete.test.ts index 3b6b770f..aa0720f6 100644 --- a/test/integration/environments-delete.test.ts +++ b/test/integration/environments-delete.test.ts @@ -7,6 +7,7 @@ import nock from 'nock'; import { answerPrompts, onTerminal } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; import getFixture from '../fixtures/environment-get.json'; import listFixture from '../fixtures/environments-list.json'; @@ -42,6 +43,7 @@ function deleteArgs(...extra: string[]): string[] { } function lookupAndRemoval(status = 200, body: unknown = getFixture) { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const lookup = nock(LAUNCH_HUB_URL) .matchHeader('x-project-uid', PROJECT_UID) .get(ENV_PATH) diff --git a/test/integration/environments-list-command.test.ts b/test/integration/environments-list-command.test.ts index ec64fc5f..389aadf9 100644 --- a/test/integration/environments-list-command.test.ts +++ b/test/integration/environments-list-command.test.ts @@ -11,11 +11,13 @@ 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'; +import { projectOwnership } from '../support/project-ownership'; const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; const ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const OTHER_ORG_UID = 'blt0000000000000000'; const DATA_DIR = tmpdir(); const CONFIG: Record = { @@ -75,6 +77,8 @@ describe('integration: launch:env commands driven through oclif runCommand', () const restoreWidth = stdoutOfWidth(200); try { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = environmentsOfProject(); const { error, stdout } = await runCommand( @@ -100,6 +104,7 @@ describe('integration: launch:env commands driven through oclif runCommand', () .get('/manage/projects') .query({ limit: '100', skip: '0' }) .reply(200, projectsFixture); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = environmentsOfProject(); const { error } = await runCommand( @@ -112,6 +117,21 @@ describe('integration: launch:env commands driven through oclif runCommand', () expect(scope.isDone()).toBe(true); }); + it('exits 2 with a generic not-found, and lists nothing, when the project belongs to another org', async () => { + const ownership = projectOwnership(LAUNCH_HUB_URL, OTHER_ORG_UID, PROJECT_UID); + const listing = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}/environments`).query(true).reply(200, listFixture); + + const { error } = await runCommand( + ['launch:env:list', '--org', OTHER_ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No project with UID "${PROJECT_UID}" found in this organization.`); + expect(ownership.isDone()).toBe(true); + expect(listing.isDone()).toBe(false); + }); + 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); @@ -123,6 +143,8 @@ describe('integration: launch:env commands driven through oclif runCommand', () const restoreWidth = stdoutOfWidth(200); try { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scan = environmentsOfProject(); const fetch = nock(LAUNCH_HUB_URL) .matchHeader('x-organization-uid', ORG_UID) @@ -152,6 +174,8 @@ describe('integration: launch:env commands driven through oclif runCommand', () }); it('exits 1 with the CLI wording when the environment does not exist', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); nock(LAUNCH_HUB_URL) .get(`/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`) .reply(404, notFoundFixture); @@ -166,6 +190,7 @@ describe('integration: launch:env commands driven through oclif runCommand', () }); it('exits 2 when no environment of the project carries the --env name, sending no environment GET', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scan = environmentsOfProject(); const fetch = nock(LAUNCH_HUB_URL).get(new RegExp(`/environments/[0-9a-f]{24}$`)).reply(200, getFixture); @@ -181,6 +206,7 @@ describe('integration: launch:env commands driven through oclif runCommand', () }); it('exits 2 naming --env when it is missing and there is no terminal to prompt on', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const { error } = await runCommand( ['launch:env:get', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR], config, diff --git a/test/integration/environments-update.test.ts b/test/integration/environments-update.test.ts index a61ae793..a23dd3c9 100644 --- a/test/integration/environments-update.test.ts +++ b/test/integration/environments-update.test.ts @@ -8,6 +8,7 @@ import nock from 'nock'; import { answerPrompts, onTerminal } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; import getFixture from '../fixtures/environment-get.json'; import projectFixture from '../fixtures/project-get.json'; @@ -41,6 +42,8 @@ function updateArgs(...extra: string[]): string[] { } function reads(project: unknown = projectFixture) { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + 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), @@ -111,7 +114,8 @@ describe('integration: launch:env:update on the wire', () => { expect(onWire).toEqual([]); }); - it('exits 2 naming the updatable flags and sends nothing when none was supplied', async () => { + it('exits 2 naming the updatable flags and sends no update when none was supplied', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const { error } = await runCommand(updateArgs(), config); expect(error?.oclif?.exit).toBe(2); @@ -184,12 +188,12 @@ describe('integration: launch:env:update on the wire', () => { const sent: unknown[] = []; reads(); const put = update(sent); - const prompts = answerPrompts({ 'Which settings do you want to change?': ['name'], Name: 'renamed' }); + const prompts = answerPrompts({ 'Select the fields to update:': ['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.messages).toEqual(['Select the fields to update:', 'Name']); expect(prompts.payloads[0].type).toBe('checkbox'); expect(put.isDone()).toBe(true); expect(sent).toEqual([{ name: 'renamed' }]); @@ -199,7 +203,7 @@ describe('integration: launch:env:update on the wire', () => { 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' }); + answerPrompts({ 'Select the fields to update:': ['name'], Name: 'production' }); const { error, stdout } = await onTerminal(() => runCommand(updateArgs(), config)); diff --git a/test/integration/organization-prompt.test.ts b/test/integration/organization-prompt.test.ts index 161f7807..c948f265 100644 --- a/test/integration/organization-prompt.test.ts +++ b/test/integration/organization-prompt.test.ts @@ -8,6 +8,7 @@ import nock from 'nock'; import { pretendTerminal } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; import getFixture from '../fixtures/project-get.json'; import listFixture from '../fixtures/projects-list.json'; @@ -144,6 +145,7 @@ describe('integration: the organization prompt', () => { .matchHeader('x-organization-uid', ORG_UID) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await onTerminal([ @@ -165,6 +167,7 @@ describe('integration: the organization prompt', () => { useSession(session()); const inquired = answering([ORG_UID]); nock(CMA_URL).get('/v3/organizations').query(ORGANIZATION_QUERY).reply(200, ORGANIZATIONS); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const update = nock(LAUNCH_HUB_URL) .matchHeader('x-organization-uid', ORG_UID) .put(`/manage/projects/${PROJECT_UID}`, { name: 'Renamed Site' }) @@ -195,6 +198,7 @@ describe('integration: the organization prompt', () => { .matchHeader('x-organization-uid', ORG_UID) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const remove = nock(LAUNCH_HUB_URL, { badheaders: ['content-type'] }) .matchHeader('x-organization-uid', ORG_UID) diff --git a/test/integration/projects-command-flows.test.ts b/test/integration/projects-command-flows.test.ts index 0d051313..2fb37c8e 100644 --- a/test/integration/projects-command-flows.test.ts +++ b/test/integration/projects-command-flows.test.ts @@ -82,6 +82,7 @@ describe('integration: end-to-end command flows', () => { .matchHeader('x-project-uid', PROJECT_UID) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await runLaunch(['launch:projects:get', '--data-dir', dir]); @@ -92,7 +93,7 @@ describe('integration: end-to-end command flows', () => { [ `Using the organization and project UIDs from ${join(dir, PROJECT_CONFIG_FILE)}.`, '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 sample-project \u2502 GITPROVIDER \u2502 a1b2c3d4e5f60718293a4b5c \u2502 - \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', @@ -109,6 +110,7 @@ describe('integration: end-to-end command flows', () => { .matchHeader('x-organization-uid', ORG_UID) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await runLaunch(['launch:projects:get']); @@ -125,13 +127,14 @@ describe('integration: end-to-end command flows', () => { .matchHeader('x-organization-uid', ORG_UID) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await runLaunch(['launch:projects:get', '--config', path]); expect(error).toBeUndefined(); expect(scope.isDone()).toBe(true); - expect(stdout).toContain('\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502'); + expect(stdout).toContain('\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502'); }); it('prefers an org passed on argv over the one the project folder config holds', async () => { @@ -142,7 +145,8 @@ describe('integration: end-to-end command flows', () => { .matchHeader('x-organization-uid', otherOrg) .get(`/manage/projects/${PROJECT_UID}`) .query({}) - .reply(200, getFixture); + .times(2) + .reply(200, { project: { ...getFixture.project, organizationUid: otherOrg } }); const { error } = await runLaunch(['launch:projects:get', '--data-dir', dir, '--org', otherOrg]); @@ -157,6 +161,7 @@ describe('integration: end-to-end command flows', () => { .matchHeader('authorization', `Bearer ${accessToken}`) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await runLaunch([ @@ -181,6 +186,7 @@ describe('integration: end-to-end command flows', () => { .matchHeader('authtoken', (value) => value === undefined) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error } = await runLaunch([ @@ -226,6 +232,7 @@ describe('integration: end-to-end command flows', () => { .matchHeader('authorization', `Bearer ${freshToken}`) .get(`/manage/projects/${PROJECT_UID}`) .query({}) + .times(2) .reply(200, getFixture); const { error, stdout } = await runLaunch([ diff --git a/test/integration/projects-delete.test.ts b/test/integration/projects-delete.test.ts index 8f1adf4a..3f1b332f 100644 --- a/test/integration/projects-delete.test.ts +++ b/test/integration/projects-delete.test.ts @@ -7,6 +7,7 @@ import nock from 'nock'; import { CTRL_C, answerPrompts, onTerminal, pretendTerminal } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; import getFixture from '../fixtures/project-get.json'; import listFixture from '../fixtures/projects-list.json'; @@ -67,6 +68,7 @@ describe('integration: launch:projects:delete on the wire', () => { }); it('deletes the project and reports it when --yes is given', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const lookup = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); const removal = nock(LAUNCH_HUB_URL, BODYLESS).delete(`/manage/projects/${PROJECT_UID}`).query({}).reply(204); @@ -103,6 +105,7 @@ describe('integration: launch:projects:delete on the wire', () => { }); it('exits 2 naming --yes, and never sends the delete, without a terminal and without --yes', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const lookup = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); const removal = nock(LAUNCH_HUB_URL, BODYLESS).delete(`/manage/projects/${PROJECT_UID}`).query({}).reply(204); @@ -143,6 +146,7 @@ describe('integration: launch:projects:delete on the wire', () => { }); it('says the project was not deleted, exits 3 and never sends the delete when the user declines the prompt', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const lookup = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); const removal = nock(LAUNCH_HUB_URL, BODYLESS).delete(`/manage/projects/${PROJECT_UID}`).query({}).reply(204); const restore = pretendTerminal(); @@ -178,6 +182,7 @@ describe('integration: launch:projects:delete on the wire', () => { }); it('exits 3 as cancelled, not 130, and never sends the delete when Ctrl-C is pressed at the confirmation', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const lookup = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); const removal = nock(LAUNCH_HUB_URL, BODYLESS).delete(`/manage/projects/${PROJECT_UID}`).query({}).reply(204); const question = 'Are you sure you want to delete this project?'; @@ -212,6 +217,7 @@ describe('integration: launch:projects:delete on the wire', () => { }); it('exits 1 when the API refuses the delete for want of permission', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); nock(LAUNCH_HUB_URL, BODYLESS) .delete(`/manage/projects/${PROJECT_UID}`) diff --git a/test/integration/projects-get.test.ts b/test/integration/projects-get.test.ts index 640ff4bc..6c534ce9 100644 --- a/test/integration/projects-get.test.ts +++ b/test/integration/projects-get.test.ts @@ -121,7 +121,7 @@ describe('integration: GET /projects/{project_uid}', () => { expect(lines).toEqual([ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 sample-project \u2502 GITPROVIDER \u2502 a1b2c3d4e5f60718293a4b5c \u2502 - \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', @@ -151,7 +151,7 @@ describe('integration: GET /projects/{project_uid}', () => { expect(lines).toEqual([ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 docs-site \u2502 FILEUPLOAD \u2502 c3d4e5f60718293a4b5c6d7e \u2502 - \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', diff --git a/test/integration/projects-list-command.test.ts b/test/integration/projects-list-command.test.ts index 3c02d4a7..90570981 100644 --- a/test/integration/projects-list-command.test.ts +++ b/test/integration/projects-list-command.test.ts @@ -12,6 +12,7 @@ import listFixture from '../fixtures/projects-list.json'; import notFoundFixture from '../fixtures/project-not-found.json'; import { stdoutOfWidth } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; @@ -76,7 +77,7 @@ describe('integration: shipped commands driven through oclif runCommand', () => expect(stdout).toBe( [ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 sample-project \u2502 GITPROVIDER \u2502 a1b2c3d4e5f60718293a4b5c \u2502', '\u251c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u253c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u253c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2524', @@ -117,6 +118,7 @@ describe('integration: shipped commands driven through oclif runCommand', () => }); it('prints the detail block for launch:projects:get', async () => { + const ownership = projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, getFixture); const { error, stdout } = await runLaunch([ @@ -130,11 +132,12 @@ describe('integration: shipped commands driven through oclif runCommand', () => ]); expect(error).toBeUndefined(); + expect(ownership.isDone()).toBe(true); expect(scope.isDone()).toBe(true); expect(stdout).toBe( [ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502 DESCRIPTION \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502 DESCRIPTION \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 sample-project \u2502 GITPROVIDER \u2502 a1b2c3d4e5f60718293a4b5c \u2502 - \u2502', '\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518', diff --git a/test/integration/projects-list.test.ts b/test/integration/projects-list.test.ts index 403c819e..5f2be9df 100644 --- a/test/integration/projects-list.test.ts +++ b/test/integration/projects-list.test.ts @@ -158,7 +158,7 @@ describe('integration: GET /projects', () => { expect(lines).toEqual([ '\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510', - '\u2502 NAME \u2502 TYPE \u2502 UID \u2502', + '\u2502 NAME \u2502 TYPE \u2502 PROJECT UID \u2502', '\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524', '\u2502 sample-project \u2502 GITPROVIDER \u2502 a1b2c3d4e5f60718293a4b5c \u2502', '\u251c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u253c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u253c\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2524', diff --git a/test/integration/projects-update.test.ts b/test/integration/projects-update.test.ts index 8ea6e976..542ca5a6 100644 --- a/test/integration/projects-update.test.ts +++ b/test/integration/projects-update.test.ts @@ -8,6 +8,7 @@ import nock from 'nock'; import listFixture from '../fixtures/projects-list.json'; import notFoundFixture from '../fixtures/project-not-found.json'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; @@ -63,6 +64,7 @@ describe('integration: launch:projects:update on the wire', () => { }); it('puts only the supplied field in the PUT body and reports success', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL) .put(`/manage/projects/${PROJECT_UID}`, { name: 'Renamed Site' }) .query({}) @@ -90,6 +92,7 @@ describe('integration: launch:projects:update on the wire', () => { }); it('puts both fields when both were supplied and reports success once', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL) .put(`/manage/projects/${PROJECT_UID}`, { name: 'Renamed Site', description: 'A new blurb' }) .query({}) @@ -148,7 +151,8 @@ describe('integration: launch:projects:update on the wire', () => { expect(stdout).toBe('✔ Project updated successfully.\n'); }); - it('exits 2 without sending anything when neither --name nor --description was supplied', async () => { + it('exits 2 without sending the update when neither --name nor --description was supplied', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL).put(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, {}); const { error } = await runCommand( @@ -163,7 +167,8 @@ describe('integration: launch:projects:update on the wire', () => { expect(nock.pendingMocks()).toHaveLength(1); }); - it('exits 2 without sending anything when --name is longer than the server allows', async () => { + it('exits 2 without sending the update when --name is longer than the server allows', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL).put(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, {}); const { error } = await runCommand( @@ -187,7 +192,8 @@ describe('integration: launch:projects:update on the wire', () => { expect(scope.isDone()).toBe(false); }); - it('exits 2 without sending anything when --description is longer than the server allows', async () => { + it('exits 2 without sending the update when --description is longer than the server allows', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL).put(`/manage/projects/${PROJECT_UID}`).query({}).reply(200, {}); const { error } = await runCommand( @@ -213,6 +219,7 @@ describe('integration: launch:projects:update on the wire', () => { it('sends a name of exactly the server limit rather than refusing it', async () => { const atLimit = 'n'.repeat(200); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); const scope = nock(LAUNCH_HUB_URL) .put(`/manage/projects/${PROJECT_UID}`, { name: atLimit }) .query({}) @@ -228,6 +235,7 @@ describe('integration: launch:projects:update on the wire', () => { }); it('exits 1 with the project wording when the API rejects a duplicate name', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); nock(LAUNCH_HUB_URL) .put(`/manage/projects/${PROJECT_UID}`) .query({}) @@ -243,6 +251,7 @@ describe('integration: launch:projects:update on the wire', () => { }); it('exits 1 when the API does not know the project uid', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); nock(LAUNCH_HUB_URL).put(`/manage/projects/${PROJECT_UID}`).query({}).reply(404, notFoundFixture); const { error } = await runCommand( diff --git a/test/integration/retired-commands.test.ts b/test/integration/retired-commands.test.ts index b7ad7541..2db6784b 100644 --- a/test/integration/retired-commands.test.ts +++ b/test/integration/retired-commands.test.ts @@ -60,6 +60,12 @@ describe('integration: retired V1 command names', () => { nock.cleanAll(); }); + it('prints a retired name once as a lowercase error line, the same shape as every other error', async () => { + const { stderr } = await runCommand(['launch:environments'], config); + + expect(stderr).toBe('error: csdx launch:environments was removed in Launch CLI v2. Use csdx launch:env:list instead.\n'); + }); + it('exits 2 naming the three V2 create commands for the bare launch name', async () => { const { error } = await runCommand(['launch'], config); diff --git a/test/integration/variables-list.test.ts b/test/integration/variables-list.test.ts index 5e931dd5..80958698 100644 --- a/test/integration/variables-list.test.ts +++ b/test/integration/variables-list.test.ts @@ -9,6 +9,7 @@ import getFixture from '../fixtures/environment-get.json'; import notFoundFixture from '../fixtures/environment-not-found.json'; import { stdoutOfWidth } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; @@ -52,6 +53,7 @@ describe('integration: launch:variables:list driven through oclif runCommand', ( routeConsoleLogToStdout(); jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID).persist(); }); afterEach(() => { diff --git a/test/integration/variables-set-unset.test.ts b/test/integration/variables-set-unset.test.ts index 133e6eac..97c39354 100644 --- a/test/integration/variables-set-unset.test.ts +++ b/test/integration/variables-set-unset.test.ts @@ -12,6 +12,7 @@ import cmsFixture from '../fixtures/cms-environment-variables.json'; import getFixture from '../fixtures/environment-get.json'; import { answerPrompts, onTerminal } from '../support/terminal'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; @@ -110,6 +111,7 @@ describe('integration: launch:variables:set and :unset driven through oclif runC routeConsoleLogToStdout(); jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID).persist(); }); afterEach(() => { diff --git a/test/support/project-ownership.ts b/test/support/project-ownership.ts new file mode 100644 index 00000000..53bf8ee0 --- /dev/null +++ b/test/support/project-ownership.ts @@ -0,0 +1,11 @@ +import nock from 'nock'; + +import projectFixture from '../fixtures/project-get.json'; + +export function projectOwnership(hubUrl: string, org: string, project: string): nock.Scope { + return nock(hubUrl) + .matchHeader('x-organization-uid', org) + .matchHeader('x-project-uid', project) + .get(`/manage/projects/${project}`) + .reply(200, projectFixture); +}