diff --git a/.talismanrc b/.talismanrc index e7c6bb76..6d1a44ce 100644 --- a/.talismanrc +++ b/.talismanrc @@ -2,7 +2,7 @@ fileignoreconfig: - filename: src/core/failure.test.ts checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts - checksum: f43448a6d3855c3823d65dd66150bbd26410ae0be8a921476ca8a948d65caa16 + checksum: 4f1a3d7b6b6684986f720a44dcd4d4c014a64dd15c41d41d0266afb0ebe828c0 - filename: test/integration/manage-base-path.test.ts checksum: 79eb9ad420ea47d5bee8045d812c45b010bbeeafcaf5f85d11653140b0cd0db1 - filename: src/transport/rest-client.ts @@ -87,7 +87,7 @@ fileignoreconfig: - "'SECRET=hunter2'" - "formFieldKey" - filename: test/integration/projects-create.test.ts - checksum: 0161c3bba176134df0bc1a3a29cda4e1c0b143afbee202c110cdfed19b08d8e0 + checksum: e37d9a0fdce75f942836f621a2e281cb9b448cd583673ebcacb075de3faaf187 - filename: test/fixtures/README.md checksum: d801c84c8b7d7f38f967573e9a3a70da4405b7cbcbf10d2a6e5be3d65fb3daa1 allowed_patterns: @@ -167,11 +167,11 @@ fileignoreconfig: ignore_detectors: - filename - filename: src/environments/environment.inputs.test.ts - checksum: 499a4601152dded32b766abd8e43aa2ddd88e4e62fc5389b00a268d730178565 + checksum: 3590697ca0f0f0c0c7b6e64eddfecbdef8838ff260315c70178786490664682c ignore_detectors: - filename - filename: src/core/inputs.ts - checksum: 638c1093562cc19038395b498202d06aef53b8d2537ee80b715611f19f83c78b + checksum: a4094f68a2fb6b012cfc259f686e362d5a00b34dce85f9d46f1ead270263f15e allowed_patterns: - "FlagKey" - "InputKeys" @@ -224,11 +224,12 @@ fileignoreconfig: allowed_patterns: - "FlagKey" - filename: src/resources.test.ts + checksum: 742d9faaa855390c853963388bb2479355fe69f0ee05b5a8965de091621d711d allowed_patterns: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: 562fc91581867ca4682ce2181379aeedd6c50ff666fa706c558eabfb688e1a46 + checksum: 89b7e72236e7218326eb3c9c3176c5ab36d58e79a9bf845f422fe28d98c144a1 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -246,13 +247,13 @@ fileignoreconfig: - filename: src/core/project-config.test.ts checksum: 65c0a613ec77d4dc6727224d4d097de4a02b300339286db692dbaea9379565ae - filename: src/core/resolve.ts - checksum: 5d150db11cb1c976911ecba61336118ddad051942da95f809695c0cf0346de42 + checksum: a5608d22286efdd4915639faff6e05a588e60e205c74e0398e389822eb8c7292 - filename: src/core/resolve.test.ts - checksum: b62b53aedd32ab01212508d79bf4c306dfbd9b0c9788e5fc449886eb770e0377 + checksum: 8740a27f759a106e0ddcf38874f83394d2226dd8a908d9499898d774fea3a7a0 - filename: src/git/local-repository.ts checksum: b9091ed135dfebdf590a5412bbfba2b2f96d4a177cf944a20909b93405268e9f - filename: test/guards/layering.test.ts - checksum: 2f1b3934e685c2366e45c75f379d63c0ca7bee11108df1e0010ead13159c6b9b + checksum: fea55eeb036ea4acb1cc4d177ae9cbfc1a06bbc5a1ea65b2f5c82717cd0251a9 - filename: src/commands/launch/projects/create.test.ts checksum: 9c40b4b6ed4f7d678ecb921a71f5af7284cd685c1337f47144456ae43a38b1ad - filename: src/commands/launch/projects/delete.test.ts @@ -260,11 +261,11 @@ fileignoreconfig: - filename: test/integration/projects-delete.test.ts checksum: bb59cbe1f4f7b3ac6aa7944067c1ab1214a7c5d20229e9d6577cdaabec56622e - filename: src/core/launch-command.ts - checksum: b14abd1d4cee3e93c4458ab3567b1f252564bda7e12773cfa1e0d678967664a4 + checksum: 01904855aca54cbbcb846069cee5a0732da1db082110e7e454a5c1c532ab02fb - filename: src/core/launch-command.test.ts checksum: c4d3785f25377db560bb9daa5d0d080a0131056e6c31e3b45cb4f1f2347305ed - filename: src/projects/project.inputs.test.ts - checksum: eaecaba9eddec128186871550fc5802166fa56a2e89741d72ac955fcfe2f952f + checksum: 8242f4d40388f1a7f1d08c11d2571cb45a9294069a8248a5272bc5606807bb60 - filename: test/integration/projects-command-flows.test.ts checksum: 30858bb3dff8a22406efa4ac82d882d1501797cdda24e09555aecc0bcd8dca0b - filename: src/core/redact.test.ts @@ -292,7 +293,7 @@ fileignoreconfig: - filename: src/functions/function.errors.test.ts checksum: ac0a55e5bee974fefa9fb95541adba63e9a2c2c6e6c47b87c8ca1022542e75f0 - filename: src/transport/errors.test.ts - checksum: b7e28a9062e29a87c8c8b882c6543d50e90e12509c5629ee362ffcac430d01f1 + checksum: f38547e2a4639ae150b53f955963ce00c1922a4223c89e489ba5b7ca75a691d0 - filename: src/transport/auth-strategy.ts checksum: 6fe87def1b508cb67203d4445d9bd889570e512ed4194dfc4fdd3182bc8b87b2 - filename: src/functions/function.logger.test.ts @@ -304,7 +305,7 @@ fileignoreconfig: - filename: test/credential-guard.setup.ts checksum: 519ccbeb0552fdb1d5f27308c56477c0258ee6cc0adec8f618e8e5bc047cbf6d - filename: src/environments/types.ts - checksum: 8f57348764a69aeb7c2351faf3a5c80fda483a1a1f009e126ff71a9679d92457 + checksum: 6c054295075e51e9ce0f4514ebdc86a48bb74db9932a2baaa9f9158e9c14c6e8 - filename: src/commands/launch/env/get.test.ts checksum: 6e739ef12bc0d887e652e23ee54d9fac30dd6a3505cf464cdaefc0e696cf3e51 - filename: test/integration/environments-delete.test.ts @@ -324,13 +325,13 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: 6d13077c028465448536779a02a635c55069d5aeaeea6f6e4882226da8b9f75e + checksum: 9c87e9e46dbc9606b4946bb6d84ce36352d8f6d733280f5f48e0a65bd6cf6ded allowed_patterns: - "password-protection" - "-p, --password" - "-u, --username" - filename: test/integration/environments-create.test.ts - checksum: 1b87f454b1534bf0b73ebb52cdaf9af231beb57d5746e521a4504edad819c89a + checksum: 0c64a4408d3c150a33c442403688d1e1d058f415cfdba704e372455927daa7e4 - filename: src/commands/launch/variables/list.test.ts checksum: 87ccb762f4931326fc50bca1c01aeb4a16d552e4dfa141d00370fdfafa4363be - filename: src/commands/launch/variables/set.test.ts @@ -411,8 +412,52 @@ fileignoreconfig: checksum: 5ea6a0fcd331496ca19773e90dab81e09eb5290b8f22a1880bffc7916f3778b2 - filename: test/integration/variables-set-unset.test.ts checksum: 7af9dde4711849d0908d0de54347055a7c2a8d8920f9fcc0b24382b02bceaed9 +- filename: test/fixtures/deployments-list.json + checksum: 2f10f24dc0ceb8e85f5201b7c1530c0c540c044ef50b732cc29e07289112316e +- filename: test/integration/deployments-get.test.ts + checksum: beb431ee58fbb3cf72da369514bbeede2c7358f95960979157362053f5b89789 +- filename: test/integration/deployments-list.test.ts + checksum: c661f0bd3682ea5b0bfeed782a7c5d03ed75bd86aa1c283bb4b2bda1325bab00 +- filename: src/deployments/deployment.inputs.test.ts + checksum: 4c6244f99f62f9731ee925526d17f3ee611d3562cc51cace09ad740ba20c4c84 +- filename: test/fixtures/deployment-get.json + checksum: acae8ee5ba96a2aafcf43e0b35b3bd22df78bb9ddbe3bad5e7f86c537316721a +- filename: src/commands/launch/deployments/get.test.ts + checksum: ae3346f213f9ba5a0a32ef2f2f88146e705838f1eca035de659fc4f8d9c0223b +- filename: src/commands/launch/deployments/list.test.ts + checksum: e493cd51e7c65bbf5b4f71704478b7a669bca4342efc865a1363bc508fe6f9f0 +- filename: src/deployments/deployment.presenter.test.ts + checksum: e393d6bb6ce3fd92920f5003603b161186085ea8939fae704e6f71bd9e1778bc +- filename: test/integration/deployments-create.test.ts + checksum: fd367019e10b47121898440e4b3a54fb2041d6302bee2dea28a97ad58eb88ee9 +- filename: src/deployments/deployment.watcher.test.ts + checksum: c2cdab908cba7d4e162d06f59a471f918c959eb9c220ebc3666f0c6299f141b2 +- filename: src/deployments/deployment.create.ts + checksum: ca64f3b29a68c4a2755a88a141e2cd9dbd6fa851a2776876d78c40f3168492e3 +- filename: src/commands/launch/deployments/create.test.ts + checksum: 50c3f35c7246b49f6ebf5e48a9a9646ccbcb563c0c0e042fd38917e2f98cbba3 +- filename: src/deployments/deployment.create.test.ts + checksum: 6545afa21a23b7e5de18ad8ff417f88a184577e7042713cfda90758ccbcd9869 +- filename: test/integration/deployments-rollback.test.ts + checksum: 08400c5435c84cd162fefc585f6c32cddfcaa8909d2369341748b421e7f12073 +- filename: test/integration/deployments-cancel.test.ts + checksum: 02c012da974f1f9b8b851d7e00878fef50c1ba7e32b6acf72a309582c66aa5c2 +- filename: src/commands/launch/deployments/cancel.test.ts + checksum: da8d09de7f03cfe67df75ebc1636b36feb6688cf85984c357fd5304852d126e5 +- filename: src/commands/launch/deployments/rollback.test.ts + checksum: 5331e774d07387b1691c47168afa8d454bdee60d26ac9c6ed3f4640fc85ff8b8 +- filename: src/deployments/deployments.api.test.ts + checksum: b27d91317c17f1754d5853c68f4968e63dc2b3579eab26b4c772cf3cd3efbdd0 +- filename: src/deployments/deployment.prompt.test.ts + checksum: 3367e53ce2f0284c14524da893e7f82ef613fcdac1e9689ac408709e7f7c153d +- filename: test/integration/deployments-request-bodies.test.ts + checksum: 37d518b1d7671dc1bb9edd147f381c63ddb22e8a413dd43c1c280fb583e83ad2 - filename: src/cache/cache.inputs.test.ts checksum: 4c602f53a296caec6bab752c905a8abb3da2462ed83b0a5337e8838e87cd9dbd - filename: src/commands/launch/cache/purge.test.ts checksum: f1a319cc3af775f28919e56fa17bd7b7e6b89cb4541db506ca8e5fac5edb6026 +- filename: src/deployments/commit.prompt.test.ts + checksum: d811a8b35d640708a9d0e7b03d006fcae4718192adee94c47f625ce7662ea715 +- filename: src/deployments/commit.prompt.ts + checksum: 6991eb373ee016ce44fc4f47044d559cfeefa1c411526d6fd88d77c568379041 version: "1.0" diff --git a/AGENTS.md b/AGENTS.md index 317ac0f4..3bf2587a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ file and line that broke its rule, and each was proven red by reintroducing its | `test/guards/prompt-types.test.ts` | Every prompt call in `src` names a literal `type`, and every such type is registered with the real inquirer `cliux.inquire` uses once `LaunchCommand.init` has run. inquirer silently turns an unregistered type into a plain text box. | D1: `search-list` used, never registered | | `test/integration/transport-content-type.test.ts` | For every method in `HTTP_METHODS`, a bodyless request carries no content type and a request with a body carries `application/json`, asserted on the wire. `withoutDefaultContentType` suppresses the utility client's default, so a bodyless POST, PUT or PATCH (for example `deployments:cancel`) no longer goes out as `application/x-www-form-urlencoded`. Only `utility-http-client.ts` builds an `HttpClient` and only `rest-client.ts` sets the JSON type. | D5: every DELETE sent `Content-Type: application/json` | | `test/guards/tty-streams.test.ts` | `stdin.isTTY`, and the `isTTY` it becomes on the service context, are read only to decide whether the CLI may prompt (a read next to a prompt or a refusal to prompt) or on the plumbing lines that carry it there. Anything deciding what to draw reads `outputIsTTY` / `process.stdout.isTTY`. | D6: heartbeat drawn into a redirected file | +| `test/integration/deployments-request-bodies.test.ts` | A deployments mutation puts on the wire only the fields the management service DTO declares - `{commitHash?, uploadUid?}` for a create, `{deployment, reason?}` for a rollback - and never `environment`, which is the URL path. The service validates with `whitelist` and `forbidNonWhitelisted`, so any other key is a bare 400. | Live harness: `deployments:create` and `:rollback` sent `{environment, ...}` and failed with `Bad request` | | `test/integration/retired-commands.test.ts` (named commands) | Every `launch:…` command named anywhere in `src` - a retirement message, a failure hint, an example - is either a registered command or on `PLANNED_COMMANDS` with the ticket that builds it, and a command drops off that list once it ships. | Review: retirement messages and hints pointed at commands the beta does not have | | `test/guards/prompt-funnel.test.ts` | Every prompt goes through `LaunchCommand`'s `ux`, built once as `cancelOnInterrupt(cliux)`, so Ctrl-C at any prompt exits 3. The wrapper stays attached to `SIGINT` until the signal has reached every listener (it detaches in a microtask): inquirer's `cli-cursor` loads `signal-exit`, which re-raises `SIGINT` - exit 130 - when it finds itself the only listener, so a `once` listener made every list and checkbox prompt exit 130 (`src/core/interruptible-ux.test.ts` pins it). No source calls `cliux.inquire` / `prompt` / `confirm` under any name, hands `cliux` on as a value, or imports a prompt library; `search-list.ts` may register a type but never prompt. | D2: Ctrl-C exited 130 | @@ -151,8 +152,9 @@ src/ each name the other in the layering allow-list deployments/ the deployment repository, the status classification, the wait/stream loop and LogTail that projects:create, deployments:create - and logs:get all drive, and the DeploymentFollower that watches one - deployment and announces its live URL + and logs:get all drive, the DeploymentFollower that watches one + deployment and announces its live URL, and the DeploymentCreator behind + deployments:create. It reaches uploads, for a FileUpload create uploads/ zipping a folder, the signed upload URL, and the transfer behind every FileUpload source (FolderUploader). The UploadScope picks the endpoint: { org } for a new project, { org, project } for a new environment, @@ -640,6 +642,23 @@ there is **no server-side maximum at all**. each repository builds on `scanPages`, because the page size is that repository's decision and not a CLI-wide one; the scan-limit error each raises carries its own wording. +**The deployment commands (CL-7170).** `deployments:list`, `:get`, `:create`, `:rollback` and `:cancel`. + +- `--deployment` is a 24-hex uid, validated while parsing (`FlagValueError`, exit 2, nothing sent; `LaunchCommand.init` + rethrows it as a plain `UsageError` so oclif's `Parsing --deployment` prefix never reaches the user), so `get`, + `rollback` and `cancel` share it. It depends on `--org`, `--project` and `--env`. `get` leaves it optional and + defaults to the latest deployment; `rollback` and `cancel` declare `picker` on the input (`all`, or `in-flight` + for `cancel`), which is the only case where the spec's `promptWhen` lets the picker run (`promptForDeployment`, + `src/deployments/deployment.prompt.ts`), so a terminal gets a list of the environment's newest deployments and + anything else gets the exit-2 missing-value error. A supplied flag never prompts. +- The service answers HTTP 200 `{ "deployment": null }` for a deployment that is not in the environment, so + `DeploymentsApi.get` words that as the same 404 `launch.DEPLOYMENT.NOT_FOUND` a missing one gets, and the commands + report the generic `No deployment with UID "" found in this environment.` +- Request bodies carry no `environment`; see the guard table. A validation rejection the service sends keyed by + field with only the generic `Bad request` is worded by `LaunchApiError` from the field and its code. +- Every table cell is stripped of control characters and line breaks by `renderTable`, and the presenter shows + only the first line of a commit message, truncated to 72 characters. + **The deployment wait/stream loop.** `src/deployments/deployment.watcher.ts` polls a deployment to a terminal state and is the single place any command waits on one. `POST /projects` creates the project, its first environment **and** its first deployment, so `projects:create` is its first @@ -686,7 +705,7 @@ shade, and in green when stdout is a terminal (`styled` in `src/core/style.ts`, redirected output stays free of escape codes). Once `POST /projects` succeeds, before the config file is written or anything is watched, create prints V1's green `info: New project created successfully` (`projectCreatedLine`); a failed create never prints it. On success create then prints V1's -`Deployment URL ` line - label bold, url blue on a terminal - before the project summary, +`Deployment URL: ` line - label bold, url blue on a terminal - before the project summary, waits `SITE_OPEN_DELAY_MS` (six seconds, V1's wait, because a site opened the moment it reports live can still answer "site not reachable") and opens that url in the browser, terminal or not, as V1 did. The wait goes through the injected `sleep`, so it is awaited and the browser opens before the @@ -696,9 +715,8 @@ parseable timestamp printed: with a `timestamp` the service returns what came st ascending order, and without one it returns the newest page in **descending** order, so never let the cursor drop the query. The poll that sees the terminal status still reads the logs once more before returning, so the last lines of a build are never lost. The logs are auxiliary: a -failed read prints one ` ! Could not read the deployment logs (...)` notice for the whole wait, is -retried from the same cursor on the next poll, and never changes the outcome - the status poll alone -decides that. +failed read prints nothing, is retried from the same cursor on the next poll, and never changes the +outcome - the status poll alone decides that. **Writing a test for it: the fake clock must advance.** `now()` and `sleep()` are injected and both are required, not optional with defaults. A fake `now: () => 0` with a `sleep` that does nothing @@ -812,12 +830,11 @@ project and `--config`. **Deployment failure is a partial success, and the wording says so.** The project and environment are real and are **not** rolled back. Everything after `POST /projects` - finding the first environment, finding its first deployment, and the wait itself - goes through the same wording when it fails, so a -502 from a lookup still tells the user what exists. `deploymentFailureMessage` names the status, the project and -environment that survived, and both follow-up commands with their scope already filled in - -`deployments:create` to retry and `logs:get` to inspect. It exits **1**, not 2: nothing about the -invocation was wrong. A wait that runs out is worded differently: the deployment may still finish, so -the message says not to start another one and names `deployments:get` and `logs:get` instead of -`deployments:create`. +502 from a lookup still tells the user what exists. `firstDeploymentFailureMessage` is the one builder: +it names the deployment and its status, says what was created (when something was) and ends with one +command - `logs:get` for a failed deployment, `deployments:get` for a wait that ran out or could not be +followed, `deployments:list` when no deployment was found - with its scope already filled in. It exits +**1**, not 2: nothing about the invocation was wrong. **The FileUpload archive.** `src/uploads/upload.archive.ts` owns the exclusion list (`node_modules`, `.git`, `.env`, `.env.local`, `.next`, `logs`, `.vscode`, `.cs-launch.json`) and diff --git a/README.md b/README.md index 105dfb54..d9537aac 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ with exit code 2 and name their V2 replacements. `launch:functions:serve` is liv * [`csdx launch`](#csdx-launch) * [`csdx launch:cache:purge`](#csdx-launchcachepurge) * [`csdx launch:deployments`](#csdx-launchdeployments) +* [`csdx launch:deployments:cancel`](#csdx-launchdeploymentscancel) +* [`csdx launch:deployments:create`](#csdx-launchdeploymentscreate) +* [`csdx launch:deployments:get`](#csdx-launchdeploymentsget) +* [`csdx launch:deployments:list`](#csdx-launchdeploymentslist) +* [`csdx launch:deployments:rollback`](#csdx-launchdeploymentsrollback) * [`csdx launch:env:create`](#csdx-launchenvcreate) * [`csdx launch:env:delete`](#csdx-launchenvdelete) * [`csdx launch:env:get`](#csdx-launchenvget) @@ -154,6 +159,153 @@ DESCRIPTION _See code: [src/commands/launch/deployments/index.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/index.ts)_ +## `csdx launch:deployments:cancel` + +Cancel a deployment of a Launch environment that is still in progress + +``` +USAGE + $ csdx launch:deployments:cancel [-c ] [-d ] [--org ] [--project ] [--env ] [--deployment + ] [-y] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + -y, --yes Skip the confirmation prompt + --deployment= Deployment UID + --env= Environment name or UID + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Cancel a deployment of a Launch environment that is still in progress + +EXAMPLES + $ csdx launch:deployments:cancel --org --project --env --deployment + + $ csdx launch:deployments:cancel --org --project --env --deployment --yes +``` + +_See code: [src/commands/launch/deployments/cancel.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/cancel.ts)_ + +## `csdx launch:deployments:create` + +Deploy a Launch environment: the branch tip or a chosen commit on a GitHub project, the project directory or the last uploaded bundle on a FileUpload project + +``` +USAGE + $ csdx launch:deployments:create [-c ] [-d ] [--org ] [--project ] [--env ] [--commit + | --last-upload] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --commit= GitHub projects: deploy this commit. Pass --commit with no value to pick one from the recent + commits + --env= Environment name or UID + --last-upload FileUpload projects: redeploy the last uploaded bundle without uploading a new one + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Deploy a Launch environment: the branch tip or a chosen commit on a GitHub project, the project directory or the last + uploaded bundle on a FileUpload project + +EXAMPLES + $ csdx launch:deployments:create --org --project --env + + $ csdx launch:deployments:create --org --project --env --commit + + $ csdx launch:deployments:create --org --project --env --last-upload +``` + +_See code: [src/commands/launch/deployments/create.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/create.ts)_ + +## `csdx launch:deployments:get` + +Show a deployment of a Launch environment, the latest one unless --deployment names another + +``` +USAGE + $ csdx launch:deployments:get [-c ] [-d ] [--org ] [--project ] [--env ] [--deployment + ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --deployment= Deployment UID + --env= Environment name or UID + --org= Organization UID + --project= Project name or UID + +DESCRIPTION + Show a deployment of a Launch environment, the latest one unless --deployment names another + +EXAMPLES + $ csdx launch:deployments:get --org --project --env + + $ csdx launch:deployments:get --org --project --env --deployment +``` + +_See code: [src/commands/launch/deployments/get.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/get.ts)_ + +## `csdx launch:deployments:list` + +List the deployments of a Launch environment + +``` +USAGE + $ csdx launch:deployments:list [-c ] [-d ] [--org ] [--project ] [--env ] [--limit + ] [--skip ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --env= Environment name or UID + --limit= Number of records to fetch (1-1000) + --org= Organization UID + --project= Project name or UID + --skip= Number of records to skip + +DESCRIPTION + List the deployments of a Launch environment + +EXAMPLES + $ csdx launch:deployments:list --org --project --env +``` + +_See code: [src/commands/launch/deployments/list.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/list.ts)_ + +## `csdx launch:deployments:rollback` + +Roll a Launch environment back to a previous deployment + +``` +USAGE + $ csdx launch:deployments:rollback [-c ] [-d ] [--org ] [--project ] [--env ] [--deployment + ] [--reason ] [-y] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + -y, --yes Skip the confirmation prompt + --deployment= Deployment UID + --env= Environment name or UID + --org= Organization UID + --project= Project name or UID + --reason= Why the environment is being rolled back, recorded with the rollback + +DESCRIPTION + Roll a Launch environment back to a previous deployment + +EXAMPLES + $ csdx launch:deployments:rollback --org --project --env --deployment + + $ csdx launch:deployments:rollback --org --project --env --deployment --reason "bad release" --yes +``` + +_See code: [src/commands/launch/deployments/rollback.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/deployments/rollback.ts)_ + ## `csdx launch:env:create` Create an environment in a Launch project and follow its first deployment diff --git a/package-lock.json b/package-lock.json index a5620761..b9780bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5039,6 +5039,8 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -5046,6 +5048,8 @@ }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/bowser": { @@ -5182,6 +5186,8 @@ }, "node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -5834,7 +5840,9 @@ "license": "MIT" }, "node_modules/csv-parse": { - "version": "4.16.3", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", + "integrity": "sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==", "license": "MIT" }, "node_modules/csv-stringify": { @@ -10456,6 +10464,8 @@ }, "node_modules/media-typer": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -11575,6 +11585,8 @@ }, "node_modules/raw-body": { "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -13377,6 +13389,8 @@ }, "node_modules/type-is": { "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { "media-typer": "0.3.0", diff --git a/package.json b/package.json index 2783427d..9402a24b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "launch:cache": { "description": "Purge the CDN cache of a Launch environment" }, + "launch:deployments": { + "description": "Create, inspect, roll back and cancel the deployments of a Launch environment" + }, "launch:site": { "description": "Open the live site of a Launch environment" }, @@ -116,6 +119,8 @@ "ajv": "^6.12.6" }, "qs": "^6.16.1", + "body-parser": "^1.20.9", + "csv-parse": "^7.0.2", "tmp": "^0.2.4", "fast-uri": "^3.1.9", "proxy-addr": "^2.0.8", @@ -153,6 +158,11 @@ "launch": "LNCH", "launch:cache:purge": "LNCH-CCH-PRG", "launch:deployments": "LNCH-DPLMNT", + "launch:deployments:cancel": "LNCH-DPLMNT-CNCL", + "launch:deployments:create": "LNCH-DPLMNT-CRT", + "launch:deployments:get": "LNCH-DPLMNT-GET", + "launch:deployments:list": "LNCH-DPLMNT-LS", + "launch:deployments:rollback": "LNCH-DPLMNT-RBK", "launch:environments": "LNCH-ENV", "launch:env:create": "LNCH-ENV-CRT", "launch:env:delete": "LNCH-ENV-DEL", diff --git a/src/commands/launch/deployments/cancel.test.ts b/src/commands/launch/deployments/cancel.test.ts new file mode 100644 index 00000000..35ddc193 --- /dev/null +++ b/src/commands/launch/deployments/cancel.test.ts @@ -0,0 +1,199 @@ +import { UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import { cancelNotAllowedMessage } from '../../../deployments/deployment.errors'; +import { LaunchApiError } from '../../../transport/errors'; +import DeploymentsCancel from './cancel'; + +const UID = 'd1e2f3a4b5c6d7e8f9012345'; +const QUESTION = `Are you sure you want to cancel deployment "${UID}"?`; +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1', deployment: UID }; + +interface Harness { + answer?: boolean; + isTTY?: boolean; + outputIsTTY?: boolean; + yes?: boolean; + getFails?: Error; + found?: Record; + cancelFails?: Error; +} + +function commandUnderTest(harness: Harness) { + const lines: string[] = []; + const inquired: unknown[] = []; + const calls: string[] = []; + const got: unknown[] = []; + const cancelled: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: unknown) => { + inquired.push(payload); + return harness.answer as never; + }, + }; + const command = Object.create(DeploymentsCancel.prototype) as DeploymentsCancel; + + Object.assign(command, { + ux, + resolved: { org: 'org1', project: 'proj1', env: 'env1', deployment: UID, yes: harness.yes ?? false }, + services: { + ux, + isTTY: harness.isTTY ?? true, + outputIsTTY: harness.outputIsTTY ?? false, + api: { + deployments: { + get: async (params: unknown) => { + calls.push('get'); + got.push(params); + + if (harness.getFails) { + throw harness.getFails; + } + + return harness.found ?? { uid: UID, status: 'DEPLOYING' }; + }, + cancel: async (params: unknown) => { + calls.push('cancel'); + cancelled.push(params); + + if (harness.cancelFails) { + throw harness.cancelFails; + } + }, + }, + }, + }, + }); + + return { command, lines, inquired, calls, got, cancelled }; +} + +describe('launch:deployments:cancel', () => { + it('fetches the deployment, confirms that it cannot be undone, cancels it and reports it', async () => { + const { command, lines, inquired, calls, got, cancelled } = commandUnderTest({ answer: true }); + + await command.run(); + + expect(inquired).toEqual([{ type: 'confirm', name: 'confirm', message: QUESTION, default: false }]); + expect(calls).toEqual(['get', 'cancel']); + expect(got).toEqual([SCOPE]); + expect(cancelled).toEqual([SCOPE]); + expect(lines).toEqual([`✔ Deployment "${UID}" cancelled successfully.`]); + }); + + it('skips the prompt when --yes was passed and colours the success line when stdout is a terminal', async () => { + const { command, lines, inquired, calls } = commandUnderTest({ yes: true, isTTY: false, outputIsTTY: true }); + + await command.run(); + + expect(inquired).toEqual([]); + expect(calls).toEqual(['get', 'cancel']); + expect(lines).toEqual([`\u001b[32m✔ Deployment "${UID}" cancelled successfully.\u001b[39m`]); + }); + + it('says it was not cancelled and exits 3 without sending the cancel when the user declines', async () => { + const { command, lines, calls, cancelled } = commandUnderTest({ answer: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as { oclif?: { exit?: number } }; + + expect(error.oclif?.exit).toBe(3); + expect(calls).toEqual(['get']); + expect(cancelled).toEqual([]); + expect(lines).toEqual(['Deployment not cancelled.']); + }); + + it('exits 2 naming --yes without sending the cancel when there is no terminal', async () => { + const { command, lines, inquired, calls, cancelled } = commandUnderTest({ isTTY: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe(`${QUESTION} Pass --yes to confirm without an interactive terminal.`); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(cancelled).toEqual([]); + expect(lines).toEqual([]); + }); + + it('turns a 404 NOT_FOUND from the lookup into the generic not-found usage error before asking', async () => { + const missing = new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'x' }]); + const { command, inquired, calls, cancelled } = commandUnderTest({ answer: true, getFails: missing }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe(`No deployment with UID "${UID}" found in this environment.`); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(cancelled).toEqual([]); + }); + + it('propagates another failure from the lookup without asking or cancelling', async () => { + const failure = new LaunchApiError(500, []); + const { command, lines, calls, inquired } = commandUnderTest({ answer: true, getFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + + expect(calls).toEqual(['get']); + expect(inquired).toEqual([]); + expect(lines).toEqual([]); + }); + + it.each([ + ['a finished deployment', { uid: UID, status: 'LIVE' }], + ['a failed deployment', { uid: UID, status: 'FAILED' }], + ['a deployment with no status', { uid: UID }], + ['an in-flight deployment the service marks as not cancellable', { uid: UID, status: 'DEPLOYING', isCancellable: false }], + ])('refuses %s with the cancel-not-allowed error before asking or posting', async (_label, found) => { + const { command, lines, inquired, calls, cancelled } = commandUnderTest({ answer: true, yes: true, found }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as LaunchApiError; + + expect(error).toBeInstanceOf(LaunchApiError); + expect(error.exitCode).toBe(1); + expect(error.message).toBe(cancelNotAllowedMessage(UID)); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(cancelled).toEqual([]); + expect(lines).toEqual([]); + }); + + it.each([ + ['an absent isCancellable', { uid: UID, status: 'QUEUED' }], + ['isCancellable true', { uid: UID, status: 'DEPLOYING', isCancellable: true }], + ['a null isCancellable', { uid: UID, status: 'DEPLOYING', isCancellable: null }], + ])('goes on to ask and cancel an in-flight deployment with %s', async (_label, found) => { + const { command, inquired, calls, cancelled } = commandUnderTest({ answer: true, found }); + + await command.run(); + + expect(inquired).toHaveLength(1); + expect(calls).toEqual(['get', 'cancel']); + expect(cancelled).toEqual([SCOPE]); + }); + + it('propagates a failure from the cancel rather than reporting a success', async () => { + const failure = new LaunchApiError(400, [{ message: 'x' }]); + const { command, lines, calls } = commandUnderTest({ yes: true, cancelFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + + expect(calls).toEqual(['get', 'cancel']); + expect(lines).toEqual([]); + }); + + it('declares the deployment as required and opts into the confirm gate', () => { + expect(DeploymentsCancel.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: { required: true, picker: 'in-flight' }, + yes: {}, + }); + expect(Object.keys(DeploymentsCancel.flags).sort()).toEqual(Object.keys(DeploymentsCancel.inputs).sort()); + }); +}); diff --git a/src/commands/launch/deployments/cancel.ts b/src/commands/launch/deployments/cancel.ts new file mode 100644 index 00000000..f2166c9e --- /dev/null +++ b/src/commands/launch/deployments/cancel.ts @@ -0,0 +1,55 @@ +import { EXIT_CANCELLED } from '../../../core/constants'; +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { + deploymentCancelQuestion, + deploymentCancelledLine, + deploymentNotCancelledLine, +} from '../../../deployments/deployment.presenter'; +import { IN_FLIGHT_DEPLOYMENTS, isCancellable } from '../../../deployments/deployment.prompt'; +import { cancelNotAllowedError } from '../../../deployments/deployment.errors'; +import { fetchDeployment } from '../../../deployments/deployment.resolver'; + +const cancelInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: { required: true, picker: IN_FLIGHT_DEPLOYMENTS }, + yes: {}, +}); + +export default class DeploymentsCancel extends LaunchCommand { + static description = 'Cancel a deployment of a Launch environment that is still in progress'; + + static examples = [ + '$ csdx launch:deployments:cancel --org --project --env ' + + '--deployment ', + '$ csdx launch:deployments:cancel --org --project --env ' + + '--deployment --yes', + ]; + + static inputs = cancelInputs; + + static flags = flagsFor(cancelInputs); + + async run(): Promise { + const { org, project, env: environment, deployment } = this.resolved; + const scope = { org, project, environment, deployment }; + const outputIsTTY = this.services.outputIsTTY === true; + + const found = await fetchDeployment(this.services.api.deployments, scope); + + if (!isCancellable(found)) { + throw cancelNotAllowedError(deployment); + } + + if (!(await this.confirm(deploymentCancelQuestion(deployment)))) { + this.ux.print(deploymentNotCancelledLine(outputIsTTY)); + this.exit(EXIT_CANCELLED); + } + + await this.services.api.deployments.cancel(scope); + + this.ux.print(deploymentCancelledLine(deployment, outputIsTTY)); + } +} diff --git a/src/commands/launch/deployments/create.test.ts b/src/commands/launch/deployments/create.test.ts new file mode 100644 index 00000000..63e21eec --- /dev/null +++ b/src/commands/launch/deployments/create.test.ts @@ -0,0 +1,121 @@ +import type { LaunchError } from '../../../core/errors'; +import { UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import { PICK_COMMIT } from '../../../deployments/deployment.inputs'; +import { firstDeploymentFailureMessage } from '../../../deployments/deployment.presenter'; +import DeploymentsCreate from './create'; + +const DATA_DIR = '/work/site'; +const CONFIG_PATH = '/work/site/.cs-launch.json'; + +function commandUnderTest(resolved: Record, projectType = 'GITPROVIDER') { + const lines: string[] = []; + const ux: UxLike = { print: (message: string) => lines.push(message), inquire: async () => undefined as never }; + const created: unknown[] = []; + const command = Object.create(DeploymentsCreate.prototype) as DeploymentsCreate; + + Object.assign(command, { + ux, + resolved: { org: 'org1', project: 'proj1', env: 'env1', 'last-upload': false, ...resolved }, + dataDir: DATA_DIR, + configPath: CONFIG_PATH, + services: { + ux, + isTTY: false, + outputIsTTY: false, + api: { + projects: { get: async () => ({ uid: 'proj1', projectType }) }, + deployments: { + create: async (params: unknown) => { + created.push(params); + return { uid: 'd1', status: 'QUEUED' }; + }, + get: async () => ({ uid: 'd1', status: 'LIVE' }), + }, + deploymentLogs: { after: async () => [] }, + }, + }, + }); + + return { command, lines, created }; +} + +describe('launch:deployments:create', () => { + it('declares org, project and env as required inputs and commit and last-upload as optional ones', () => { + expect(DeploymentsCreate.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + commit: {}, + 'last-upload': {}, + }); + }); + + it('exposes exactly the flags it declares, beside the base flags', () => { + expect(Object.keys(DeploymentsCreate.flags).sort()).toEqual(['commit', 'env', 'last-upload', 'org', 'project']); + expect(Object.keys(DeploymentsCreate.baseFlags).sort()).toEqual(['config', 'data-dir']); + }); + + it('names flags the failure message of a first deployment sends the user to', () => { + const message = firstDeploymentFailureMessage({ + created: 'x', + plural: false, + org: 'o', + project: 'p', + status: 'FAILED', + }); + + for (const flag of ['org', 'project']) { + expect(message).toContain(`--${flag} `); + expect(Object.keys(DeploymentsCreate.flags)).toContain(flag); + } + }); + + it('sends a plain run to the branch tip of the resolved environment in the resolved scope', async () => { + const { command, created } = commandUnderTest({}); + + await command.run(); + + expect(created).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); + }); + + it('sends --commit as the commit hash', async () => { + const { command, created } = commandUnderTest({ commit: 'abc123' }); + + await command.run(); + + expect(created).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', commitHash: 'abc123' }]); + }); + + it('refuses a bare --commit with exit 2 naming --commit , sending nothing and never deploying the branch tip', async () => { + const { command, created } = commandUnderTest({ commit: PICK_COMMIT }); + + const failure = await command.run().catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as LaunchError).exitCode).toBe(2); + expect((failure as Error).message).toContain('--commit '); + expect(created).toEqual([]); + }); + + it('refuses --last-upload on a GitHub project and --commit on a FileUpload project without sending a create', async () => { + const onGit = commandUnderTest({ 'last-upload': true }); + const onUpload = commandUnderTest({ commit: 'abc123' }, 'FILEUPLOAD'); + + const gitFailure = await onGit.command.run().catch((error: Error) => error); + const uploadFailure = await onUpload.command.run().catch((error: Error) => error); + + expect(gitFailure).toBeInstanceOf(UsageError); + expect(uploadFailure).toBeInstanceOf(UsageError); + expect(onGit.created).toEqual([]); + expect(onUpload.created).toEqual([]); + }); + + it('redeploys the last bundle of a FileUpload project when --last-upload is given', async () => { + const { command, created } = commandUnderTest({ 'last-upload': true }, 'FILEUPLOAD'); + + await command.run(); + + expect(created).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); + }); +}); diff --git a/src/commands/launch/deployments/create.ts b/src/commands/launch/deployments/create.ts new file mode 100644 index 00000000..cc60c75b --- /dev/null +++ b/src/commands/launch/deployments/create.ts @@ -0,0 +1,51 @@ +import { flagsFor, inputs } from '../../../core/inputs'; +import { LaunchCommand } from '../../../core/launch-command'; +import { promptForCommit } from '../../../deployments/commit.prompt'; +import { DeploymentCreator } from '../../../deployments/deployment.create'; +import { withBareCommit } from '../../../deployments/deployment.inputs'; +import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; + +const createInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + commit: {}, + 'last-upload': {}, +}); + +export default class DeploymentsCreate extends LaunchCommand { + static description = + 'Deploy a Launch environment: the branch tip or a chosen commit on a GitHub project, ' + + 'the project directory or the last uploaded bundle on a FileUpload project'; + + static examples = [ + '$ csdx launch:deployments:create --org --project --env ', + '$ csdx launch:deployments:create --org --project --env ' + '--commit ', + '$ csdx launch:deployments:create --org --project --env ' + '--last-upload', + ]; + + static inputs = createInputs; + + static flags = flagsFor(createInputs); + + async init(): Promise { + this.argv = withBareCommit(this.argv); + await super.init(); + } + + async run(): Promise { + const resolved = this.resolved; + + await new DeploymentCreator(this.services, defaultWatchTiming(), { + pickCommit: (scope) => promptForCommit(this.services, scope), + }).create({ + org: resolved.org, + project: resolved.project, + environment: resolved.env, + dataDir: this.dataDir, + configPath: this.configPath, + commit: resolved.commit, + lastUpload: resolved['last-upload'], + }); + } +} diff --git a/src/commands/launch/deployments/get.test.ts b/src/commands/launch/deployments/get.test.ts new file mode 100644 index 00000000..a47b69f8 --- /dev/null +++ b/src/commands/launch/deployments/get.test.ts @@ -0,0 +1,191 @@ +import { UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import { LaunchApiError } from '../../../transport/errors'; +import DeploymentsGet from './get'; + +function commandUnderTest(options: { + resolved?: Record; + projectType?: string; + deployment?: unknown; + latest?: unknown; + failures?: { project?: Error; get?: Error; latest?: Error }; +}) { + const lines: string[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const calls: string[] = []; + const projectCalls: unknown[] = []; + const getCalls: unknown[] = []; + const latestCalls: unknown[] = []; + const command = Object.create(DeploymentsGet.prototype) as DeploymentsGet; + + Object.assign(command, { + ux, + resolved: options.resolved ?? { org: 'org1', project: 'proj1', env: 'env1' }, + services: { + ux, + isTTY: false, + outputIsTTY: false, + api: { + projects: { + get: async (params: unknown) => { + calls.push('projects.get'); + projectCalls.push(params); + + if (options.failures?.project) { + throw options.failures.project; + } + + return { uid: 'proj1', projectType: options.projectType ?? 'GITPROVIDER' }; + }, + }, + deployments: { + get: async (params: unknown) => { + calls.push('deployments.get'); + getCalls.push(params); + + if (options.failures?.get) { + throw options.failures.get; + } + + return options.deployment; + }, + latest: async (params: unknown) => { + calls.push('deployments.latest'); + latestCalls.push(params); + + if (options.failures?.latest) { + throw options.failures.latest; + } + + return options.latest; + }, + }, + }, + }, + }); + + return { command, lines, calls, projectCalls, getCalls, latestCalls }; +} + +const DEPLOYMENT = { + uid: 'd1', + status: 'LIVE', + gitBranch: 'main', + commitHash: 'abcdef0123456789', + commitMessage: 'Ship it', + deploymentUrl: 'site.example.test', + environmentVariables: [{ key: 'A', value: 'b' }], +}; + +function valueOf(lines: string[], field: string): string | undefined { + const row = lines.find((line) => line.startsWith(`│ ${field} `)); + + return row?.split('│')[2].trim(); +} + +describe('launch:deployments:get', () => { + it('fetches the named deployment in the resolved environment and prints a FIELD | VALUE table', async () => { + const { command, lines, getCalls, latestCalls, projectCalls } = commandUnderTest({ + resolved: { org: 'org1', project: 'proj1', env: 'env1', deployment: 'd1' }, + deployment: DEPLOYMENT, + }); + + await command.run(); + + expect(projectCalls).toEqual([{ org: 'org1', project: 'proj1' }]); + expect(getCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', deployment: 'd1' }]); + expect(latestCalls).toEqual([]); + expect(lines[1]).toContain('FIELD'); + expect(valueOf(lines, 'DEPLOYMENT UID')).toBe('d1'); + expect(valueOf(lines, 'STATUS')).toBe('LIVE'); + expect(valueOf(lines, 'BRANCH')).toBe('main'); + expect(valueOf(lines, 'COMMIT')).toBe('abcdef0123456789'); + expect(valueOf(lines, 'COMMIT MESSAGE')).toBe('Ship it'); + expect(valueOf(lines, 'URL')).toBe('https://site.example.test'); + expect(valueOf(lines, 'ENVIRONMENT VARIABLES')).toBe( + '1 (list them with csdx launch:variables:list --org org1 --project proj1 --env env1)', + ); + expect(lines.join('\n')).not.toContain('BUILD LOGS'); + expect(lines.join('\n')).not.toContain('launch:logs:get'); + }); + + it('shows the latest deployment through the one-row lookup when --deployment is not given', async () => { + const { command, lines, getCalls, latestCalls } = commandUnderTest({ latest: DEPLOYMENT }); + + await command.run(); + + expect(latestCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); + expect(getCalls).toEqual([]); + expect(valueOf(lines, 'DEPLOYMENT UID')).toBe('d1'); + }); + + it('exits 2 saying there are no deployments when the environment has none', async () => { + const { command, lines } = commandUnderTest({ latest: undefined }); + + const failure = await command.run().catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toBe('No deployments found in this environment.'); + expect(lines).toEqual([]); + }); + + it('turns a missing deployment into the generic wording, naming no other scope', async () => { + const { command, lines } = commandUnderTest({ + resolved: { org: 'org1', project: 'proj1', env: 'env1', deployment: 'd404' }, + failures: { get: new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'nope' }]) }, + }); + + const failure = await command.run().catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe('No deployment with UID "d404" found in this environment.'); + expect(lines).toEqual([]); + }); + + it('hides branch and commit rows for a file-upload project and keeps them for a git project', async () => { + const fileUpload = commandUnderTest({ latest: DEPLOYMENT, projectType: 'FILEUPLOAD' }); + const git = commandUnderTest({ latest: DEPLOYMENT, projectType: 'GITPROVIDER' }); + + await fileUpload.command.run(); + await git.command.run(); + + expect(fileUpload.lines.join('\n')).not.toContain('BRANCH'); + expect(fileUpload.lines.join('\n')).not.toContain('COMMIT'); + expect(fileUpload.lines.join('\n')).not.toContain('Ship it'); + expect(valueOf(git.lines, 'BRANCH')).toBe('main'); + expect(valueOf(git.lines, 'COMMIT')).toBe('abcdef0123456789'); + }); + + it.each([ + ['projects.get', { project: new LaunchApiError(500, []) }, { deployment: 'd1' }], + ['deployments.get', { get: new LaunchApiError(500, []) }, { deployment: 'd1' }], + ['deployments.latest', { latest: new LaunchApiError(500, []) }, {}], + ])('propagates a %s failure as the same instance with exit 1 and prints nothing', async (_name, failures, extra) => { + const { command, lines } = commandUnderTest({ + resolved: { org: 'org1', project: 'proj1', env: 'env1', ...extra }, + failures, + }); + + const failure = await command.run().catch((error: Error) => error); + + expect(failure).toBe(Object.values(failures)[0]); + expect((failure as LaunchApiError).exitCode).toBe(1); + expect(lines).toEqual([]); + }); + + it('declares org, project and env as required and deployment as optional, and exactly those flags', () => { + expect(DeploymentsGet.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: {}, + }); + expect(Object.keys(DeploymentsGet.flags).sort()).toEqual(['deployment', 'env', 'org', 'project']); + }); +}); diff --git a/src/commands/launch/deployments/get.ts b/src/commands/launch/deployments/get.ts new file mode 100644 index 00000000..cbcdf63c --- /dev/null +++ b/src/commands/launch/deployments/get.ts @@ -0,0 +1,55 @@ +import { UsageError } from '../../../core/errors'; +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { renderTable } from '../../../core/render'; +import { + DEPLOYMENT_DETAIL_COLUMNS, + DEPLOYMENT_VALUE_COLUMN, + deploymentDetailRows, +} from '../../../deployments/deployment.presenter'; +import { fetchDeployment } from '../../../deployments/deployment.resolver'; + +const getInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: {}, +}); + +export default class DeploymentsGet extends LaunchCommand { + static description = 'Show a deployment of a Launch environment, the latest one unless --deployment names another'; + + static examples = [ + '$ csdx launch:deployments:get --org --project --env ', + '$ csdx launch:deployments:get --org --project --env ' + + '--deployment ', + ]; + + static inputs = getInputs; + + static flags = flagsFor(getInputs); + + async run(): Promise { + const projectScope = { org: this.resolved.org, project: this.resolved.project }; + const scope = { ...projectScope, environment: this.resolved.env }; + const project = await this.services.api.projects.get(projectScope); + const requested = this.resolved.deployment; + const deployment = + requested === undefined + ? await this.services.api.deployments.latest(scope) + : await fetchDeployment(this.services.api.deployments, { ...scope, deployment: requested }); + + if (deployment === undefined) { + throw new UsageError('No deployments found in this environment.'); + } + + renderTable( + this.ux, + DEPLOYMENT_DETAIL_COLUMNS, + deploymentDetailRows(deployment, project.projectType !== 'FILEUPLOAD', scope), + process.stdout.columns, + DEPLOYMENT_VALUE_COLUMN, + this.services.outputIsTTY, + ); + } +} diff --git a/src/commands/launch/deployments/list.test.ts b/src/commands/launch/deployments/list.test.ts new file mode 100644 index 00000000..f4eb869a --- /dev/null +++ b/src/commands/launch/deployments/list.test.ts @@ -0,0 +1,172 @@ +import type { UxLike } from '../../../core/prompt'; +import DeploymentsList from './list'; + +function commandUnderTest(options: { + page?: unknown; + resolved?: Record; + projectType?: string; + outputIsTTY?: boolean; + projectFailure?: Error; + listFailure?: Error; +}) { + const lines: string[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const projectCalls: unknown[] = []; + const listCalls: unknown[] = []; + const command = Object.create(DeploymentsList.prototype) as DeploymentsList; + + Object.assign(command, { + ux, + resolved: options.resolved ?? { org: 'org1', project: 'proj1', env: 'env1', limit: 50, skip: 0 }, + services: { + ux, + isTTY: false, + outputIsTTY: options.outputIsTTY ?? false, + api: { + projects: { + get: async (params: unknown) => { + projectCalls.push(params); + + if (options.projectFailure) { + throw options.projectFailure; + } + + return { uid: 'proj1', projectType: options.projectType ?? 'GITPROVIDER' }; + }, + }, + deployments: { + list: async (params: unknown) => { + listCalls.push(params); + + if (options.listFailure) { + throw options.listFailure; + } + + return options.page; + }, + }, + }, + }, + }); + + return { command, lines, projectCalls, listCalls }; +} + +const GIT_PAGE = { + pagination: { count: 2 }, + deployments: [ + { uid: 'd1', status: 'LIVE', gitBranch: 'main', commitHash: 'abcdef0123456789', updatedAt: '2026-09-01' }, + { uid: 'd2', status: 'FAILED', gitBranch: 'develop', commitHash: '1234567abcdef', updatedAt: '2026-09-02' }, + ], +}; + +describe('launch:deployments:list', () => { + it('lists the deployments of the resolved environment as a table with a pagination footer', async () => { + const { command, lines, projectCalls, listCalls } = commandUnderTest({ page: GIT_PAGE }); + + await command.run(); + + expect(projectCalls).toEqual([{ org: 'org1', project: 'proj1' }]); + expect(listCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', limit: 50, skip: 0 }]); + expect(lines[1]).toBe('│ DEPLOYMENT UID │ STATUS │ COMMIT │ UPDATED │ BRANCH │'); + expect(lines[3]).toBe('│ d1 │ LIVE │ abcdef0 │ 2026-09-01 │ main │'); + expect(lines[5]).toBe('│ d2 │ FAILED │ 1234567 │ 2026-09-02 │ develop │'); + expect(lines[7]).toBe('Showing 1-2 of 2'); + }); + + it('leaves the commit and branch columns out for a file-upload project', async () => { + const { command, lines } = commandUnderTest({ page: GIT_PAGE, projectType: 'FILEUPLOAD' }); + + await command.run(); + + expect(lines[1]).toBe('│ DEPLOYMENT UID │ STATUS │ UPDATED │'); + expect(lines.join('\n')).not.toContain('BRANCH'); + expect(lines.join('\n')).not.toContain('COMMIT'); + expect(lines.join('\n')).not.toContain('abcdef0'); + expect(lines.join('\n')).not.toContain('develop'); + }); + + it('numbers the footer from the --skip the command was given, never from the response', async () => { + const rows = Array.from({ length: 10 }, (_, index) => ({ uid: `d${index}`, status: 'LIVE' })); + const { command, lines } = commandUnderTest({ + page: { pagination: { count: 25, skip: 99 }, deployments: rows }, + resolved: { org: 'org1', project: 'proj1', env: 'env1', limit: 10, skip: 10 }, + }); + + await command.run(); + + expect(lines[lines.length - 1]).toBe('Showing 11-20 of 25'); + }); + + it.each([ + [true, '\u001b[33mNo records found.\u001b[39m'], + [false, 'No records found.'], + ])('prints No records found. for an empty list and no footer (terminal: %s)', async (outputIsTTY, line) => { + const { command, lines } = commandUnderTest({ page: { pagination: { count: 0 }, deployments: [] }, outputIsTTY }); + + await command.run(); + + expect(lines).toEqual([line]); + }); + + it('skips an entry with no uid and shows a dash for a status the rest of the entries lack', async () => { + const { command, lines } = commandUnderTest({ + page: { + pagination: { count: 3 }, + deployments: [{ status: 'LIVE' }, { uid: ' ', status: 'LIVE' }, { uid: 'd7' }], + }, + }); + + await command.run(); + + const rows = lines.filter((line) => line.startsWith('│') && !line.includes('DEPLOYMENT UID')); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain('d7'); + expect(rows[0]).toContain('-'); + expect(rows[0]).not.toContain('LIVE'); + expect(lines[lines.length - 1]).toBe('Showing 1-1 of 3'); + }); + + it('prints No records found. rather than a table when every entry lacks a uid', async () => { + const { command, lines } = commandUnderTest({ + page: { pagination: { count: 1 }, deployments: [{ status: 'LIVE' }] }, + }); + + await command.run(); + + expect(lines).toEqual(['No records found.']); + }); + + it('propagates the project lookup failure as the same instance and lists nothing', async () => { + const failure = new Error('project 500'); + const { command, lines, listCalls } = commandUnderTest({ page: GIT_PAGE, projectFailure: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(lines).toEqual([]); + expect(listCalls).toEqual([]); + }); + + it('propagates the list failure as the same instance and prints nothing', async () => { + const failure = new Error('list 500'); + const { command, lines } = commandUnderTest({ page: GIT_PAGE, listFailure: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(lines).toEqual([]); + }); + + it('declares org, project and env as required inputs with limit and skip, and exactly those flags', () => { + expect(DeploymentsList.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + limit: {}, + skip: {}, + }); + expect(Object.keys(DeploymentsList.flags).sort()).toEqual(['env', 'limit', 'org', 'project', 'skip']); + }); +}); diff --git a/src/commands/launch/deployments/list.ts b/src/commands/launch/deployments/list.ts new file mode 100644 index 00000000..59b98da5 --- /dev/null +++ b/src/commands/launch/deployments/list.ts @@ -0,0 +1,47 @@ +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { renderPagination, renderTable } from '../../../core/render'; +import { deploymentColumns } from '../../../deployments/deployment.presenter'; +import { hasUid } from '../../../transport/envelope'; + +const listInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + limit: {}, + skip: {}, +}); + +export default class DeploymentsList extends LaunchCommand { + static description = 'List the deployments of a Launch environment'; + + static examples = [ + '$ csdx launch:deployments:list --org --project --env ', + ]; + + static inputs = listInputs; + + static flags = flagsFor(listInputs); + + async run(): Promise { + const scope = { org: this.resolved.org, project: this.resolved.project }; + const project = await this.services.api.projects.get(scope); + const page = await this.services.api.deployments.list({ + ...scope, + environment: this.resolved.env, + limit: this.resolved.limit, + skip: this.resolved.skip, + }); + const deployments = page.deployments.filter(hasUid); + + renderTable( + this.ux, + deploymentColumns(project.projectType !== 'FILEUPLOAD'), + deployments, + process.stdout.columns, + undefined, + this.services.outputIsTTY, + ); + renderPagination(this.ux, { ...page.pagination, skip: this.resolved.skip }, deployments.length); + } +} diff --git a/src/commands/launch/deployments/rollback.test.ts b/src/commands/launch/deployments/rollback.test.ts new file mode 100644 index 00000000..01dfe3c3 --- /dev/null +++ b/src/commands/launch/deployments/rollback.test.ts @@ -0,0 +1,194 @@ +import { UsageError } from '../../../core/errors'; +import type { UxLike } from '../../../core/prompt'; +import { LaunchApiError } from '../../../transport/errors'; +import DeploymentsRollback from './rollback'; + +const UID = 'd1e2f3a4b5c6d7e8f9012345'; +const QUESTION = `Are you sure you want to roll back this environment to deployment "${UID}"?`; + +interface Harness { + answer?: boolean; + isTTY?: boolean; + outputIsTTY?: boolean; + yes?: boolean; + reason?: string; + getFails?: Error; + rollbackFails?: Error; +} + +function commandUnderTest(harness: Harness) { + const lines: string[] = []; + const inquired: unknown[] = []; + const calls: string[] = []; + const got: unknown[] = []; + const rolledBack: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async (payload: unknown) => { + inquired.push(payload); + return harness.answer as never; + }, + }; + const command = Object.create(DeploymentsRollback.prototype) as DeploymentsRollback; + + Object.assign(command, { + ux, + resolved: { + org: 'org1', + project: 'proj1', + env: 'env1', + deployment: UID, + reason: harness.reason, + yes: harness.yes ?? false, + }, + services: { + ux, + isTTY: harness.isTTY ?? true, + outputIsTTY: harness.outputIsTTY ?? false, + api: { + deployments: { + get: async (params: unknown) => { + calls.push('get'); + got.push(params); + + if (harness.getFails) { + throw harness.getFails; + } + + return { uid: UID, status: 'LIVE' }; + }, + rollback: async (params: unknown) => { + calls.push('rollback'); + rolledBack.push(params); + + if (harness.rollbackFails) { + throw harness.rollbackFails; + } + }, + }, + }, + }, + }); + + return { command, lines, inquired, calls, got, rolledBack }; +} + +describe('launch:deployments:rollback', () => { + it('fetches the deployment, confirms, rolls back with the reason and reports it', async () => { + const { command, lines, inquired, calls, got, rolledBack } = commandUnderTest({ + answer: true, + reason: 'bad release', + }); + + await command.run(); + + expect(inquired).toEqual([{ type: 'confirm', name: 'confirm', message: QUESTION, default: false }]); + expect(calls).toEqual(['get', 'rollback', 'get']); + expect(got).toEqual([ + { org: 'org1', project: 'proj1', environment: 'env1', deployment: UID }, + { org: 'org1', project: 'proj1', environment: 'env1', deployment: UID }, + ]); + expect(rolledBack).toEqual([ + { org: 'org1', project: 'proj1', environment: 'env1', deployment: UID, reason: 'bad release' }, + ]); + expect(lines).toEqual([ + `Rollback to deployment "${UID}" started.`, + `✔ Environment rolled back to deployment "${UID}" successfully.`, + ]); + }); + + it('skips the prompt when --yes was passed and colours the success line when stdout is a terminal', async () => { + const { command, lines, inquired, calls } = commandUnderTest({ yes: true, isTTY: false, outputIsTTY: true }); + + await command.run(); + + expect(inquired).toEqual([]); + expect(calls).toEqual(['get', 'rollback', 'get']); + expect(lines).toEqual([ + `Rollback to deployment "${UID}" started.`, + `\u001b[32m✔ Environment rolled back to deployment "${UID}" successfully.\u001b[39m`, + ]); + }); + + it('hands an absent reason to the api as undefined', async () => { + const { command, rolledBack } = commandUnderTest({ yes: true }); + + await command.run(); + + expect(rolledBack).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', deployment: UID, reason: undefined }]); + }); + + it('says it was not rolled back and exits 3 without sending the rollback when the user declines', async () => { + const { command, lines, calls, rolledBack } = commandUnderTest({ answer: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as { oclif?: { exit?: number } }; + + expect(error.oclif?.exit).toBe(3); + expect(calls).toEqual(['get']); + expect(rolledBack).toEqual([]); + expect(lines).toEqual(['Environment not rolled back.']); + }); + + it('exits 2 naming --yes without sending the rollback when there is no terminal', async () => { + const { command, lines, inquired, calls, rolledBack } = commandUnderTest({ isTTY: false }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe(`${QUESTION} Pass --yes to confirm without an interactive terminal.`); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(rolledBack).toEqual([]); + expect(lines).toEqual([]); + }); + + it('turns a 404 NOT_FOUND from the lookup into the generic not-found usage error before asking', async () => { + const missing = new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'x' }]); + const { command, inquired, calls, rolledBack } = commandUnderTest({ answer: true, getFails: missing }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as UsageError; + + expect(error).toBeInstanceOf(UsageError); + expect(error.exitCode).toBe(2); + expect(error.message).toBe(`No deployment with UID "${UID}" found in this environment.`); + expect(inquired).toEqual([]); + expect(calls).toEqual(['get']); + expect(rolledBack).toEqual([]); + }); + + it('propagates another failure from the lookup without asking or rolling back', async () => { + const failure = new LaunchApiError(500, []); + const { command, lines, calls, inquired } = commandUnderTest({ answer: true, getFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + + expect(calls).toEqual(['get']); + expect(inquired).toEqual([]); + expect(lines).toEqual([]); + }); + + it('propagates a failure from the rollback rather than reporting a success', async () => { + const failure = new LaunchApiError(409, [{ code: 'launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', message: 'x' }]); + const { command, lines, calls } = commandUnderTest({ yes: true, rollbackFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + + expect(calls).toEqual(['get', 'rollback']); + expect(lines).toEqual([]); + }); + + it('declares the deployment as required, reason optional and opts into the confirm gate', () => { + expect(DeploymentsRollback.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: { required: true, picker: 'all' }, + reason: {}, + yes: {}, + }); + expect(Object.keys(DeploymentsRollback.flags).sort()).toEqual(Object.keys(DeploymentsRollback.inputs).sort()); + }); +}); diff --git a/src/commands/launch/deployments/rollback.ts b/src/commands/launch/deployments/rollback.ts new file mode 100644 index 00000000..860edd8a --- /dev/null +++ b/src/commands/launch/deployments/rollback.ts @@ -0,0 +1,53 @@ +import { EXIT_CANCELLED } from '../../../core/constants'; +import { LaunchCommand } from '../../../core/launch-command'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { + deploymentNotRolledBackLine, + deploymentRollbackQuestion, + rolledBackLine, +} from '../../../deployments/deployment.presenter'; +import { ALL_DEPLOYMENTS } from '../../../deployments/deployment.prompt'; +import { fetchDeployment } from '../../../deployments/deployment.resolver'; +import { DeploymentRollback } from '../../../deployments/deployment.rollback'; +import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; + +const rollbackInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + deployment: { required: true, picker: ALL_DEPLOYMENTS }, + reason: {}, + yes: {}, +}); + +export default class DeploymentsRollback extends LaunchCommand { + static description = 'Roll a Launch environment back to a previous deployment'; + + static examples = [ + '$ csdx launch:deployments:rollback --org --project --env ' + + '--deployment ', + '$ csdx launch:deployments:rollback --org --project --env ' + + '--deployment --reason "bad release" --yes', + ]; + + static inputs = rollbackInputs; + + static flags = flagsFor(rollbackInputs); + + async run(): Promise { + const { org, project, env: environment, deployment, reason } = this.resolved; + const scope = { org, project, environment }; + const outputIsTTY = this.services.outputIsTTY === true; + + await fetchDeployment(this.services.api.deployments, { ...scope, deployment }); + + if (!(await this.confirm(deploymentRollbackQuestion(deployment)))) { + this.ux.print(deploymentNotRolledBackLine(outputIsTTY)); + this.exit(EXIT_CANCELLED); + } + + await new DeploymentRollback(this.services, defaultWatchTiming()).rollback({ ...scope, deployment, reason }); + + this.ux.print(rolledBackLine(deployment, outputIsTTY)); + } +} diff --git a/src/core/errors.ts b/src/core/errors.ts index 27223abc..b7088416 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -22,6 +22,13 @@ export class UsageError extends LaunchError { } } +export class FlagValueError extends UsageError { + constructor(readonly detail: string) { + super(detail); + this.name = 'FlagValueError'; + } +} + export class CancelledError extends LaunchError { readonly exitCode = EXIT_CANCELLED; diff --git a/src/core/inputs.ts b/src/core/inputs.ts index af9c92f2..b3fb7cba 100644 --- a/src/core/inputs.ts +++ b/src/core/inputs.ts @@ -5,6 +5,7 @@ import { catalog } from '../resources'; export interface InputSpec { required?: boolean; + picker?: string; } export interface AnyInputs { readonly [key: string]: InputSpec } diff --git a/src/core/launch-command.ts b/src/core/launch-command.ts index 065576dd..15aeb3a0 100644 --- a/src/core/launch-command.ts +++ b/src/core/launch-command.ts @@ -8,7 +8,7 @@ import type { ProjectConfig } from './project-config'; import { ProjectConfigStore, configSourceNotice } from './project-config'; import type { RegionLike } from './region'; import { resolveLaunchHubUrl } from './region'; -import { LaunchError, UsageError } from './errors'; +import { FlagValueError, LaunchError, UsageError } from './errors'; import type { FlagKey } from '../resources'; import { catalog, resolutionTable } from '../resources'; import type { AnyInputs, Resolved } from './inputs'; @@ -123,6 +123,8 @@ export abstract class LaunchCommand extends Com flags: this.contract.flags, baseFlags: LaunchCommand.baseFlags, strict: true, + }).catch((error: unknown) => { + throw error instanceof FlagValueError ? new UsageError(error.detail) : error; }); this.requireAuth(); diff --git a/src/core/render.test.ts b/src/core/render.test.ts index 6f00cc8d..847ac1f1 100644 --- a/src/core/render.test.ts +++ b/src/core/render.test.ts @@ -274,6 +274,23 @@ describe('renderTable', () => { expect(lines.join('\n')).not.toContain('\u001b'); }); + it.each([ + ['a line feed', 'Ship it\n\nCo-Authored-By: Claude'], + ['a carriage return', 'Ship it\r\nbody'], + ['a tab', 'Ship\tit'], + ['a colour escape', 'Ship \u001b[31mit\u001b[39m'], + ['a bell and a C1 control', 'Ship\u0007 it\u0085'], + ])('keeps a cell holding %s on one line, so the box keeps its shape', (_label, value) => { + const { ux, lines } = fakeUx(); + + renderTable(ux, [{ header: 'MESSAGE', value: (row: { value: string }) => row.value }], [{ value }, { value: 'ok' }]); + + expect(lines).toHaveLength(7); + lines.forEach((line) => expect(line).not.toMatch(/\p{Cc}/u)); + expect(new Set(lines.map((line) => [...line].length)).size).toBe(1); + expect(lines[3].startsWith('\u2502 Ship')).toBe(true); + }); + it('prints nothing rather than an unclosable box when there are no columns to size', () => { const { ux, lines } = fakeUx(); diff --git a/src/core/render.ts b/src/core/render.ts index 0188d545..f7a134d8 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -1,4 +1,5 @@ import { styled } from './style'; +import { withoutControlCharacters } from './values'; import type { UxLike } from './prompt'; export interface Pagination { @@ -16,7 +17,7 @@ const UNPRINTABLE = '-'; function cellText(value: unknown): string { if (typeof value === 'string') { - return value; + return withoutControlCharacters(value); } if (value === undefined || value === null) { diff --git a/src/core/resolution.ts b/src/core/resolution.ts index f5d1a045..b0a6b911 100644 --- a/src/core/resolution.ts +++ b/src/core/resolution.ts @@ -1,6 +1,6 @@ import type { ApiSurface, Catalog, FlagKey } from '../resources'; import { DEFAULT_LIMIT } from './constants'; -import type { ValueOf } from './inputs'; +import type { InputSpec, ValueOf } from './inputs'; import type { ProjectConfigKey } from './project-config'; import type { UxLike } from './prompt'; @@ -11,21 +11,23 @@ export interface ResolveServices { } export interface PromptArgs { + input: InputSpec; services: ResolveServices; resolved: { [P in D]: ValueOf }; } export type InputSource = 'flag' | 'config' | 'prompt' | 'default'; -export interface NormalizeArgs extends PromptArgs { +export interface NormalizeArgs extends Omit, 'input'> { source: InputSource; } -export interface LooseNormalizeArgs extends LooseArgs { +export interface LooseNormalizeArgs extends Omit { source: InputSource; } export interface LooseArgs { + input: InputSpec; services: ResolveServices; resolved: Partial>; } @@ -34,6 +36,7 @@ export interface ResolutionSpec { configPath?: ProjectConfigKey; configLabel?: string; dependsOn?: readonly D[]; + promptWhen?(input: InputSpec): boolean; prompt?(args: PromptArgs): Promise; normalize?(value: T, args: NormalizeArgs): Promise; default?: T; @@ -43,6 +46,7 @@ export interface AnyResolutionSpec { configPath?: ProjectConfigKey; configLabel?: string; dependsOn?: readonly FlagKey[]; + promptWhen?(input: InputSpec): boolean; prompt?(args: LooseArgs): Promise; normalize?(value: unknown, args: LooseNormalizeArgs): Promise; default?: unknown; diff --git a/src/core/resolve.test.ts b/src/core/resolve.test.ts index cc699081..7e9f78bf 100644 --- a/src/core/resolve.test.ts +++ b/src/core/resolve.test.ts @@ -492,6 +492,54 @@ describe('resolveInputs', () => { expect(inquired).toHaveLength(1); }); + it('hands the prompt the command own declaration of the input and prompts only where promptWhen accepts it', async () => { + const declared: unknown[] = []; + const table = { + deployment: { + promptWhen: (input: { picker?: string }) => input.picker !== undefined, + prompt: async (args: { input: unknown }) => { + declared.push(args.input); + return 'picked'; + }, + }, + } as unknown as Record; + const ask = (spec: AnyInputs) => + withResolution(table, () => + resolveInputs(spec, { parsed: {}, projectConfig: {}, services: services({ isTTY: true }) }), + ); + + const withPicker = await ask({ deployment: { required: true, picker: 'in-flight' } } as AnyInputs); + const without = await ask({ deployment: {} } as AnyInputs); + + expect(withPicker).toEqual({ deployment: 'picked' }); + expect(declared).toEqual([{ required: true, picker: 'in-flight' }]); + expect(without).toEqual({ deployment: undefined }); + }); + + it('does not ask promptWhen at all when the flag already carries the value', async () => { + const asked: unknown[] = []; + const table = { + deployment: { + promptWhen: (input: unknown) => { + asked.push(input); + return true; + }, + prompt: async () => 'picked', + }, + } as unknown as Record; + + const resolved = await withResolution(table, () => + resolveInputs({ deployment: { required: true } } as AnyInputs, { + parsed: { deployment: 'given' }, + projectConfig: {}, + services: services({ isTTY: true }), + }), + ); + + expect(resolved).toEqual({ deployment: 'given' }); + expect(asked).toEqual([]); + }); + it('keeps a numeric zero supplied on argv rather than treating it as absent', async () => { const resolved = await resolveInputs(inputs({ limit: {}, skip: {} }), { parsed: { limit: 0, skip: 0 }, diff --git a/src/core/resolve.ts b/src/core/resolve.ts index 43aaca12..008abbab 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -99,9 +99,9 @@ export async function resolveInputsTraced( source = 'config'; } - if (isAbsent(value) && rule.prompt && args.services.isTTY) { + if (isAbsent(value) && rule.prompt && args.services.isTTY && (rule.promptWhen?.(spec[key]) ?? true)) { requireDependencies(rule); - value = await rule.prompt({ services: args.services, resolved }); + value = await rule.prompt({ input: spec[key], services: args.services, resolved }); source = 'prompt'; } diff --git a/src/core/values.test.ts b/src/core/values.test.ts index 2d97555f..65bf4004 100644 --- a/src/core/values.test.ts +++ b/src/core/values.test.ts @@ -1,5 +1,5 @@ import { UsageError } from './errors'; -import { asSentence, isRecord, messageOf, oneOf, withinLength } from './values'; +import { asSentence, isRecord, messageOf, oneOf, withinLength, withoutControlCharacters } from './values'; describe('input value helpers', () => { it.each([ @@ -65,3 +65,29 @@ describe('input value helpers', () => { ); }); }); + +describe('withoutControlCharacters', () => { + it.each([ + ['a line feed', 'a\nb', 'a b'], + ['a run of line breaks and a carriage return', 'a\r\n\n\nb', 'a b'], + ['a tab', 'a\tb', 'a b'], + ['a bell', 'a\u0007b', 'a b'], + ['a delete', 'a\u007fb', 'a b'], + ['a C1 control', 'a\u0085b', 'a b'], + ['a colour escape sequence', 'a\u001b[31mb\u001b[39m', 'ab'], + ['the last C0 control and the first C1 neighbour', 'a\u001fb\u00a0c', 'a b\u00a0c'], + ['no control character', 'plain text \u2026 \u{1f600}', 'plain text \u2026 \u{1f600}'], + ['an empty string', '', ''], + ])('replaces %s with one space', (_label, value, expected) => { + expect(withoutControlCharacters(value)).toBe(expected); + }); + + it('leaves the printable boundaries either side of the C0 and C1 ranges alone', () => { + expect(withoutControlCharacters('a b~\u00a0')).toBe('a b~\u00a0'); + expect(withoutControlCharacters('\u001f')).toBe(' '); + expect(withoutControlCharacters('\u0020')).toBe(' '); + expect(withoutControlCharacters('\u007e')).toBe('~'); + expect(withoutControlCharacters('\u009f')).toBe(' '); + expect(withoutControlCharacters('\u00a0')).toBe('\u00a0'); + }); +}); diff --git a/src/core/values.ts b/src/core/values.ts index c862e65c..da2e63d4 100644 --- a/src/core/values.ts +++ b/src/core/values.ts @@ -1,9 +1,38 @@ +import { stripVTControlCharacters } from 'node:util'; + import { UsageError } from './errors'; export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const LAST_C0_CONTROL = 0x1f; +const FIRST_DELETE_OR_C1_CONTROL = 0x7f; +const LAST_C1_CONTROL = 0x9f; + +function isControl(unit: string): boolean { + const code = unit.charCodeAt(0); + + return code <= LAST_C0_CONTROL || (code >= FIRST_DELETE_OR_C1_CONTROL && code <= LAST_C1_CONTROL); +} + +export function withoutControlCharacters(value: string): string { + let text = ''; + let inRun = false; + + for (const unit of stripVTControlCharacters(value)) { + if (isControl(unit)) { + text += inRun ? '' : ' '; + inRun = true; + } else { + text += unit; + inRun = false; + } + } + + return text; +} + export function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/deployments/commit.prompt.test.ts b/src/deployments/commit.prompt.test.ts new file mode 100644 index 00000000..11258e0d --- /dev/null +++ b/src/deployments/commit.prompt.test.ts @@ -0,0 +1,133 @@ +import { randomBytes } from 'node:crypto'; + +import { CancelledError, UsageError } from '../core/errors'; + +import { LaunchApiError } from '../transport/errors'; +import { commitChoice, promptForCommit } from './commit.prompt'; +import type { Commit } from '../environments/types'; + +const SCOPE = { + org: randomBytes(9).toString('hex'), + project: randomBytes(12).toString('hex'), + environment: randomBytes(12).toString('hex'), +}; + +function harness(commits: Commit[] | Error, answer: unknown = undefined, isTTY = true) { + const scopes: unknown[] = []; + const payloads: Record[] = []; + const deps = { + isTTY, + api: { + environments: { + commits: async (scope: unknown) => { + scopes.push(scope); + if (commits instanceof Error) { + throw commits; + } + return commits; + }, + }, + }, + ux: { + print: () => undefined, + inquire: async (payload: unknown) => { + payloads.push(payload as Record); + return answer as never; + }, + }, + } as unknown as Parameters[0]; + + return { deps, scopes, payloads }; +} + +describe('promptForCommit', () => { + it('lists short hash and subject, returns the full sha of the answer and reads with the full scope', async () => { + const sha = randomBytes(20).toString('hex'); + const { deps, scopes, payloads } = harness([{ sha, shortSha: 'abc1234', message: 'fix it\nlong body' }], sha); + + const chosen = await promptForCommit(deps, SCOPE); + + expect(chosen).toBe(sha); + expect(scopes).toEqual([SCOPE]); + expect(payloads).toHaveLength(1); + expect(payloads[0]).toMatchObject({ type: 'list', choices: [{ name: 'abc1234 fix it', value: sha }] }); + }); + + it.each([ + ['an empty list', []], + ['commits without a sha', [{ shortSha: 'abc1234', message: 'm' }, { sha: '', message: 'n' }, { sha: ' ' }]], + ])('refuses %s at a terminal with exit 2 naming --commit , asking nothing', async (_label, commits) => { + const { deps, payloads } = harness(commits as Commit[]); + + const failure = await promptForCommit(deps, SCOPE).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toBe('No commits are available to choose from. Pass --commit instead.'); + expect(payloads).toEqual([]); + }); + + it('skips a hashless commit but still offers the others', async () => { + const sha = randomBytes(20).toString('hex'); + const { deps, payloads } = harness([{ message: 'no sha' }, { sha, message: 'ok' }], sha); + + await promptForCommit(deps, SCOPE); + + expect(payloads[0]).toMatchObject({ choices: [{ value: sha }] }); + expect((payloads[0].choices as unknown[]).length).toBe(1); + }); + + it('refuses without a terminal before reading commits or asking', async () => { + const { deps, scopes, payloads } = harness([{ sha: randomBytes(20).toString('hex') }], 'x', false); + + const failure = await promptForCommit(deps, SCOPE).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe( + 'Choosing a commit needs an interactive terminal. Pass --commit instead.', + ); + expect(scopes).toEqual([]); + expect(payloads).toEqual([]); + }); + + it('propagates a failed commits lookup unchanged and asks nothing', async () => { + const failure = new LaunchApiError(500, []); + const { deps, payloads } = harness(failure); + + await expect(promptForCommit(deps, SCOPE)).rejects.toBe(failure); + expect(payloads).toEqual([]); + }); + + it('treats an empty answer as a cancellation', async () => { + const { deps } = harness([{ sha: randomBytes(20).toString('hex'), message: 'm' }], undefined); + + await expect(promptForCommit(deps, SCOPE)).rejects.toBeInstanceOf(CancelledError); + }); + + it('falls back to the first seven characters of the sha and to the hash alone when there is no subject', () => { + const sha = randomBytes(20).toString('hex'); + + expect(commitChoice({ sha, message: '' })).toEqual({ name: sha.slice(0, 7), value: sha }); + expect(commitChoice({ sha, shortSha: ' ', message: 'x' })).toEqual({ name: `${sha.slice(0, 7)} x`, value: sha }); + }); + + it.each([ + ['a colour sequence', 'fix \u001b[31mred\u001b[0m', 'abc1234 fix red'], + ['an OSC title sequence', 'x\u001b]0;pwned\u0007y', 'abc1234 xy'], + ['a lone carriage return', 'first\rsecond', 'abc1234 first'], + ['a carriage return and line feed', 'first\r\nsecond', 'abc1234 first'], + ['a leading carriage return', '\rhidden', 'abc1234'], + ['a tab and a bell', 'a\tb\u0007c', 'abc1234 a b c'], + ])('never lets %s in a commit message reach the label', (_label, message, expected) => { + const sha = randomBytes(20).toString('hex'); + + expect(commitChoice({ sha, shortSha: 'abc1234', message })).toEqual({ name: expected, value: sha }); + }); + + it('strips control characters from the short hash too and falls back when nothing is left', () => { + const sha = randomBytes(20).toString('hex'); + + expect(commitChoice({ sha, shortSha: 'ab\u001b[31mc12\u001b[0m', message: 'm' }).name).toBe('abc12 m'); + expect(commitChoice({ sha, shortSha: '\u0007', message: '' }).name).toBe(sha.slice(0, 7)); + }); +}); diff --git a/src/deployments/commit.prompt.ts b/src/deployments/commit.prompt.ts new file mode 100644 index 00000000..1baa8ad5 --- /dev/null +++ b/src/deployments/commit.prompt.ts @@ -0,0 +1,43 @@ +import { UsageError } from '../core/errors'; +import type { Choice } from '../core/prompt'; +import { askOption } from '../core/prompt'; +import type { ServiceContext } from '../core/service-context'; +import { withoutControlCharacters } from '../core/values'; +import type { DeploymentScope } from './deployments.api'; + +const SHORT_SHA_LENGTH = 7; + +interface ListedCommit { + sha?: string; + shortSha?: string; + message?: string; +} + +const NO_TERMINAL = 'Choosing a commit needs an interactive terminal. Pass --commit instead.'; + +const NO_COMMITS = 'No commits are available to choose from. Pass --commit instead.'; + +export function commitChoice(commit: ListedCommit & { sha: string }): Choice { + const listed = withoutControlCharacters(commit.shortSha ?? '').trim(); + const short = listed === '' ? withoutControlCharacters(commit.sha.slice(0, SHORT_SHA_LENGTH)).trim() : listed; + const subject = withoutControlCharacters((commit.message ?? '').split(/\r\n|[\n\r]/)[0]).trim(); + + return { name: subject === '' ? short : `${short} ${subject}`, value: commit.sha }; +} + +export async function promptForCommit(deps: ServiceContext, scope: DeploymentScope): Promise { + if (!deps.isTTY) { + throw new UsageError(NO_TERMINAL); + } + + const commits = await deps.api.environments.commits(scope); + const usable = commits.filter( + (commit): commit is ListedCommit & { sha: string } => typeof commit.sha === 'string' && commit.sha.trim() !== '', + ); + + if (usable.length === 0) { + throw new UsageError(NO_COMMITS); + } + + return askOption(deps.ux, 'Choose a commit for deployment', usable.map(commitChoice)); +} diff --git a/src/deployments/deployment.create.test.ts b/src/deployments/deployment.create.test.ts new file mode 100644 index 00000000..52c10de8 --- /dev/null +++ b/src/deployments/deployment.create.test.ts @@ -0,0 +1,451 @@ +import { randomBytes } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { LaunchError } from '../core/errors'; +import { UsageError } from '../core/errors'; +import type { ServiceContext } from '../core/service-context'; +import type { ApiSurface } from '../resources'; +import { LaunchApiError } from '../transport/errors'; +import type { CreateDeploymentRequest } from './deployment.create'; +import { DeploymentCreator } from './deployment.create'; +import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from './deployment.errors'; +import { PICK_COMMIT } from './deployment.inputs'; +import type { DeploymentOutcome, WatchTiming } from './deployment.watcher'; +import type { Deployment } from './types'; + +const ORG = `blt${randomBytes(8).toString('hex')}`; +const PROJECT = randomBytes(12).toString('hex'); +const ENVIRONMENT = randomBytes(12).toString('hex'); +const DEPLOYMENT = randomBytes(12).toString('hex'); +const SHA = randomBytes(20).toString('hex'); +const MISSING_DIR = join(tmpdir(), `launch-no-such-dir-${randomBytes(6).toString('hex')}`); + +const BASE: CreateDeploymentRequest = { + org: ORG, + project: PROJECT, + environment: ENVIRONMENT, + dataDir: MISSING_DIR, + configPath: join(MISSING_DIR, '.cs-launch.json'), + lastUpload: false, +}; + +class FakeSignals { + readonly listeners: (() => void)[] = []; + readonly registered: string[] = []; + + on(signal: 'SIGINT', listener: () => void): void { + this.registered.push(signal); + this.listeners.push(listener); + } + + removeListener(_signal: 'SIGINT', listener: () => void): void { + const index = this.listeners.indexOf(listener); + + if (index >= 0) { + this.listeners.splice(index, 1); + } + } + + interrupt(): void { + for (const listener of [...this.listeners]) { + listener(); + } + } +} + +interface Harness { + creator: DeploymentCreator; + signals: FakeSignals; + created: unknown[]; + methods: string[]; + signedUrlCalls: unknown[]; + fetched: unknown[]; + watched: { scope: unknown; stop?: AbortSignal }[]; + announced: Deployment[]; + picked: unknown[]; + printed: string[]; +} + +function harness( + options: { + projectType?: string; + outcome?: DeploymentOutcome | (() => Promise); + watch?: (scope: unknown, stop?: AbortSignal) => Promise; + createFails?: Error; + projectFails?: Error; + announceFails?: Error; + pick?: () => Promise; + } = {}, +): Harness { + const created: unknown[] = []; + const methods: string[] = []; + const signedUrlCalls: unknown[] = []; + const fetched: unknown[] = []; + const watched: { scope: unknown; stop?: AbortSignal }[] = []; + const announced: Deployment[] = []; + const picked: unknown[] = []; + const printed: string[] = []; + const signals = new FakeSignals(); + + const api = { + projects: { + get: async (params: unknown) => { + fetched.push(params); + + if (options.projectFails !== undefined) { + throw options.projectFails; + } + + return { uid: PROJECT, projectType: options.projectType ?? 'GITPROVIDER' }; + }, + }, + deployments: { + create: async (params: unknown) => { + methods.push('create'); + created.push(params); + + if (options.createFails !== undefined) { + throw options.createFails; + } + + return { uid: DEPLOYMENT, status: 'QUEUED' }; + }, + }, + uploads: { + signedUploadUrl: async (params: unknown) => { + signedUrlCalls.push(params); + throw new Error('no upload expected'); + }, + }, + } as unknown as ApiSurface; + + const services: ServiceContext = { + api, + ux: { print: (message: string) => printed.push(message), inquire: async () => undefined as never }, + isTTY: false, + outputIsTTY: false, + }; + + const timing: WatchTiming = { + sleep: async () => undefined, + now: () => 0, + pollDelayMs: 1, + maxBackoffSteps: 1, + timeoutMs: 1, + }; + + const follower = { + watch: async (scope: unknown, stop?: AbortSignal): Promise => { + watched.push({ scope, stop }); + + if (options.watch !== undefined) { + return options.watch(scope, stop); + } + + const outcome = options.outcome ?? { kind: 'success', status: 'LIVE', deployment: { uid: DEPLOYMENT, status: 'LIVE' } }; + + return typeof outcome === 'function' ? outcome() : outcome; + }, + announceLive: async (deployment: Deployment) => { + if (options.announceFails !== undefined) { + throw options.announceFails; + } + + announced.push(deployment); + }, + }; + + const creator = new DeploymentCreator(services, timing, { + pickCommit: async (scope) => { + picked.push(scope); + + return options.pick === undefined ? SHA : options.pick(); + }, + signals, + follower, + }); + + return { creator, signals, created, methods, signedUrlCalls, fetched, watched, announced, picked, printed }; +} + +async function failureOf(run: Promise): Promise { + return run.then( + () => { + throw new Error('expected the creation to fail'); + }, + (error: Error) => error, + ); +} + +describe('DeploymentCreator on a GitHub project', () => { + it('deploys the branch tip with a body of only the environment, then follows the new deployment and announces it live', async () => { + const { creator, created, fetched, watched, announced, picked, signedUrlCalls, signals } = harness(); + + await creator.create(BASE); + + expect(fetched).toEqual([{ org: ORG, project: PROJECT }]); + expect(created).toEqual([{ org: ORG, project: PROJECT, environment: ENVIRONMENT }]); + expect(Object.keys(created[0] as object)).not.toContain('commitHash'); + expect(Object.keys(created[0] as object)).not.toContain('uploadUid'); + expect(watched.map((entry) => entry.scope)).toEqual([ + { org: ORG, project: PROJECT, environment: ENVIRONMENT, deployment: DEPLOYMENT }, + ]); + expect(announced).toEqual([{ uid: DEPLOYMENT, status: 'LIVE' }]); + expect(picked).toEqual([]); + expect(signedUrlCalls).toEqual([]); + expect(signals.listeners).toEqual([]); + }); + + it('deploys the commit named by --commit and never asks the picker', async () => { + const { creator, created, picked } = harness(); + + await creator.create({ ...BASE, commit: SHA }); + + expect(created).toEqual([{ org: ORG, project: PROJECT, environment: ENVIRONMENT, commitHash: SHA }]); + expect(picked).toEqual([]); + }); + + it('deploys the commit the picker answers for a bare --commit, handing the picker the full scope', async () => { + const answer = randomBytes(20).toString('hex'); + const { creator, created, picked } = harness({ pick: async () => answer }); + + await creator.create({ ...BASE, commit: PICK_COMMIT }); + + expect(picked).toEqual([{ org: ORG, project: PROJECT, environment: ENVIRONMENT }]); + expect(created).toEqual([{ org: ORG, project: PROJECT, environment: ENVIRONMENT, commitHash: answer }]); + }); + + it('creates nothing when the picker refuses, passing its error through unchanged', async () => { + const refusal = new UsageError('Pass --commit .'); + const { creator, created } = harness({ + pick: async () => { + throw refusal; + }, + }); + + const failure = await failureOf(creator.create({ ...BASE, commit: PICK_COMMIT })); + + expect(failure).toBe(refusal); + expect(created).toEqual([]); + }); + + it('refuses --last-upload with exit 2 naming --commit and creates nothing', async () => { + const { creator, created, watched } = harness(); + + const failure = await failureOf(creator.create({ ...BASE, lastUpload: true })); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as LaunchError).exitCode).toBe(2); + expect(failure.message).toContain('--last-upload'); + expect(failure.message).toContain('--commit '); + expect(created).toEqual([]); + expect(watched).toEqual([]); + }); +}); + +describe('DeploymentCreator on a FileUpload project', () => { + const FILE = { projectType: 'FILEUPLOAD' }; + + it.each([ + ['a sha', SHA], + ['a bare --commit', PICK_COMMIT], + ])('refuses %s with exit 2 naming --last-upload, asking and sending nothing', async (_label, commit) => { + const { creator, created, picked, signedUrlCalls } = harness(FILE); + + const failure = await failureOf(creator.create({ ...BASE, commit })); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as LaunchError).exitCode).toBe(2); + expect(failure.message).toContain('--commit'); + expect(failure.message).toContain('--last-upload'); + expect(created).toEqual([]); + expect(picked).toEqual([]); + expect(signedUrlCalls).toEqual([]); + }); + + it('redeploys the last bundle with a body of only the environment and makes no signed-url call', async () => { + const { creator, created, signedUrlCalls, printed } = harness(FILE); + + await creator.create({ ...BASE, lastUpload: true }); + + expect(created).toEqual([{ org: ORG, project: PROJECT, environment: ENVIRONMENT }]); + expect(Object.keys(created[0] as object)).not.toContain('uploadUid'); + expect(signedUrlCalls).toEqual([]); + expect(printed).toEqual([]); + }); + + it('refuses a missing project directory naming the path and both ways out, --data-dir and --last-upload', async () => { + const { creator, created, signedUrlCalls } = harness(FILE); + + const failure = await failureOf(creator.create(BASE)); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as LaunchError).exitCode).toBe(2); + expect(failure.message).toBe( + `No files to upload were found in "${MISSING_DIR}". ` + + 'Use --data-dir to point to your project folder, or --last-upload to redeploy the last upload.', + ); + expect(created).toEqual([]); + expect(signedUrlCalls).toEqual([]); + }); +}); + +describe('DeploymentCreator on a FileUpload project whose archive is refused for another reason', () => { + it('keeps that error instead of rewording it as a missing directory', async () => { + const { creator, created, signedUrlCalls } = harness({ projectType: 'FILEUPLOAD' }); + const dataDir = mkdtempSync(join(tmpdir(), 'launch-tiny-dir-')); + writeFileSync(join(dataDir, 'index.html'), 'x'); + + const failure = await failureOf(creator.create({ ...BASE, dataDir })); + + expect(failure).toBeInstanceOf(UsageError); + expect(failure.message).not.toContain('No files to upload'); + expect(failure.message).not.toContain('--last-upload'); + expect(created).toEqual([]); + expect(signedUrlCalls).toEqual([]); + }); +}); + +describe('DeploymentCreator waiting for the outcome', () => { + const deployment = { uid: DEPLOYMENT }; + const logsHint = `csdx launch:logs:get --org ${ORG} --project ${PROJECT} --env ${ENVIRONMENT} --deployment ${DEPLOYMENT}`; + const getHint = `csdx launch:deployments:get --org ${ORG} --project ${PROJECT} --env ${ENVIRONMENT} --deployment ${DEPLOYMENT}`; + + it('resolves and announces the site for a DEPLOYED outcome as it does for LIVE', async () => { + const { creator, announced } = harness({ outcome: { kind: 'success', status: 'DEPLOYED', deployment } }); + + await creator.create(BASE); + + expect(announced).toEqual([deployment]); + }); + + it.each([['ARCHIVED'], ['SKIPPED'], ['CANCELLED'], ['FAILED']])( + 'raises DeploymentUnsuccessfulError naming %s and the log command with the full scope, announcing nothing', + async (status) => { + const { creator, announced } = harness({ outcome: { kind: 'failure', status, deployment } }); + + const failure = await failureOf(creator.create(BASE)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as LaunchError).exitCode).toBe(1); + expect(failure.message).toBe(`Deployment "${DEPLOYMENT}" ended with status ${status}. Run ${logsHint} to see the cause.`); + expect(announced).toEqual([]); + }, + ); + + it('raises DeploymentUnsuccessfulError on a timeout that points at deployments:get and does not suggest another create', async () => { + const { creator, announced } = harness({ outcome: { kind: 'timed-out', status: 'DEPLOYING', deployment } }); + + const failure = await failureOf(creator.create(BASE)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as LaunchError).exitCode).toBe(1); + expect(failure.message).toBe( + `Deployment "${DEPLOYMENT}" was still DEPLOYING when the CLI stopped waiting. Run ${getHint} to check its status.`, + ); + expect(failure.message).not.toContain('deployments:create'); + expect(announced).toEqual([]); + }); + + it('words a poll that gave up with the deployment it left running, naming the status command', async () => { + const outage = new LaunchApiError(503, [{ code: 'x', message: 'Service unavailable.' }]); + const { creator } = harness({ + watch: async () => { + throw outage; + }, + }); + + const failure = await failureOf(creator.create(BASE)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect(failure.message).toBe( + `Deployment "${DEPLOYMENT}" could not be followed to completion: Service unavailable. ` + + `Run ${getHint} to check its status.`, + ); + }); + + const rejections = { + projectFails: new LaunchApiError(500, [{ code: 'a', message: 'a' }]), + createFails: new LaunchApiError(500, [{ code: 'b', message: 'b' }]), + announceFails: new Error('browser failed'), + }; + + it.each([ + ['projects.get', 'projectFails'], + ['deployments.create', 'createFails'], + ['announceLive', 'announceFails'], + ] as const)('passes a rejection from %s through as the same instance', async (_step, key) => { + const { creator, signals } = harness({ [key]: rejections[key] }); + + const failure = await failureOf(creator.create(BASE)); + + expect(failure).toBe(rejections[key]); + expect(signals.listeners).toEqual([]); + }); + + it('stops no deployment and starts no second one when the create request fails', async () => { + const { creator, methods, watched } = harness({ createFails: new LaunchApiError(500, [{ code: 'c', message: 'c' }]) }); + + await failureOf(creator.create(BASE)); + + expect(methods).toEqual(['create']); + expect(watched).toEqual([]); + }); +}); + +describe('DeploymentCreator interrupted with Ctrl-C while waiting', () => { + function waitingForever(stopHolder: { stop?: AbortSignal }) { + return (_scope: unknown, stop?: AbortSignal) => + new Promise((_resolve, reject) => { + stopHolder.stop = stop; + stop?.addEventListener('abort', () => reject(stop.reason), { once: true }); + }); + } + + it('stops the wait, says the deployment was not cancelled and names deployments:get, sends no cancel, and exits 3', async () => { + const holder: { stop?: AbortSignal } = {}; + const { creator, signals, methods } = harness({ watch: waitingForever(holder) }); + + const running = failureOf(creator.create(BASE)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(signals.registered).toEqual(['SIGINT']); + expect(signals.listeners).toHaveLength(1); + expect(holder.stop?.aborted).toBe(false); + + signals.interrupt(); + const failure = await running; + + expect(holder.stop?.aborted).toBe(true); + expect(failure).toBeInstanceOf(DeploymentWaitInterruptedError); + expect((failure as LaunchError).exitCode).toBe(3); + expect(failure.message).toBe( + `Stopped waiting. Deployment "${DEPLOYMENT}" was not cancelled and continues on Launch. ` + + `Check it with csdx launch:deployments:get --org ${ORG} --project ${PROJECT} --env ${ENVIRONMENT} ` + + `--deployment ${DEPLOYMENT}.`, + ); + expect(methods).toEqual(['create']); + expect(signals.listeners).toEqual([]); + }); + + it('does not word an interrupt as a failed deployment', async () => { + const holder: { stop?: AbortSignal } = {}; + const { creator, signals } = harness({ watch: waitingForever(holder) }); + + const running = failureOf(creator.create(BASE)); + await new Promise((resolve) => setImmediate(resolve)); + signals.interrupt(); + + expect(await running).not.toBeInstanceOf(DeploymentUnsuccessfulError); + }); + + it('registers no listener when nothing is waited on', async () => { + const { creator, signals } = harness({ projectFails: new Error('lookup failed') }); + + await failureOf(creator.create(BASE)); + + expect(signals.registered).toEqual([]); + }); +}); diff --git a/src/deployments/deployment.create.ts b/src/deployments/deployment.create.ts new file mode 100644 index 00000000..866f4cfe --- /dev/null +++ b/src/deployments/deployment.create.ts @@ -0,0 +1,171 @@ +import { UsageError } from '../core/errors'; +import type { InterruptSource } from '../core/interruptible-ux'; +import type { ServiceContext } from '../core/service-context'; +import { asSentence, messageOf } from '../core/values'; +import { NoUploadDirectoryError } from '../uploads/upload.errors'; +import { FolderUploader } from '../uploads/upload.folder'; +import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from './deployment.errors'; +import type { DeploymentScope as FollowedDeployment } from './deployment.follower'; +import { DeploymentFollower } from './deployment.follower'; +import { PICK_COMMIT } from './deployment.inputs'; +import { firstDeploymentFailureMessage } from './deployment.presenter'; +import type { DeploymentOutcome, WatchTiming } from './deployment.watcher'; +import type { DeploymentScope } from './deployments.api'; +import type { Deployment, IdentifiedDeployment } from './types'; + +const FILE_UPLOAD_PROJECT = 'FILEUPLOAD'; + +export interface CreateDeploymentRequest extends DeploymentScope { + dataDir: string; + configPath: string; + commit?: string; + lastUpload: boolean; +} + +export interface DeploymentFollowing { + watch(scope: FollowedDeployment, stop?: AbortSignal): Promise; + announceLive(deployment: Deployment): Promise; +} + +export interface DeploymentCreatorDeps { + pickCommit(scope: DeploymentScope): Promise; + signals?: InterruptSource; + follower?: DeploymentFollowing; +} + +interface Source { + commitHash?: string; + uploadUid?: string; +} + +function lastUploadOnGitHub(): UsageError { + return new UsageError( + '--last-upload applies only to FileUpload projects. Use --commit to deploy a specific commit.', + ); +} + +function commitOnFileUpload(): UsageError { + return new UsageError( + '--commit applies only to GitHub projects. Use --last-upload to redeploy the last uploaded bundle.', + ); +} + +function noFilesToDeploy(dataDir: string): UsageError { + return new UsageError( + `No files to upload were found in "${dataDir}". ` + + 'Use --data-dir to point to your project folder, or --last-upload to redeploy the last upload.', + ); +} + +export class DeploymentCreator { + private readonly follower: DeploymentFollowing; + private readonly signals: InterruptSource; + + constructor( + private readonly services: ServiceContext, + timing: WatchTiming, + private readonly deps: DeploymentCreatorDeps, + ) { + this.follower = deps.follower ?? new DeploymentFollower(services, timing); + this.signals = deps.signals ?? process; + } + + async create(request: CreateDeploymentRequest): Promise { + const scope = { org: request.org, project: request.project, environment: request.environment }; + const project = await this.services.api.projects.get({ org: request.org, project: request.project }); + const source = + project.projectType === FILE_UPLOAD_PROJECT + ? await this.fileUploadSource(request, scope) + : await this.gitSource(request, scope); + + const deployment = await this.services.api.deployments.create({ ...scope, ...source }); + + await this.follow(scope, deployment); + } + + private async gitSource(request: CreateDeploymentRequest, scope: DeploymentScope): Promise { + if (request.lastUpload) { + throw lastUploadOnGitHub(); + } + + if (request.commit === undefined) { + return {}; + } + + return { commitHash: request.commit === PICK_COMMIT ? await this.deps.pickCommit(scope) : request.commit }; + } + + private async fileUploadSource(request: CreateDeploymentRequest, scope: DeploymentScope): Promise { + if (request.commit !== undefined) { + throw commitOnFileUpload(); + } + + if (request.lastUpload) { + return {}; + } + + try { + const uploader = new FolderUploader(this.services); + + return { uploadUid: await uploader.upload(scope, request.dataDir, [request.configPath]) }; + } catch (error) { + throw error instanceof NoUploadDirectoryError ? noFilesToDeploy(request.dataDir) : error; + } + } + + private async follow(scope: DeploymentScope, deployment: IdentifiedDeployment): Promise { + const followed = { ...scope, deployment: deployment.uid }; + const outcome = await this.waitFor(followed); + + if (outcome.kind === 'success') { + await this.follower.announceLive(outcome.deployment); + return; + } + + throw this.unsuccessful(followed, { status: outcome.status, timedOut: outcome.kind === 'timed-out' }); + } + + private async waitFor(followed: FollowedDeployment): Promise { + const stop = new AbortController(); + const interrupt = (): void => stop.abort(new DeploymentWaitInterruptedError(this.interruptedMessage(followed))); + + this.signals.on('SIGINT', interrupt); + + try { + return await this.follower.watch(followed, stop.signal); + } catch (error) { + if (stop.signal.aborted) { + throw stop.signal.reason; + } + + throw this.unsuccessful(followed, { status: 'NONE', reason: asSentence(messageOf(error)) }); + } finally { + this.signals.removeListener('SIGINT', interrupt); + } + } + + private interruptedMessage(followed: FollowedDeployment): string { + return ( + `Stopped waiting. Deployment "${followed.deployment}" was not cancelled and continues on Launch. ` + + `Check it with csdx launch:deployments:get --org ${followed.org} --project ${followed.project} ` + + `--env ${followed.environment} --deployment ${followed.deployment}.` + ); + } + + private unsuccessful( + followed: FollowedDeployment, + result: { status: string; timedOut?: boolean; reason?: string }, + ): DeploymentUnsuccessfulError { + return new DeploymentUnsuccessfulError( + firstDeploymentFailureMessage({ + org: followed.org, + project: followed.project, + environmentUid: followed.environment, + deploymentUid: followed.deployment, + status: result.status, + timedOut: result.timedOut, + reason: result.reason, + }), + ); + } +} diff --git a/src/deployments/deployment.errors.ts b/src/deployments/deployment.errors.ts index a4224d44..1cdad101 100644 --- a/src/deployments/deployment.errors.ts +++ b/src/deployments/deployment.errors.ts @@ -1,10 +1,45 @@ -import { EXIT_RUNTIME } from '../core/constants'; +import { EXIT_CANCELLED, EXIT_RUNTIME } from '../core/constants'; import { LaunchError } from '../core/errors'; +import { LaunchApiError } from '../transport/errors'; import type { ErrorMessages } from '../transport/errors'; +export const DEPLOYMENT_NOT_FOUND_CODE = 'launch.DEPLOYMENT.NOT_FOUND'; + +export const INVALID_DEPLOYMENT_UID_CODE = 'launch.DEPLOYMENT.INVALID_DEPLOYMENT_UID'; + +export const INVALID_DEPLOYMENT_UID_RAW_MESSAGE = + 'Invalid deployment_uid. Argument passed in must be a string of 12 bytes or a string of 24 hex characters.'; + +const INVALID_DEPLOYMENT_UID_MESSAGE = + 'The deployment UID is invalid. Provide a 24-character hexadecimal deployment UID.'; + +export const CANCEL_NOT_ALLOWED_CODE = 'launch.DEPLOYMENT.CANCEL_NOT_ALLOWED'; + +export const CANCEL_NOT_ALLOWED_RAW_MESSAGE = + 'This deployment has progressed beyond the point where it can be cancelled.'; + +export function cancelNotAllowedMessage(deployment: string): string { + return `Deployment "${deployment}" cannot be cancelled because it is no longer in progress.`; +} + +export function cancelNotAllowedError(deployment: string, status = 400): LaunchApiError { + return new LaunchApiError(status, [{ code: CANCEL_NOT_ALLOWED_CODE, message: cancelNotAllowedMessage(deployment) }]); +} + export const DEPLOYMENT_ERROR_MESSAGES: ErrorMessages = { - 'launch.DEPLOYMENT.NOT_FOUND': 'No deployment found with that UID.', - 'launch.DEPLOYMENT.CREATE_FAILED': 'The Launch API could not start a deployment.', + [DEPLOYMENT_NOT_FOUND_CODE]: 'No deployment found with that UID.', + [INVALID_DEPLOYMENT_UID_CODE]: INVALID_DEPLOYMENT_UID_MESSAGE, + [INVALID_DEPLOYMENT_UID_RAW_MESSAGE]: INVALID_DEPLOYMENT_UID_MESSAGE, + 'launch.DEPLOYMENT.CREATE_FAILED': 'The deployment could not be started. Please try again or contact support.', + 'launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE': + 'This deployment is not eligible for rollback. Choose a deployment that was live earlier.', + 'launch.DEPLOYMENT.NO_LIVE_DEPLOYMENT_FOUND': + 'This environment has no live deployment, so a rollback is not possible.', + 'launch.DEPLOYMENT.ROLLBACK_INVALID_STATUS': + 'The status of this deployment does not allow a rollback. Choose a deployment that was live earlier.', + 'launch.DEPLOYMENT.ROLLBACK_TO_CURRENT_DEPLOYMENT': + 'This deployment is already live in this environment. Choose a different deployment for rollback.', + 'launch.DEPLOYMENT.ROLLBACK_FAILED': 'The rollback could not be completed. Please try again or contact support.', 'launch.ENVIRONMENT.NOT_FOUND': 'No environment found with that UID.', }; @@ -16,3 +51,12 @@ export class DeploymentUnsuccessfulError extends LaunchError { this.name = 'DeploymentUnsuccessfulError'; } } + +export class DeploymentWaitInterruptedError extends LaunchError { + readonly exitCode = EXIT_CANCELLED; + + constructor(message: string) { + super(message); + this.name = 'DeploymentWaitInterruptedError'; + } +} diff --git a/src/deployments/deployment.follower.test.ts b/src/deployments/deployment.follower.test.ts index 25509573..b15bf2f1 100644 --- a/src/deployments/deployment.follower.test.ts +++ b/src/deployments/deployment.follower.test.ts @@ -5,7 +5,7 @@ import type { WatchTiming } from './deployment.watcher'; const SCOPE = { org: 'org1', project: 'p1', environment: 'e1', deployment: 'd1' }; -function harness(options: { withoutOpenUrl?: boolean } = {}) { +function harness(options: { withoutOpenUrl?: boolean; statuses?: string[] } = {}) { const timeline: string[] = []; const polled: unknown[] = []; const logged: unknown[] = []; @@ -14,7 +14,8 @@ function harness(options: { withoutOpenUrl?: boolean } = {}) { deployments: { get: async (params: unknown) => { polled.push(params); - return { uid: 'd1', status: 'LIVE' }; + const statuses = options.statuses ?? ['LIVE']; + return { uid: 'd1', status: statuses[Math.min(polled.length, statuses.length) - 1] }; }, }, deploymentLogs: { @@ -58,6 +59,42 @@ describe('DeploymentFollower.watch', () => { }); }); +describe('DeploymentFollower.waitUntilLive', () => { + it('polls the target in the scope it was given until it is LIVE, whatever it starts as, and reads no build logs', async () => { + const { follower, polled, logged, timeline } = harness({ statuses: ['ARCHIVED', 'DEPLOYED', 'LIVE'] }); + + const outcome = await follower.waitUntilLive(SCOPE); + + expect(outcome).toEqual(expect.objectContaining({ kind: 'success', status: 'LIVE' })); + expect(polled).toEqual([SCOPE, SCOPE, SCOPE]); + expect(logged).toEqual([]); + expect(timeline).toEqual(['sleep 1000', 'sleep 2000']); + }); + + it('hands the stop signal to the wait so an already stopped wait never polls', async () => { + const { follower, polled } = harness(); + const reason = new Error('interrupted'); + const controller = new AbortController(); + controller.abort(reason); + + await expect(follower.waitUntilLive(SCOPE, controller.signal)).rejects.toBe(reason); + expect(polled).toEqual([]); + }); +}); + +describe('DeploymentFollower.watch stopped from outside', () => { + it('hands the stop signal to the wait loop so an already stopped wait never polls', async () => { + const { follower, polled, logged } = harness(); + const reason = new Error('interrupted'); + const controller = new AbortController(); + controller.abort(reason); + + await expect(follower.watch(SCOPE, controller.signal)).rejects.toBe(reason); + expect(polled).toEqual([]); + expect(logged).toEqual([]); + }); +}); + describe('DeploymentFollower.announceLive', () => { it('prints the deployment url, then opens it once the site has had time to come up', async () => { const { follower, timeline } = harness(); @@ -65,7 +102,7 @@ describe('DeploymentFollower.announceLive', () => { await follower.announceLive({ uid: 'd1', deploymentUrl: 'site.example.test' }); expect(timeline).toEqual([ - 'print Deployment URL https://site.example.test', + 'print Deployment URL: https://site.example.test', `sleep ${SITE_OPEN_DELAY_MS}`, 'open https://site.example.test', ]); @@ -76,7 +113,7 @@ describe('DeploymentFollower.announceLive', () => { await follower.announceLive({ uid: 'd1' }, [{}, { url: 'custom.example.test' }, { url: 'other.example.test' }]); - expect(timeline).toEqual(['print Deployment URL https://custom.example.test']); + expect(timeline).toEqual(['print Deployment URL: https://custom.example.test']); }); it('announces the preview url, with a scheme, when the deployment url is empty and a domain also exists', async () => { @@ -87,7 +124,7 @@ describe('DeploymentFollower.announceLive', () => { ]); expect(timeline).toEqual([ - 'print Deployment URL https://preview.example.test', + 'print Deployment URL: https://preview.example.test', `sleep ${SITE_OPEN_DELAY_MS}`, 'open https://preview.example.test', ]); @@ -106,6 +143,6 @@ describe('DeploymentFollower.announceLive', () => { await follower.announceLive({ uid: 'd1', deploymentUrl: 'https://site.example.test' }); - expect(timeline).toEqual(['print Deployment URL https://site.example.test']); + expect(timeline).toEqual(['print Deployment URL: https://site.example.test']); }); }); diff --git a/src/deployments/deployment.follower.ts b/src/deployments/deployment.follower.ts index 679b84fe..60fbb45b 100644 --- a/src/deployments/deployment.follower.ts +++ b/src/deployments/deployment.follower.ts @@ -4,12 +4,15 @@ import type { ServiceContext } from '../core/service-context'; import type { SiteDomain } from '../site/site.url'; import { siteUrlOf } from '../site/site.url'; import { deploymentUrlLine } from './deployment.presenter'; +import { normalizeStatus } from './deployment.status'; import type { DeploymentOutcome, WatchTiming } from './deployment.watcher'; import { watchDeployment } from './deployment.watcher'; import type { Deployment } from './types'; export const SITE_OPEN_DELAY_MS = 6000; +const LIVE_STATUS = 'LIVE'; + export interface DeploymentScope { org: string; project: string; @@ -23,17 +26,31 @@ export class DeploymentFollower { private readonly timing: WatchTiming, ) {} - watch(scope: DeploymentScope): Promise { + watch(scope: DeploymentScope, stop?: AbortSignal): Promise { return watchDeployment({ ...this.timing, ux: this.services.ux, outputIsTTY: this.services.outputIsTTY === true, loader: this.loader(), + stop, logs: (after) => this.services.api.deploymentLogs.after({ ...scope, timestamp: after }), poll: () => this.services.api.deployments.get(scope), }); } + waitUntilLive(scope: DeploymentScope, stop?: AbortSignal): Promise { + return watchDeployment({ + ...this.timing, + ux: this.services.ux, + outputIsTTY: this.services.outputIsTTY === true, + loader: silentLoader, + stop, + logs: async () => [], + poll: () => this.services.api.deployments.get(scope), + until: (deployment) => normalizeStatus(deployment.status) === LIVE_STATUS, + }); + } + async announceLive(deployment: Deployment, domains: readonly SiteDomain[] = []): Promise { const url = siteUrlOf(deployment, domains); diff --git a/src/deployments/deployment.inputs.test.ts b/src/deployments/deployment.inputs.test.ts index 42d173ff..94378de7 100644 --- a/src/deployments/deployment.inputs.test.ts +++ b/src/deployments/deployment.inputs.test.ts @@ -2,77 +2,221 @@ import { randomBytes } from 'node:crypto'; import { Parser } from '@oclif/core'; -import { EXIT_USAGE } from '../core/constants'; import { UsageError } from '../core/errors'; -import type { ResolveServices } from '../core/resolution'; +import { inputs } from '../core/inputs'; +import { resolveInputs } from '../core/resolve'; import type { ApiSurface } from '../resources'; -import type { UxLike } from '../core/prompt'; -import { DEPLOYMENT_DEPENDENCIES, deploymentFlags, deploymentResolution } from './deployment.inputs'; +import { catalog } from '../resources'; +import { + BLANK_COMMIT, + PICK_COMMIT, + deploymentFlags, + deploymentResolution, + DEPLOYMENT_DEPENDENCIES, + withBareCommit, +} from './deployment.inputs'; + +const getInputs = inputs({ org: { required: true }, project: { required: true }, env: { required: true }, deployment: {} }); +const createInputs = inputs({ org: { required: true }, commit: {}, 'last-upload': {} }); +const ORG = 'blt4d9e2a7c1f6b3085'; +const PROJECT = 'a1b2c3d4e5f60718293a4b5c'; +const ENVIRONMENT = 'e1f2a3b4c5d6e7f8091a2b3c'; +const SCOPE_ARGV = ['--org', 'blt4d9e2a7c1f6b3085', '--project', PROJECT, '--env', ENVIRONMENT]; +const SHA = '9fceb02d0ae598e95dc970b74767f19372d61af8'; +const ownedProject = { get: async (scope: { org: string; project: string }) => ({ uid: scope.project, organizationUid: scope.org }) }; +const services = { api: { projects: ownedProject } as unknown as ApiSurface, ux: { print: () => undefined, inquire: async () => undefined as never }, isTTY: false }; + +async function resolvedFrom(argv: string[]) { + const parsed = await Parser.parse([...SCOPE_ARGV, ...argv], { + flags: { org: catalog.org, project: catalog.project, env: catalog.env, deployment: catalog.deployment }, + }); -const LOWER = randomBytes(12).toString('hex'); -const UPPER = LOWER.toUpperCase(); + return resolveInputs(getInputs, { parsed: parsed.flags, projectConfig: {}, services }); +} -function services(): ResolveServices { - const ux: UxLike = { print: () => undefined, inquire: async () => undefined as never }; +async function createResolvedFrom(argv: string[]) { + const parsed = await Parser.parse(withBareCommit(argv), { + flags: { org: catalog.org, commit: catalog.commit, 'last-upload': catalog['last-upload'] }, + }); - return { api: {} as ApiSurface, ux, isTTY: false }; + return resolveInputs(createInputs, { parsed: parsed.flags, projectConfig: {}, services }); } -function normalize(value: string): Promise { - const spec = deploymentResolution.deployment as { normalize(value: string, args: unknown): Promise }; +describe('deployment inputs', () => { + it('names the flags the deployments commands add and shares them through the catalog', () => { + expect(Object.keys(deploymentFlags)).toEqual(['deployment', 'commit', 'reason', 'last-upload']); + expect(Object.keys(deploymentResolution)).toEqual(['deployment', 'commit', 'reason', 'last-upload']); + expect(catalog.deployment).toBe(deploymentFlags.deployment); + expect(catalog.commit).toBe(deploymentFlags.commit); + expect(catalog.reason).toBe(deploymentFlags.reason); + expect(catalog['last-upload']).toBe(deploymentFlags['last-upload']); + expect(deploymentFlags.reason.required).toBeFalsy(); + expect(deploymentFlags.deployment.required).toBeFalsy(); + expect(deploymentFlags.commit.required).toBeFalsy(); + expect(deploymentFlags['last-upload'].required).toBeFalsy(); + }); - return spec.normalize(value, { - services: services(), - resolved: { org: 'org1', project: 'proj1', env: 'env1' }, - source: 'flag', + it('declares --commit and --last-upload as mutually exclusive on the flag so --help shows it', () => { + expect(deploymentFlags.commit.exclusive).toEqual(['last-upload']); }); -} -describe('deployment inputs', () => { - it('names the one flag it contributes, and declares it optional', () => { - expect(Object.keys(deploymentFlags)).toEqual(['deployment']); - expect((deploymentFlags.deployment as { required?: boolean }).required).toBeFalsy(); + it('reads a bare --commit as the commit picker and still parses the flags after it', async () => { + for (const argv of [ + ['--commit', '--org', ORG], + ['--org', ORG, '--commit'], + ]) { + const resolved = await createResolvedFrom(argv); + + expect(resolved.commit).toBe(PICK_COMMIT); + expect(resolved.org).toBe(ORG); + } }); - it('depends on the organization, project and environment and offers no prompt', () => { - expect(DEPLOYMENT_DEPENDENCIES).toEqual({ deployment: ['org', 'project', 'env'] }); - expect(deploymentResolution.deployment.dependsOn).toEqual(['org', 'project', 'env']); - expect('prompt' in deploymentResolution.deployment).toBe(false); - expect('configPath' in deploymentResolution.deployment).toBe(false); + it('reads a bare --commit as the picker when it is the only flag, leaving org to its own required check', async () => { + const parsed = await Parser.parse(withBareCommit(['--commit']), { flags: { commit: catalog.commit } }); + + expect(parsed.flags.commit).toBe(PICK_COMMIT); + }); + + it('reads a bare --commit followed by --last-upload as two flags, so the exclusive rule refuses them with exit 2', async () => { + const failure = await Parser.parse(withBareCommit(['--commit', '--last-upload']), { + flags: { commit: catalog.commit, 'last-upload': catalog['last-upload'] }, + }).catch((error: Error & { oclif?: { exit?: number } }) => error); + + expect((failure as Error).message).toContain('cannot also be provided when using --commit'); + expect((failure as { oclif?: { exit?: number } }).oclif?.exit).toBe(2); + }); + + it('reads the sha after --commit, trimmed, whichever way it is spelled', async () => { + for (const argv of [ + ['--commit', SHA, '--org', ORG], + [`--commit=${SHA}`, '--org', ORG], + ['--commit', ` ${SHA} `, '--org', ORG], + ]) { + const resolved = await createResolvedFrom(argv); + + expect(resolved.commit).toBe(SHA); + expect(resolved.org).toBe(ORG); + } + }); + + it.each([[''], [' '], ['\t']])('refuses --commit %p with exit 2 naming --commit , never a branch-tip deploy', async (blank) => { + for (const argv of [ + ['--commit', blank, '--org', ORG], + [`--commit=${blank}`, '--org', ORG], + ]) { + const failure = await createResolvedFrom(argv).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toContain('--commit '); + } + }); + + it('leaves --commit absent and --last-upload false when neither is passed', async () => { + const resolved = await createResolvedFrom(['--org', ORG]); + + expect(resolved.commit).toBeUndefined(); + expect(resolved['last-upload']).toBe(false); + }); + + it('reads --last-upload as true, and the sentinels contain a NUL no user can type on a command line', async () => { + const resolved = await createResolvedFrom(['--last-upload', '--org', ORG]); + + expect(resolved['last-upload']).toBe(true); + expect(PICK_COMMIT).toContain('\0'); + expect(BLANK_COMMIT).toContain('\0'); + expect(PICK_COMMIT).not.toBe(BLANK_COMMIT); }); - it.each([[LOWER], [UPPER]])('accepts the 24-hex uid %s unchanged', async (uid) => { - await expect(normalize(uid)).resolves.toBe(uid); + it('stops looking for --commit after the -- terminator and leaves other tokens untouched', () => { + expect(withBareCommit(['--org', ORG, '--', '--commit'])).toEqual(['--org', ORG, '--', '--commit']); + expect(withBareCommit(['--org', ORG])).toEqual(['--org', ORG]); }); + it('keeps a deployment uid, trimmed of the whitespace around it', async () => { + const resolved = await resolvedFrom(['--deployment', ' d1e2f3a4b5c6d7e8f9012345 ']); + + expect(resolved.deployment).toBe('d1e2f3a4b5c6d7e8f9012345'); + }); + + it.each([[''], [' '], ['\t']])('reads --deployment %p as absent so the latest lookup applies', async (blank) => { + const resolved = await resolvedFrom(['--deployment', blank]); + + expect(resolved.deployment).toBeUndefined(); + }); + + it('leaves the value absent when the flag is not passed', async () => { + const resolved = await resolvedFrom([]); + + expect(resolved.deployment).toBeUndefined(); + }); + + it.each([['D1E2F3A4B5C6D7E8F9012345'], [' d1E2f3a4b5c6d7e8f9012345\t']])( + 'accepts %p as a deployment uid whatever its case', + async (value) => { + const resolved = await resolvedFrom(['--deployment', value]); + + expect(resolved.deployment).toBe(value.trim()); + }, + ); + it.each([ - ['a 23-character value', LOWER.slice(0, 23)], - ['a 25-character value', `${LOWER}d`], - ['a non-hex value of 24 characters', `g${LOWER.slice(1)}`], - ['a name', 'my-deploy'], - ['an empty value', ''], - ['a uid with surrounding spaces', ` ${LOWER} `], - ])('refuses %s with a usage error naming --deployment', async (_label, value) => { - const refusal = (await normalize(value).catch((thrown: unknown) => thrown)) as UsageError; + ['6accca6d34'], + ['d1e2f3a4b5c6d7e8f901234'], + ['d1e2f3a4b5c6d7e8f90123456'], + ['g1e2f3a4b5c6d7e8f9012345'], + ['d1e2f3a4b5c6d7e8f9012345\nd1e2f3a4b5c6d7e8f9012345'], + ['12 byte str'], + ])('refuses %p as a usage error before anything is sent', async (value) => { + const failure = await resolvedFrom(['--deployment', value]).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toContain( + `Invalid --deployment value "${value}". Provide a 24-character hexadecimal deployment UID.`, + ); + }); - expect(refusal).toBeInstanceOf(UsageError); - expect(refusal.exitCode).toBe(EXIT_USAGE); - expect(refusal.message).toBe(`--deployment must be a 24-character deployment UID; "${value}" is not one.`); + it('depends on the organization, project and environment, so the picker has a scope to list', () => { + expect(deploymentResolution.deployment.dependsOn).toEqual(['org', 'project', 'env']); + expect(DEPLOYMENT_DEPENDENCIES).toEqual({ deployment: ['org', 'project', 'env'] }); }); + it.each([ + [{ required: true, picker: 'any' }, true], + [{ required: true, picker: 'in-flight' }, true], + [{}, false], + [{ required: true }, false], + ])('prompts for a deployment declared as %p: %p', (input, prompts) => { + expect(deploymentResolution.deployment.promptWhen(input)).toBe(prompts); + }); +}); + +describe('deployment flag at parse time', () => { + const UID = randomBytes(12).toString('hex'); + it('refuses a value that is not a uid while the arguments are parsed, before anything is resolved', async () => { const refusal = (await Parser.parse(['--deployment', 'not-a-uid'], { flags: deploymentFlags }).catch( (thrown: unknown) => thrown, )) as UsageError; expect(refusal).toBeInstanceOf(UsageError); - expect(refusal.exitCode).toBe(EXIT_USAGE); - expect(refusal.message).toContain('--deployment'); + expect(refusal.exitCode).toBe(2); + expect(refusal.message).toContain( + 'Invalid --deployment value "not-a-uid". Provide a 24-character hexadecimal deployment UID.', + ); + }); + + it.each([[UID], [UID.toUpperCase()], [` ${UID} `]])('parses %p from argv as the trimmed uid', async (value) => { + const parsed = await Parser.parse(['--deployment', value], { flags: deploymentFlags }); + + expect(parsed.flags.deployment).toBe(value.trim()); }); - it('parses a 24-hex uid from argv untouched', async () => { - const parsed = await Parser.parse(['--deployment', UPPER], { flags: deploymentFlags }); + it.each([[''], [' ']])('lets the blank value %p through the parser so it reads as absent', async (value) => { + const parsed = await Parser.parse(['--deployment', value], { flags: deploymentFlags }); - expect(parsed.flags.deployment).toBe(UPPER); + expect(parsed.flags.deployment).toBe(value); }); }); diff --git a/src/deployments/deployment.inputs.ts b/src/deployments/deployment.inputs.ts index f97aebcf..9850a77a 100644 --- a/src/deployments/deployment.inputs.ts +++ b/src/deployments/deployment.inputs.ts @@ -1,29 +1,119 @@ import { Flags } from '@contentstack/cli-utilities'; -import { UsageError } from '../core/errors'; -import { UID_PATTERN } from '../core/resource-ref'; +import { FlagValueError, UsageError } from '../core/errors'; import type { ResolutionSpec } from '../core/resolution'; +import { UID_PATTERN } from '../core/resource-ref'; +import { promptForDeployment } from './deployment.prompt'; + +export const PICK_COMMIT = '\0pick-commit'; + +export const BLANK_COMMIT = '\0blank-commit'; + +const COMMIT_FLAG = '--commit'; + +const FLAG_TERMINATOR = '--'; + +function isBlank(value: string): boolean { + return value.trim() === ''; +} + +function looksLikeFlag(value: string): boolean { + return value.startsWith('-') && value !== '-'; +} + +function commitToken(value: string): string { + return `${COMMIT_FLAG}=${value}`; +} + +export function withBareCommit(argv: readonly string[]): string[] { + const rewritten: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (token === FLAG_TERMINATOR) { + rewritten.push(...argv.slice(index)); + break; + } + + if (token.startsWith(`${COMMIT_FLAG}=`)) { + const value = token.slice(COMMIT_FLAG.length + 1); + + rewritten.push(isBlank(value) ? commitToken(BLANK_COMMIT) : token); + continue; + } + + if (token !== COMMIT_FLAG) { + rewritten.push(token); + continue; + } + + const next = argv[index + 1]; + + if (next === undefined || looksLikeFlag(next)) { + rewritten.push(commitToken(PICK_COMMIT)); + } else if (isBlank(next)) { + rewritten.push(commitToken(BLANK_COMMIT)); + index += 1; + } else { + rewritten.push(token); + } + } + + return rewritten; +} export const DEPLOYMENT_DEPENDENCIES = { deployment: ['org', 'project', 'env'] } as const; function deploymentUidOf(value: string): string { - if (!UID_PATTERN.test(value)) { - throw new UsageError(`--deployment must be a 24-character deployment UID; "${value}" is not one.`); + const uid = value.trim(); + + if (!UID_PATTERN.test(uid)) { + throw new FlagValueError(`Invalid --deployment value "${uid}". Provide a 24-character hexadecimal deployment UID.`); } - return value; + return uid; } export const deploymentFlags = { deployment: Flags.string({ description: 'Deployment UID', - parse: async (input) => deploymentUidOf(input), + parse: async (input) => (input.trim() === '' ? input : deploymentUidOf(input)), + }), + commit: Flags.string({ + description: + 'GitHub projects: deploy this commit. Pass --commit with no value to pick one from the recent commits', + exclusive: ['last-upload'], + }), + reason: Flags.string({ description: 'Why the environment is being rolled back, recorded with the rollback' }), + 'last-upload': Flags.boolean({ + description: 'FileUpload projects: redeploy the last uploaded bundle without uploading a new one', }), }; export const deploymentResolution = { deployment: { dependsOn: DEPLOYMENT_DEPENDENCIES.deployment, + promptWhen: (input) => input.picker !== undefined, + prompt: ({ input, services, resolved }) => + promptForDeployment( + services, + { org: resolved.org, project: resolved.project, environment: resolved.env }, + input.picker, + ), normalize: async (value) => deploymentUidOf(value), } satisfies ResolutionSpec, + commit: { + normalize: async (value) => { + if (value === BLANK_COMMIT) { + throw new UsageError('--commit needs a commit hash. Pass --commit .'); + } + + return value.trim(); + }, + } satisfies ResolutionSpec, + reason: { + normalize: async (value) => value.trim(), + } satisfies ResolutionSpec, + 'last-upload': { default: false } satisfies ResolutionSpec, }; diff --git a/src/deployments/deployment.presenter.test.ts b/src/deployments/deployment.presenter.test.ts index 476abda0..771b4ced 100644 --- a/src/deployments/deployment.presenter.test.ts +++ b/src/deployments/deployment.presenter.test.ts @@ -4,13 +4,23 @@ import { colouredLogMessage, firstDeploymentFailureMessage, deploymentLogLine, - deploymentLogsUnavailableLine, + deploymentCancelQuestion, + deploymentCancelledLine, + deploymentColumns, + deploymentNotCancelledLine, + deploymentNotRolledBackLine, + deploymentRollbackQuestion, + rolledBackLine, + deploymentDetailRows, + deploymentVariablesSummary, deploymentUrlLine, deploymentUrlOf, wrapLogMessage, } from './deployment.presenter'; const UID = randomBytes(12).toString('hex'); +const HINT_SCOPE = '--org org1 --project proj1 --env env1'; +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; const ESCAPE = '\u001b'; const GREEN = '\u001b[38;2;34;139;34m'; @@ -18,8 +28,8 @@ const RESET = '\u001b[0m'; describe('deployment presenter', () => { it.each([ - [true, '\u001b[1mDeployment URL\u001b[22m \u001b[36mhttps://site.example.test\u001b[39m'], - [false, 'Deployment URL https://site.example.test'], + [true, '\u001b[1mDeployment URL:\u001b[22m \u001b[36mhttps://site.example.test\u001b[39m'], + [false, 'Deployment URL: https://site.example.test'], ])('labels the deployment url in bold and the url in cyan only on a terminal (terminal: %s)', (outputIsTTY, line) => { expect(deploymentUrlLine('https://site.example.test', outputIsTTY)).toBe(line); }); @@ -139,28 +149,27 @@ describe('deployment presenter', () => { '2026-09-25 10:00:03.000: a message that is well past any narrow terminal', ); }); - - it('names what failed when a log fetch rejects with something other than an Error', () => { - expect(deploymentLogsUnavailableLine('socket hang up')).toBe( - ' ! Could not read the deployment logs (socket hang up). Still waiting on the deployment.', - ); - }); }); describe('firstDeploymentFailureMessage', () => { const ENVIRONMENT = { created: 'The environment "staging" (env9)', plural: false }; const SCOPE = { org: 'org1', project: 'proj1', environmentUid: 'env9' }; - it('names the status, what survived and both follow-up commands for a failed deployment', () => { + it('names the deployment, its status and the logs command for a failed deployment', () => { + expect(firstDeploymentFailureMessage({ ...SCOPE, deploymentUid: 'dep1', status: 'FAILED' })).toBe( + 'Deployment "dep1" ended with status FAILED. ' + + 'Run csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see the cause.', + ); + }); + + it('adds what survived a failure, in the singular', () => { expect(firstDeploymentFailureMessage({ ...ENVIRONMENT, ...SCOPE, deploymentUid: 'dep1', status: 'FAILED' })).toBe( - 'The deployment did not succeed; its last status was FAILED. ' + - 'The environment "staging" (env9) was created and has not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see why it did not succeed.', + 'Deployment "dep1" ended with status FAILED. The environment "staging" (env9) was created. ' + + 'Run csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see the cause.', ); }); - it('agrees the verbs with a plural survivor and leaves out the scope it does not know', () => { + it('agrees the verb with a plural survivor and leaves out the scope it does not know', () => { expect( firstDeploymentFailureMessage({ created: 'The project "site" (p1) and its environment "Default"', @@ -172,23 +181,221 @@ describe('firstDeploymentFailureMessage', () => { }), ).toBe( 'The deployment could not be followed to completion: Lookup refused. ' + - 'The project "site" (p1) and its environment "Default" were created and have not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project p1 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project p1 to see why it did not succeed.', + 'The project "site" (p1) and its environment "Default" were created. ' + + 'Run csdx launch:deployments:get --org org1 --project p1 to check its status.', + ); + }); + + it('words a deployment that could not be followed, with its uid', () => { + expect(firstDeploymentFailureMessage({ ...SCOPE, deploymentUid: 'dep1', status: 'NONE', reason: 'Request timed out.' })).toBe( + 'Deployment "dep1" could not be followed to completion: Request timed out. ' + + 'Run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to check its status.', + ); + }); + + it('says no deployment was found when there is neither a uid nor a reason', () => { + expect(firstDeploymentFailureMessage({ ...ENVIRONMENT, ...SCOPE, status: 'NONE' })).toBe( + 'No deployment was found to follow. The environment "staging" (env9) was created. ' + + 'Run csdx launch:deployments:list --org org1 --project proj1 --env env9 to check.', ); }); it.each([ [false, 'The environment "staging" (env9) was created.'], [true, 'The environment "staging" (env9) were created.'], - ])('words a wait that ran out as possibly still finishing (plural %p)', (plural, created) => { + ])('words a wait that ran out as still in progress (plural %p)', (plural, created) => { expect( firstDeploymentFailureMessage({ ...ENVIRONMENT, plural, ...SCOPE, deploymentUid: 'dep1', status: 'DEPLOYING', timedOut: true }), ).toBe( - 'The deployment was still DEPLOYING when the CLI stopped waiting for it; it may still finish. ' + - `${created} Do not start another deployment yet: ` + - 'run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to see how it ends, ' + - 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to follow it.', + 'Deployment "dep1" was still DEPLOYING when the CLI stopped waiting. ' + + `${created} ` + + 'Run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to check its status.', ); }); + + it('never mentions a rollback', () => { + const message = firstDeploymentFailureMessage({ ...ENVIRONMENT, ...SCOPE, deploymentUid: 'dep1', status: 'FAILED' }); + + expect(message).not.toMatch(/rolled back|roll back/i); + }); +}); + +const STATUSES = ['QUEUED', 'LIVE', 'DEPLOYED', 'ARCHIVED', 'DEPLOYING', 'SKIPPED', 'FAILED', 'CANCELLED', 'PROMOTING']; + +function rowOf(rows: { field: string; value: string }[], field: string): string | undefined { + return rows.find((row) => row.field === field)?.value; +} + +function cellsOf(deployment: object, header: string): string { + const column = deploymentColumns(true).find((candidate) => candidate.header === header); + + return column?.value(deployment) ?? 'no such column'; +} + +describe('deployment table presenter', () => { + it.each(STATUSES)('writes the status %s verbatim in the list cell and the get row', (status) => { + expect(cellsOf({ uid: UID, status }, 'STATUS')).toBe(status); + expect(rowOf(deploymentDetailRows({ uid: UID, status }, true, SCOPE), 'STATUS')).toBe(status); + }); + + it.each([[undefined], [null], [''], [' ']])('shows a dash for a status of %p', (status) => { + expect(cellsOf({ uid: UID, status }, 'STATUS')).toBe('-'); + expect(rowOf(deploymentDetailRows({ uid: UID, status } as never, true, SCOPE), 'STATUS')).toBe('-'); + }); + + it('labels the id DEPLOYMENT UID in the list and the get view, never a bare UID', () => { + const headers = deploymentColumns(true).map((column) => column.header); + const fields = deploymentDetailRows({ uid: UID }, true, SCOPE).map((row) => row.field); + + expect(headers[0]).toBe('DEPLOYMENT UID'); + expect(fields[0]).toBe('DEPLOYMENT UID'); + expect([...headers, ...fields]).not.toContain('UID'); + }); + + it('shows the git columns and rows for a git project and none of them for a file-upload project', () => { + const deployment = { uid: UID, gitBranch: 'main', commitHash: 'abcdef0123456789', commitMessage: 'Ship it' }; + const gitHeaders = deploymentColumns(true).map((column) => column.header); + const plainHeaders = deploymentColumns(false).map((column) => column.header); + const gitRows = deploymentDetailRows(deployment, true, SCOPE); + const plainRows = deploymentDetailRows(deployment, false, SCOPE); + + expect(gitHeaders).toEqual(['DEPLOYMENT UID', 'STATUS', 'COMMIT', 'UPDATED', 'BRANCH']); + expect(plainHeaders).toEqual(['DEPLOYMENT UID', 'STATUS', 'UPDATED']); + expect(rowOf(gitRows, 'BRANCH')).toBe('main'); + expect(rowOf(gitRows, 'COMMIT')).toBe('abcdef0123456789'); + expect(rowOf(gitRows, 'COMMIT MESSAGE')).toBe('Ship it'); + expect(plainRows.map((row) => row.field)).not.toContain('BRANCH'); + expect(plainRows.map((row) => row.field)).not.toContain('COMMIT'); + expect(plainRows.map((row) => row.field)).not.toContain('COMMIT MESSAGE'); + }); + + it('shortens the commit in the list to seven characters and leaves a shorter value whole', () => { + expect(cellsOf({ uid: UID, commitHash: 'abcdef0123456789' }, 'COMMIT')).toBe('abcdef0'); + expect(cellsOf({ uid: UID, commitHash: 'abcdefg' }, 'COMMIT')).toBe('abcdefg'); + expect(cellsOf({ uid: UID, commitHash: 'abc' }, 'COMMIT')).toBe('abc'); + expect(cellsOf({ uid: UID }, 'COMMIT')).toBe('-'); + }); + + it('counts environment variables without showing a key or a value and points at variables:list', () => { + const keys = [0, 1, 2].map(() => randomBytes(6).toString('hex')); + const values = [0, 1, 2].map(() => randomBytes(6).toString('hex')); + const deployment = { + uid: UID, + environmentVariables: keys.map((key, index) => ({ key, value: values[index] })), + }; + + const rows = deploymentDetailRows(deployment, true, SCOPE); + const shown = JSON.stringify(rows); + + expect(rowOf(rows, 'ENVIRONMENT VARIABLES')).toBe(`3 (list them with csdx launch:variables:list ${HINT_SCOPE})`); + for (const secret of [...keys, ...values]) { + expect(shown).not.toContain(secret); + } + }); + + it.each([[null], [[]], [undefined]])('counts %p environment variables as 0 without throwing', (variables) => { + const deployment = { uid: UID, environmentVariables: variables } as never; + + expect(deploymentVariablesSummary(deployment, SCOPE)).toBe(`0 (list them with csdx launch:variables:list ${HINT_SCOPE})`); + expect(() => deploymentDetailRows(deployment, true, SCOPE)).not.toThrow(); + }); + + it('shows no build logs row, because the API has no build logs field and the logs command is not a deployment view', () => { + const fields = deploymentDetailRows({ uid: 'dep9' }, true, SCOPE).map((row) => row.field); + + expect(fields).toEqual([ + 'DEPLOYMENT UID', + 'STATUS', + 'BRANCH', + 'COMMIT', + 'COMMIT MESSAGE', + 'URL', + 'ENVIRONMENT VARIABLES', + 'CREATED AT', + 'UPDATED AT', + ]); + expect(JSON.stringify(deploymentDetailRows({ uid: 'dep9' }, true, SCOPE))).not.toContain('launch:logs:get'); + }); + + it.each([ + ['a subject and a trailer', 'Ship it\n\nCo-Authored-By: Claude <[noreply@anthropic.com](mailto:noreply@anthropic.com)>', 'Ship it'], + ['windows line endings', 'Ship it\r\nbody', 'Ship it'], + ['a leading blank line', '\n\n Ship it \nbody', 'Ship it'], + ['a tab inside the subject', 'Ship\tit', 'Ship it'], + ['only whitespace', ' \n\t\n ', '-'], + ['an empty message', '', '-'], + ])('shows only the first line of a commit message holding %s', (_label, message, expected) => { + const rows = deploymentDetailRows({ uid: UID, commitMessage: message }, true, SCOPE); + + expect(rowOf(rows, 'COMMIT MESSAGE')).toBe(expected); + }); + + it('truncates a long commit subject to seventy-two characters with an ellipsis and leaves seventy-two whole', () => { + const exact = 'x'.repeat(72); + const over = 'x'.repeat(73); + + expect(rowOf(deploymentDetailRows({ uid: UID, commitMessage: exact }, true, SCOPE), 'COMMIT MESSAGE')).toBe(exact); + expect(rowOf(deploymentDetailRows({ uid: UID, commitMessage: over }, true, SCOPE), 'COMMIT MESSAGE')).toBe( + `${'x'.repeat(71)}\u2026`, + ); + }); + + it('shows a dash for a field made only of control characters', () => { + expect(rowOf(deploymentDetailRows({ uid: UID, gitBranch: '\u0007\u001b[31m' }, true, SCOPE), 'BRANCH')).toBe('-'); + }); + + it('never lets a control character or a line break into any cell of the list or the get view', () => { + const hostile = 'a\nb\rc\td\u001b[31me\u0007f\u0085g'; + const deployment = { + uid: hostile, + status: hostile, + commitHash: hostile, + commitMessage: hostile, + gitBranch: hostile, + deploymentUrl: hostile, + createdAt: hostile, + updatedAt: hostile, + }; + const cells = [ + ...deploymentColumns(true).map((column) => column.value(deployment)), + ...deploymentDetailRows(deployment, true, SCOPE).flatMap((row) => [row.field, row.value]), + ]; + + for (const cell of cells) { + expect(cell).not.toMatch(/\p{Cc}/u); + } + }); + + it('falls back to a dash for every absent field and prefers the deployment url over the preview url', () => { + const rows = deploymentDetailRows({ uid: UID }, true, SCOPE); + + for (const field of ['BRANCH', 'COMMIT', 'COMMIT MESSAGE', 'URL', 'CREATED AT', 'UPDATED AT']) { + expect(rowOf(rows, field)).toBe('-'); + } + + expect( + rowOf(deploymentDetailRows({ uid: UID, deploymentUrl: 'a.example.test', previewUrl: 'b.example.test' }, true, SCOPE), 'URL'), + ).toBe('https://a.example.test'); + }); +}); + +describe('rollback and cancel wording', () => { + it('words the confirm questions with the deployment uid', () => { + expect(deploymentRollbackQuestion(UID)).toBe(`Are you sure you want to roll back this environment to deployment "${UID}"?`); + expect(deploymentCancelQuestion(UID)).toBe(`Are you sure you want to cancel deployment "${UID}"?`); + }); + + it('prints the success lines in green on a terminal and as exact plain text otherwise', () => { + expect(rolledBackLine(UID, true)).toBe(`\u001b[32m✔ Environment rolled back to deployment "${UID}" successfully.\u001b[39m`); + expect(rolledBackLine(UID, false)).toBe(`✔ Environment rolled back to deployment "${UID}" successfully.`); + expect(deploymentCancelledLine(UID, true)).toBe(`\u001b[32m✔ Deployment "${UID}" cancelled successfully.\u001b[39m`); + expect(deploymentCancelledLine(UID, false)).toBe(`✔ Deployment "${UID}" cancelled successfully.`); + }); + + it('prints the decline lines in yellow on a terminal and plain otherwise', () => { + expect(deploymentNotRolledBackLine(true)).toBe('\u001b[33mEnvironment not rolled back.\u001b[39m'); + expect(deploymentNotRolledBackLine(false)).toBe('Environment not rolled back.'); + expect(deploymentNotCancelledLine(true)).toBe('\u001b[33mDeployment not cancelled.\u001b[39m'); + expect(deploymentNotCancelledLine(false)).toBe('Deployment not cancelled.'); + }); }); diff --git a/src/deployments/deployment.presenter.ts b/src/deployments/deployment.presenter.ts index 6e695a30..83683976 100644 --- a/src/deployments/deployment.presenter.ts +++ b/src/deployments/deployment.presenter.ts @@ -1,7 +1,9 @@ import { stripVTControlCharacters } from 'node:util'; +import type { TableColumn } from '../core/render'; import { styled } from '../core/style'; -import { messageOf } from '../core/values'; +import { isAbsent, withoutControlCharacters } from '../core/values'; +import type { DeploymentScope } from './deployments.api'; import type { Deployment, DeploymentLog } from './types'; const GUTTER = ' '; @@ -28,7 +30,7 @@ export function deploymentUrlOf(deployment: Deployment): string | undefined { } export function deploymentUrlLine(url: string, outputIsTTY: boolean): string { - return `${styled('Deployment URL', 'bold', outputIsTTY)} ${styled(url, 'cyan', outputIsTTY)}`; + return `${styled('Deployment URL:', 'bold', outputIsTTY)} ${styled(url, 'cyan', outputIsTTY)}`; } export function colouredLogMessage(message: string): string { @@ -135,15 +137,9 @@ export function deploymentLogLine(log: DeploymentLog, outputIsTTY: boolean, colu .join('\n'); } -export function deploymentLogsUnavailableLine(error: unknown): string { - const reason = messageOf(error); - - return ` ! Could not read the deployment logs (${reason}). Still waiting on the deployment.`; -} - export interface FirstDeploymentFailure { - created: string; - plural: boolean; + created?: string; + plural?: boolean; org: string; project: string; status: string; @@ -153,30 +149,205 @@ export interface FirstDeploymentFailure { timedOut?: boolean; } +function createdSentence(failure: FirstDeploymentFailure): string { + if (failure.created === undefined) { + return ''; + } + + return ` ${failure.created} ${failure.plural === true ? 'were' : 'was'} created.`; +} + export function firstDeploymentFailureMessage(failure: FirstDeploymentFailure): string { const scope = `--org ${failure.org} --project ${failure.project}`; const environment = failure.environmentUid === undefined ? '' : ` --env ${failure.environmentUid}`; const deployment = failure.deploymentUid === undefined ? '' : ` --deployment ${failure.deploymentUid}`; - const were = failure.plural ? 'were' : 'was'; + const subject = failure.deploymentUid === undefined ? 'The deployment' : `Deployment "${failure.deploymentUid}"`; + const created = createdSentence(failure); + + if (failure.deploymentUid === undefined && failure.reason === undefined && failure.timedOut !== true) { + return ( + `No deployment was found to follow.${created} ` + + `Run csdx launch:deployments:list ${scope}${environment} to check.` + ); + } if (failure.timedOut === true) { return ( - `The deployment was still ${failure.status} when the CLI stopped waiting for it; it may still finish. ` + - `${failure.created} ${were} created. Do not start another deployment yet: ` + - `run csdx launch:deployments:get ${scope}${environment}${deployment} to see how it ends, ` + - `or csdx launch:logs:get ${scope}${environment}${deployment} to follow it.` + `${subject} was still ${failure.status} when the CLI stopped waiting.${created} ` + + `Run csdx launch:deployments:get ${scope}${environment}${deployment} to check its status.` ); } - const opening = - failure.reason === undefined - ? `The deployment did not succeed; its last status was ${failure.status}. ` - : `The deployment could not be followed to completion: ${failure.reason} `; + if (failure.reason !== undefined) { + return ( + `${subject} could not be followed to completion: ${failure.reason}${created} ` + + `Run csdx launch:deployments:get ${scope}${environment}${deployment} to check its status.` + ); + } return ( - opening + - `${failure.created} ${were} created and ${failure.plural ? 'have' : 'has'} not been rolled back. ` + - `Run csdx launch:deployments:create ${scope}${environment} to try the deployment again, ` + - `or csdx launch:logs:get ${scope}${environment}${deployment} to see why it did not succeed.` + `${subject} ended with status ${failure.status}.${created} ` + + `Run csdx launch:logs:get ${scope}${environment}${deployment} to see the cause.` ); } + +export function rollbackStartedLine(uid: string): string { + return `Rollback to deployment "${uid}" started.`; +} + +export interface RollbackFollowUp { + org: string; + project: string; + environment: string; + deployment: string; +} + +function checkRollbackCommand(followUp: RollbackFollowUp): string { + return ( + `csdx launch:deployments:get --org ${followUp.org} --project ${followUp.project} ` + + `--env ${followUp.environment} --deployment ${followUp.deployment}` + ); +} + +export function rollbackInterruptedMessage(followUp: RollbackFollowUp): string { + return ( + `Stopped waiting. The rollback to deployment "${followUp.deployment}" was not cancelled and continues on Launch. ` + + `Check it with ${checkRollbackCommand(followUp)}.` + ); +} + +export function rollbackUnconfirmedMessage( + followUp: RollbackFollowUp, + result: { status: string; reason?: string }, +): string { + const check = `Run ${checkRollbackCommand(followUp)} to check whether the rollback completed.`; + + if (result.reason !== undefined) { + return `The rollback to deployment "${followUp.deployment}" could not be confirmed: ${result.reason} ${check}`; + } + + return `Deployment "${followUp.deployment}" was still ${result.status} when the CLI stopped waiting. ${check}`; +} + +export function deploymentRollbackQuestion(uid: string): string { + return `Are you sure you want to roll back this environment to deployment "${uid}"?`; +} + +export function deploymentCancelQuestion(uid: string): string { + return `Are you sure you want to cancel deployment "${uid}"?`; +} + +export function rolledBackLine(uid: string, outputIsTTY: boolean): string { + return styled(`\u2714 Environment rolled back to deployment "${uid}" successfully.`, 'green', outputIsTTY); +} + +export function deploymentCancelledLine(uid: string, outputIsTTY: boolean): string { + return styled(`\u2714 Deployment "${uid}" cancelled successfully.`, 'green', outputIsTTY); +} + +export function deploymentNotRolledBackLine(outputIsTTY: boolean): string { + return styled('Environment not rolled back.', 'yellow', outputIsTTY); +} + +export function deploymentNotCancelledLine(outputIsTTY: boolean): string { + return styled('Deployment not cancelled.', 'yellow', outputIsTTY); +} + +export interface DeploymentDetailRow { + field: string; + value: string; +} + +const SHORT_COMMIT_LENGTH = 7; + +const SUBJECT_MAX_LENGTH = 72; + +function singleLine(value: string): string { + return withoutControlCharacters(value).trim(); +} + +function text(value: string | undefined | null): string { + if (value === undefined || value === null || isAbsent(value)) { + return '-'; + } + + const line = singleLine(value); + + return line === '' ? '-' : line; +} + +function commitSubject(message: string | undefined | null): string { + const subject = (message ?? '') + .split(/\r\n|[\n\r]/) + .map(singleLine) + .find((line) => line !== ''); + + if (subject === undefined) { + return '-'; + } + + const units = [...subject]; + + return units.length > SUBJECT_MAX_LENGTH ? `${units.slice(0, SUBJECT_MAX_LENGTH - 1).join('')}\u2026` : subject; +} + +function shortCommit(value: string | undefined): string { + const hash = text(value); + + return hash.length > SHORT_COMMIT_LENGTH ? hash.slice(0, SHORT_COMMIT_LENGTH) : hash; +} + +export function deploymentColumns(showGit: boolean): TableColumn[] { + const columns: TableColumn[] = [ + { header: 'DEPLOYMENT UID', value: (deployment) => text(deployment.uid) }, + { header: 'STATUS', value: (deployment) => text(deployment.status) }, + ]; + + if (showGit) { + columns.push({ header: 'COMMIT', value: (deployment) => shortCommit(deployment.commitHash) }); + } + + columns.push({ header: 'UPDATED', value: (deployment) => text(deployment.updatedAt) }); + + if (showGit) { + columns.push({ header: 'BRANCH', value: (deployment) => text(deployment.gitBranch) }); + } + + return columns; +} + +export const DEPLOYMENT_DETAIL_COLUMNS: TableColumn[] = [ + { header: 'FIELD', value: (row) => row.field }, + { header: 'VALUE', value: (row) => row.value }, +]; + +export const DEPLOYMENT_VALUE_COLUMN = 1; + +export function deploymentVariablesSummary(deployment: Deployment, scope: DeploymentScope): string { + const count = Array.isArray(deployment.environmentVariables) ? deployment.environmentVariables.length : 0; + + const flags = `--org ${scope.org} --project ${scope.project} --env ${scope.environment}`; + + return `${count} (list them with csdx launch:variables:list ${flags})`; +} + +export function deploymentDetailRows( + deployment: Deployment, + showGit: boolean, + scope: DeploymentScope, +): DeploymentDetailRow[] { + const gitRows: DeploymentDetailRow[] = [ + { field: 'BRANCH', value: text(deployment.gitBranch) }, + { field: 'COMMIT', value: text(deployment.commitHash) }, + { field: 'COMMIT MESSAGE', value: commitSubject(deployment.commitMessage) }, + ]; + return [ + { field: 'DEPLOYMENT UID', value: text(deployment.uid) }, + { field: 'STATUS', value: text(deployment.status) }, + ...(showGit ? gitRows : []), + { field: 'URL', value: text(deploymentUrlOf(deployment)) }, + { field: 'ENVIRONMENT VARIABLES', value: deploymentVariablesSummary(deployment, scope) }, + { field: 'CREATED AT', value: text(deployment.createdAt) }, + { field: 'UPDATED AT', value: text(deployment.updatedAt) }, + ]; +} diff --git a/src/deployments/deployment.prompt.test.ts b/src/deployments/deployment.prompt.test.ts new file mode 100644 index 00000000..e39b871f --- /dev/null +++ b/src/deployments/deployment.prompt.test.ts @@ -0,0 +1,283 @@ +import { CancelledError, UsageError } from '../core/errors'; +import type { UxLike } from '../core/prompt'; +import type { ApiSurface } from '../resources'; +import { ALL_DEPLOYMENTS, IN_FLIGHT_DEPLOYMENTS, deploymentChoice, promptForDeployment } from './deployment.prompt'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; +const UID_A = 'a1b2c3d4e5f60718293a4b5c'; +const UID_B = 'b1b2c3d4e5f60718293a4b5c'; +const UID_C = 'c1b2c3d4e5f60718293a4b5c'; +const UID_D = 'd1b2c3d4e5f60718293a4b5c'; + +function deps(answers: unknown[], page: unknown) { + const asked: unknown[] = []; + const printed: string[] = []; + const requested: unknown[] = []; + let index = 0; + + const ux: UxLike = { + print: (message: string) => { + printed.push(message); + }, + inquire: async (payload: unknown) => { + asked.push(payload); + const answer = answers[index]; + index += 1; + return answer as never; + }, + }; + const api = { + deployments: { + list: async (params: unknown) => { + requested.push(params); + return page; + }, + }, + } as unknown as ApiSurface; + + return { deps: { api, ux }, asked, printed, requested }; +} + +const PAGE = { + pagination: { count: 4, limit: 100 }, + deployments: [ + { uid: UID_A, status: 'DEPLOYING', commitHash: '9fceb02d0ae598e95dc970b74767f19372d61af8' }, + { uid: UID_B, status: 'LIVE', createdAt: '2026-05-01T10:00:00.000Z' }, + { uid: UID_C, status: 'QUEUED' }, + { uid: UID_D, status: 'FAILED', commitHash: '1234567890' }, + ], +}; + +describe('promptForDeployment', () => { + it('offers the rollback-eligible deployments newest first, without the live one, as one list of full uids', async () => { + const { deps: d, asked, printed, requested } = deps([UID_D], PAGE); + + await expect(promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS)).resolves.toBe(UID_D); + expect(requested).toEqual([{ ...SCOPE, limit: 100, skip: 0, rollbackEligible: true }]); + expect(asked).toEqual([ + { + type: 'list', + name: 'value', + message: 'Choose a deployment for rollback', + choices: [ + { name: `${UID_A} DEPLOYING 9fceb02`, value: UID_A }, + { name: `${UID_C} QUEUED`, value: UID_C }, + { name: `${UID_D} FAILED 1234567`, value: UID_D }, + ], + default: undefined, + loop: false, + pageSize: 3, + }, + ]); + expect(printed).toEqual([]); + }); + + it('offers only the deployments in flight for a cancel, and words the question for it', async () => { + const { deps: d, asked, requested } = deps([UID_C], PAGE); + + await expect(promptForDeployment(d, SCOPE, IN_FLIGHT_DEPLOYMENTS)).resolves.toBe(UID_C); + expect(requested).toEqual([{ ...SCOPE, limit: 100, skip: 0 }]); + expect(Object.keys(requested[0] as object)).not.toContain('rollbackEligible'); + expect((asked[0] as { choices: unknown }).choices).toEqual([ + { name: `${UID_A} DEPLOYING 9fceb02`, value: UID_A }, + { name: `${UID_C} QUEUED`, value: UID_C }, + ]); + expect((asked[0] as { message: string }).message).toBe('Choose a deployment for cancellation'); + }); + + it('leaves out a deployment the server marks not cancellable, and keeps one that does not say', async () => { + const { deps: d, asked } = deps([UID_C], { + pagination: { count: 4 }, + deployments: [ + { uid: UID_A, status: 'DEPLOYING', isCancellable: false }, + { uid: UID_B, status: 'DEPLOYING', isCancellable: true }, + { uid: UID_C, status: 'QUEUED' }, + { uid: UID_D, status: 'QUEUED', isCancellable: null }, + ], + }); + + await promptForDeployment(d, SCOPE, IN_FLIGHT_DEPLOYMENTS); + + expect((asked[0] as { choices: { value: string }[] }).choices.map((choice) => choice.value)).toEqual([ + UID_B, + UID_C, + UID_D, + ]); + }); + + it('refuses a cancel with exit 2 when every in-flight deployment is past the point of cancelling', async () => { + const { deps: d, asked } = deps([UID_A], { + pagination: { count: 1 }, + deployments: [{ uid: UID_A, status: 'DEPLOYING', isCancellable: false }], + }); + + const failure = await promptForDeployment(d, SCOPE, IN_FLIGHT_DEPLOYMENTS).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as Error).message).toBe('No in-progress deployments are available for cancellation.'); + expect(asked).toEqual([]); + }); + + it('leaves out only the newest live deployment, the current one, and keeps an older live row and the rest', async () => { + const { deps: d, asked } = deps([UID_B], { + pagination: { count: 4 }, + deployments: [ + { uid: UID_A, status: 'FAILED' }, + { uid: UID_B, status: 'live' }, + { uid: UID_C, status: 'LIVE' }, + { uid: UID_D, status: 'DEPLOYED' }, + ], + }); + + await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS); + + expect((asked[0] as { choices: { value: string }[] }).choices.map((choice) => choice.value)).toEqual([ + UID_A, + UID_C, + UID_D, + ]); + }); + + it('offers everything the server sent when none of it is live', async () => { + const { deps: d, asked } = deps([UID_A], { pagination: { count: 2 }, deployments: [{ uid: UID_A }, { uid: UID_B, status: 'DEPLOYED' }] }); + + await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS); + + expect((asked[0] as { choices: unknown[] }).choices).toHaveLength(2); + }); + + it.each([[undefined], [''], [' ']])('labels a deployment whose status is %p with a dash', async (status) => { + const { deps: d, asked } = deps([UID_A], { pagination: { count: 1 }, deployments: [{ uid: UID_A, status }] }); + + await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS); + + expect((asked[0] as { choices: unknown }).choices).toEqual([{ name: `${UID_A} -`, value: UID_A }]); + }); + + it('leaves out a deployment that carries no uid, since choosing it could address nothing', async () => { + const { deps: d, asked } = deps([UID_B], { + pagination: { count: 3 }, + deployments: [{ status: 'FAILED' }, { uid: ' ', status: 'FAILED' }, { uid: UID_B, status: 'FAILED' }], + }); + + await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS); + + expect((asked[0] as { choices: unknown }).choices).toEqual([{ name: `${UID_B} FAILED`, value: UID_B }]); + }); + + it('says how many deployments the page left out when the environment has more', async () => { + const { deps: d, printed } = deps([UID_A], { pagination: { count: 250, limit: 100 }, deployments: PAGE.deployments }); + + await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS); + + expect(printed).toEqual(['Showing the first 4 of 250 deployments. Use --deployment to reach any of them.']); + }); + + it.each([ + ['no deployments', { pagination: { count: 0 }, deployments: [] }], + ['only the live one', { pagination: { count: 1 }, deployments: [{ uid: UID_A, status: 'LIVE' }] }], + ['only entries without a uid', { pagination: { count: 1 }, deployments: [{ status: 'LIVE' }] }], + ])('refuses a rollback with exit 2 and never prompts when the page holds %s', async (_label, page) => { + const { deps: d, asked } = deps([UID_A], page); + + const failure = await promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toBe('No deployments are available for rollback.'); + expect(asked).toEqual([]); + }); + + it('refuses a cancel with exit 2 when every deployment has finished, an unknown status included', async () => { + const { deps: d, asked } = deps([UID_A], { + pagination: { count: 3 }, + deployments: [ + { uid: UID_A, status: 'LIVE' }, + { uid: UID_B, status: 'PROMOTING' }, + { uid: UID_C }, + ], + }); + + const failure = await promptForDeployment(d, SCOPE, IN_FLIGHT_DEPLOYMENTS).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toBe('No in-progress deployments are available for cancellation.'); + expect(asked).toEqual([]); + }); + + it('treats an undeclared picker as offering every deployment', async () => { + const { deps: d, asked } = deps([UID_A], PAGE); + + await promptForDeployment(d, SCOPE, undefined); + + expect((asked[0] as { choices: unknown[] }).choices).toHaveLength(3); + }); + + it.each([[undefined], [''], [null]])('turns an answer of %p at the prompt into a cancellation', async (answer) => { + const { deps: d } = deps([answer], PAGE); + + await expect(promptForDeployment(d, SCOPE, ALL_DEPLOYMENTS)).rejects.toBeInstanceOf(CancelledError); + }); + + it('propagates a failed list unchanged and asks nothing', async () => { + const failure = new Error('boom'); + const asked: unknown[] = []; + const api = { + deployments: { + list: async () => { + throw failure; + }, + }, + } as unknown as ApiSurface; + const ux: UxLike = { + print: () => undefined, + inquire: async (payload: unknown) => { + asked.push(payload); + return undefined as never; + }, + }; + + await expect(promptForDeployment({ api, ux }, SCOPE, ALL_DEPLOYMENTS)).rejects.toBe(failure); + expect(asked).toEqual([]); + }); + + it('shortens a commit to seven characters and prefers it over the creation time', () => { + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', commitHash: 'abcdef0123', createdAt: 'x' }).name).toBe( + `${UID_A} LIVE abcdef0`, + ); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', commitHash: ' ', updatedAt: 'later' }).name).toBe( + `${UID_A} LIVE later`, + ); + }); + + it('never lets a control character in the status, the commit or the timestamp reach the label', () => { + expect(deploymentChoice({ uid: UID_A, status: 'LI\u001b[31mVE\u001b[0m\r', commitHash: 'abc\u001b]0;x\u0007def0123' }).name).toBe( + `${UID_A} LIVE abcdef0`, + ); + expect(deploymentChoice({ uid: UID_A, status: '\u0007', createdAt: '2026\r\n05\u001b[2J' }).name).toBe( + `${UID_A} - 2026 05`, + ); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', createdAt: '\u0007', updatedAt: 'later' }).name).toBe( + `${UID_A} LIVE later`, + ); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', createdAt: '\u0007' }).name).toBe(`${UID_A} LIVE`); + }); + + it('puts the deployment number in front of the uid, keeping zero and refusing absent, null and blank values', () => { + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: 12 }).name).toBe(`#12 ${UID_A} LIVE`); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: 0 }).name).toBe(`#0 ${UID_A} LIVE`); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE' }).name).toBe(`${UID_A} LIVE`); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: null as never }).name).toBe(`${UID_A} LIVE`); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: ' ' as never }).name).toBe( + `${UID_A} LIVE`, + ); + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: 12 }).value).toBe(UID_A); + }); + + it('never lets a control character in the deployment number reach the label', () => { + expect(deploymentChoice({ uid: UID_A, status: 'LIVE', deploymentNumber: '7\u001b[31m\r\n8' as never }).name).toBe( + `#7 8 ${UID_A} LIVE`, + ); + }); +}); diff --git a/src/deployments/deployment.prompt.ts b/src/deployments/deployment.prompt.ts new file mode 100644 index 00000000..e67f3a30 --- /dev/null +++ b/src/deployments/deployment.prompt.ts @@ -0,0 +1,97 @@ +import { PICKER_PAGE_SIZE } from '../core/constants'; +import { UsageError } from '../core/errors'; +import type { Choice, PromptDeps } from '../core/prompt'; +import { askOption, noteTruncation } from '../core/prompt'; +import { withoutControlCharacters } from '../core/values'; +import { hasUid } from '../transport/envelope'; +import { classifyStatus } from './deployment.status'; +import type { DeploymentScope } from './deployments.api'; +import type { Deployment, IdentifiedDeployment } from './types'; + +export const ALL_DEPLOYMENTS = 'all'; + +export const IN_FLIGHT_DEPLOYMENTS = 'in-flight'; + +const SHORT_COMMIT_LENGTH = 7; + +const NO_DEPLOYMENTS = 'No deployments are available for rollback.'; + +const NO_IN_FLIGHT_DEPLOYMENTS = 'No in-progress deployments are available for cancellation.'; + +const QUESTIONS: Record = { + [ALL_DEPLOYMENTS]: 'Choose a deployment for rollback', + [IN_FLIGHT_DEPLOYMENTS]: 'Choose a deployment for cancellation', +}; + +function printable(value: string | undefined): string { + return withoutControlCharacters(value ?? '').trim(); +} + +function when(deployment: IdentifiedDeployment): string | undefined { + const commit = printable(deployment.commitHash); + + if (commit) { + return commit.slice(0, SHORT_COMMIT_LENGTH); + } + + return printable(deployment.createdAt) || printable(deployment.updatedAt) || undefined; +} + +export function isCancellable(deployment: Pick): boolean { + return classifyStatus(deployment.status) === 'in-flight' && deployment.isCancellable !== false; +} + +function withoutCurrent(deployments: IdentifiedDeployment[]): IdentifiedDeployment[] { + const current = deployments.findIndex((deployment) => deployment.status?.trim().toUpperCase() === 'LIVE'); + + return deployments.filter((_deployment, index) => index !== current); +} + +function numbered(deployment: IdentifiedDeployment): string | undefined { + const number = deployment.deploymentNumber; + + if (number === undefined || number === null) { + return undefined; + } + + const text = printable(String(number)); + + return text ? `#${text}` : undefined; +} + +export function deploymentChoice(deployment: IdentifiedDeployment): Choice { + const status = printable(deployment.status) || '-'; + const detail = when(deployment); + + const parts = [numbered(deployment), deployment.uid, status, detail].filter((part) => part !== undefined); + + return { name: parts.join(' '), value: deployment.uid }; +} + +export async function promptForDeployment( + deps: PromptDeps, + scope: DeploymentScope, + picker: string | undefined, +): Promise { + const inFlightOnly = picker === IN_FLIGHT_DEPLOYMENTS; + const page = await deps.api.deployments.list({ + ...scope, + limit: PICKER_PAGE_SIZE, + skip: 0, + ...(inFlightOnly ? {} : { rollbackEligible: true }), + }); + const identified = page.deployments.filter(hasUid); + const choosable = inFlightOnly ? identified.filter(isCancellable) : withoutCurrent(identified); + + if (choosable.length === 0) { + throw new UsageError(inFlightOnly ? NO_IN_FLIGHT_DEPLOYMENTS : NO_DEPLOYMENTS); + } + + if (!inFlightOnly) { + noteTruncation(deps.ux, page.pagination.count, page.deployments.length, 'deployments', '--deployment'); + } + + const question = QUESTIONS[inFlightOnly ? IN_FLIGHT_DEPLOYMENTS : ALL_DEPLOYMENTS]; + + return askOption(deps.ux, question, choosable.map(deploymentChoice)); +} diff --git a/src/deployments/deployment.resolver.test.ts b/src/deployments/deployment.resolver.test.ts new file mode 100644 index 00000000..9250e882 --- /dev/null +++ b/src/deployments/deployment.resolver.test.ts @@ -0,0 +1,60 @@ +import { UsageError } from '../core/errors'; +import { LaunchApiError } from '../transport/errors'; +import { fetchDeployment } from './deployment.resolver'; +import type { GetDeploymentParams } from './deployments.api'; + +const PARAMS: GetDeploymentParams = { org: 'org1', project: 'proj1', environment: 'env1', deployment: 'dep1' }; + +function fakeApi(outcome: () => Promise) { + const asked: GetDeploymentParams[] = []; + + return { + asked, + api: { + get: async (params: GetDeploymentParams) => { + asked.push(params); + return outcome(); + }, + } as never, + }; +} + +describe('fetchDeployment', () => { + it('returns the deployment the api found, asking for exactly the scope it was given', async () => { + const deployment = { uid: 'dep1', status: 'LIVE' }; + const { api, asked } = fakeApi(async () => deployment); + + await expect(fetchDeployment(api, PARAMS)).resolves.toBe(deployment); + expect(asked).toEqual([PARAMS]); + }); + + it('turns a 404 DEPLOYMENT.NOT_FOUND into the generic usage error naming only the uid and the environment', async () => { + const { api } = fakeApi(async () => { + throw new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'Deployment not found.' }]); + }); + + const failure = await fetchDeployment(api, PARAMS).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(UsageError); + expect((failure as UsageError).exitCode).toBe(2); + expect((failure as Error).message).toBe('No deployment with UID "dep1" found in this environment.'); + }); + + it.each([ + [404, 'launch.ENVIRONMENT.NOT_FOUND'], + [500, 'launch.DEPLOYMENT.NOT_FOUND'], + [500, 'launch.UNKNOWN'], + ])('lets a %i %s through as the same api error', async (status, code) => { + const failure = new LaunchApiError(status, [{ code, message: 'x' }]); + const { api } = fakeApi(async () => Promise.reject(failure)); + + await expect(fetchDeployment(api, PARAMS)).rejects.toBe(failure); + }); + + it('lets a failure that is not an api error through untouched', async () => { + const failure = new Error('socket'); + const { api } = fakeApi(async () => Promise.reject(failure)); + + await expect(fetchDeployment(api, PARAMS)).rejects.toBe(failure); + }); +}); diff --git a/src/deployments/deployment.resolver.ts b/src/deployments/deployment.resolver.ts new file mode 100644 index 00000000..2a29e782 --- /dev/null +++ b/src/deployments/deployment.resolver.ts @@ -0,0 +1,24 @@ +import { UsageError } from '../core/errors'; +import { LaunchApiError } from '../transport/errors'; +import { DEPLOYMENT_NOT_FOUND_CODE } from './deployment.errors'; +import type { DeploymentsApi, GetDeploymentParams } from './deployments.api'; +import type { Deployment } from './types'; + +export function deploymentNotFound(uid: string): UsageError { + return new UsageError(`No deployment with UID "${uid}" found in this environment.`); +} + +export async function fetchDeployment( + deployments: Pick, + params: GetDeploymentParams, +): Promise { + try { + return await deployments.get(params); + } catch (error) { + if (error instanceof LaunchApiError && error.status === 404 && error.code === DEPLOYMENT_NOT_FOUND_CODE) { + throw deploymentNotFound(params.deployment); + } + + throw error; + } +} diff --git a/src/deployments/deployment.rollback.test.ts b/src/deployments/deployment.rollback.test.ts new file mode 100644 index 00000000..7bcff842 --- /dev/null +++ b/src/deployments/deployment.rollback.test.ts @@ -0,0 +1,235 @@ +import { randomBytes } from 'node:crypto'; + +import type { LaunchError } from '../core/errors'; +import type { ServiceContext } from '../core/service-context'; +import type { ApiSurface } from '../resources'; +import { LaunchApiError } from '../transport/errors'; +import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from './deployment.errors'; +import { DeploymentRollback } from './deployment.rollback'; +import type { DeploymentOutcome, WatchTiming } from './deployment.watcher'; + +const ORG = `blt${randomBytes(8).toString('hex')}`; +const PROJECT = randomBytes(12).toString('hex'); +const ENVIRONMENT = randomBytes(12).toString('hex'); +const TARGET = randomBytes(12).toString('hex'); + +const REQUEST = { org: ORG, project: PROJECT, environment: ENVIRONMENT, deployment: TARGET, reason: 'bad release' }; +const CHECK = + `csdx launch:deployments:get --org ${ORG} --project ${PROJECT} --env ${ENVIRONMENT} --deployment ${TARGET}`; + +class FakeSignals { + readonly listeners: (() => void)[] = []; + readonly registered: string[] = []; + + on(signal: 'SIGINT', listener: () => void): void { + this.registered.push(signal); + this.listeners.push(listener); + } + + removeListener(_signal: 'SIGINT', listener: () => void): void { + const index = this.listeners.indexOf(listener); + + if (index >= 0) { + this.listeners.splice(index, 1); + } + } + + interrupt(): void { + for (const listener of [...this.listeners]) { + listener(); + } + } +} + +function harness( + options: { + outcome?: DeploymentOutcome; + wait?: (scope: unknown, stop?: AbortSignal) => Promise; + rollbackFails?: Error; + } = {}, +) { + const timeline: string[] = []; + const rolledBack: unknown[] = []; + const waited: { scope: unknown; stop?: AbortSignal }[] = []; + const signals = new FakeSignals(); + + const api = { + deployments: { + rollback: async (params: unknown) => { + timeline.push('rollback'); + rolledBack.push(params); + + if (options.rollbackFails !== undefined) { + throw options.rollbackFails; + } + }, + }, + } as unknown as ApiSurface; + + const services: ServiceContext = { + api, + ux: { print: (message: string) => timeline.push(`print ${message}`), inquire: async () => undefined as never }, + isTTY: false, + outputIsTTY: false, + }; + + const timing: WatchTiming = { + sleep: async () => undefined, + now: () => 0, + pollDelayMs: 1, + maxBackoffSteps: 1, + timeoutMs: 1, + }; + + const follower = { + waitUntilLive: async (scope: unknown, stop?: AbortSignal): Promise => { + timeline.push('wait'); + waited.push({ scope, stop }); + + if (options.wait !== undefined) { + return options.wait(scope, stop); + } + + return options.outcome ?? { kind: 'success', status: 'LIVE', deployment: { uid: TARGET, status: 'LIVE' } }; + }, + }; + + const rollback = new DeploymentRollback(services, timing, { signals, follower }); + + return { rollback, signals, timeline, rolledBack, waited }; +} + +async function failureOf(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected the rollback to fail'); + }, + (error: unknown) => error as Error, + ); +} + +describe('DeploymentRollback', () => { + it('sends the rollback, says it started, then waits for the target in its own scope and says nothing more', async () => { + const { rollback, timeline, rolledBack, waited } = harness(); + + await rollback.rollback(REQUEST); + + expect(rolledBack).toEqual([REQUEST]); + expect(waited.map((call) => call.scope)).toEqual([ + { org: ORG, project: PROJECT, environment: ENVIRONMENT, deployment: TARGET }, + ]); + expect(timeline).toEqual(['rollback', `print Rollback to deployment "${TARGET}" started.`, 'wait']); + }); + + it('hands an absent reason on as undefined', async () => { + const { rollback, rolledBack } = harness(); + + await rollback.rollback({ ...REQUEST, reason: undefined }); + + expect(rolledBack).toEqual([{ ...REQUEST, reason: undefined }]); + }); + + it('propagates a rejected rollback request without announcing a start or waiting', async () => { + const failure = new LaunchApiError(409, [{ code: 'launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', message: 'x' }]); + const { rollback, timeline, waited } = harness({ rollbackFails: failure }); + + await expect(rollback.rollback(REQUEST)).rejects.toBe(failure); + + expect(timeline).toEqual(['rollback']); + expect(waited).toEqual([]); + }); + + it('reports a target that is still not LIVE when the wait ran out, naming the status and deployments:get, exit 1', async () => { + const { rollback } = harness({ + outcome: { kind: 'timed-out', status: 'ARCHIVED', deployment: { uid: TARGET, status: 'ARCHIVED' } }, + }); + + const failure = await failureOf(rollback.rollback(REQUEST)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as LaunchError).exitCode).toBe(1); + expect(failure.message).toBe( + `Deployment "${TARGET}" was still ARCHIVED when the CLI stopped waiting. ` + + `Run ${CHECK} to check whether the rollback completed.`, + ); + }); + + it('reports a poll error as an unconfirmed rollback with its reason, exit 1', async () => { + const { rollback } = harness({ + wait: async () => { + throw new LaunchApiError(503, []); + }, + }); + + const failure = await failureOf(rollback.rollback(REQUEST)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect((failure as LaunchError).exitCode).toBe(1); + expect(failure.message).toMatch( + new RegExp(`^The rollback to deployment "${TARGET}" could not be confirmed: .+\\. Run ${CHECK} to check whether`), + ); + expect(failure.message).not.toContain('successfully'); + }); + + it('refuses to call any other outcome a success', async () => { + const { rollback } = harness({ + outcome: { kind: 'failure', status: 'FAILED', deployment: { uid: TARGET, status: 'FAILED' } }, + }); + + const failure = await failureOf(rollback.rollback(REQUEST)); + + expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); + expect(failure.message).toContain('FAILED'); + }); +}); + +describe('DeploymentRollback interrupted with Ctrl-C while waiting', () => { + function waitingForever(stopHolder: { stop?: AbortSignal }) { + return (_scope: unknown, stop?: AbortSignal) => + new Promise((_resolve, reject) => { + stopHolder.stop = stop; + stop?.addEventListener('abort', () => reject(stop.reason), { once: true }); + }); + } + + it('stops the wait, says the rollback was not cancelled and names deployments:get, sends nothing more, and exits 3', async () => { + const holder: { stop?: AbortSignal } = {}; + const { rollback, signals, rolledBack } = harness({ wait: waitingForever(holder) }); + + const running = failureOf(rollback.rollback(REQUEST)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(signals.registered).toEqual(['SIGINT']); + expect(holder.stop?.aborted).toBe(false); + + signals.interrupt(); + const failure = await running; + + expect(holder.stop?.aborted).toBe(true); + expect(failure).toBeInstanceOf(DeploymentWaitInterruptedError); + expect((failure as LaunchError).exitCode).toBe(3); + expect(failure.message).toBe( + `Stopped waiting. The rollback to deployment "${TARGET}" was not cancelled and continues on Launch. ` + + `Check it with ${CHECK}.`, + ); + expect(rolledBack).toHaveLength(1); + expect(signals.listeners).toEqual([]); + }); + + it('registers no listener when the rollback request is refused', async () => { + const { rollback, signals } = harness({ rollbackFails: new Error('refused') }); + + await failureOf(rollback.rollback(REQUEST)); + + expect(signals.registered).toEqual([]); + }); + + it('removes the listener after a wait that ended on its own', async () => { + const { rollback, signals } = harness(); + + await rollback.rollback(REQUEST); + + expect(signals.registered).toEqual(['SIGINT']); + expect(signals.listeners).toEqual([]); + }); +}); diff --git a/src/deployments/deployment.rollback.ts b/src/deployments/deployment.rollback.ts new file mode 100644 index 00000000..1eee7840 --- /dev/null +++ b/src/deployments/deployment.rollback.ts @@ -0,0 +1,81 @@ +import type { InterruptSource } from '../core/interruptible-ux'; +import type { ServiceContext } from '../core/service-context'; +import { asSentence, messageOf } from '../core/values'; +import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from './deployment.errors'; +import type { DeploymentScope as FollowedDeployment } from './deployment.follower'; +import { DeploymentFollower } from './deployment.follower'; +import { + rollbackInterruptedMessage, + rollbackStartedLine, + rollbackUnconfirmedMessage, +} from './deployment.presenter'; +import type { DeploymentOutcome, WatchTiming } from './deployment.watcher'; +import type { DeploymentScope } from './deployments.api'; + +export interface RollbackRequest extends DeploymentScope { + deployment: string; + reason?: string; +} + +export interface RollbackFollowing { + waitUntilLive(scope: FollowedDeployment, stop?: AbortSignal): Promise; +} + +export interface DeploymentRollbackDeps { + signals?: InterruptSource; + follower?: RollbackFollowing; +} + +export class DeploymentRollback { + private readonly follower: RollbackFollowing; + private readonly signals: InterruptSource; + + constructor( + private readonly services: ServiceContext, + timing: WatchTiming, + deps: DeploymentRollbackDeps = {}, + ) { + this.follower = deps.follower ?? new DeploymentFollower(services, timing); + this.signals = deps.signals ?? process; + } + + async rollback(request: RollbackRequest): Promise { + await this.services.api.deployments.rollback(request); + + this.services.ux.print(rollbackStartedLine(request.deployment)); + + const followed = { + org: request.org, + project: request.project, + environment: request.environment, + deployment: request.deployment, + }; + const outcome = await this.waitFor(followed); + + if (outcome.kind !== 'success') { + throw new DeploymentUnsuccessfulError(rollbackUnconfirmedMessage(followed, { status: outcome.status })); + } + } + + private async waitFor(followed: FollowedDeployment): Promise { + const stop = new AbortController(); + const interrupt = (): void => + stop.abort(new DeploymentWaitInterruptedError(rollbackInterruptedMessage(followed))); + + this.signals.on('SIGINT', interrupt); + + try { + return await this.follower.waitUntilLive(followed, stop.signal); + } catch (error) { + if (stop.signal.aborted) { + throw stop.signal.reason; + } + + throw new DeploymentUnsuccessfulError( + rollbackUnconfirmedMessage(followed, { status: 'NONE', reason: asSentence(messageOf(error)) }), + ); + } finally { + this.signals.removeListener('SIGINT', interrupt); + } + } +} diff --git a/src/deployments/deployment.watcher.test.ts b/src/deployments/deployment.watcher.test.ts index 0d534f1f..73d4534f 100644 --- a/src/deployments/deployment.watcher.test.ts +++ b/src/deployments/deployment.watcher.test.ts @@ -441,7 +441,7 @@ describe('deployment log streaming', () => { expect(DEPLOYMENT_LOGS_FROM).toBe('1970-01-01T00:00:00.000Z'); }); - it('reports a failing log fetch once, keeps waiting on the deployment and resumes the logs where it left off', async () => { + it('prints nothing for a failing log fetch, keeps waiting on the deployment and resumes the logs where it left off', async () => { const { deps, lines, since } = streamingHarness( ['DEPLOYING', 'DEPLOYING', 'DEPLOYING', 'LIVE'], [ @@ -456,7 +456,6 @@ describe('deployment log streaming', () => { expect(outcome).toEqual(expect.objectContaining({ kind: 'success', status: 'LIVE' })); expect(lines).toEqual([ - ' ! Could not read the deployment logs (Bad gateway). Still waiting on the deployment.', '2026-09-25 10:00:05.000: Build complete', ]); expect(since).toEqual([ @@ -627,3 +626,235 @@ describe('deployment log streaming', () => { ); }); +describe('deployment wait loop stopped from outside', () => { + function stoppable(statuses: string[]) { + const events: string[] = []; + const controller = new AbortController(); + let polls = 0; + let release: () => void = () => undefined; + + const deps = { + poll: async (): Promise => { + const status = statuses[Math.min(polls, statuses.length - 1)]; + polls += 1; + events.push(`poll ${status}`); + return { uid: UID, status }; + }, + logs: async (): Promise => [], + loader: { + start: (message: string) => events.push(`start ${message}`), + stop: () => events.push('stop'), + }, + ux: { print: (message: string) => events.push(`print ${message}`), inquire: async () => undefined as never }, + outputIsTTY: false, + sleep: (ms: number) => + new Promise((resolve) => { + events.push(`sleep ${ms}`); + release = resolve; + }), + now: () => 0, + pollDelayMs: 1000, + maxBackoffSteps: 3, + timeoutMs: 60_000, + stop: controller.signal, + }; + + return { deps, events, controller, polls: () => polls, release: () => release() }; + } + + it('abandons the sleep, stops the loader and rejects with the stop reason when stopped between polls', async () => { + const { deps, events, controller, polls } = stoppable(['DEPLOYING']); + const reason = new Error('interrupted'); + + const waiting = watchDeployment(deps).catch((error: Error) => error); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(reason); + + expect(await waiting).toBe(reason); + expect(polls()).toBe(1); + expect(events).toEqual(['poll DEPLOYING', `start ${DEPLOYMENT_LOADER_MESSAGE}`, 'sleep 1000', 'stop']); + }); + + it('does not poll at all when it is already stopped', async () => { + const { deps, events, controller, polls } = stoppable(['LIVE']); + const reason = new Error('already interrupted'); + controller.abort(reason); + + await expect(watchDeployment(deps)).rejects.toBe(reason); + expect(polls()).toBe(0); + expect(events).toEqual(['stop']); + }); + + it('stops waiting between failing polls too, rather than retrying once it was told to stop', async () => { + const { deps, events, controller } = stoppable(['DEPLOYING']); + const reason = new Error('interrupted during retry'); + const blip = new Error('502 Bad Gateway'); + const failing = { ...deps, poll: async (): Promise => Promise.reject(blip) }; + + const waiting = watchDeployment(failing).catch((error: Error) => error); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(reason); + + expect(await waiting).toBe(reason); + expect(events).toEqual(['sleep 1000', 'stop']); + }); + + it.each([ + ['the normal delay', 'DEPLOYING', undefined], + ['the error backoff', undefined, new Error('502 Bad Gateway')], + ])( + 'rejects with the stop reason without sleeping when the stop lands while the poll is in flight (%s)', + async (_label, status, failure) => { + jest.useFakeTimers(); + + try { + const { deps, events, controller } = stoppable(['DEPLOYING']); + const reason = new Error('interrupted during the poll'); + const slept: number[] = []; + const aborting = { + ...deps, + poll: async (): Promise => { + events.push('poll'); + controller.abort(reason); + + if (failure) { + throw failure; + } + + return { uid: UID, status }; + }, + sleep: (ms: number) => + new Promise((resolve) => { + slept.push(ms); + setTimeout(resolve, ms); + }), + }; + + await expect(watchDeployment(aborting)).rejects.toBe(reason); + expect(slept).toEqual([]); + expect(events).toEqual(['poll', 'stop']); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }, + ); + + it('rejects with the stop reason without sleeping when the stop lands while the logs are being read', async () => { + jest.useFakeTimers(); + + try { + const { deps, events, controller } = stoppable(['DEPLOYING']); + const reason = new Error('interrupted during the logs read'); + const slept: number[] = []; + const aborting = { + ...deps, + logs: async (): Promise => { + controller.abort(reason); + return []; + }, + sleep: (ms: number) => + new Promise((resolve) => { + slept.push(ms); + setTimeout(resolve, ms); + }), + }; + + await expect(watchDeployment(aborting)).rejects.toBe(reason); + expect(slept).toEqual([]); + expect(events).toEqual(['poll DEPLOYING', `start ${DEPLOYMENT_LOADER_MESSAGE}`, 'stop']); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects with the stop reason instead of reporting a terminal status that arrived after the stop', async () => { + const { deps, events, controller } = stoppable(['LIVE']); + const reason = new Error('interrupted during the final poll'); + const aborting = { + ...deps, + poll: async (): Promise => { + controller.abort(reason); + return { uid: UID, status: 'LIVE' }; + }, + logs: async (): Promise => { + events.push('logs'); + return []; + }, + }; + + await expect(watchDeployment(aborting)).rejects.toBe(reason); + expect(events).toEqual(['stop']); + }); + + it('carries on normally when the sleep ends before any stop and ignores a stop that arrives after the end', async () => { + const { deps, controller, polls, release } = stoppable(['DEPLOYING', 'LIVE']); + + const waiting = watchDeployment(deps); + await new Promise((resolve) => setImmediate(resolve)); + release(); + const outcome = await waiting; + controller.abort(new Error('too late')); + + expect(outcome.kind).toBe('success'); + expect(polls()).toBe(2); + }); + + it('passes a failing sleep through unchanged', async () => { + const { deps } = stoppable(['DEPLOYING']); + const broken = new Error('clock failed'); + + await expect(watchDeployment({ ...deps, sleep: () => Promise.reject(broken) })).rejects.toBe(broken); + }); +}); + +describe('deployment wait loop with an until condition', () => { + const isLive = (deployment: Deployment): boolean => deployment.status === 'LIVE'; + + it('keeps waiting through statuses that would otherwise end the wait until the condition holds', async () => { + const { deps, slept, pollCount } = harness(['ARCHIVED', 'FAILED', 'DEPLOYED', 'LIVE']); + + const outcome = await watchDeployment({ ...deps, until: isLive }); + + expect(outcome.kind).toBe('success'); + expect(outcome.status).toBe('LIVE'); + expect(pollCount()).toBe(4); + expect(slept).toEqual([1000, 2000, 3000]); + }); + + it('hands every polled deployment to the condition', async () => { + const { deps } = harness(['ARCHIVED', 'LIVE']); + const seen: (string | undefined)[] = []; + + await watchDeployment({ + ...deps, + until: (deployment) => { + seen.push(deployment.status); + return isLive(deployment); + }, + }); + + expect(seen).toEqual(['ARCHIVED', 'LIVE']); + }); + + it('reports a timeout with the last status when the condition never holds', async () => { + const { deps, slept, pollCount } = harness(['ARCHIVED'], { timeoutMs: 5000 }); + + const outcome = await watchDeployment({ ...deps, until: isLive }); + + expect(outcome.kind).toBe('timed-out'); + expect(outcome.status).toBe('ARCHIVED'); + expect(slept).toEqual([1000, 2000]); + expect(pollCount()).toBe(3); + }); + + it('propagates a poll failure that keeps recurring', async () => { + const outage = new Error('500 Internal Server Error'); + const { deps, pollCount } = failingHarness([outage]); + + await expect(watchDeployment({ ...deps, until: isLive })).rejects.toThrow('500 Internal Server Error'); + + expect(pollCount()).toBe(DEPLOYMENT_MAX_POLL_ERRORS); + }); +}); diff --git a/src/deployments/deployment.watcher.ts b/src/deployments/deployment.watcher.ts index 73ab6e16..79fd798b 100644 --- a/src/deployments/deployment.watcher.ts +++ b/src/deployments/deployment.watcher.ts @@ -3,10 +3,7 @@ import type { UxLike } from '../core/prompt'; import { RetryPolicy } from '../transport/retry-policy'; import { LogTail } from './deployment.log-tail'; import { classifyStatus, normalizeStatus } from './deployment.status'; -import { - deploymentLogLine, - deploymentLogsUnavailableLine, -} from './deployment.presenter'; +import { deploymentLogLine } from './deployment.presenter'; import type { Deployment, DeploymentLog } from './types'; export const DEPLOYMENT_POLL_DELAY_MS = 2000; @@ -29,6 +26,8 @@ export interface DeploymentWatchDeps extends WatchTiming { ux: UxLike; loader: Loader; outputIsTTY: boolean; + stop?: AbortSignal; + until?(deployment: Deployment): boolean; } export type DeploymentOutcomeKind = 'success' | 'failure' | 'timed-out'; @@ -66,15 +65,56 @@ export async function watchDeployment(deps: DeploymentWatchDeps): Promise { + const { stop } = deps; + + if (stop === undefined) { + return deps.sleep(ms); + } + + if (stop.aborted) { + return Promise.reject(stop.reason); + } + + return new Promise((resolve, reject) => { + const abandon = (): void => reject(stop.reason); + + stop.addEventListener('abort', abandon, { once: true }); + deps.sleep(ms).then( + () => { + stop.removeEventListener('abort', abandon); + resolve(); + }, + (error: unknown) => { + stop.removeEventListener('abort', abandon); + reject(error); + }, + ); + }); +} + +async function freshLogs(deps: DeploymentWatchDeps, tail: LogTail): Promise { + try { + return tail.fresh(await deps.logs(tail.since())); + } catch { + return []; + } +} + +function untilKind(until: (deployment: Deployment) => boolean, deployment: Deployment): 'success' | 'in-flight' { + return until(deployment) ? 'success' : 'in-flight'; +} + async function waitFor(deps: DeploymentWatchDeps): Promise { const backoff = new RetryPolicy({ retryDelayMs: deps.pollDelayMs }); const deadline = deps.now() + deps.timeoutMs; let attempt = 0; let consecutiveErrors = 0; const tail = new LogTail(); - let logsFailureReported = false; for (;;) { + deps.stop?.throwIfAborted(); + let deployment: Deployment; try { @@ -89,26 +129,17 @@ async function waitFor(deps: DeploymentWatchDeps): Promise { throw error; } - await deps.sleep(retryIn); + await sleepUnlessStopped(deps, retryIn); continue; } + deps.stop?.throwIfAborted(); + const status = normalizeStatus(deployment.status); - const kind = classifyStatus(deployment.status); + const kind = deps.until === undefined ? classifyStatus(deployment.status) : untilKind(deps.until, deployment); const terminal = kind === 'success' || kind === 'failure'; - let logs: DeploymentLog[] = []; - - try { - logs = tail.fresh(await deps.logs(tail.since())); - } catch (error) { - if (!logsFailureReported) { - deps.ux.print(deploymentLogsUnavailableLine(error)); - logsFailureReported = true; - } - } - - for (const log of logs) { + for (const log of await freshLogs(deps, tail)) { deps.ux.print(deploymentLogLine(log, deps.outputIsTTY, process.stdout.columns)); } @@ -124,6 +155,6 @@ async function waitFor(deps: DeploymentWatchDeps): Promise { } deps.loader.start(DEPLOYMENT_LOADER_MESSAGE); - await deps.sleep(delay); + await sleepUnlessStopped(deps, delay); } } diff --git a/src/deployments/deployments.api.test.ts b/src/deployments/deployments.api.test.ts index 26ff7e84..a7c9e7ec 100644 --- a/src/deployments/deployments.api.test.ts +++ b/src/deployments/deployments.api.test.ts @@ -2,7 +2,12 @@ import { randomBytes } from 'node:crypto'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; -import { DEPLOYMENT_ERROR_MESSAGES, DeploymentUnsuccessfulError } from './deployment.errors'; +import { + CANCEL_NOT_ALLOWED_RAW_MESSAGE, + DEPLOYMENT_ERROR_MESSAGES, + DeploymentUnsuccessfulError, + INVALID_DEPLOYMENT_UID_RAW_MESSAGE, +} from './deployment.errors'; import { DeploymentsApi } from './deployments.api'; const ORG = randomBytes(9).toString('hex'); @@ -70,6 +75,18 @@ describe('DeploymentsApi', () => { ); }); + it.each([ + [{}, { limit: undefined, skip: undefined }], + [{ rollbackEligible: false }, { limit: undefined, skip: undefined }], + [{ rollbackEligible: true }, { limit: undefined, skip: undefined, rollbackEligible: 'true' }], + ])('asks the list for %j with the query %j', async (extra, query) => { + const { client, requests } = fakeRestClient({ pagination: { count: 0 }, deployments: [] }); + + await new DeploymentsApi(client).list({ ...SCOPE, ...extra }); + + expect(requests[0].query).toStrictEqual(query); + }); + it('unwraps the deployment envelope on a single get', async () => { const deployment = { uid: DEPLOYMENT, status: 'DEPLOYING' }; const { client, requests } = fakeRestClient({ deployment }); @@ -93,6 +110,35 @@ describe('DeploymentsApi', () => { ); }); + it('words a 200 with a null deployment, which is how the server answers a deployment outside the environment, as the 404 not-found error', async () => { + const { client, messages } = fakeRestClient({ deployment: null }); + + const failure = (await new DeploymentsApi(client) + .get({ ...SCOPE, deployment: DEPLOYMENT }) + .catch((error: unknown) => error)) as LaunchApiError; + + expect(failure).toBeInstanceOf(LaunchApiError); + expect(failure.status).toBe(404); + expect(failure.code).toBe('launch.DEPLOYMENT.NOT_FOUND'); + expect(failure.message).toBe(DEPLOYMENT_ERROR_MESSAGES['launch.DEPLOYMENT.NOT_FOUND']); + expect(messages).toHaveLength(1); + }); + + it.each([[undefined], ['a string'], [7], [[]]])( + 'keeps a get whose deployment is %p a malformed response, because the server only ever answers null for a missing deployment', + async (deployment) => { + const { client } = fakeRestClient({ deployment }); + + const failure = (await new DeploymentsApi(client) + .get({ ...SCOPE, deployment: DEPLOYMENT }) + .catch((error: unknown) => error)) as LaunchApiError; + + expect(failure).toBeInstanceOf(LaunchApiError); + expect(failure.status).toBe(200); + expect(failure.code).toBe('launch.RESPONSE.MALFORMED'); + }, + ); + it('raises a malformed-response error when a get returns nothing at all', async () => { const { client } = fakeRestClient(undefined); @@ -129,6 +175,67 @@ describe('DeploymentsApi', () => { await expect(new DeploymentsApi(client).latest(SCOPE)).resolves.toBeUndefined(); }); + it.each([ + ['the branch tip', {}, {}], + ['a commit', { commitHash: 'abc123' }, { commitHash: 'abc123' }], + ['an upload', { uploadUid: 'up-1' }, { uploadUid: 'up-1' }], + ])('creates %s with a POST of exactly the given body, scoped to the org and project, and returns the new uid', async (_label, extra, body) => { + const created = { uid: DEPLOYMENT, status: 'QUEUED' }; + const { client, requests, messages } = fakeRestClient({ deployment: created }); + + const result = await new DeploymentsApi(client).create({ ...SCOPE, ...extra }); + + expect(result).toBe(created); + expect(requests).toEqual([{ method: 'POST', path: BASE, orgUid: ORG, projectUid: PROJECT, body }]); + expect(messages[0]).toBe(DEPLOYMENT_ERROR_MESSAGES); + }); + + it('leaves commitHash and uploadUid out of the body when they are undefined', async () => { + const { client, requests } = fakeRestClient({ deployment: { uid: DEPLOYMENT } }); + + await new DeploymentsApi(client).create({ ...SCOPE, commitHash: undefined, uploadUid: undefined }); + + expect(requests[0].body).toEqual({}); + expect(Object.keys(requests[0].body as object)).toEqual([]); + }); + + it('never puts the environment in the create body, because the server rejects any field its DTO does not declare', async () => { + const { client, requests } = fakeRestClient({ deployment: { uid: DEPLOYMENT } }); + + await new DeploymentsApi(client).create({ ...SCOPE, commitHash: 'abc123' }); + + expect(requests[0].path).toBe(BASE); + expect(requests[0].body).not.toHaveProperty('environment'); + }); + + it.each([[undefined], [null], [''], [' ']])( + 'raises a malformed-response error when the created deployment has uid %p', + async (uid) => { + const { client } = fakeRestClient({ deployment: { uid, status: 'QUEUED' } }); + + const failure = await new DeploymentsApi(client).create(SCOPE).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as Error).message).toBe('The Launch API returned a deployment without a deployment uid.'); + }, + ); + + it('raises a malformed-response error when the create response has no deployment', async () => { + const { client } = fakeRestClient({ uid: DEPLOYMENT }); + + const failure = await new DeploymentsApi(client).create(SCOPE).catch((error: Error) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as Error).message).toBe('The Launch API returned a deployment response without a deployment.'); + }); + + it('propagates a rejected create unchanged', async () => { + const rejection = new LaunchApiError(500, [{ code: 'launch.DEPLOYMENT.CREATE_FAILED', message: 'x' }]); + const client = { request: async () => Promise.reject(rejection) } as unknown as RestApiClient; + + await expect(new DeploymentsApi(client).create(SCOPE)).rejects.toBe(rejection); + }); + it('carries exit code 1 on an unsuccessful deployment', () => { const error = new DeploymentUnsuccessfulError('Deployment FAILED.'); @@ -136,4 +243,130 @@ describe('DeploymentsApi', () => { expect(error.name).toBe('DeploymentUnsuccessfulError'); expect(error.message).toBe('Deployment FAILED.'); }); + + it('posts the rollback body with the trimmed reason and the deployment scope', async () => { + const { client, requests, messages } = fakeRestClient({}); + + await new DeploymentsApi(client).rollback({ ...SCOPE, deployment: DEPLOYMENT, reason: ' bad release ' }); + + expect(requests).toEqual([ + { + method: 'POST', + path: `${BASE}/rollback`, + orgUid: ORG, + projectUid: PROJECT, + body: { deployment: DEPLOYMENT, reason: 'bad release' }, + }, + ]); + expect(messages[0]).toBe(DEPLOYMENT_ERROR_MESSAGES); + }); + + it.each([[undefined], [''], [' ']])('leaves the reason out of the rollback body for %j', async (reason) => { + const { client, requests } = fakeRestClient({}); + + await new DeploymentsApi(client).rollback({ ...SCOPE, deployment: DEPLOYMENT, reason }); + + expect(requests[0].body).toEqual({ deployment: DEPLOYMENT }); + expect(Object.keys(requests[0].body as object)).toEqual(['deployment']); + }); + + it.each([ + ['launch.DEPLOYMENT.CREATE_FAILED', 'The deployment could not be started. Please try again or contact support.'], + ['launch.DEPLOYMENT.ROLLBACK_FAILED', 'The rollback could not be completed. Please try again or contact support.'], + ])('words %s with the approved sentence', (code, sentence) => { + const failure = new LaunchApiError(500, [{ code, message: 'raw' }], DEPLOYMENT_ERROR_MESSAGES); + + expect(failure.message).toBe(sentence); + }); + + it.each([ + ['launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', 409], + ['launch.DEPLOYMENT.NO_LIVE_DEPLOYMENT_FOUND', 409], + ['launch.DEPLOYMENT.NOT_FOUND', 404], + ['launch.DEPLOYMENT.ROLLBACK_INVALID_STATUS', 400], + ['launch.DEPLOYMENT.ROLLBACK_TO_CURRENT_DEPLOYMENT', 400], + ['launch.DEPLOYMENT.ROLLBACK_FAILED', 500], + ])('words the rollback refusal %s in the CLI own words', (code, status) => { + const failure = new LaunchApiError(status, [{ code, message: 'raw' }], DEPLOYMENT_ERROR_MESSAGES); + + expect(failure.exitCode).toBe(1); + expect(failure.message).not.toBe(code); + expect(failure.message).not.toBe('raw'); + expect(failure.message).toBe(DEPLOYMENT_ERROR_MESSAGES[code]); + }); + + it.each([ + ['the code', [{ field: 'deployment', code: 'launch.DEPLOYMENT.INVALID_DEPLOYMENT_UID', message: 'raw' }]], + ['the raw message as the code', [{ code: INVALID_DEPLOYMENT_UID_RAW_MESSAGE, message: INVALID_DEPLOYMENT_UID_RAW_MESSAGE }]], + ])('words an invalid deployment uid refused with %s in the CLI own words', (_name, errors) => { + const failure = new LaunchApiError(400, errors, DEPLOYMENT_ERROR_MESSAGES); + + expect(failure.message).toBe('The deployment UID is invalid. Provide a 24-character hexadecimal deployment UID.'); + expect(failure.message).not.toContain('12 bytes'); + expect(failure.message).not.toBe('raw'); + }); + + it('gives the rollback refusals three distinct messages', () => { + const messages = [ + 'launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', + 'launch.DEPLOYMENT.NO_LIVE_DEPLOYMENT_FOUND', + 'launch.DEPLOYMENT.NOT_FOUND', + ].map((code) => DEPLOYMENT_ERROR_MESSAGES[code]); + + expect(new Set(messages).size).toBe(3); + }); + + it('propagates a rejected rollback unchanged', async () => { + const rejection = new LaunchApiError(409, [{ code: 'launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', message: 'x' }]); + const client = { request: async () => Promise.reject(rejection) } as unknown as RestApiClient; + + await expect(new DeploymentsApi(client).rollback({ ...SCOPE, deployment: DEPLOYMENT })).rejects.toBe(rejection); + }); + + it('posts a bodyless cancel to the deployment cancel path', async () => { + const { client, requests, messages } = fakeRestClient({}); + + await new DeploymentsApi(client).cancel({ ...SCOPE, deployment: DEPLOYMENT }); + + expect(requests).toEqual([ + { method: 'POST', path: `${BASE}/${DEPLOYMENT}/cancel`, orgUid: ORG, projectUid: PROJECT }, + ]); + expect('body' in requests[0]).toBe(false); + expect(messages[0]).toBe(DEPLOYMENT_ERROR_MESSAGES); + }); + + it.each([ + ['the code', [{ code: 'launch.DEPLOYMENT.CANCEL_NOT_ALLOWED', message: 'x' }]], + ['the raw message as the code', [{ code: CANCEL_NOT_ALLOWED_RAW_MESSAGE, message: CANCEL_NOT_ALLOWED_RAW_MESSAGE }]], + ['the raw message alone', [{ message: CANCEL_NOT_ALLOWED_RAW_MESSAGE }]], + ])('words a cancel refused with %s as the cancel-not-allowed message', async (_name, errors) => { + const rejection = new LaunchApiError(400, errors, DEPLOYMENT_ERROR_MESSAGES); + const client = { request: async () => Promise.reject(rejection) } as unknown as RestApiClient; + + const failure = (await new DeploymentsApi(client) + .cancel({ ...SCOPE, deployment: DEPLOYMENT }) + .catch((error: unknown) => error)) as LaunchApiError; + + expect(failure).toBeInstanceOf(LaunchApiError); + expect(failure.exitCode).toBe(1); + expect(failure.status).toBe(400); + expect(failure.code).toBe('launch.DEPLOYMENT.CANCEL_NOT_ALLOWED'); + expect(failure.message).toBe(`Deployment "${DEPLOYMENT}" cannot be cancelled because it is no longer in progress.`); + expect(failure.message).not.toBe(CANCEL_NOT_ALLOWED_RAW_MESSAGE); + }); + + it('leaves a 400 with another message, and a non-400 with the raw message, untouched on cancel', async () => { + const other = new LaunchApiError(400, [{ message: 'something else' }]); + const wrongStatus = new LaunchApiError(409, [{ message: CANCEL_NOT_ALLOWED_RAW_MESSAGE }]); + const notFound = new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'x' }], DEPLOYMENT_ERROR_MESSAGES); + const plain = new Error('boom'); + + for (const rejection of [other, wrongStatus, notFound, plain]) { + const client = { request: async () => Promise.reject(rejection) } as unknown as RestApiClient; + + await expect(new DeploymentsApi(client).cancel({ ...SCOPE, deployment: DEPLOYMENT })).rejects.toBe(rejection); + } + + expect(notFound.message).toBe('No deployment found with that UID.'); + }); }); diff --git a/src/deployments/deployments.api.ts b/src/deployments/deployments.api.ts index 6dc306ff..88f2514c 100644 --- a/src/deployments/deployments.api.ts +++ b/src/deployments/deployments.api.ts @@ -1,6 +1,14 @@ import { assertPage, hasUid, malformed, unwrap } from '../transport/envelope'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; -import { DEPLOYMENT_ERROR_MESSAGES } from './deployment.errors'; +import { isRecord } from '../core/values'; +import { LaunchApiError } from '../transport/errors'; +import { + CANCEL_NOT_ALLOWED_CODE, + CANCEL_NOT_ALLOWED_RAW_MESSAGE, + DEPLOYMENT_ERROR_MESSAGES, + DEPLOYMENT_NOT_FOUND_CODE, + cancelNotAllowedError, +} from './deployment.errors'; import type { Deployment, DeploymentResponse, DeploymentsPage, IdentifiedDeployment } from './types'; export * from './types'; @@ -14,12 +22,35 @@ export interface DeploymentScope { export interface ListDeploymentsParams extends DeploymentScope { limit?: number; skip?: number; + rollbackEligible?: boolean; } export interface GetDeploymentParams extends DeploymentScope { deployment: string; } +export interface CreateDeploymentParams extends DeploymentScope { + commitHash?: string; + uploadUid?: string; +} + +export interface RollbackDeploymentParams extends DeploymentScope { + deployment: string; + reason?: string; +} + +function isCancelNotAllowed(error: unknown): error is LaunchApiError { + return ( + error instanceof LaunchApiError && + error.errors.some( + (entry) => + entry.code === CANCEL_NOT_ALLOWED_CODE || + (error.status === 400 && + (entry.code === CANCEL_NOT_ALLOWED_RAW_MESSAGE || entry.message === CANCEL_NOT_ALLOWED_RAW_MESSAGE)), + ) + ); +} + export class DeploymentsApi { constructor(private readonly client: RestApiClient) {} @@ -37,7 +68,11 @@ export class DeploymentsApi { path: DeploymentsApi.base(params), orgUid: params.org, projectUid: params.project, - query: { limit: params.limit, skip: params.skip }, + query: { + limit: params.limit, + skip: params.skip, + ...(params.rollbackEligible === true ? { rollbackEligible: 'true' } : {}), + }, }); assertPage(response, 'deployments', 'deployment list'); @@ -57,9 +92,78 @@ export class DeploymentsApi { projectUid: params.project, }); + if (isRecord(response) && response.deployment === null) { + throw new LaunchApiError( + 404, + [{ code: DEPLOYMENT_NOT_FOUND_CODE, message: 'Deployment not found.' }], + DEPLOYMENT_ERROR_MESSAGES, + ); + } + return unwrap(response, 'deployment', 'deployment response'); } + async create(params: CreateDeploymentParams): Promise { + const body: { commitHash?: string; uploadUid?: string } = {}; + + if (params.commitHash !== undefined) { + body.commitHash = params.commitHash; + } + + if (params.uploadUid !== undefined) { + body.uploadUid = params.uploadUid; + } + + const response = await this.request({ + method: 'POST', + path: DeploymentsApi.base(params), + orgUid: params.org, + projectUid: params.project, + body, + }); + const deployment = unwrap(response, 'deployment', 'deployment response'); + + if (!hasUid(deployment)) { + throw malformed('The Launch API returned a deployment without a deployment uid.'); + } + + return deployment; + } + + async rollback(params: RollbackDeploymentParams): Promise { + const body: { deployment: string; reason?: string } = { deployment: params.deployment }; + const reason = params.reason?.trim(); + + if (reason !== undefined && reason !== '') { + body.reason = reason; + } + + await this.request({ + method: 'POST', + path: `${DeploymentsApi.base(params)}/rollback`, + orgUid: params.org, + projectUid: params.project, + body, + }); + } + + async cancel(params: GetDeploymentParams): Promise { + try { + await this.request({ + method: 'POST', + path: `${DeploymentsApi.pathOf(params)}/cancel`, + orgUid: params.org, + projectUid: params.project, + }); + } catch (error) { + if (isCancelNotAllowed(error)) { + throw cancelNotAllowedError(params.deployment, error.status); + } + + throw error; + } + } + async latest(params: DeploymentScope): Promise { const page = await this.list({ ...params, limit: 1, skip: 0 }); const [deployment] = page.deployments; diff --git a/src/deployments/types.ts b/src/deployments/types.ts index 7c4a25fb..0c3564c2 100644 --- a/src/deployments/types.ts +++ b/src/deployments/types.ts @@ -28,6 +28,8 @@ export interface Deployment { gitBranch?: string; commitHash?: string; commitMessage?: string; + environmentVariables?: unknown[] | null; + isCancellable?: boolean; createdAt?: string; updatedAt?: string; } diff --git a/src/environments/environment.create.test.ts b/src/environments/environment.create.test.ts index b409b038..6c93e66c 100644 --- a/src/environments/environment.create.test.ts +++ b/src/environments/environment.create.test.ts @@ -363,24 +363,20 @@ describe('EnvironmentCreator', () => { expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1); expect((failure as Error).message).toBe( - 'The deployment did not succeed; its last status was FAILED. ' + - 'The environment "staging" (env9) was created and has not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see why it did not succeed.', + 'Deployment "dep1" ended with status FAILED. The environment "staging" (env9) was created. ' + + 'Run csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to see the cause.', ); expect(lines).toContain('\u2714 Environment created successfully.'); expect(calls.some((call) => call.startsWith('announce'))).toBe(false); }); - it('words a wait that ran out as possibly still finishing, naming deployments:get instead of a retry', async () => { + it('words a wait that ran out with the status and deployments:get', async () => { const { creator } = creatorUnderTest({ outcome: { kind: 'timed-out', status: 'DEPLOYING' } }); await expect(creator.create(request())).rejects.toThrow( new DeploymentUnsuccessfulError( - 'The deployment was still DEPLOYING when the CLI stopped waiting for it; it may still finish. ' + - 'The environment "staging" (env9) was created. Do not start another deployment yet: ' + - 'run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to see how it ends, ' + - 'or csdx launch:logs:get --org org1 --project proj1 --env env9 --deployment dep1 to follow it.', + 'Deployment "dep1" was still DEPLOYING when the CLI stopped waiting. The environment "staging" (env9) was created. ' + + 'Run csdx launch:deployments:get --org org1 --project proj1 --env env9 --deployment dep1 to check its status.', ), ); }); @@ -400,10 +396,8 @@ describe('EnvironmentCreator', () => { await expect(creator.create(request())).rejects.toThrow( new DeploymentUnsuccessfulError( - 'The deployment did not succeed; its last status was NONE. ' + - 'The environment "staging" (env9) was created and has not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project proj1 --env env9 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project proj1 --env env9 to see why it did not succeed.', + 'No deployment was found to follow. The environment "staging" (env9) was created. ' + + 'Run csdx launch:deployments:list --org org1 --project proj1 --env env9 to check.', ), ); expect(calls.filter((call) => call.startsWith('latest'))).toHaveLength(3); @@ -411,17 +405,21 @@ describe('EnvironmentCreator', () => { }); it.each([ - [{ latestFails: new Error('lookup refused') }, ''], - [{ watchFails: new Error('watch refused') }, ' --deployment dep1'], - ])('words a failed step after the create the same partial-success way (%p)', async (fails, deployment) => { + [{ latestFails: new Error('lookup refused') }, '', 'The deployment'], + [{ watchFails: new Error('watch refused') }, ' --deployment dep1', 'Deployment "dep1"'], + ])('words a failed step after the create the same partial-success way (%p)', async (fails, deployment, subject) => { const { creator } = creatorUnderTest(fails); const failure = await creator.create(request()).catch((error: unknown) => error); expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); - expect((failure as Error).message).toMatch(/^The deployment could not be followed to completion: \w+ refused\. /); - expect((failure as Error).message).toContain('The environment "staging" (env9) was created and has not been rolled back.'); - expect((failure as Error).message).toContain(`csdx launch:logs:get --org org1 --project proj1 --env env9${deployment} to see`); + expect((failure as Error).message).toMatch( + new RegExp(`^${subject} could not be followed to completion: \\w+ refused\\. `), + ); + expect((failure as Error).message).toContain('The environment "staging" (env9) was created.'); + expect((failure as Error).message).toContain( + `csdx launch:deployments:get --org org1 --project proj1 --env env9${deployment} to check its status.`, + ); }); it('propagates a refused create without saying the environment was created', async () => { @@ -440,7 +438,7 @@ describe('EnvironmentCreator', () => { }); await expect(creator.create(request({ name: 'pre-release' }))).rejects.toThrow( - 'The environment "pre-release" (env9) was created and has not been rolled back.', + 'The environment "pre-release" (env9) was created.', ); }); }); diff --git a/src/environments/environment.inputs.test.ts b/src/environments/environment.inputs.test.ts index cb78aa34..fdf78e5a 100644 --- a/src/environments/environment.inputs.test.ts +++ b/src/environments/environment.inputs.test.ts @@ -226,7 +226,7 @@ describe('the --env input', () => { it('prompts with the environment picker scoped to the resolved organization and project', async () => { const { services: s, listed, asked } = envServices([]); - await expect(environmentResolution.env.prompt({ services: s, resolved: RESOLVED_SCOPE })).resolves.toBe('e9'); + await expect(environmentResolution.env.prompt({ input: {}, services: s, resolved: RESOLVED_SCOPE })).resolves.toBe('e9'); expect(listed).toEqual([{ ...RESOLVED_SCOPE, limit: 100, skip: 0 }]); expect((asked[0] as { message: string }).message).toBe('Choose an environment'); }); diff --git a/src/environments/environments.api.test.ts b/src/environments/environments.api.test.ts index 77489629..0d6fb801 100644 --- a/src/environments/environments.api.test.ts +++ b/src/environments/environments.api.test.ts @@ -54,7 +54,10 @@ async function drain(iterator: AsyncGenerator): Promise { it('lists environments as a project-scoped GET carrying limit and skip', async () => { - const page = { pagination: { count: 1, limit: 10, skip: 0 }, environments: [{ uid: ENVIRONMENT, name: 'Default' }] }; + const page = { + pagination: { count: 1, limit: 10, skip: 0 }, + environments: [{ uid: ENVIRONMENT, name: 'Default' }], + }; const { client, requests, messages } = fakeRestClient(page); const result = await new EnvironmentsApi(client).list({ ...SCOPE, limit: 10, skip: 0 }); @@ -108,7 +111,10 @@ describe('EnvironmentsApi', () => { it.each([[undefined], [null], [''], [' ']])( 'refuses a first environment whose uid is %p rather than handing back one nothing can address', async (uid) => { - const { client } = fakeRestClient({ pagination: { count: 1, limit: 1 }, environments: [{ uid, name: 'Default' }] }); + const { client } = fakeRestClient({ + pagination: { count: 1, limit: 1 }, + environments: [{ uid, name: 'Default' }], + }); const failure = await new EnvironmentsApi(client).first(SCOPE).catch((error: Error) => error); @@ -146,7 +152,9 @@ describe('EnvironmentsApi', () => { .catch((error: Error) => error); expect(failure).toBeInstanceOf(LaunchApiError); - expect((failure as Error).message).toBe('The Launch API returned an environment response without an environment.'); + expect((failure as Error).message).toBe( + 'The Launch API returned an environment response without an environment.', + ); }, ); @@ -209,7 +217,9 @@ describe('EnvironmentsApi', () => { }); it('stops once the reported count is reached exactly, even though the last page was full', async () => { - const { client, requests } = pagingRestClient([{ count: ENVIRONMENT_SCAN_PAGE_SIZE, size: ENVIRONMENT_SCAN_PAGE_SIZE }]); + const { client, requests } = pagingRestClient([ + { count: ENVIRONMENT_SCAN_PAGE_SIZE, size: ENVIRONMENT_SCAN_PAGE_SIZE }, + ]); const collected = await drain(new EnvironmentsApi(client).pages(SCOPE)); @@ -403,7 +413,12 @@ describe('EnvironmentsApi', () => { 'refuses a create response %p that carries no addressable environment', async (body) => { const { client } = fakeRestClient(body); - const input = { name: 'x', outputDirectory: './out', frameworkPreset: 'OTHER' as const, environmentVariables: [] }; + const input = { + name: 'x', + outputDirectory: './out', + frameworkPreset: 'OTHER' as const, + environmentVariables: [], + }; await expect(new EnvironmentsApi(client).create({ ...SCOPE, input })).rejects.toBeInstanceOf(LaunchApiError); }, @@ -414,4 +429,49 @@ describe('EnvironmentsApi', () => { await expect(new EnvironmentsApi(client).first(SCOPE)).resolves.toBeUndefined(); }); + + it('reads the commits of an environment with a limit of 15 under the project scope and unwraps them', async () => { + const commits = [{ sha: randomBytes(20).toString('hex'), shortSha: 'abc1234', message: 'm' }]; + const { client, requests, messages } = fakeRestClient({ pagination: { count: 1 }, commits }); + + const result = await new EnvironmentsApi(client).commits({ ...SCOPE, environment: ENVIRONMENT }); + + expect(result).toEqual(commits); + expect(requests).toEqual([ + { + method: 'GET', + path: `/projects/${PROJECT}/environments/${ENVIRONMENT}/commits`, + orgUid: ORG, + projectUid: PROJECT, + query: { limit: 15 }, + }, + ]); + expect(messages).toEqual([ENVIRONMENT_ERROR_MESSAGES]); + }); + + it.each([ + ['an empty body', {}], + ['a non-array member', { commits: 'x' }], + ['a null body', null], + ])('raises a malformed-response error for %s on the commits read', async (_label, body) => { + const { client } = fakeRestClient(body); + + const failure = await new EnvironmentsApi(client) + .commits({ ...SCOPE, environment: ENVIRONMENT }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(LaunchApiError); + expect((failure as Error).message).toContain('commit list'); + }); + + it('propagates a failed commits read unchanged', async () => { + const failure = new LaunchApiError(500, []); + const client = { + request: async () => { + throw failure; + }, + } as unknown as RestApiClient; + + await expect(new EnvironmentsApi(client).commits({ ...SCOPE, environment: ENVIRONMENT })).rejects.toBe(failure); + }); }); diff --git a/src/environments/environments.api.ts b/src/environments/environments.api.ts index 118581ce..1aa525a0 100644 --- a/src/environments/environments.api.ts +++ b/src/environments/environments.api.ts @@ -1,11 +1,12 @@ import { MAX_PAGES } from '../core/constants'; import { scanPages } from '../core/paging'; import { UsageError } from '../core/errors'; -import { assertPage, hasUid, malformed, unwrap } from '../transport/envelope'; +import { assertArray, assertPage, hasUid, malformed, unwrap } from '../transport/envelope'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; import { DUPLICATE_NAME_CODE, ENVIRONMENT_ERROR_MESSAGES } from './environment.errors'; import type { + Commit, CreateEnvironmentInput, Environment, EnvironmentUpdate, @@ -23,6 +24,7 @@ export interface ListEnvironmentsParams { } export const ENVIRONMENT_SCAN_PAGE_SIZE = 100; +export const COMMIT_PICKER_LIMIT = 15; export interface PageEnvironmentsParams { org: string; @@ -155,6 +157,20 @@ export class EnvironmentsApi { }); } + async commits(params: EnvironmentParams): Promise { + const response = await this.request<{ commits: Commit[] }>({ + method: 'GET', + path: `/projects/${params.project}/environments/${params.environment}/commits`, + orgUid: params.org, + projectUid: params.project, + query: { limit: COMMIT_PICKER_LIMIT }, + }); + + assertArray(response, 'commits', 'commit list'); + + return response.commits; + } + async first(params: { org: string; project: string }): Promise { const page = await this.list({ ...params, limit: 1, skip: 0 }); const [environment] = page.environments; diff --git a/src/environments/types.ts b/src/environments/types.ts index e3d180fe..52562664 100644 --- a/src/environments/types.ts +++ b/src/environments/types.ts @@ -4,6 +4,19 @@ import type { FrameworkPreset } from './frameworks'; export type { Pagination }; +export interface CommitAuthor { + name?: string; + date?: string; +} + +export interface Commit { + sha?: string; + shortSha?: string; + message?: string; + author?: CommitAuthor; + url?: string; +} + export interface EnvironmentVariableInput { key: string; value: string; diff --git a/src/projects/project.create.test.ts b/src/projects/project.create.test.ts index 515fb68f..b4989f65 100644 --- a/src/projects/project.create.test.ts +++ b/src/projects/project.create.test.ts @@ -425,7 +425,7 @@ describe('ProjectCreator on the GitHub path', () => { gitProviderMetadata: { gitProvider: 'GitHub' }, }, }); - expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL https://my-site.example.test']); + expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL: https://my-site.example.test']); }); it('sends the project description and the two toggles only when they were supplied', async () => { @@ -767,7 +767,7 @@ describe('ProjectCreator on the GitHub path', () => { await creator.create(gitRequest()); expect(printed).not.toContain(CONNECTION_IDENTIFIED); - expect(printed).toEqual([repositoryFound(), PROJECT_CREATED, 'Deployment URL https://my-site.example.test']); + expect(printed).toEqual([repositoryFound(), PROJECT_CREATED, 'Deployment URL: https://my-site.example.test']); expect(gitCalls[0]).toEqual({ org: ORG, provider: 'GitHub', namespace: 'my-org', search: 'my-repo', limit: 100, skip: 0 }); expect(created).toHaveLength(1); }); @@ -1282,7 +1282,7 @@ describe('ProjectCreator on the FileUpload path', () => { const failure = await creator.create({ org: ORG, dataDir, configPath: configPathIn(dataDir) }).catch((error: Error) => error); expect(failure).toBeInstanceOf(UsageError); - expect((failure as Error).message).toContain('over the 100 MB Launch accepts for a file upload'); + expect((failure as Error).message).toContain('above the 100 MB maximum for an upload'); expect(calls.signedUploadUrl).toEqual([]); expect(uploadArchive).not.toHaveBeenCalled(); expect(asked).toEqual(['Choose a project type to proceed']); @@ -1295,7 +1295,7 @@ describe('ProjectCreator on the FileUpload path', () => { const failure = await creator.create(uploadRequest()).catch((error: Error) => error); expect(failure).toBeInstanceOf(UsageError); - expect((failure as Error).message).toContain('under the 1 KB Launch accepts for a file upload'); + expect((failure as Error).message).toContain('below the 1 KB minimum for an upload'); expect(calls.signedUploadUrl).toEqual([]); expect(uploadArchive).not.toHaveBeenCalled(); expect(created).toEqual([]); @@ -1336,7 +1336,7 @@ describe('ProjectCreator on the FileUpload path', () => { }); const { creator, printed } = harness(); - await expect(creator.create(uploadRequest())).rejects.toThrow('over the 100 MB Launch accepts'); + await expect(creator.create(uploadRequest())).rejects.toThrow('above the 100 MB maximum for an upload'); expect(printed).toEqual(['Preparing zip file...']); }); @@ -1406,7 +1406,7 @@ describe('ProjectCreator on the FileUpload path', () => { writeFileSync(join(dataDir, '.env'), 'SECRET=1'); const { creator } = harness(); - await expect(creator.create(uploadRequest())).rejects.toThrow('Nothing to upload'); + await expect(creator.create(uploadRequest())).rejects.toThrow('No files to upload were found in'); expect(uploadArchive).not.toHaveBeenCalled(); }); @@ -1470,13 +1470,13 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL https://my-site.example.test']); + expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL: https://my-site.example.test']); }); it.each([ - [true, false, 'Deployment URL https://my-site.example.test'], - [false, true, '\u001b[1mDeployment URL\u001b[22m \u001b[36mhttps://my-site.example.test\u001b[39m'], - [false, undefined, 'Deployment URL https://my-site.example.test'], + [true, false, 'Deployment URL: https://my-site.example.test'], + [false, true, '\u001b[1mDeployment URL:\u001b[22m \u001b[36mhttps://my-site.example.test\u001b[39m'], + [false, undefined, 'Deployment URL: https://my-site.example.test'], ])( 'styles the deployment url by where the output goes, not by stdin (stdin terminal: %s, output terminal: %s)', async (isTTY, outputIsTTY, line) => { @@ -1493,7 +1493,7 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toContain('Deployment URL https://my-site.example.test'); + expect(printed).toContain('Deployment URL: https://my-site.example.test'); }); it('exits 1 on a failed deployment, saying what survived and how to retry and inspect it', async () => { @@ -1504,11 +1504,9 @@ describe('ProjectCreator waiting on the first deployment', () => { expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1); expect((failure as Error).message).toBe( - 'The deployment did not succeed; its last status was FAILED. ' + - 'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' + - 'to see why it did not succeed.', + 'Deployment "d1" ended with status FAILED. ' + + 'The project "My Site" (p1) and its environment "Default" were created. ' + + 'Run csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 to see the cause.', ); }); @@ -1518,8 +1516,7 @@ describe('ProjectCreator waiting on the first deployment', () => { const failure = await creator.create(gitRequest()).catch((error: Error) => error); expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); - expect((failure as Error).message).toContain('The deployment was still DEPLOYING when the CLI stopped waiting'); - expect((failure as Error).message).toContain('Do not start another deployment yet'); + expect((failure as Error).message).toContain('was still DEPLOYING when the CLI stopped waiting.'); expect((failure as Error).message).not.toContain('deployments:create'); }); @@ -1529,7 +1526,7 @@ describe('ProjectCreator waiting on the first deployment', () => { const failure = await creator.create(gitRequest()).catch((error: Error) => error); expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); - expect((failure as Error).message).toContain('its last status was NONE'); + expect((failure as Error).message).toContain('No deployment was found to follow.'); expect((failure as Error).message).not.toContain('--deployment'); }); @@ -1538,7 +1535,7 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toContain('Deployment URL https://my-site.example.test'); + expect(printed).toContain('Deployment URL: https://my-site.example.test'); }); it('waits for the first environment to appear rather than calling an empty page a failure', async () => { @@ -1548,7 +1545,7 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toContain('Deployment URL https://my-site.example.test'); + expect(printed).toContain('Deployment URL: https://my-site.example.test'); }); it('exits 1 disclosing the project when looking up its first environment failed', async () => { @@ -1560,7 +1557,7 @@ describe('ProjectCreator waiting on the first deployment', () => { expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1); expect((failure as Error).message).toContain('Launch API request failed with status 502.'); expect((failure as Error).message).toContain('The project "My Site" (p1)'); - expect((failure as Error).message).toContain('have not been rolled back'); + expect((failure as Error).message).toContain('were created.'); expect((failure as Error).message).not.toContain('--environment'); expect(calls.latest).toEqual([]); }); @@ -1586,7 +1583,7 @@ describe('ProjectCreator waiting on the first deployment', () => { expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1); expect((failure as Error).message).toContain('The Launch API answered 502 Bad Gateway.'); - expect((failure as Error).message).toContain('have not been rolled back'); + expect((failure as Error).message).toContain('were created.'); expect((failure as Error).message).toContain('--org org1 --project p1'); expect((failure as Error).message).toContain('--env e1'); expect((failure as Error).message).toContain('--deployment d1'); @@ -1599,7 +1596,7 @@ describe('ProjectCreator waiting on the first deployment', () => { expect(failure).toBeInstanceOf(DeploymentUnsuccessfulError); expect((failure as Error).message).toContain('socket hang up.'); - expect((failure as Error).message).toContain('have not been rolled back'); + expect((failure as Error).message).toContain('were created.'); }); it('exits 1 naming no environment when the API reported none either', async () => { @@ -1627,7 +1624,7 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toContain('Deployment URL https://domain.example.test'); + expect(printed).toContain('Deployment URL: https://domain.example.test'); }); it('reports no url at all when neither the deployment nor the environment has one', async () => { @@ -1654,7 +1651,7 @@ describe('ProjectCreator waiting on the first deployment', () => { expect(SITE_OPEN_DELAY_MS).toBe(6000); expect(opened).toEqual(['https://my-site.example.test']); expect(timeline.slice(-3)).toEqual([ - 'print Deployment URL https://my-site.example.test', + 'print Deployment URL: https://my-site.example.test', 'sleep 6000', 'open https://my-site.example.test', ]); @@ -1700,7 +1697,7 @@ describe('ProjectCreator waiting on the first deployment', () => { await creator.create(gitRequest()); - expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL https://my-site.example.test']); + expect(printed).toEqual([CONNECTION_IDENTIFIED, repositoryFound(), PROJECT_CREATED, 'Deployment URL: https://my-site.example.test']); expect(timeline).not.toContain(`sleep ${SITE_OPEN_DELAY_MS}`); }); diff --git a/src/projects/project.inputs.test.ts b/src/projects/project.inputs.test.ts index a1956452..8b1df082 100644 --- a/src/projects/project.inputs.test.ts +++ b/src/projects/project.inputs.test.ts @@ -139,7 +139,7 @@ describe('the --type flag', () => { }; await expect( - projectResolution.type.prompt({ services: { api: {} as ApiSurface, ux, isTTY: true }, resolved: {} }), + projectResolution.type.prompt({ input: {}, services: { api: {} as ApiSurface, ux, isTTY: true }, resolved: {} }), ).resolves.toBe('GitHub'); expect(asked).toEqual(['Choose a project type to proceed']); }); diff --git a/src/projects/project.presenter.test.ts b/src/projects/project.presenter.test.ts index 83e275ec..37dcf02e 100644 --- a/src/projects/project.presenter.test.ts +++ b/src/projects/project.presenter.test.ts @@ -55,7 +55,7 @@ describe('projectNotUpdatedLine', () => { }); describe('project create presentation', () => { - it('says the project and environment survived a failed deployment and how to retry and inspect it', () => { + it('says the project and environment survived a failed deployment and what the cause command is', () => { const message = deploymentFailureMessage({ org: 'org1', projectName: 'My Site', @@ -67,15 +67,13 @@ describe('project create presentation', () => { }); expect(message).toBe( - 'The deployment did not succeed; its last status was FAILED. ' + - 'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' + - 'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' + - 'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' + - 'to see why it did not succeed.', + 'Deployment "d1" ended with status FAILED. ' + + 'The project "My Site" (p1) and its environment "Default" were created. ' + + 'Run csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 to see the cause.', ); }); - it('tells a user whose wait ran out that the deployment may still finish, and never to start another one', () => { + it('tells a user whose wait ran out the deployment status and how to check it', () => { const message = deploymentFailureMessage({ org: 'org1', projectName: 'My Site', @@ -88,10 +86,9 @@ describe('project create presentation', () => { }); expect(message).toBe( - 'The deployment was still DEPLOYING when the CLI stopped waiting for it; it may still finish. ' + - 'The project "My Site" (p1) and its environment "Default" were created. Do not start another deployment yet: ' + - 'run csdx launch:deployments:get --org org1 --project p1 --env e1 --deployment d1 to see how it ends, ' + - 'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 to follow it.', + 'Deployment "d1" was still DEPLOYING when the CLI stopped waiting. ' + + 'The project "My Site" (p1) and its environment "Default" were created. ' + + 'Run csdx launch:deployments:get --org org1 --project p1 --env e1 --deployment d1 to check its status.', ); expect(message).not.toContain('deployments:create'); }); @@ -106,7 +103,6 @@ describe('project create presentation', () => { }); expect(message).not.toContain('undefined'); - expect(message).toContain('Run csdx launch:deployments:create --org org1 --project p1 to try the deployment again'); - expect(message).toContain('or csdx launch:logs:get --org org1 --project p1 to see why it did not succeed.'); + expect(message).toContain('Run csdx launch:deployments:list --org org1 --project p1 to check.'); }); }); diff --git a/src/resources.test.ts b/src/resources.test.ts index 2a7af82b..89c2056a 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -98,7 +98,7 @@ describe('resolution', () => { const spy = jest.spyOn(prompt, 'promptForProject').mockResolvedValue(PROJECT_UID); const { services, resolved } = args(); - await expect(table.project.prompt?.({ services, resolved })).resolves.toBe(PROJECT_UID); + await expect(table.project.prompt?.({ input: {}, services, resolved })).resolves.toBe(PROJECT_UID); expect(spy).toHaveBeenCalledWith(services, 'org1'); spy.mockRestore(); @@ -177,7 +177,7 @@ describe('resolution', () => { const spy = jest.spyOn(organizationPrompt, 'promptForOrganization').mockResolvedValue('org9'); const { services } = args(); - await expect(table.org.prompt?.({ services, resolved: {} })).resolves.toBe('org9'); + await expect(table.org.prompt?.({ input: {}, services, resolved: {} })).resolves.toBe('org9'); expect(spy).toHaveBeenCalledWith(services); expect(table.org.normalize).toBeUndefined(); expect(table.org.dependsOn).toBeUndefined(); diff --git a/src/resources.ts b/src/resources.ts index c30530ae..b9f30b2f 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -54,9 +54,7 @@ export const DEPENDENCIES = { ...PROJECT_DEPENDENCIES, ...ENVIRONMENT_DEPENDENCIES, ...DEPLOYMENT_DEPENDENCIES, -} satisfies Partial< - Record ->; +} satisfies Partial>; export type DependenciesOf = K extends keyof typeof DEPENDENCIES ? (typeof DEPENDENCIES)[K][number] diff --git a/src/transport/errors.test.ts b/src/transport/errors.test.ts index 3ef1b960..c5d1daa4 100644 --- a/src/transport/errors.test.ts +++ b/src/transport/errors.test.ts @@ -50,6 +50,64 @@ describe('parseErrorEnvelope', () => { expect(error.message).toBe('too long'); }); + it('words a field-keyed validation failure whose message is only Bad request from the field and its code', () => { + const error = parseErrorEnvelope(400, { + errors: [{ environment: { code: 'property environment should not exist', message: 'Bad request' } }], + status: 400, + }); + + expect(error.message).toBe('The Launch API rejected the request: environment: property environment should not exist'); + expect(error.status).toBe(400); + expect(error.exitCode).toBe(1); + expect(error.code).toBe('property environment should not exist'); + }); + + it('names at most the first three rejected fields, each with its own detail', () => { + const error = parseErrorEnvelope(400, { + errors: [ + { a: { code: 'a is bad', message: 'Bad request' } }, + { b: { code: 'b is bad' } }, + { e: { message: 'no code' } }, + { c: { code: 'c-code', message: 'c is bad' } }, + { d: { code: 'd is bad', message: 'Bad request' } }, + { code: 'launch.BODY_EMPTY', message: 'empty' }, + ], + }); + + expect(error.message).toBe('The Launch API rejected the request: a: a is bad; b: b is bad; c: c is bad'); + }); + + it.each([['Bad request'], ['bad request'], [' Bad request '], ['']])( + 'treats %j as the generic message and falls back to the code', + (message) => { + const error = parseErrorEnvelope(400, { errors: [{ name: { code: 'name is bad', message } }] }); + + expect(error.message).toBe('The Launch API rejected the request: name: name is bad'); + }, + ); + + it('keeps a mapped code wording over the field-keyed wording', () => { + const error = parseErrorEnvelope( + 400, + { errors: [{ name: { code: 'launch.RESOURCE.DUPLICATE_NAME', message: 'Bad request' } }] }, + MESSAGES, + ); + + expect(error.message).toBe('Something of that name already exists.'); + }); + + it('falls back to the message when the first field has a generic message and no code', () => { + const error = parseErrorEnvelope(400, { errors: [{ name: { message: 'Bad request' } }] }); + + expect(error.message).toBe('Bad request'); + }); + + it('leaves a Bad request with no field alone', () => { + const error = parseErrorEnvelope(400, { errors: [{ code: 'x', message: 'Bad request' }] }); + + expect(error.message).toBe('Bad request'); + }); + it.each<[string, unknown]>([ ['a field whose value is not an object', { name: 'oops' }], ['a field whose value is null', { name: null }], diff --git a/src/transport/errors.ts b/src/transport/errors.ts index 8920991a..982e83d0 100644 --- a/src/transport/errors.ts +++ b/src/transport/errors.ts @@ -15,6 +15,29 @@ export function messageForCode(messages: ErrorMessages, code?: string): string | return code === undefined ? undefined : messages[code]; } +const GENERIC_REJECTION = 'bad request'; + +const FIELDS_NAMED = 3; + +function isGeneric(message: string | undefined): boolean { + return message === undefined || message.trim() === '' || message.trim().toLowerCase() === GENERIC_REJECTION; +} + +function rejectedFields(errors: ApiErrorEntry[]): string | undefined { + const [primary] = errors; + + if (primary?.field === undefined || !isGeneric(primary.message)) { + return undefined; + } + + const details = errors + .filter((entry) => entry.field !== undefined && entry.code !== undefined) + .slice(0, FIELDS_NAMED) + .map((entry) => `${entry.field}: ${isGeneric(entry.message) ? entry.code : entry.message}`); + + return details.length === 0 ? undefined : `The Launch API rejected the request: ${details.join('; ')}`; +} + export class LaunchApiError extends LaunchError { readonly exitCode = EXIT_RUNTIME; @@ -25,7 +48,10 @@ export class LaunchApiError extends LaunchError { constructor(status: number, errors: ApiErrorEntry[], messages: ErrorMessages = {}) { const [primary] = errors; super( - messageForCode(messages, primary?.code) ?? primary?.message ?? `Launch API request failed with status ${status}.`, + messageForCode(messages, primary?.code) ?? + rejectedFields(errors) ?? + primary?.message ?? + `Launch API request failed with status ${status}.`, ); this.name = 'LaunchApiError'; this.status = status; diff --git a/src/uploads/upload.archive.test.ts b/src/uploads/upload.archive.test.ts index cd4b2988..8a59a70b 100644 --- a/src/uploads/upload.archive.test.ts +++ b/src/uploads/upload.archive.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { UsageError } from '../core/errors'; import { UPLOAD_EXCLUDED_NAMES, archiveDirectory, isExcludedName } from './upload.archive'; +import { NoUploadDirectoryError } from './upload.errors'; let root: string; @@ -147,13 +148,46 @@ describe('project archive', () => { expect(() => archiveDirectory(missing)).toThrow(UsageError); expect(() => archiveDirectory(missing)).toThrow( - `No local project directory at "${missing}". Pass --data-dir with the folder you want to upload.`, + `No files to upload were found in "${missing}". Pass your project folder with --data-dir.`, + ); + }); + + it('marks a missing path, a file in place of a folder and an empty folder as having no uploadable directory', () => { + file('index.html'); + + expect(() => archiveDirectory(join(root, 'no-such-folder'))).toThrow(NoUploadDirectoryError); + expect(() => archiveDirectory(join(root, 'index.html'))).toThrow(NoUploadDirectoryError); + rmSync(join(root, 'index.html')); + expect(() => archiveDirectory(root)).toThrow(NoUploadDirectoryError); + }); + + it('words a missing path, a file in place of a folder and an empty folder the same way', () => { + file('index.html'); + const message = (folder: string): string => { + try { + archiveDirectory(folder); + return 'no error'; + } catch (error) { + return (error as Error).message.replace(folder, ''); + } + }; + const empty = join(root, 'empty'); + mkdirSync(empty); + + expect(message(join(root, 'no-such-folder'))).toBe( + 'No files to upload were found in "". Pass your project folder with --data-dir.', + ); + expect(message(join(root, 'index.html'))).toBe( + 'No files to upload were found in "". Pass your project folder with --data-dir.', + ); + expect(message(empty)).toBe( + 'No files to upload were found in "". Pass your project folder with --data-dir.', ); }); it('refuses an empty directory rather than uploading an empty archive', () => { expect(() => archiveDirectory(root)).toThrow(UsageError); - expect(() => archiveDirectory(root)).toThrow('Nothing to upload'); + expect(() => archiveDirectory(root)).toThrow(`No files to upload were found in "${root}".`); }); it('refuses a directory that holds nothing but excluded paths', () => { @@ -162,8 +196,7 @@ describe('project archive', () => { file('.cs-launch.json', '{}'); expect(() => archiveDirectory(root)).toThrow( - `Nothing to upload from "${root}" once ${UPLOAD_EXCLUDED_NAMES.join(', ')} are excluded. ` + - 'Pass --data-dir with the folder you want to upload.', + `No files to upload were found in "${root}". Pass your project folder with --data-dir.`, ); }); @@ -186,7 +219,7 @@ describe('project archive', () => { expect(failure).toBeInstanceOf(UsageError); expect((failure as UsageError).exitCode).toBe(2); expect((failure as UsageError).message).toContain('secret.txt'); - expect((failure as UsageError).message).toContain('could not be read'); + expect((failure as UsageError).message).toContain('cannot be read'); }); it('refuses with a usage error naming a directory it could not list', () => { @@ -208,7 +241,7 @@ describe('project archive', () => { expect(failure).toBeInstanceOf(UsageError); expect((failure as UsageError).message).toContain('locked'); - expect((failure as UsageError).message).toContain('could not be read'); + expect((failure as UsageError).message).toContain('cannot be read'); }); it('names every path the story requires it to exclude', () => { diff --git a/src/uploads/upload.archive.ts b/src/uploads/upload.archive.ts index 09f3a38f..86cc612a 100644 --- a/src/uploads/upload.archive.ts +++ b/src/uploads/upload.archive.ts @@ -4,6 +4,7 @@ import { lstatSync, readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { UsageError } from '../core/errors'; +import { NoUploadDirectoryError } from './upload.errors'; export const UPLOAD_EXCLUDED_NAMES: readonly string[] = [ 'node_modules', @@ -45,10 +46,13 @@ function directoryStats(root: string) { } } +function noFilesToUpload(root: string): string { + return `No files to upload were found in "${root}". Pass your project folder with --data-dir.`; +} + function unreadable(relative: string): UsageError { return new UsageError( - `The file "${relative}" could not be read, so the upload was not attempted. ` + - 'Fix its permissions, or pass --data-dir with a folder the CLI can read.', + `The file "${relative}" cannot be read. Fix its permissions or pass a different folder with --data-dir.`, ); } @@ -96,9 +100,7 @@ export function archiveDirectory(root: string, excludedFiles: readonly string[] const stats = directoryStats(root); if (stats === undefined || !stats.isDirectory()) { - throw new UsageError( - `No local project directory at "${root}". Pass --data-dir with the folder you want to upload.`, - ); + throw new NoUploadDirectoryError(noFilesToUpload(root)); } const found: Collected = { files: new Map(), links: [] }; @@ -111,10 +113,7 @@ export function archiveDirectory(root: string, excludedFiles: readonly string[] const entries = [...found.files.keys()].sort(); if (entries.length === 0) { - throw new UsageError( - `Nothing to upload from "${root}" once ${UPLOAD_EXCLUDED_NAMES.join(', ')} are excluded. ` + - 'Pass --data-dir with the folder you want to upload.', - ); + throw new NoUploadDirectoryError(noFilesToUpload(root)); } const zip = new AdmZip(); diff --git a/src/uploads/upload.errors.ts b/src/uploads/upload.errors.ts index de74d5b9..928e4c81 100644 --- a/src/uploads/upload.errors.ts +++ b/src/uploads/upload.errors.ts @@ -1,5 +1,5 @@ import { EXIT_RUNTIME } from '../core/constants'; -import { LaunchError } from '../core/errors'; +import { LaunchError, UsageError } from '../core/errors'; import type { ErrorMessages } from '../transport/errors'; export class UploadFailedError extends LaunchError { @@ -11,6 +11,13 @@ export class UploadFailedError extends LaunchError { } } +export class NoUploadDirectoryError extends UsageError { + constructor(message: string) { + super(message); + this.name = 'NoUploadDirectoryError'; + } +} + export const UPLOAD_ERROR_MESSAGES: ErrorMessages = { 'launch.PROJECT.FILE_UPLOAD_SIGNED_URL.GET_FAILED': 'The Launch API could not prepare an upload for your project files.', diff --git a/src/uploads/upload.transfer.test.ts b/src/uploads/upload.transfer.test.ts index 5778df4b..a32261dc 100644 --- a/src/uploads/upload.transfer.test.ts +++ b/src/uploads/upload.transfer.test.ts @@ -242,8 +242,7 @@ describe('refuseArchiveOutsideLimits', () => { it('names the archive size, the limit, and how to shrink the upload', () => { expect(() => refuseArchiveOutsideLimits(150 * 1024 * 1024)).toThrow( - 'Your project files zip to 150.0 MB, over the 100 MB Launch accepts for a file upload. ' + - 'Pass --data-dir with a folder holding only the files to deploy.', + 'The project archive is 150.0 MB, above the 100 MB maximum for an upload. Pass a smaller folder using --data-dir.', ); }); @@ -258,8 +257,7 @@ describe('refuseArchiveOutsideLimits', () => { it('refuses an archive under 1 KB before uploading, naming its size and how to fix it', () => { expect(() => refuseArchiveOutsideLimits(512)).toThrow(UsageError); expect(() => refuseArchiveOutsideLimits(512)).toThrow( - 'Your project files zip to 0.5 KB, under the 1 KB Launch accepts for a file upload. ' + - 'Pass --data-dir with the folder holding your site\'s files.', + 'The project archive is 0.5 KB, below the 1 KB minimum for an upload. Pass a folder with more files using --data-dir.', ); }); }); diff --git a/src/uploads/upload.transfer.ts b/src/uploads/upload.transfer.ts index f446c277..5cf5eacc 100644 --- a/src/uploads/upload.transfer.ts +++ b/src/uploads/upload.transfer.ts @@ -27,9 +27,8 @@ export const UPLOAD_CHUNK_BYTES = 256 * 1024; export function refuseArchiveOutsideLimits(bytes: number): void { if (bytes < MIN_UPLOAD_BYTES) { throw new UsageError( - `Your project files zip to ${(bytes / BYTES_PER_KB).toFixed(1)} KB, under the ` + - `${MIN_UPLOAD_BYTES / BYTES_PER_KB} KB Launch accepts for a file upload. ` + - 'Pass --data-dir with the folder holding your site\'s files.', + `The project archive is ${(bytes / BYTES_PER_KB).toFixed(1)} KB, below the ` + + `${MIN_UPLOAD_BYTES / BYTES_PER_KB} KB minimum for an upload. Pass a folder with more files using --data-dir.`, ); } @@ -38,9 +37,8 @@ export function refuseArchiveOutsideLimits(bytes: number): void { } throw new UsageError( - `Your project files zip to ${(bytes / BYTES_PER_MB).toFixed(1)} MB, over the ` + - `${MAX_UPLOAD_BYTES / BYTES_PER_MB} MB Launch accepts for a file upload. ` + - 'Pass --data-dir with a folder holding only the files to deploy.', + `The project archive is ${(bytes / BYTES_PER_MB).toFixed(1)} MB, above the ` + + `${MAX_UPLOAD_BYTES / BYTES_PER_MB} MB maximum for an upload. Pass a smaller folder using --data-dir.`, ); } diff --git a/test/fixtures/deployment-get.json b/test/fixtures/deployment-get.json new file mode 100644 index 00000000..61ba3673 --- /dev/null +++ b/test/fixtures/deployment-get.json @@ -0,0 +1,17 @@ +{ + "deployment": { + "uid": "d1e2f3a4b5c6d7e8f9012345", + "status": "LIVE", + "deploymentNumber": 2, + "gitBranch": "main", + "commitHash": "9fceb02d0ae598e95dc970b74767f19372d61af8", + "commitMessage": "Ship the homepage", + "deploymentUrl": "production-sample-project.contentstackapps.com", + "environmentVariables": [ + { "key": "KEY_ONE", "value": "value-one" }, + { "key": "KEY_TWO", "value": "value-two" } + ], + "createdAt": "2025-11-07T08:00:00.000Z", + "updatedAt": "2025-11-07T08:10:00.000Z" + } +} diff --git a/test/fixtures/deployment-not-found.json b/test/fixtures/deployment-not-found.json new file mode 100644 index 00000000..3144004e --- /dev/null +++ b/test/fixtures/deployment-not-found.json @@ -0,0 +1,9 @@ +{ + "errors": [ + { + "code": "launch.DEPLOYMENT.NOT_FOUND", + "message": "Deployment not found." + } + ], + "status": 404 +} diff --git a/test/fixtures/deployments-list.json b/test/fixtures/deployments-list.json new file mode 100644 index 00000000..dd9a7116 --- /dev/null +++ b/test/fixtures/deployments-list.json @@ -0,0 +1,29 @@ +{ + "pagination": { + "count": 2, + "limit": 100 + }, + "deployments": [ + { + "uid": "d1e2f3a4b5c6d7e8f9012345", + "status": "LIVE", + "deploymentNumber": 2, + "gitBranch": "main", + "commitHash": "9fceb02d0ae598e95dc970b74767f19372d61af8", + "commitMessage": "Ship the homepage", + "deploymentUrl": "production-sample-project.contentstackapps.com", + "createdAt": "2025-11-07T08:00:00.000Z", + "updatedAt": "2025-11-07T08:10:00.000Z" + }, + { + "uid": "c0d1e2f3a4b5c6d7e8f90123", + "status": "FAILED", + "deploymentNumber": 1, + "gitBranch": "develop", + "commitHash": "1111111aaaaaaa2222222bbbbbbb333333333ccc", + "commitMessage": "Break the build", + "createdAt": "2025-11-06T07:39:19.230Z", + "updatedAt": "2025-11-06T07:39:53.582Z" + } + ] +} diff --git a/test/guards/layering.test.ts b/test/guards/layering.test.ts index 11bfc7b6..f27742bf 100644 --- a/test/guards/layering.test.ts +++ b/test/guards/layering.test.ts @@ -13,7 +13,7 @@ const RESOURCE_EDGES: Record = { projects: ['environments', 'deployments', 'git', 'uploads', 'variables'], functions: [], environments: ['deployments', 'git', 'uploads', 'variables'], - deployments: ['site'], + deployments: ['uploads', 'site'], git: [], organizations: [], uploads: [], diff --git a/test/integration/deployments-cancel.test.ts b/test/integration/deployments-cancel.test.ts new file mode 100644 index 00000000..7dc79a96 --- /dev/null +++ b/test/integration/deployments-cancel.test.ts @@ -0,0 +1,435 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, cliux, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import getFixture from '../fixtures/deployment-get.json'; +import notFoundFixture from '../fixtures/deployment-not-found.json'; +import { CTRL_C, answerPrompts, onTerminal, pretendTerminal } 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 = 'e1f2a3b4c5d6e7f8091a2b3c'; +const DEPLOYMENT_UID = 'd1e2f3a4b5c6d7e8f9012345'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +const DEPLOYMENTS = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments`; +const QUESTION = `Are you sure you want to cancel deployment "${DEPLOYMENT_UID}"?`; +const CANCEL_PATH = `${DEPLOYMENTS}/${DEPLOYMENT_UID}/cancel`; +const POST_PENDING = `POST ${LAUNCH_HUB_URL}:443${CANCEL_PATH}`; +const BODYLESS = { badheaders: ['content-type'] }; +const RAW_REFUSAL = 'This deployment has progressed beyond the point where it can be cancelled.'; +const CANCEL_WORDING = `Deployment "${DEPLOYMENT_UID}" cannot be cancelled because it is no longer in progress.`; + +let config: Interfaces.Config; +let onWire: string[]; + +function recordWire(): void { + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); +} + +function hub(): nock.Scope { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +function args(...extra: string[]): string[] { + return [ + 'launch:deployments:cancel', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--env', + ENV_UID, + ...extra, + '--data-dir', + DATA_DIR, + ]; +} + +function cancelScope(): nock.Scope { + return nock(LAUNCH_HUB_URL, BODYLESS) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +const OLDER_UID = 'c0d1e2f3a4b5c6d7e8f90123'; +const RUNNING_UID = 'f1e2d3c4b5a60718293a4b5c'; +const QUEUED_UID = 'f2e2d3c4b5a60718293a4b5c'; +const LISTED = { + pagination: { count: 4, limit: 100 }, + deployments: [ + { uid: RUNNING_UID, status: 'DEPLOYING', commitHash: '1234567aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + { uid: QUEUED_UID, status: 'QUEUED' }, + { uid: DEPLOYMENT_UID, status: 'LIVE', commitHash: '9fceb02d0ae598e95dc970b74767f19372d61af8' }, + { uid: OLDER_UID, status: 'FAILED', createdAt: '2025-11-06T07:39:19.230Z' }, + ], +}; + +function listing(page: unknown = LISTED): nock.Scope { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + + return hub().get(DEPLOYMENTS).query({ limit: 100, skip: 0 }).reply(200, page as nock.Body); +} + +const IN_FLIGHT_FIXTURE = { deployment: { ...getFixture.deployment, status: 'DEPLOYING' } }; + +function lookup(body: unknown = IN_FLIGHT_FIXTURE): nock.Scope { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + + return hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, body as nock.Body); +} + +describe('integration: launch:deployments:cancel on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + recordWire(); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('looks the deployment up, then posts a bodyless cancel, when --yes is given', async () => { + const order: string[] = []; + const found = lookup(); + found.on('replied', () => order.push('lookup')); + const headers: Record[] = []; + const bodies: string[] = []; + const cancel = cancelScope() + .post(CANCEL_PATH, (body: unknown) => { + bodies.push(JSON.stringify(body)); + return true; + }) + .reply(200, getFixture); + cancel.on('request', (request: { headers: Record }) => { + order.push('cancel'); + headers.push(request.headers); + }); + const prompts = answerPrompts({}); + + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error).toBeUndefined(); + expect(order).toEqual(['lookup', 'cancel']); + expect(cancel.isDone()).toBe(true); + expect(bodies).toEqual(['""']); + expect(headers[0]['content-type']).toBeUndefined(); + expect(prompts.messages).toEqual([]); + expect(stdout).toBe(`✔ Deployment "${DEPLOYMENT_UID}" cancelled successfully.\n`); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming --deployment and sends nothing when it is omitted, empty or whitespace', async () => { + for (const extra of [[], ['--deployment', '""'], ['--deployment', '" "']]) { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + + const { error } = await runCommand(args(...extra, '--yes'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Missing required value for --deployment'); + expect(cancel.isDone()).toBe(false); + } + + expect(onWire).toEqual([]); + }); + + it('says the deployment was not cancelled, exits 3 and never posts when the user declines', async () => { + const found = lookup(); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + const restore = pretendTerminal(); + const inquired: unknown[] = []; + jest.spyOn(cliux, 'inquire').mockImplementation(async (payload: unknown) => { + inquired.push(payload); + return false as never; + }); + + try { + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(3); + expect(stdout).toBe('Deployment not cancelled.\n'); + expect(inquired).toEqual([{ type: 'confirm', name: 'confirm', message: QUESTION, default: false }]); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + } finally { + restore(); + } + }); + + it('exits 2 naming --yes, and never posts, without a terminal and without --yes', async () => { + const found = lookup(); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${QUESTION} Pass --yes to confirm without an interactive terminal.`); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it('exits 3 and never posts when Ctrl-C is pressed at the confirmation', async () => { + const found = lookup(); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + const prompts = answerPrompts({ [QUESTION]: CTRL_C }); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toBe('Cancelled. Nothing was changed.'); + expect(prompts.messages).toEqual([QUESTION]); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('posts exactly once and exits 0 when the user says yes at the prompt', async () => { + lookup(); + const cancel = cancelScope().post(CANCEL_PATH).once().reply(200, getFixture); + const prompts = answerPrompts({ [QUESTION]: true }); + + const { error, stdout } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([QUESTION]); + expect(cancel.isDone()).toBe(true); + expect(stdout).toBe(`✔ Deployment "${DEPLOYMENT_UID}" cancelled successfully.\n`); + expect(onWire).toEqual([]); + }); + + it('reports the generic not-found wording, exit 2, and never posts for an unknown or foreign deployment', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(404, notFoundFixture); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(prompts.messages).toEqual([]); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it.each([ + ['the CANCEL_NOT_ALLOWED code', { code: 'launch.DEPLOYMENT.CANCEL_NOT_ALLOWED', message: 'x' }], + ['the raw server message as the code', { code: RAW_REFUSAL, message: RAW_REFUSAL }], + ['the raw server message alone', { message: RAW_REFUSAL }], + ])('exits 1 with readable wording, posting once, when the cancel is refused with %s', async (_name, entry) => { + lookup(); + const cancel = cancelScope() + .post(CANCEL_PATH) + .once() + .reply(400, { errors: [entry], status: 400 }); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe(CANCEL_WORDING); + expect(cancel.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it.each([ + ['a finished deployment', getFixture], + ['a deployment the service marks as not cancellable', { deployment: { ...IN_FLIGHT_FIXTURE.deployment, isCancellable: false } }], + ])('asks nothing and posts nothing for %s, exiting 1 with the cancel wording', async (_name, body) => { + const found = lookup(body); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe(CANCEL_WORDING); + expect(prompts.messages).toEqual([]); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it('exits 1 with the deployment wording when the cancel itself answers 404 NOT_FOUND', async () => { + lookup(); + cancelScope().post(CANCEL_PATH).reply(404, notFoundFixture); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('No deployment found with that UID.'); + }); + + it('offers only the deployments in flight at a terminal when --deployment is omitted and cancels the chosen full uid', async () => { + const listed = listing(); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub() + .get(`${DEPLOYMENTS}/${QUEUED_UID}`) + .reply(200, { deployment: { uid: QUEUED_UID, status: 'QUEUED' } }); + const cancel = cancelScope().post(`${DEPLOYMENTS}/${QUEUED_UID}/cancel`).reply(200, {}); + const question = `Are you sure you want to cancel deployment "${QUEUED_UID}"?`; + const prompts = answerPrompts({ 'Choose a deployment for cancellation': QUEUED_UID, [question]: true }); + + const { error, stdout } = await onTerminal(() => runCommand(args(), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual(['Choose a deployment for cancellation', question]); + expect(prompts.payloads[0]).toMatchObject({ + type: 'list', + choices: [ + { name: `${RUNNING_UID} DEPLOYING 1234567`, value: RUNNING_UID }, + { name: `${QUEUED_UID} QUEUED`, value: QUEUED_UID }, + ], + }); + expect(listed.isDone()).toBe(true); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(true); + expect(stdout).toBe(`✔ Deployment "${QUEUED_UID}" cancelled successfully.\n`); + expect(onWire).toEqual([]); + }); + + it('never lists or prompts for a deployment when --deployment is given, and the list interceptor stays pending', async () => { + lookup(); + const listed = hub().get(DEPLOYMENTS).query({ limit: 100, skip: 0 }).reply(200, LISTED); + cancelScope().post(CANCEL_PATH).reply(200, {}); + const prompts = answerPrompts({ [QUESTION]: true }); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([QUESTION]); + expect(listed.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('exits 2 saying nothing is in progress, with nothing asked, when every deployment has finished', async () => { + listing({ + pagination: { count: 2, limit: 100 }, + deployments: [ + { uid: DEPLOYMENT_UID, status: 'LIVE' }, + { uid: OLDER_UID, status: 'FAILED' }, + ], + }); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args(), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('No in-progress deployments are available for cancellation.'); + expect(prompts.messages).toEqual([]); + expect(onWire).toEqual([]); + }); + + it('leaves a deployment the server marks not cancellable out of the picker', async () => { + listing({ + pagination: { count: 2, limit: 100 }, + deployments: [ + { uid: RUNNING_UID, status: 'DEPLOYING', isCancellable: false }, + { uid: QUEUED_UID, status: 'QUEUED' }, + ], + }); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + hub().get(`${DEPLOYMENTS}/${QUEUED_UID}`).reply(200, { deployment: { uid: QUEUED_UID, status: 'QUEUED' } }); + cancelScope().post(`${DEPLOYMENTS}/${QUEUED_UID}/cancel`).reply(200, {}); + const prompts = answerPrompts({ + 'Choose a deployment for cancellation': QUEUED_UID, + [`Are you sure you want to cancel deployment "${QUEUED_UID}"?`]: true, + }); + + const { error } = await onTerminal(() => runCommand(args(), config)); + + expect(error).toBeUndefined(); + expect(prompts.payloads[0]).toMatchObject({ choices: [{ name: `${QUEUED_UID} QUEUED`, value: QUEUED_UID }] }); + expect(onWire).toEqual([]); + }); + + it('exits 3 and cancels nothing when Ctrl-C is pressed at the deployment picker', async () => { + listing(); + const cancel = cancelScope().post(`${DEPLOYMENTS}/${RUNNING_UID}/cancel`).reply(200, {}); + const prompts = answerPrompts({ 'Choose a deployment for cancellation': CTRL_C }); + + const { error } = await onTerminal(() => runCommand(args(), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toBe('Cancelled. Nothing was changed.'); + expect(prompts.messages).toEqual(['Choose a deployment for cancellation']); + expect(cancel.isDone()).toBe(false); + expect(onWire).toEqual([]); + expect(process.listenerCount('SIGINT')).toBe(0); + }); + + it('exits 2 for a deployment uid of another environment, answered as 200 with a null deployment, and never cancels', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, { deployment: null }); + const cancel = cancelScope().post(CANCEL_PATH).reply(200, {}); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(found.isDone()).toBe(true); + expect(cancel.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it('exits 2 for a malformed deployment uid and sends nothing at all', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/6accca6d34`).reply(200, getFixture); + + const { error } = await runCommand(args('--deployment', '6accca6d34', '--yes'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('Invalid --deployment value "6accca6d34". Provide a 24-character hexadecimal deployment UID.'); + expect(found.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); +}); diff --git a/test/integration/deployments-create.test.ts b/test/integration/deployments-create.test.ts new file mode 100644 index 00000000..bd858942 --- /dev/null +++ b/test/integration/deployments-create.test.ts @@ -0,0 +1,689 @@ +import { randomBytes } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import * as watcher from '../../src/deployments/deployment.watcher'; +import fileUploadProject from '../fixtures/project-get-fileupload.json'; +import { answerPrompts, CTRL_C, onTerminal, stdinReportingTTY, stdoutReportingTTY } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; + +jest.mock('open', () => jest.fn().mockResolvedValue(undefined)); + +const realWatchTiming = watcher.defaultWatchTiming; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const UPLOAD_HOST = 'https://uploads.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = 'a1b2c3d4e5f60718293a4b5c'; +const ENV_UID = 'e1f2a3b4c5d6e7f8091a2b3c'; +const DEPLOYMENT_UID = 'd1e2f3a4b5c6d7e8f9012345'; +const COMMIT = '9fceb02d0ae598e95dc970b74767f19372d61af8'; +const SITE_PAGE = `

site

`; + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +const DEPLOYMENTS = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments`; +const SIGNED_URL = `${DEPLOYMENTS}/upload/signed_url`; +const COMMITS = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/commits`; +const LOGS = `/logs/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments/${DEPLOYMENT_UID}/logs/deployment-logs`; +const LOGS_HINT = `csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} --deployment ${DEPLOYMENT_UID}`; +const GET_HINT = `csdx launch:deployments:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} --deployment ${DEPLOYMENT_UID}`; + +let config: Interfaces.Config; +let onWire: string[]; +let dataDir: string; +let missingDir: string; + +function recordWire(): void { + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); +} + +function hub(): nock.Scope { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +function createArgs(...extra: string[]): string[] { + return ['launch:deployments:create', '--org', ORG_UID, '--project', PROJECT_UID, '--env', ENV_UID, ...extra]; +} + +function gitProject(): void { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); +} + +function fileProject(): void { + nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).reply(200, fileUploadProject); + nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).reply(200, fileUploadProject); +} + +interface Created { + scope: nock.Scope; + body: () => unknown; + contentType: () => string | string[] | undefined; +} + +function captureCreate( + status = 201, + reply: unknown = { deployment: { uid: DEPLOYMENT_UID, status: 'QUEUED' } }, +): Created { + let sent: unknown; + let contentType: string | string[] | undefined; + const scope = hub() + .post(DEPLOYMENTS, (body: unknown) => { + sent = body; + return true; + }) + .query({}) + .reply(status, function () { + contentType = this.req.headers['content-type']; + return reply as nock.Body; + }); + + return { scope, body: () => sent, contentType: () => contentType }; +} + +function followUp(statuses: string[], logs: { message: string; timestamp: string }[][] = []): nock.Scope[] { + const scopes: nock.Scope[] = []; + + statuses.forEach((status, index) => { + scopes.push( + hub() + .get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`) + .query({}) + .reply(200, { deployment: { uid: DEPLOYMENT_UID, status, deploymentUrl: 'my-site.example.test' } }), + ); + scopes.push( + hub() + .get(LOGS) + .query(true) + .reply(200, { deploymentLogs: logs[index] ?? [] }), + ); + }); + + return scopes; +} + +function signedUpload(): { scope: nock.Scope; uploadUid: string } { + const uploadUid = randomBytes(12).toString('hex'); + const scope = hub() + .get(SIGNED_URL) + .query({}) + .reply(200, { + uploadUrl: `${UPLOAD_HOST}/bucket`, + expiresIn: Date.now() + 600_000, + uploadUid, + method: 'PUT', + headers: [ + { key: 'x-ms-blob-type', value: 'BlockBlob' }, + { key: 'Content-Type', value: 'application/zip' }, + ], + }); + + return { scope, uploadUid }; +} + +describe('integration: launch:deployments:create on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + dataDir = mkdtempSync(join(tmpdir(), 'launch-deployments-create-')); + missingDir = join(dataDir, 'no-such-folder'); + writeFileSync(join(dataDir, 'index.html'), SITE_PAGE); + recordWire(); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => undefined, + })); + }); + + afterEach(() => { + process.exitCode = 0; + rmSync(dataDir, { recursive: true, force: true }); + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('T20: deploys the branch tip of a GitHub project with an empty body, the environment being in the path, and exits 0 on LIVE', async () => { + gitProject(); + const create = captureCreate(); + const signed = signedUpload(); + const commits = hub().get(COMMITS).query(true).reply(200, { commits: [] }); + followUp(['LIVE']); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error).toBeUndefined(); + expect(create.scope.isDone()).toBe(true); + expect(create.body()).toEqual({}); + expect(Object.keys(create.body() as object)).toEqual([]); + expect(create.contentType()).toBe('application/json'); + expect(signed.scope.isDone()).toBe(false); + expect(commits.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual(expect.arrayContaining([expect.stringContaining('upload/signed_url')])); + expect(onWire).toEqual([]); + }); + + it('T21: deploys the named commit with a body of only the commit hash, asking nothing', async () => { + gitProject(); + const create = captureCreate(); + const signed = signedUpload(); + const commits = hub().get(COMMITS).query(true).reply(200, { commits: [] }); + followUp(['LIVE']); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(createArgs('--commit', COMMIT, '--data-dir', dataDir), config)); + + expect(error).toBeUndefined(); + expect(create.body()).toEqual({ commitHash: COMMIT }); + expect(prompts.messages).toEqual([]); + expect(commits.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T22: refuses --last-upload on a GitHub project with exit 2 naming --commit and sends nothing', async () => { + gitProject(); + const create = captureCreate(); + const signed = signedUpload(); + + const { error } = await runCommand(createArgs('--last-upload', '--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--commit'); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T23: zips and uploads the project directory, then deploys the new upload uid and exits 0 on LIVE', async () => { + fileProject(); + const signed = signedUpload(); + const put = nock(UPLOAD_HOST) + .matchHeader('x-ms-blob-type', 'BlockBlob') + .matchHeader('content-type', 'application/zip') + .put('/bucket') + .reply(201, ''); + const create = captureCreate(); + followUp(['LIVE']); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error).toBeUndefined(); + expect(signed.scope.isDone()).toBe(true); + expect(put.isDone()).toBe(true); + expect(create.body()).toEqual({ uploadUid: signed.uploadUid }); + expect(onWire).toEqual([]); + }); + + it('T24: refuses --commit on a FileUpload project that has a directory, naming --last-upload', async () => { + fileProject(); + const create = captureCreate(); + const signed = signedUpload(); + + const { error } = await runCommand(createArgs('--commit', COMMIT, '--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--last-upload'); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T25: refuses a bare --commit on a FileUpload project with a directory at a terminal, naming --last-upload, with no prompt', async () => { + fileProject(); + const create = captureCreate(); + const signed = signedUpload(); + const commits = hub().get(COMMITS).query(true).reply(200, { commits: [] }); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(createArgs('--commit', '--data-dir', dataDir), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--last-upload'); + expect(prompts.messages).toEqual([]); + expect(commits.isDone()).toBe(false); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T26: redeploys the last bundle with --last-upload, sending an empty body and never asking for a signed url', async () => { + fileProject(); + const signed = signedUpload(); + const put = nock(UPLOAD_HOST).put('/bucket').reply(201, ''); + const create = captureCreate(); + followUp(['LIVE']); + + const { error } = await runCommand(createArgs('--last-upload', '--data-dir', dataDir), config); + + expect(error).toBeUndefined(); + expect(create.body()).toEqual({}); + expect(Object.keys(create.body() as object)).toEqual([]); + expect(signed.scope.isDone()).toBe(false); + expect(nock.pendingMocks().some((mock) => mock.includes('upload/signed_url'))).toBe(true); + expect(put.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T27: refuses a missing project directory with exit 2, naming the path, --data-dir and --last-upload, and sends nothing', async () => { + fileProject(); + const create = captureCreate(); + const signed = signedUpload(); + + const { error } = await runCommand(createArgs('--data-dir', missingDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain(`"${missingDir}"`); + expect(error?.message).toContain('--data-dir'); + expect(error?.message).toContain('--last-upload'); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T28: refuses --commit on a FileUpload project with no directory, naming --last-upload', async () => { + fileProject(); + const create = captureCreate(); + const signed = signedUpload(); + + const { error } = await runCommand(createArgs('--commit', COMMIT, '--data-dir', missingDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--last-upload'); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it.each([ + ['at a terminal', true], + ['without a terminal', false], + ])( + 'T29: refuses a bare --commit on a FileUpload project with no directory %s, naming --last-upload', + async (_label, terminal) => { + fileProject(); + const create = captureCreate(); + const signed = signedUpload(); + const commits = hub().get(COMMITS).query(true).reply(200, { commits: [] }); + const prompts = answerPrompts({}); + const restore = stdinReportingTTY(terminal); + + try { + const { error } = await runCommand(createArgs('--commit', '--data-dir', missingDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--last-upload'); + } finally { + restore(); + } + + expect(prompts.messages).toEqual([]); + expect(commits.isDone()).toBe(false); + expect(create.scope.isDone()).toBe(false); + expect(signed.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }, + ); + + it('T30: redeploys the last bundle with no directory present, sending an empty body and attempting no archive', async () => { + fileProject(); + const signed = signedUpload(); + const create = captureCreate(); + followUp(['LIVE']); + + const { error, stdout } = await runCommand(createArgs('--last-upload', '--data-dir', missingDir), config); + + expect(error).toBeUndefined(); + expect(create.body()).toEqual({}); + expect(signed.scope.isDone()).toBe(false); + expect(stdout).not.toContain('Preparing zip file'); + expect(onWire).toEqual([]); + }); + + it.each([ + ['a GitHub project', gitProject], + ['a FileUpload project', fileProject], + ])('T31: refuses --commit with --last-upload on %s with exit 2 before any request', async (_label, project) => { + project(); + const create = captureCreate(); + + const { error } = await runCommand(createArgs('--commit', COMMIT, '--last-upload', '--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(create.scope.isDone()).toBe(false); + expect(nock.pendingMocks().filter((mock) => mock.endsWith(`/manage/projects/${PROJECT_UID}`))).toHaveLength(2); + expect(onWire).toEqual([]); + }); + + it('T50: asks one list prompt over the commits at a terminal and deploys the full sha of the chosen one', async () => { + gitProject(); + const create = captureCreate(); + const first = randomBytes(20).toString('hex'); + const second = randomBytes(20).toString('hex'); + let asked: unknown; + const commits = hub() + .get(COMMITS) + .query((query) => { + asked = query; + return true; + }) + .reply(200, { + pagination: { count: 2 }, + commits: [ + { sha: first, shortSha: first.slice(0, 7), message: 'first change', author: { name: 'A' }, url: 'u1' }, + { + sha: second, + shortSha: second.slice(0, 7), + message: 'second change\nbody line', + author: { name: 'B' }, + url: 'u2', + }, + ], + }); + followUp(['LIVE']); + const prompts = answerPrompts({ 'Choose a commit for deployment': second }); + + const { error } = await onTerminal(() => runCommand(createArgs('--commit', '--data-dir', dataDir), config)); + + expect(error).toBeUndefined(); + expect(commits.isDone()).toBe(true); + expect(asked).toEqual({ limit: '15' }); + expect(prompts.messages).toEqual(['Choose a commit for deployment']); + expect(prompts.payloads[0]).toMatchObject({ + type: 'list', + choices: [ + { name: `${first.slice(0, 7)} first change`, value: first }, + { name: `${second.slice(0, 7)} second change`, value: second }, + ], + }); + expect(create.body()).toEqual({ commitHash: second }); + expect(onWire).toEqual([]); + }); + + it('T51: refuses a bare --commit without a terminal with exit 2 naming --commit , before any commits request or prompt', async () => { + gitProject(); + const create = captureCreate(); + const commits = hub() + .get(COMMITS) + .query(true) + .reply(200, { commits: [{ sha: COMMIT }] }); + const prompts = answerPrompts({}); + const restore = stdinReportingTTY(false); + + try { + const { error } = await runCommand(createArgs('--commit', '--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--commit '); + } finally { + restore(); + } + + expect(prompts.messages).toEqual([]); + expect(commits.isDone()).toBe(false); + expect(create.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T52: exits 3 with the cancelled message when Ctrl-C hits the commit picker, creating nothing', async () => { + gitProject(); + const create = captureCreate(); + hub() + .get(COMMITS) + .query(true) + .reply(200, { commits: [{ sha: COMMIT, shortSha: COMMIT.slice(0, 7), message: 'm' }] }); + answerPrompts({ 'Choose a commit for deployment': CTRL_C }); + + const { error } = await onTerminal(() => runCommand(createArgs('--commit', '--data-dir', dataDir), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toContain('Cancelled. Nothing was changed.'); + expect(create.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('exits 1 and creates nothing when the commits lookup fails at a terminal', async () => { + gitProject(); + const create = captureCreate(); + hub().get(COMMITS).query(true).reply(400, { error_code: 'launch.ENVIRONMENT.NOT_FOUND' }); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(createArgs('--commit', '--data-dir', dataDir), config)); + + expect(error?.oclif?.exit).toBe(1); + expect(prompts.messages).toEqual([]); + expect(create.scope.isDone()).toBe(false); + }); + + it('T31: also refuses a bare --commit with --last-upload before any request', async () => { + gitProject(); + const create = captureCreate(); + + const { error } = await runCommand(createArgs('--commit', '--last-upload', '--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(2); + expect(create.scope.isDone()).toBe(false); + expect(nock.pendingMocks().filter((mock) => mock.endsWith(`/manage/projects/${PROJECT_UID}`))).toHaveLength(2); + expect(onWire).toEqual([]); + }); + + it('refuses --commit with a blank value with exit 2 naming --commit , never deploying the branch tip', async () => { + gitProject(); + const create = captureCreate(); + + const { error } = await runCommand( + [ + 'launch:deployments:create', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--env', + ENV_UID, + '--commit=', + '--data-dir', + dataDir, + ], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('--commit '); + expect(create.scope.isDone()).toBe(false); + }); + + it('T33: waits through QUEUED and DEPLOYING to LIVE, streams the build log once and prints the Deployment URL', async () => { + gitProject(); + captureCreate(); + followUp( + ['QUEUED', 'DEPLOYING', 'LIVE'], + [[], [{ message: 'Installing dependencies', timestamp: '2026-09-25T10:00:00.000Z' }], []], + ); + + const { error, stdout } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error).toBeUndefined(); + expect(stdout.split('Installing dependencies').length - 1).toBe(1); + expect(stdout).toContain('Deployment URL: https://my-site.example.test'); + expect(nock.pendingMocks()).toEqual([]); + expect(onWire).toEqual([]); + }); + + it('T33: exits 1 on a FAILED deployment, naming FAILED and the logs command with the full scope', async () => { + gitProject(); + captureCreate(); + followUp(['DEPLOYING', 'FAILED']); + + const { error, stdout } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toContain('FAILED'); + expect(error?.message).toContain(LOGS_HINT); + expect(stdout).not.toContain('Deployment URL'); + expect(onWire).toEqual([]); + }); + + it('T33: exits 1 when the wait runs out, pointing at deployments:get rather than another create', async () => { + gitProject(); + captureCreate(); + followUp(['DEPLOYING']); + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => undefined, + timeoutMs: 1, + })); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toContain('still DEPLOYING'); + expect(error?.message).toContain(GET_HINT); + expect(error?.message).not.toContain('deployments:create'); + }); + + it.each([ + [400, 'a 400'], + [503, 'a 503'], + ])( + 'T36: exits 1 with the CLI wording on %s CREATE_FAILED, putting the POST on the wire once and polling nothing', + async (status) => { + gitProject(); + const create = captureCreate(status, { + errors: [{ code: 'launch.DEPLOYMENT.CREATE_FAILED', message: 'raw server text' }], + status, + }); + const poll = followUp(['LIVE']); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('The deployment could not be started. Please try again or contact support.'); + expect(create.scope.isDone()).toBe(true); + expect(poll.every((scope) => !scope.isDone())).toBe(true); + expect(onWire).toEqual([]); + }, + ); + + it('words a field-keyed validation rejection from the server with the field and its reason, never a bare Bad request', async () => { + gitProject(); + const create = captureCreate(400, { + errors: [{ environment: { code: 'property environment should not exist', message: 'Bad request' } }], + status: 400, + }); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('The Launch API rejected the request: environment: property environment should not exist'); + expect(error?.message).not.toBe('Bad request'); + expect(create.scope.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('T38: refuses an archive under the minimum size with exit 2 before asking for a signed url', async () => { + fileProject(); + const tiny = mkdtempSync(join(tmpdir(), 'launch-deployments-tiny-')); + writeFileSync(join(tiny, 'a.txt'), 'x'); + const signed = signedUpload(); + const create = captureCreate(); + + try { + const { error } = await runCommand(createArgs('--data-dir', tiny), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('below the'); + expect(signed.scope.isDone()).toBe(false); + expect(create.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + } finally { + rmSync(tiny, { recursive: true, force: true }); + } + }); + + it('T38: exits 1 with readable wording when the signed url cannot be had, and posts nothing', async () => { + fileProject(); + const signed = hub() + .get(SIGNED_URL) + .query({}) + .reply(400, { + errors: [{ code: 'launch.DEPLOYMENT.FILE_UPLOAD_SIGNED_URL.GET_FAILED', message: 'raw' }], + status: 400, + }); + const create = captureCreate(); + + const { error } = await runCommand(createArgs('--data-dir', dataDir), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('The Launch API could not prepare an upload for your deployment files.'); + expect(signed.isDone()).toBe(true); + expect(create.scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('T40: draws the no-directory error red when stdout is a terminal', async () => { + fileProject(); + const restoreOut = stdoutReportingTTY(true); + + try { + const { error, stderr } = await runCommand(createArgs('--data-dir', missingDir), config, { stripAnsi: false }); + + expect(error?.oclif?.exit).toBe(2); + expect(stderr).toContain('\u001b[31merror: No files to upload were found in'); + } finally { + restoreOut(); + } + }); + + it('T40: draws it plain when only stdin is a terminal and stdout is piped', async () => { + fileProject(); + const restoreOut = stdoutReportingTTY(false); + const restoreIn = stdinReportingTTY(true); + + try { + const { error, stderr } = await runCommand(createArgs('--data-dir', missingDir), config, { stripAnsi: false }); + + expect(error?.oclif?.exit).toBe(2); + expect(stderr).toContain('error: No files to upload were found in'); + expect(stderr).not.toContain('\u001b'); + } finally { + restoreIn(); + restoreOut(); + } + }); +}); diff --git a/test/integration/deployments-get.test.ts b/test/integration/deployments-get.test.ts new file mode 100644 index 00000000..49e20714 --- /dev/null +++ b/test/integration/deployments-get.test.ts @@ -0,0 +1,293 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import getFixture from '../fixtures/deployment-get.json'; +import notFoundFixture from '../fixtures/deployment-not-found.json'; +import listFixture from '../fixtures/deployments-list.json'; +import fileUploadProject from '../fixtures/project-get-fileupload.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 = 'e1f2a3b4c5d6e7f8091a2b3c'; +const DEPLOYMENT_UID = 'd1e2f3a4b5c6d7e8f9012345'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +const DEPLOYMENTS = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments`; + +let config: Interfaces.Config; +let onWire: string[]; + +function recordWire(): void { + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); +} + +function hub(): nock.Scope { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +function getArgs(...extra: string[]): string[] { + return [ + 'launch:deployments:get', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--env', + ENV_UID, + ...extra, + '--data-dir', + DATA_DIR, + ]; +} + +function valueOf(stdout: string, field: string): string | undefined { + const row = stdout.split('\n').find((line) => line.startsWith(`│ ${field} `)); + + return row?.split('│')[2].trim(); +} + +function ownedProject(): void { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); +} + +describe('integration: launch:deployments:get on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + recordWire(); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('shows the requested deployment with its status, branch, commit, url and an environment variable count', async () => { + const restoreWidth = stdoutOfWidth(300); + + try { + ownedProject(); + const scope = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, getFixture); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(valueOf(stdout, 'DEPLOYMENT UID')).toBe(DEPLOYMENT_UID); + expect(valueOf(stdout, 'STATUS')).toBe('LIVE'); + expect(valueOf(stdout, 'BRANCH')).toBe('main'); + expect(valueOf(stdout, 'COMMIT')).toBe('9fceb02d0ae598e95dc970b74767f19372d61af8'); + expect(valueOf(stdout, 'COMMIT MESSAGE')).toBe('Ship the homepage'); + expect(valueOf(stdout, 'URL')).toBe('https://production-sample-project.contentstackapps.com'); + expect(valueOf(stdout, 'ENVIRONMENT VARIABLES')).toContain('2'); + expect(stdout).not.toContain('BUILD LOGS'); + expect(stdout).not.toContain('launch:logs:get'); + expect(stdout).not.toContain('KEY_ONE'); + expect(stdout).not.toContain('value-one'); + expect(onWire).toEqual([]); + } finally { + restoreWidth(); + } + }); + + it('asks for exactly one deployment when --deployment is absent and never reads the latest field', async () => { + const restoreWidth = stdoutOfWidth(300); + + try { + ownedProject(); + const latest = hub() + .get(DEPLOYMENTS) + .query({ limit: '1', skip: '0' }) + .reply(200, { ...listFixture, latest: 'ffffffffffffffffffffffff' }); + const other = hub().get(DEPLOYMENTS).query({ limit: '100', skip: '0' }).reply(200, listFixture); + + const { error, stdout } = await runCommand(getArgs(), config); + + expect(error).toBeUndefined(); + expect(latest.isDone()).toBe(true); + expect(other.isDone()).toBe(false); + expect(valueOf(stdout, 'DEPLOYMENT UID')).toBe(DEPLOYMENT_UID); + expect(stdout).not.toContain('ffffffffffffffffffffffff'); + } finally { + restoreWidth(); + } + }); + + it.each([[''], [' ']])('treats --deployment %p as absent and looks up the latest', async (blank) => { + ownedProject(); + const latest = hub().get(DEPLOYMENTS).query({ limit: '1', skip: '0' }).reply(200, listFixture); + + const { error } = await runCommand(getArgs('--deployment', blank === '' ? '""' : `"${blank}"`), config); + + expect(error).toBeUndefined(); + expect(latest.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming the empty environment when it has no deployment at all', async () => { + ownedProject(); + hub().get(DEPLOYMENTS).query({ limit: '1', skip: '0' }).reply(200, { pagination: { count: 0 }, deployments: [] }); + + const { error, stdout } = await runCommand(getArgs(), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('No deployments found in this environment.'); + expect(stdout).toBe(''); + }); + + it('exits 2 with generic wording when the server answers 200 with a null deployment for another environment', async () => { + ownedProject(); + const found = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, { deployment: null }); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(found.isDone()).toBe(true); + expect(stdout).toBe(''); + expect(onWire).toEqual([]); + }); + + it.each([['6accca6d34'], [`${DEPLOYMENT_UID}f`], ['z'.repeat(24)]])( + 'exits 2 naming --deployment for the malformed uid %s and puts no deployment request on the wire', + async (value) => { + ownedProject(); + const found = hub().get(`${DEPLOYMENTS}/${value}`).reply(200, getFixture); + const latest = hub().get(DEPLOYMENTS).query(true).reply(200, listFixture); + + const { error, stdout } = await runCommand(getArgs('--deployment', value), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`Invalid --deployment value "${value}". Provide a 24-character hexadecimal deployment UID.`); + expect(error?.message).not.toContain('Parsing'); + expect(error?.message).not.toContain('12 bytes'); + expect(stdout).toBe(''); + expect(found.isDone()).toBe(false); + expect(latest.isDone()).toBe(false); + expect(onWire).toEqual([]); + }, + ); + + it('words the server invalid-uid refusal readably when it reaches the CLI', async () => { + ownedProject(); + hub() + .get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`) + .reply(400, { errors: [{ deployment_uid: { code: 'launch.DEPLOYMENT.INVALID_DEPLOYMENT_UID', message: 'raw' } }] }); + + const { error } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('The deployment UID is invalid. Provide a 24-character hexadecimal deployment UID.'); + }); + + it('keeps a multi-line commit message to its first line so the table keeps its shape', async () => { + const restoreWidth = stdoutOfWidth(300); + + try { + ownedProject(); + hub() + .get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`) + .reply(200, { + deployment: { + ...getFixture.deployment, + commitMessage: + 'Ship the homepage\n\nCo-Authored-By: Claude <[noreply@anthropic.com](mailto:noreply@anthropic.com)>', + }, + }); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + const lines = stdout.trimEnd().split('\n'); + + expect(error).toBeUndefined(); + expect(valueOf(stdout, 'COMMIT MESSAGE')).toBe('Ship the homepage'); + expect(stdout).not.toContain('Co-Authored-By'); + expect(new Set(lines.map((line) => [...line].length)).size).toBe(1); + expect(lines.every((line) => /^[\u250c\u2502\u251c\u2514]/.test(line))).toBe(true); + } finally { + restoreWidth(); + } + }); + + it('leaves branch and commit rows out for a file-upload project', async () => { + const restoreWidth = stdoutOfWidth(300); + + try { + nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).times(2).reply(200, fileUploadProject); + hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, getFixture); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error).toBeUndefined(); + expect(valueOf(stdout, 'DEPLOYMENT UID')).toBe(DEPLOYMENT_UID); + expect(stdout).not.toContain('BRANCH'); + expect(stdout).not.toContain('COMMIT'); + expect(stdout).not.toContain('Ship the homepage'); + expect(stdout).not.toContain('9fceb02d'); + } finally { + restoreWidth(); + } + }); + + it('exits 2 with generic wording when the deployment is not in this environment', async () => { + ownedProject(); + hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(404, notFoundFixture); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(error?.message).not.toContain(ENV_UID); + expect(error?.message).not.toContain(PROJECT_UID); + expect(stdout).toBe(''); + }); + + it('exits 1 for a server failure rather than calling it a missing deployment', async () => { + ownedProject(); + hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).times(5).reply(500, { errors: [{ code: 'launch.UNKNOWN', message: 'boom' }] }); + + const { error } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(1); + }); +}); diff --git a/test/integration/deployments-list.test.ts b/test/integration/deployments-list.test.ts new file mode 100644 index 00000000..adf717d3 --- /dev/null +++ b/test/integration/deployments-list.test.ts @@ -0,0 +1,198 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import listFixture from '../fixtures/deployments-list.json'; +import environmentsFixture from '../fixtures/environments-list.json'; +import fileUploadProject from '../fixtures/project-get-fileupload.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 = 'e1f2a3b4c5d6e7f8091a2b3c'; +const PRODUCTION_ENV_UID = 'd4e5f60718293a4b5c6d7e8f'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; +let onWire: string[]; + +function recordWire(): void { + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); +} + +function hub(environment: string = ENV_UID): nock.Interceptor { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0') + .get(`/manage/projects/${PROJECT_UID}/environments/${environment}/deployments`); +} + +function listArgs(...extra: string[]): string[] { + return [ + 'launch:deployments:list', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--env', + ENV_UID, + ...extra, + '--data-dir', + DATA_DIR, + ]; +} + +describe('integration: launch:deployments:list on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + recordWire(); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('lists a git project piped with DEPLOYMENT UID, no bare UID header, and a footer from the command skip', async () => { + const restoreWidth = stdoutOfWidth(200); + + try { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const scope = hub().query({ limit: '100', skip: '0' }).reply(200, listFixture); + + const { error, stdout } = await runCommand(listArgs(), config); + const header = stdout.split('\n').find((line) => line.includes('DEPLOYMENT UID')) ?? ''; + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(header).toContain('STATUS'); + expect(header.replace('DEPLOYMENT UID', '')).not.toMatch(/\bUID\b/); + expect(stdout).toContain('d1e2f3a4b5c6d7e8f9012345'); + expect(stdout).toContain('LIVE'); + expect(stdout).toContain('FAILED'); + expect(stdout).toContain('9fceb02'); + expect(stdout).toContain('develop'); + expect(stdout.trimEnd().split('\n').pop()).toBe('Showing 1-2 of 2'); + expect(onWire).toEqual([]); + } finally { + restoreWidth(); + } + }); + + it('leaves branch and commit out of the list of a file-upload project', async () => { + const restoreWidth = stdoutOfWidth(200); + + try { + nock(LAUNCH_HUB_URL).get(`/manage/projects/${PROJECT_UID}`).times(2).reply(200, fileUploadProject); + hub().query(true).reply(200, listFixture); + + const { error, stdout } = await runCommand(listArgs(), config); + + expect(error).toBeUndefined(); + expect(stdout).toContain('DEPLOYMENT UID'); + expect(stdout).not.toContain('BRANCH'); + expect(stdout).not.toContain('COMMIT'); + expect(stdout).not.toContain('develop'); + expect(stdout).not.toContain('9fceb02'); + } finally { + restoreWidth(); + } + }); + + it.each([['1'], ['1000']])('sends --limit %s as the limit query', async (limit) => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const scope = hub().query({ limit, skip: '0' }).reply(200, listFixture); + + const { error } = await runCommand(listArgs('--limit', limit), config); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('sends --skip as the skip query', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const scope = hub().query({ limit: '100', skip: '10' }).reply(200, listFixture); + + const { error } = await runCommand(listArgs('--skip', '10'), config); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it.each([ + [['--limit', '0']], + [['--limit', '1001']], + [['--skip', '-1']], + ])('exits 2 for %p and sends nothing', async (extra) => { + const scope = hub().query(true).reply(200, listFixture); + + const { error } = await runCommand(listArgs(...extra), config); + + expect(error?.oclif?.exit).toBe(2); + expect(scope.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('resolves --env by name through the environments list and shows no picker', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const names = nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .get(`/manage/projects/${PROJECT_UID}/environments`) + .query({ limit: '100', skip: '0' }) + .reply(200, environmentsFixture); + const scope = hub(PRODUCTION_ENV_UID).query({ limit: '100', skip: '0' }).reply(200, listFixture); + const args = listArgs(); + args[args.indexOf(ENV_UID)] = 'production'; + + const { error } = await runCommand(args, config); + + expect(error).toBeUndefined(); + expect(names.isDone()).toBe(true); + expect(scope.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); +}); diff --git a/test/integration/deployments-request-bodies.test.ts b/test/integration/deployments-request-bodies.test.ts new file mode 100644 index 00000000..fb227e85 --- /dev/null +++ b/test/integration/deployments-request-bodies.test.ts @@ -0,0 +1,107 @@ +import { randomBytes, randomUUID } from 'node:crypto'; + +import nock from 'nock'; + +import { DeploymentsApi } from '../../src/deployments/deployments.api'; +import { RestApiClient } from '../../src/transport/rest-client'; + +const ORIGIN = 'https://launch-api.request-bodies.test'; +const ORG_UID = `blt${randomBytes(8).toString('hex')}`; +const PROJECT_UID = randomBytes(12).toString('hex'); +const ENV_UID = randomBytes(12).toString('hex'); +const DEPLOYMENT_UID = randomBytes(12).toString('hex'); +const SCOPE = { org: ORG_UID, project: PROJECT_UID, environment: ENV_UID }; +const BASE = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments`; + +const FIELDS_THE_SERVER_ACCEPTS = { + create: ['commitHash', 'uploadUid'], + rollback: ['deployment', 'reason'], +}; + +interface Sent { + body: unknown; + contentType: string | string[] | undefined; +} + +function buildApi(): DeploymentsApi { + return new DeploymentsApi( + new RestApiClient({ + baseUrl: `${ORIGIN}/manage`, + analyticsInfo: 'cli/2.0.0', + auth: { headers: async () => ({ authtoken: randomUUID() }) }, + retryDelayMs: 0, + sleep: async () => undefined, + }), + ); +} + +function captureMutation(path: string, reply: unknown): Sent { + const sent: Sent = { body: undefined, contentType: undefined }; + + nock(ORIGIN) + .post(path, (body: unknown) => { + sent.body = body; + return true; + }) + .reply(function () { + sent.contentType = this.req.headers['content-type']; + return [200, reply as nock.Body]; + }); + + return sent; +} + +function expectOnlyAcceptedFields(body: unknown, accepted: string[]): void { + expect(typeof body).toBe('object'); + expect(body).not.toBeNull(); + expect(body).not.toHaveProperty('environment'); + expect(Object.keys(body as object).filter((key) => !accepted.includes(key))).toEqual([]); +} + +describe('guard: a deployments mutation sends only fields the management service DTO declares, because its whitelist answers 400 to any other, the environment included', () => { + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + nock.restore(); + }); + + it.each([ + ['a redeploy', {}], + ['a commit', { commitHash: 'abc123' }], + ['an upload', { uploadUid: 'up-1' }], + ['a commit and an upload', { commitHash: 'abc123', uploadUid: 'up-1' }], + ])('sends the create for %s with no environment in the body, the environment staying in the path', async (_label, extra) => { + const sent = captureMutation(BASE, { deployment: { uid: DEPLOYMENT_UID } }); + + await buildApi().create({ ...SCOPE, ...extra }); + + expectOnlyAcceptedFields(sent.body, FIELDS_THE_SERVER_ACCEPTS.create); + expect(sent.body).toEqual(extra); + expect(sent.contentType).toBe('application/json'); + }); + + it.each([ + ['a reason', 'bad release', { deployment: DEPLOYMENT_UID, reason: 'bad release' }], + ['no reason', undefined, { deployment: DEPLOYMENT_UID }], + ['a blank reason', ' ', { deployment: DEPLOYMENT_UID }], + ])('sends the rollback with %s and no environment in the body', async (_label, reason, expected) => { + const sent = captureMutation(`${BASE}/rollback`, { message: 'Rollback triggered successfully' }); + + await buildApi().rollback({ ...SCOPE, deployment: DEPLOYMENT_UID, reason }); + + expectOnlyAcceptedFields(sent.body, FIELDS_THE_SERVER_ACCEPTS.rollback); + expect(sent.body).toEqual(expected); + expect(sent.contentType).toBe('application/json'); + }); + + it('sends the cancel with no body at all and no content type', async () => { + const sent = captureMutation(`${BASE}/${DEPLOYMENT_UID}/cancel`, {}); + + await buildApi().cancel({ ...SCOPE, deployment: DEPLOYMENT_UID }); + + expect(sent.body === undefined || sent.body === '').toBe(true); + expect(sent.contentType).toBeUndefined(); + }); +}); diff --git a/test/integration/deployments-rollback.test.ts b/test/integration/deployments-rollback.test.ts new file mode 100644 index 00000000..fff80ec5 --- /dev/null +++ b/test/integration/deployments-rollback.test.ts @@ -0,0 +1,492 @@ +import { tmpdir } from 'node:os'; + +import { authHandler, cliux, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import * as watcher from '../../src/deployments/deployment.watcher'; +import getFixture from '../fixtures/deployment-get.json'; +import notFoundFixture from '../fixtures/deployment-not-found.json'; +import { CTRL_C, answerPrompts, onTerminal, pretendTerminal } 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 = 'e1f2a3b4c5d6e7f8091a2b3c'; +const DEPLOYMENT_UID = 'd1e2f3a4b5c6d7e8f9012345'; +const DATA_DIR = tmpdir(); + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +const DEPLOYMENTS = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}/deployments`; +const QUESTION = `Are you sure you want to roll back this environment to deployment "${DEPLOYMENT_UID}"?`; +const POST_PENDING = `POST ${LAUNCH_HUB_URL}:443${DEPLOYMENTS}/rollback`; + +const realWatchTiming = watcher.defaultWatchTiming; +const STARTED = (uid: string): string => `Rollback to deployment "${uid}" started.\n`; +const SUCCESS = (uid: string): string => `✔ Environment rolled back to deployment "${uid}" successfully.\n`; + +let config: Interfaces.Config; +let onWire: string[]; + +function recordWire(): void { + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); +} + +function hub(): nock.Scope { + return nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .matchHeader('x-cs-api-version', '1.0'); +} + +function args(...extra: string[]): string[] { + return [ + 'launch:deployments:rollback', + '--org', + ORG_UID, + '--project', + PROJECT_UID, + '--env', + ENV_UID, + ...extra, + '--data-dir', + DATA_DIR, + ]; +} + +const OLDER_UID = 'c0d1e2f3a4b5c6d7e8f90123'; +const RUNNING_UID = 'f1e2d3c4b5a60718293a4b5c'; +const QUEUED_UID = 'f2e2d3c4b5a60718293a4b5c'; +const LISTED = { + pagination: { count: 4, limit: 100 }, + deployments: [ + { uid: RUNNING_UID, status: 'DEPLOYING', deploymentNumber: 5, commitHash: '1234567aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + { uid: QUEUED_UID, status: 'QUEUED' }, + { uid: DEPLOYMENT_UID, status: 'LIVE', commitHash: '9fceb02d0ae598e95dc970b74767f19372d61af8' }, + { uid: OLDER_UID, status: 'FAILED', createdAt: '2025-11-06T07:39:19.230Z' }, + ], +}; + +function listing(page: unknown = LISTED): nock.Scope { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + + return hub().get(DEPLOYMENTS).query({ limit: 100, skip: 0, rollbackEligible: 'true' }).reply(200, page as nock.Body); +} + +function followUp(uid: string, statuses: string[]): nock.Scope[] { + return statuses.map((status) => + hub().get(`${DEPLOYMENTS}/${uid}`).reply(200, { deployment: { uid, status } }), + ); +} + +function lookup(): nock.Scope { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + + return hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, getFixture); +} + +describe('integration: launch:deployments:rollback on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + recordWire(); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((key: string) => CONFIG[key]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => undefined, + })); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('looks the deployment up, then posts the rollback with the reason as JSON, when --yes is given', async () => { + const order: string[] = []; + const found = lookup(); + found.on('replied', () => order.push('lookup')); + const prompts = answerPrompts({}); + const rollback = hub() + .post(`${DEPLOYMENTS}/rollback`, { deployment: DEPLOYMENT_UID, reason: 'bad release' }) + .matchHeader('content-type', /application\/json/) + .reply(200, { deployment: getFixture.deployment }, undefined); + rollback.on('replied', () => order.push('rollback')); + const [live] = followUp(DEPLOYMENT_UID, ['LIVE']); + live.on('replied', () => order.push('poll')); + + const { error, stdout } = await runCommand( + args('--deployment', DEPLOYMENT_UID, '--reason', '"bad release"', '--yes'), + config, + ); + + expect(error).toBeUndefined(); + expect(order).toEqual(['lookup', 'rollback', 'poll']); + expect(rollback.isDone()).toBe(true); + expect(prompts.messages).toEqual([]); + expect(stdout).toBe(`${STARTED(DEPLOYMENT_UID)}${SUCCESS(DEPLOYMENT_UID)}`); + expect(onWire).toEqual([]); + }); + + it('sends no reason key when --reason is absent, empty or whitespace', async () => { + const bodies: unknown[] = []; + + for (const extra of [[], ['--reason', '""'], ['--reason', '" "']]) { + lookup(); + hub() + .post(`${DEPLOYMENTS}/rollback`, (body: unknown) => { + bodies.push(body); + return true; + }) + .reply(200, {}); + followUp(DEPLOYMENT_UID, ['LIVE']); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID, ...extra, '--yes'), config); + + expect(error).toBeUndefined(); + } + + expect(bodies).toEqual([ + { deployment: DEPLOYMENT_UID }, + { deployment: DEPLOYMENT_UID }, + { deployment: DEPLOYMENT_UID }, + ]); + expect(onWire).toEqual([]); + }); + + it('says the deployment was not rolled back, exits 3 and never posts when the user declines', async () => { + const found = lookup(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const restore = pretendTerminal(); + const inquired: unknown[] = []; + jest.spyOn(cliux, 'inquire').mockImplementation(async (payload: unknown) => { + inquired.push(payload); + return false as never; + }); + + try { + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(3); + expect(stdout).toBe('Environment not rolled back.\n'); + expect(inquired).toEqual([{ type: 'confirm', name: 'confirm', message: QUESTION, default: false }]); + expect(onWire).toEqual([]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + } finally { + restore(); + } + }); + + it('exits 2 naming --yes, and never posts, without a terminal and without --yes', async () => { + const found = lookup(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${QUESTION} Pass --yes to confirm without an interactive terminal.`); + expect(onWire).toEqual([]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + }); + + it('exits 3 and never posts when Ctrl-C is pressed at the confirmation', async () => { + const found = lookup(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const prompts = answerPrompts({ [QUESTION]: CTRL_C }); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toBe('Cancelled. Nothing was changed.'); + expect(prompts.messages).toEqual([QUESTION]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(false); + expect(onWire).toEqual([]); + expect(process.listenerCount('SIGINT')).toBe(0); + }); + + it('posts exactly once and exits 0 when the user says yes at the prompt', async () => { + const found = lookup(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).once().reply(200, {}); + followUp(DEPLOYMENT_UID, ['LIVE']); + const prompts = answerPrompts({ [QUESTION]: true }); + + const { error, stdout } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([QUESTION]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(true); + expect(stdout).toBe(`${STARTED(DEPLOYMENT_UID)}${SUCCESS(DEPLOYMENT_UID)}`); + expect(onWire).toEqual([]); + }); + + it('waits for the target to become LIVE, whatever status it starts in, and only then reports the success', async () => { + lookup(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const polls = followUp(DEPLOYMENT_UID, ['ARCHIVED', 'DEPLOYED', 'LIVE']); + + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error).toBeUndefined(); + expect(rollback.isDone()).toBe(true); + expect(polls.every((scope) => scope.isDone())).toBe(true); + expect(stdout).toBe(`${STARTED(DEPLOYMENT_UID)}${SUCCESS(DEPLOYMENT_UID)}`); + expect(onWire).toEqual([]); + }); + + it('exits 1 pointing at deployments:get, and never claims success, when the target is still not LIVE as the wait ends', async () => { + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => undefined, + timeoutMs: 1, + })); + lookup(); + hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + followUp(DEPLOYMENT_UID, ['ARCHIVED']); + + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe( + `Deployment "${DEPLOYMENT_UID}" was still ARCHIVED when the CLI stopped waiting. ` + + `Run csdx launch:deployments:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID} to check whether the rollback completed.`, + ); + expect(stdout).toBe(STARTED(DEPLOYMENT_UID)); + expect(stdout).not.toContain('successfully'); + expect(onWire).toEqual([]); + }); + + it('exits 1 pointing at deployments:get, and never claims success, when the status poll keeps failing', async () => { + lookup(); + hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const polls = [1, 2, 3].map(() => + hub() + .get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`) + .reply(500, { errors: [{ code: 'launch.SERVER.ERROR', message: 'boom' }], status: 500 }), + ); + + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toContain(`The rollback to deployment "${DEPLOYMENT_UID}" could not be confirmed`); + expect(error?.message).toContain(`csdx launch:deployments:get --org ${ORG_UID}`); + expect(error?.message).toContain(`--deployment ${DEPLOYMENT_UID}`); + expect(polls.every((scope) => scope.isDone())).toBe(true); + expect(stdout).toBe(STARTED(DEPLOYMENT_UID)); + expect(stdout).not.toContain('successfully'); + expect(onWire).toEqual([]); + }); + + it('stops waiting on Ctrl-C, exits 3 saying the rollback continues on Launch, and never claims success', async () => { + jest.spyOn(watcher, 'defaultWatchTiming').mockImplementation(() => ({ + ...realWatchTiming(), + sleep: async () => { + process.emit('SIGINT'); + }, + })); + lookup(); + hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const [first, second] = followUp(DEPLOYMENT_UID, ['ARCHIVED', 'LIVE']); + + const { error, stdout } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toBe( + `Stopped waiting. The rollback to deployment "${DEPLOYMENT_UID}" was not cancelled and continues on Launch. ` + + `Check it with csdx launch:deployments:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID}.`, + ); + expect(first.isDone()).toBe(true); + expect(second.isDone()).toBe(false); + expect(stdout).toBe(STARTED(DEPLOYMENT_UID)); + expect(process.listenerCount('SIGINT')).toBe(0); + expect(onWire).toEqual([]); + }); + + it('reports the generic not-found wording, exit 2, with no prompt and no post, for an unknown deployment', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(404, notFoundFixture); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(prompts.messages).toEqual([]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming --deployment and sends nothing when it is omitted, empty or whitespace', async () => { + for (const extra of [[], ['--deployment', '""'], ['--deployment', '" "']]) { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const { error } = await runCommand(args(...extra, '--yes'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Missing required value for --deployment'); + } + + expect(onWire).toEqual([]); + }); + + it.each([ + ['launch.DEPLOYMENT.ROLLBACK_NOT_ELIGIBLE', 409, 'This deployment is not eligible for rollback. Choose a deployment that was live earlier.'], + ['launch.DEPLOYMENT.NO_LIVE_DEPLOYMENT_FOUND', 409, 'This environment has no live deployment, so a rollback is not possible.'], + ])('exits 1 with readable wording when the rollback is refused with %s', async (code, status, message) => { + lookup(); + hub() + .post(`${DEPLOYMENTS}/rollback`) + .reply(status, { errors: [{ code, message: 'raw' }], status }); + + const { error } = await runCommand(args('--deployment', DEPLOYMENT_UID, '--yes'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe(message); + expect(onWire).toEqual([]); + }); + + it('offers the rollback-eligible deployments newest first, without the live one, at a terminal when --deployment is omitted, then confirms and rolls back to the chosen full uid', async () => { + const listed = listing(); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${OLDER_UID}`).reply(200, { deployment: { uid: OLDER_UID, status: 'FAILED' } }); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`, { deployment: OLDER_UID }).reply(200, {}); + followUp(OLDER_UID, ['LIVE']); + const question = `Are you sure you want to roll back this environment to deployment "${OLDER_UID}"?`; + const prompts = answerPrompts({ 'Choose a deployment for rollback': OLDER_UID, [question]: true }); + + const { error, stdout } = await onTerminal(() => runCommand(args(), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual(['Choose a deployment for rollback', question]); + expect(prompts.payloads[0]).toMatchObject({ + type: 'list', + choices: [ + { name: `#5 ${RUNNING_UID} DEPLOYING 1234567`, value: RUNNING_UID }, + { name: `${QUEUED_UID} QUEUED`, value: QUEUED_UID }, + { name: `${OLDER_UID} FAILED 2025-11-06T07:39:19.230Z`, value: OLDER_UID }, + ], + }); + expect(listed.isDone()).toBe(true); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(true); + expect(stdout).toBe(`${STARTED(OLDER_UID)}${SUCCESS(OLDER_UID)}`); + expect(onWire).toEqual([]); + }); + + it('never lists or prompts for a deployment when --deployment is given, and the list interceptor stays pending', async () => { + lookup(); + const listed = hub().get(DEPLOYMENTS).query({ limit: 100, skip: 0, rollbackEligible: 'true' }).reply(200, LISTED); + hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + followUp(DEPLOYMENT_UID, ['LIVE']); + const prompts = answerPrompts({ [QUESTION]: true }); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([QUESTION]); + expect(listed.isDone()).toBe(false); + expect(onWire).toEqual([]); + }); + + it('exits 2 saying there are no deployments, with nothing asked, when the environment has none', async () => { + listing({ pagination: { count: 0, limit: 100 }, deployments: [] }); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args(), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('No deployments are available for rollback.'); + expect(prompts.messages).toEqual([]); + expect(onWire).toEqual([]); + }); + + it('exits 3 and sends nothing further when Ctrl-C is pressed at the deployment picker', async () => { + listing(); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const prompts = answerPrompts({ 'Choose a deployment for rollback': CTRL_C }); + + const { error } = await onTerminal(() => runCommand(args(), config)); + + expect(error?.oclif?.exit).toBe(3); + expect(error?.message).toBe('Cancelled. Nothing was changed.'); + expect(prompts.messages).toEqual(['Choose a deployment for rollback']); + expect(rollback.isDone()).toBe(false); + expect(onWire).toEqual([]); + expect(process.listenerCount('SIGINT')).toBe(0); + }); + + it('exits 2 for a deployment uid of another environment, which the server answers as 200 with a null deployment, and never posts', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${DEPLOYMENT_UID}`).reply(200, { deployment: null }); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args('--deployment', DEPLOYMENT_UID), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No deployment with UID "${DEPLOYMENT_UID}" found in this environment.`); + expect(prompts.messages).toEqual([]); + expect(found.isDone()).toBe(true); + expect(rollback.isDone()).toBe(false); + expect(nock.pendingMocks()).toEqual([POST_PENDING]); + expect(onWire).toEqual([]); + }); + + it.each([['6accca6d34'], [`${DEPLOYMENT_UID}0`], ['z'.repeat(24)]])( + 'exits 2 for the malformed deployment uid %s and sends nothing at all', + async (value) => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const found = hub().get(`${DEPLOYMENTS}/${value}`).reply(200, getFixture); + const rollback = hub().post(`${DEPLOYMENTS}/rollback`).reply(200, {}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(args('--deployment', value, '--yes'), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`Invalid --deployment value "${value}". Provide a 24-character hexadecimal deployment UID.`); + expect(prompts.messages).toEqual([]); + expect(found.isDone()).toBe(false); + expect(rollback.isDone()).toBe(false); + expect(onWire).toEqual([]); + }, + ); +}); diff --git a/test/integration/environments-create.test.ts b/test/integration/environments-create.test.ts index ede62a66..fb662313 100644 --- a/test/integration/environments-create.test.ts +++ b/test/integration/environments-create.test.ts @@ -315,8 +315,8 @@ describe('integration: launch:env:create on the wire', () => { expect(error?.oclif?.exit).toBe(1); expect(stdout).toContain('✔ Environment created successfully.'); expect(error?.message).toContain( - `The environment "production" (${ENV_UID}) was created and has not been rolled back. ` + - `Run csdx launch:deployments:create --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID}`, + `The environment "production" (${ENV_UID}) was created. ` + + `Run csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID}`, ); }); }); diff --git a/test/integration/projects-create.test.ts b/test/integration/projects-create.test.ts index 865c4fae..f58860bd 100644 --- a/test/integration/projects-create.test.ts +++ b/test/integration/projects-create.test.ts @@ -327,7 +327,7 @@ describe('integration: launch:projects:create on the wire', () => { }); expect(stdout).toContain('info: GitHub connection identified!\n'); expect(stdout).toContain('info: New project created successfully\n'); - expect(stdout).toContain('Deployment URL https://my-site.example.test\n'); + expect(stdout).toContain('Deployment URL: https://my-site.example.test\n'); expect(stdout).not.toContain('Deployment #1 is'); expect(open).toHaveBeenCalledWith('https://my-site.example.test'); expect(slept[slept.length - 1]).toBe(6000); @@ -369,7 +369,7 @@ describe('integration: launch:projects:create on the wire', () => { const first = lines.indexOf('2026-09-25 10:00:00.123: Installing dependencies...'); expect(first).toBeGreaterThan(-1); expect(lines[first + 1]).toBe('2026-09-25 10:00:09.000: Deployed successfully'); - expect(lines[first + 2]).toBe('Deployment URL https://my-site.example.test'); + expect(lines[first + 2]).toBe('Deployment URL: https://my-site.example.test'); expect(stdout).not.toContain('Could not read the deployment logs'); expect(onWire).toEqual([]); }); @@ -382,13 +382,12 @@ describe('integration: launch:projects:create on the wire', () => { const { error } = await runCommand(gitFlags(), config); expect(error?.oclif?.exit).toBe(1); - expect(error?.message).toContain('its last status was FAILED'); + expect(error?.message).toContain('ended with status FAILED.'); expect(error?.message).toContain(`The project "My Site" (${PROJECT_UID})`); - expect(error?.message).toContain('have not been rolled back'); + expect(error?.message).toContain('were created.'); expect(error?.message).toContain( - `Run csdx launch:deployments:create --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENVIRONMENT_UID}`, + `Run csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENVIRONMENT_UID}`, ); - expect(error?.message).toContain(`csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID}`); expect(error?.message).toContain(`--deployment ${DEPLOYMENT_UID}`); expect(onWire).toEqual([]); }); diff --git a/test/integration/retired-commands.test.ts b/test/integration/retired-commands.test.ts index 5acd5e8d..91b9b7c5 100644 --- a/test/integration/retired-commands.test.ts +++ b/test/integration/retired-commands.test.ts @@ -29,10 +29,6 @@ const CONFIG: Record = { let config: Interfaces.Config; const PLANNED_COMMANDS: Record = { - 'launch:deployments:create': 'CL-7170', - 'launch:deployments:get': 'CL-7170', - 'launch:deployments:list': 'CL-7170', - 'launch:deployments:rollback': 'CL-7170', 'launch:logs:get': 'CL-7171', }; @@ -154,6 +150,36 @@ describe('integration: retired V1 command names', () => { expect(Object.entries(PLANNED_COMMANDS).filter(([id]) => registered.has(id))).toEqual([]); }); + it('registers deployments:list, deployments:get and deployments:create as real commands while launch:deployments stays retired', async () => { + const registered = new Set(config.commands.map((command) => command.id)); + const { error } = await runCommand(['launch:deployments'], config); + + expect(registered.has('launch:deployments:list')).toBe(true); + expect(registered.has('launch:deployments:get')).toBe(true); + expect(registered.has('launch:deployments:create')).toBe(true); + expect(PLANNED_COMMANDS['launch:deployments:list']).toBeUndefined(); + expect(PLANNED_COMMANDS['launch:deployments:get']).toBeUndefined(); + expect(PLANNED_COMMANDS['launch:deployments:create']).toBeUndefined(); + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe( + 'csdx launch:deployments was removed in Launch CLI v2. Use csdx launch:deployments:list instead.', + ); + }); + + it('registers deployments:rollback and deployments:cancel as real commands while launch:rollback stays retired', async () => { + const registered = new Set(config.commands.map((command) => command.id)); + const { error } = await runCommand(['launch:rollback'], config); + + expect(registered.has('launch:deployments:rollback')).toBe(true); + expect(registered.has('launch:deployments:cancel')).toBe(true); + expect(PLANNED_COMMANDS['launch:deployments:rollback']).toBeUndefined(); + expect(PLANNED_COMMANDS['launch:deployments:cancel']).toBeUndefined(); + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe( + 'csdx launch:rollback was removed in Launch CLI v2. Use csdx launch:deployments:rollback instead.', + ); + }); + it('lists the V2 topics under launch --help', async () => { const { stdout } = await runCommand(['launch', '--help'], config);