@myapihq/cli 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -239,17 +239,6 @@ export async function deploy(id, image, flags) {
239
239
  // write their own; a guard that silently passes makes them stop. Restore
240
240
  // these the moment the upstream fix lands — see
241
241
  // docs/cross-repo-prompts/backend-consolidated-2026-07-28.md.
242
- // --smoke is still not honoured; --no-promote was fixed upstream on
243
- // 2026-07-28 and works on both deploy paths. Refusing what does not work
244
- // beats accepting it, and un-refusing what does beats obstructing.
245
- if (typeof flags.smoke === 'string') {
246
- error('--smoke is not honoured yet, so this CLI refuses it rather than letting you believe a deploy was checked.\n\n' +
247
- 'Use --no-promote instead, which now works:\n' +
248
- ' 1. myapi container deploy <id> <image> --no-promote\n' +
249
- ' 2. curl the revision URL it prints, for a string only a real build emits\n' +
250
- ' 3. myapi container promote <id> <revision>\n\n' +
251
- 'That is the same verify-then-promote, done by you rather than the platform.');
252
- }
253
242
  const source = typeof flags.source === 'string' ? flags.source : undefined;
254
243
  // --image is an alias for the positional image ref.
255
244
  if (!image && typeof flags.image === 'string')
@@ -262,6 +251,28 @@ export async function deploy(id, image, flags) {
262
251
  const deployOpts = {};
263
252
  if (flags['no-promote'] === true)
264
253
  deployOpts.promote = false;
254
+ if (typeof flags.smoke === 'string')
255
+ deployOpts.smoke = _parseSmoke(flags.smoke);
256
+ // On a container's FIRST deploy there is no earlier revision to hold
257
+ // traffic, so the platform runs the assertion against the LIVE url — a
258
+ // failing build is already serving when you are told it failed. Say so
259
+ // before the deploy rather than after, because the remedy differs: on any
260
+ // later deploy a failure is contained, and here it is not.
261
+ if (deployOpts.smoke || deployOpts.promote === false) {
262
+ try {
263
+ const existing = await sdkContainer.listRevisions(config.api_key, orgId, id);
264
+ if (existing.length === 0) {
265
+ info('Note: this container has no earlier revision, so there is nothing to hold');
266
+ info('traffic while the new one is checked. A failing build WILL be serving.');
267
+ info('Deploy to a non-production container first if that matters.');
268
+ info('');
269
+ }
270
+ }
271
+ catch { /* advisory only — never block a deploy on it */ }
272
+ }
273
+ if (deployOpts.promote === false && deployOpts.smoke) {
274
+ error('--smoke already withholds traffic until the assertion passes, then promotes.\nUse one or the other: --smoke to verify-and-promote, --no-promote to hold the revision back.');
275
+ }
265
276
  // ── Source-build path (async) ───────────────────────────────────────────
266
277
  if (source) {
267
278
  let tarball;
@@ -593,13 +604,21 @@ Options:
593
604
  On a container's FIRST deploy there is nothing already
594
605
  serving, so traffic is NOT withheld and the output says so.
595
606
 
596
- --smoke Still refused — the platform accepts it and does nothing.
597
- --no-promote gives you the same verify-then-promote by hand.
607
+ --smoke '<assertion>'
608
+ Deploy, assert against the new revision, and promote it ONLY
609
+ if the assertion holds. A failure leaves the previous
610
+ revision serving and returns SMOKE_FAILED with a revision URL
611
+ to inspect. Works on both deploy paths.
612
+
613
+ Grammar: [GET|HEAD] [/path] [status N] [contains TEXT]
614
+ Assert on CONTENT — "returns 200" is true of a placeholder
615
+ page too, which is how one reached production and stayed.
598
616
 
599
617
  Examples:
600
618
  myapi container deploy <id> registry.example.com/my-app:v2
601
619
  myapi container deploy <id> --source ./my-app
602
- myapi container deploy <id> <image> --no-promote`,
620
+ myapi container deploy <id> <image> --no-promote
621
+ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'`,
603
622
  'rollback': `myapi container rollback <id> [--org <id>] [--json]
604
623
 
605
624
  Move traffic back to the previous ready revision. Seconds, no rebuild.
@@ -647,8 +666,10 @@ dependencies and long execution.
647
666
  Subcommands:
648
667
  build-logs <id> Why the last --source build failed (--tail N; default 100)
649
668
  create Register a container and get its scoped API key (returned once)
669
+ (--health-check /livez for an HTTP startup probe)
650
670
  delete <id> Soft-delete and revoke its scoped API key
651
671
  deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
672
+ (--smoke to verify before promoting; --no-promote to hold it back)
652
673
  domain <id> <domain> Bind a custom domain (--remove to unbind)
653
674
  get <id> Inspect a container
654
675
  list List containers in your org
@@ -122,11 +122,21 @@ describe('container deploy — --no-promote must REACH both paths', () => {
122
122
  await run(() => deploy('c1', 'img:v1', { org: ORG }));
123
123
  expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', {});
124
124
  });
125
- // --smoke is still not honoured upstream, and refusing beats accepting.
126
- it('still refuses --smoke, on either path', async () => {
125
+ // --smoke was refused while the platform ignored it, and is honoured now.
126
+ // The assertion has flipped twice; what has not changed is that it must
127
+ // REACH the call, on whichever path.
128
+ it('sends the parsed assertion on the image path', async () => {
129
+ sdk.container.deployContainer.mockResolvedValue({ ...DEPLOYED, promoted: true });
130
+ const { deploy } = await import('./container.js');
131
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains assets/', org: ORG }));
132
+ expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', { smoke: { method: 'GET', path: '/', contains: 'assets/' } });
133
+ });
134
+ // Combining them is a contradiction: --smoke promotes on success, and
135
+ // --no-promote withholds regardless. Refused rather than silently picking.
136
+ it('refuses --smoke together with --no-promote', async () => {
127
137
  const { deploy } = await import('./container.js');
128
- await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', org: ORG }));
129
- expect(exitError).toMatch(/--smoke is not honoured yet/);
138
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', 'no-promote': true, org: ORG }));
139
+ expect(exitError).toMatch(/one or the other/);
130
140
  expect(sdk.container.deployContainer).not.toHaveBeenCalled();
131
141
  });
132
142
  });
package/dist/errors.js CHANGED
@@ -45,6 +45,12 @@ export const ERROR_MESSAGES = {
45
45
  // on a backend deploy we do not control the timing of, and a CLI that only
46
46
  // knows the new names would print a bare code for anyone on the old build.
47
47
  // The old five can go once that deploy is everywhere.
48
+ // Deploy-guard validation, added when the platform started refusing bad
49
+ // assertions BEFORE building — a source build costs minutes you pay for, so
50
+ // catching a typo up front matters more than it looks.
51
+ INVALID_SMOKE: 'The --smoke assertion is not usable. It must assert something: add `contains <text>` or `status <code>`. A check that only requests a path passes on any response, including the placeholder page the flag exists to catch.',
52
+ SMOKE_FAILED: 'The smoke assertion did not pass, so the new revision was NOT promoted. On a container with an earlier revision that one is still serving; on a FIRST deploy there was nothing to hold traffic and the failing build is live. Inspect the revision URL in the error.',
53
+ INVALID_HEALTH_CHECK: 'That health-check path is not usable. /healthz is refused specifically: the runtime answers it before your container does, so a probe on it passes even when your app is down. Use /livez or any other path.',
48
54
  DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
49
55
  DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
50
56
  DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
6
6
  triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
7
- checksum: sha256-47eda6baa9055dadbd3e9d04df9fc425f1a7e96a9cd80c971c4951b97f1e94ff
7
+ checksum: sha256-0e68a4596027c9ccccf0cc86423deffbe71307d265aa973d3cd6261db2695152
8
8
  ---
9
9
 
10
10
  # MyContainerAPI
@@ -23,26 +23,33 @@ The lifecycle is **create → deploy → (optionally) bind a custom domain**.
23
23
  can deploy; you do not need to be able to run one.
24
24
  - `domain` puts the container on a **custom domain** — how you serve a dynamic app at `app.yourbrand.com`.
25
25
 
26
- ### Deploying safely — NOT YET POSSIBLE ON THIS PLATFORM
26
+ ### Deploying safely
27
27
 
28
- A deploy takes 100% of traffic the moment it lands. There is no dry run, no
29
- definition of correct beyond "something is listening on the port", and no way
30
- back. Plan for that.
28
+ A plain deploy takes 100% of traffic the moment it lands.
31
29
 
32
- `--no-promote` and `--smoke` exist as flags and **the CLI refuses them**: they
33
- shipped before the platform could honour them, and a guard that silently
34
- passes is worse than no guard. `--health-check` is accepted at create but does
35
- not appear on the container afterwards, so do not rely on it either.
30
+ **`--smoke '<assertion>'`** deploys the revision with no traffic, checks the
31
+ assertion, and promotes only if it holds. A failure returns `SMOKE_FAILED`,
32
+ leaves the previous revision serving, and returns a revision URL to inspect.
36
33
 
37
- Until they work, the only safe sequence is:
34
+ ```bash
35
+ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'
36
+ ```
37
+
38
+ **Assert on content, not status.** A build whose frontend never bundled still
39
+ binds its port and answers `200` — that is how a placeholder page reached
40
+ production and served a dead page for fifteen minutes.
41
+
42
+ **`--no-promote`** holds the revision back and prints a URL to test yourself,
43
+ then `myapi container promote <id> <revision>`. Use it when the check is more
44
+ than one assertion. On a container's *first* deploy nothing is serving yet, so
45
+ traffic is not withheld and the output says so.
38
46
 
39
- 1. deploy to a **non-production** container
40
- 2. verify it yourself — `curl` for a string only a real build emits, not just
41
- a 200, because a broken build returns 200 too
42
- 3. deploy the same image to production
47
+ **`myapi container rollback <id>`** returns traffic to the previous ready
48
+ revision in seconds, no rebuild.
43
49
 
44
- `myapi container promote <id> <revision>` currently fails, so a bad deploy
45
- must be fixed by deploying forward. Keep a known-good image reference to hand.
50
+ `--health-check /livez` at create makes the startup probe an HTTP request
51
+ rather than a bare TCP connect. `/healthz` is refused the runtime intercepts
52
+ it, so the probe would never reach your container.
46
53
 
47
54
  ### Custom domains (dynamic apps)
48
55
 
@@ -121,8 +128,7 @@ way that looks like an application bug.
121
128
  error envelope will not reach the caller. Return a 4xx if the reason has to
122
129
  survive.
123
130
  - **`--env`, `--cpu`, `--memory`, `--max-instances` and `--cron` are set at
124
- `create` and cannot be changed by `deploy`.** Passing them to `deploy` does
125
- nothing. Recreate the container to change them.
131
+ `create` and cannot be changed by `deploy`.** Recreate to change them.
126
132
 
127
133
  ### Keeping a service warm
128
134
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.9.0",
4
+ "version": "2.10.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.9.0"
49
+ "@myapihq/sdk": "^2.10.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",