@myapihq/cli 2.8.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.
@@ -17,6 +17,7 @@ export declare function deploy(id: string, image: string, flags: Flags): Promise
17
17
  export declare function _parseSmoke(raw: string): sdkContainer.SmokeCheck;
18
18
  export declare function revisions(id: string, flags: Flags): Promise<void>;
19
19
  export declare function promote(id: string, revision: string | undefined, flags: Flags): Promise<void>;
20
+ export declare function rollback(id: string, flags: Flags): Promise<void>;
20
21
  export declare function logs(id: string, flags: Flags): Promise<void>;
21
22
  export declare function domain(id: string, domainArg: string | undefined, flags: Flags): Promise<void>;
22
23
  export declare function buildLogs(id: string | undefined, flags: Flags): Promise<void>;
@@ -239,18 +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
- if (flags['no-promote'] === true || typeof flags.smoke === 'string') {
243
- const which = flags['no-promote'] === true ? '--no-promote' : '--smoke';
244
- error(`${which} is not honoured yet, so this CLI refuses it rather than letting you believe a deploy was guarded.\n\n` +
245
- 'It shipped in 2.7.0 and does not work end to end on either deploy path:\n' +
246
- ' --source the API accepts only the tarball on that path; the flag never reaches it\n' +
247
- ' <image-ref> the API accepts the flag, then fails while moving traffic\n\n' +
248
- 'Until it lands, the safe sequence is:\n' +
249
- ` 1. deploy to a non-production container first\n` +
250
- ` 2. check it yourself (curl for a string only a real build emits)\n` +
251
- ` 3. deploy the same image to production\n\n` +
252
- 'Reported upstream; this message goes away when the flag works.');
253
- }
254
242
  const source = typeof flags.source === 'string' ? flags.source : undefined;
255
243
  // --image is an alias for the positional image ref.
256
244
  if (!image && typeof flags.image === 'string')
@@ -258,6 +246,33 @@ export async function deploy(id, image, flags) {
258
246
  if (source && image) {
259
247
  error('Pass either an image ref or --source, not both.');
260
248
  }
249
+ // Built once, passed to BOTH branches. The original bug was building these
250
+ // inside the image branch only, so a --source deploy dropped them silently.
251
+ const deployOpts = {};
252
+ if (flags['no-promote'] === true)
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
+ }
261
276
  // ── Source-build path (async) ───────────────────────────────────────────
262
277
  if (source) {
263
278
  let tarball;
@@ -281,7 +296,7 @@ export async function deploy(id, image, flags) {
281
296
  else {
282
297
  error(`--source must be a directory or a .tar/.tar.gz/.tgz archive — got ${source}`);
283
298
  }
284
- const start = await sdkContainer.deployContainerSource(config.api_key, orgId, id, tarball, filename);
299
+ const start = await sdkContainer.deployContainerSource(config.api_key, orgId, id, tarball, filename, deployOpts);
285
300
  if (flags.json && start.status !== 'building') {
286
301
  printJson(start);
287
302
  return;
@@ -320,10 +335,7 @@ export async function deploy(id, image, flags) {
320
335
  // ── Pre-built image path (sync) ─────────────────────────────────────────
321
336
  if (!image)
322
337
  error('Missing image ref.\nUsage: myapi container deploy <id> <image-ref>\n or: myapi container deploy <id> --source <dir|tar>\n\n→ <image-ref> is a pre-built container image (e.g. a registry path).');
323
- // No DeployOptions built here: --no-promote and --smoke are refused above
324
- // until the upstream fix lands. The SDK still carries them so the wiring is
325
- // one commit away, and sdk-container.test.ts keeps them covered.
326
- const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image);
338
+ const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image, deployOpts);
327
339
  if (flags.json) {
328
340
  printJson(result);
329
341
  return;
@@ -331,9 +343,18 @@ export async function deploy(id, image, flags) {
331
343
  // An unpromoted revision must NOT read like a completed deploy. A response
332
344
  // that looked the same either way is how an agent concludes it has shipped
333
345
  // when it has not — the original outage in miniature.
334
- // Unreachable while the flags are refused above. Kept because it is the
335
- // render we want the moment they are restored, and deleting it would mean
336
- // rewriting it from memory later.
346
+ // Read `promoted` rather than assuming --no-promote was honoured. On a
347
+ // container's FIRST deploy there is nothing already serving to hold traffic,
348
+ // so the platform promotes anyway and says so — reporting "not serving"
349
+ // there would be the lie this flag exists to prevent.
350
+ if (flags['no-promote'] === true && result.promoted !== false) {
351
+ success(`Deployed container ${id} (revision ${result.revision_id})`);
352
+ info('');
353
+ info('Note: --no-promote was NOT applied. This container had nothing already');
354
+ info('serving, so withholding traffic would have left it answering nothing.');
355
+ info(`URL: ${result.url}`);
356
+ return;
357
+ }
337
358
  if (result.promoted === false) {
338
359
  success(`Revision ${result.revision_id} built — NOT serving traffic`);
339
360
  info(`Test it: ${result.revision_url ?? '(no revision URL returned)'}`);
@@ -417,13 +438,14 @@ export async function revisions(id, flags) {
417
438
  // state.
418
439
  //
419
440
  // Say so rather than render a table that reads as "nothing is live".
441
+ // The 0%-everywhere reporting bug was fixed upstream on 2026-07-28 (a v2
442
+ // traffic target of type LATEST carries no revision name, so 100% was filed
443
+ // under ""). Keeping a narrower check: all-zero on an active container is
444
+ // still worth flagging, it is just no longer expected.
420
445
  if (revs.length > 0 && revs.every(r => !r.serving && !r.traffic_percent) && container?.status === 'active') {
421
446
  info('');
422
- info('Note: every revision reports 0% traffic while this container is active and serving.');
423
- info('The traffic column is wrong, not the container. Verified on a container created');
424
- info('today, so this is not limited to older ones — an earlier version of this message');
425
- info('said redeploying fixes it, which was wrong.');
426
- info('promote depends on this data and currently fails. Reported upstream.');
447
+ info('Note: no revision reports any traffic while this container is active.');
448
+ info('That should not happen report it rather than trusting the column.');
427
449
  }
428
450
  }
429
451
  // promote moves all traffic to one revision. Omitting the revision rolls back
@@ -447,16 +469,25 @@ export async function promote(id, revision, flags) {
447
469
  if (res.message)
448
470
  info(res.message);
449
471
  }
450
- // Not a command guidance for a word that is not one. See the dispatch note.
451
- function rollbackGuidance(id) {
452
- const ref = id || '<id>';
453
- error('There is no `rollback` subcommand rolling back is promoting an older revision.\n\n' +
454
- ` myapi container revisions ${ref} # find the last good revision\n` +
455
- ` myapi container promote ${ref} <revision> # traffic moves in seconds\n\n` +
456
- 'Heads up: the platform currently reports 0% traffic on every revision of any\n' +
457
- 'container deployed before 2026-07-28, and its own roll-back-to-previous path\n' +
458
- 'returns a gateway error. Promoting a revision BY NAME works and is unaffected —\n' +
459
- 'use that. Reported upstream.');
472
+ // rollback moves traffic to the previous ready revision. The API models it as
473
+ // `promote` with no revision named — one operation, two targets — but
474
+ // `rollback` is the word someone types during an incident, so it is a verb
475
+ // here even though the SDK has a single function.
476
+ export async function rollback(id, flags) {
477
+ const config = requireConfig();
478
+ const orgId = requireOrg(flags, config, 'myapi container rollback <id> [--org <id>]');
479
+ if (!id)
480
+ error('Missing id.\nUsage: myapi container rollback <id>');
481
+ const res = await sdkContainer.promoteRevision(config.api_key, orgId, id);
482
+ if (flags.json) {
483
+ printJson(res);
484
+ return;
485
+ }
486
+ success(`Rolled back ${id} to the previous ready revision`);
487
+ if (res.serving)
488
+ info(`Now serving: ${res.serving}`);
489
+ if (res.message)
490
+ info(res.message);
460
491
  }
461
492
  // logs prints the container's recent runtime logs, newest first. By default
462
493
  // this is the container's own stdout/stderr; --scope all adds the platform
@@ -565,19 +596,33 @@ Two ways to deploy:
565
596
  built server-side (typically ~4 minutes), then deployed.
566
597
  Asynchronous — the CLI polls until it's live.
567
598
 
568
- TEMPORARILY REFUSED: --no-promote and --smoke
599
+ Options:
600
+ --no-promote Build the revision without giving it traffic, on either
601
+ deploy path. Prints a revision URL to test at, then:
602
+ myapi container promote <id> <revision>
569
603
 
570
- Both shipped in 2.7.0 and do not work end to end. Rather than accept a flag
571
- and deploy anyway, the CLI now refuses them and explains what to do instead.
572
- A guard that silently passes is worse than no guard.
604
+ On a container's FIRST deploy there is nothing already
605
+ serving, so traffic is NOT withheld and the output says so.
573
606
 
574
- Until they land: deploy to a non-production container, verify it yourself,
575
- then deploy the same image to production.
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.
576
616
 
577
617
  Examples:
578
618
  myapi container deploy <id> registry.example.com/my-app:v2
579
619
  myapi container deploy <id> --source ./my-app
580
- myapi container deploy <id> --source ./context.tar.gz`,
620
+ myapi container deploy <id> <image> --no-promote
621
+ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'`,
622
+ 'rollback': `myapi container rollback <id> [--org <id>] [--json]
623
+
624
+ Move traffic back to the previous ready revision. Seconds, no rebuild.
625
+ Refuses rather than guessing when it cannot tell what is serving.`,
581
626
  'revisions': `myapi container revisions <id> [--org <id>] [--json]
582
627
 
583
628
  Every revision the runtime currently holds, newest first, with the traffic
@@ -621,14 +666,17 @@ dependencies and long execution.
621
666
  Subcommands:
622
667
  build-logs <id> Why the last --source build failed (--tail N; default 100)
623
668
  create Register a container and get its scoped API key (returned once)
669
+ (--health-check /livez for an HTTP startup probe)
624
670
  delete <id> Soft-delete and revoke its scoped API key
625
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)
626
673
  domain <id> <domain> Bind a custom domain (--remove to unbind)
627
674
  get <id> Inspect a container
628
675
  list List containers in your org
629
676
  logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
630
677
  promote <id> <rev> Move all traffic to a revision (seconds, no rebuild)
631
678
  revisions <id> List revisions and the traffic each takes
679
+ rollback <id> Move traffic back to the previous ready revision
632
680
 
633
681
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
634
682
  return;
@@ -652,12 +700,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
652
700
  case 'promote': return promote(args[0], args[1], flags);
653
701
  case 'domain': return domain(args[0], args[1], flags);
654
702
  case 'delete': return del(args[0], flags);
655
- // `rollback` is the word people reach for during an incident, and it is
656
- // NOT a verb here — the API models rollback as `promote` with no revision.
657
- // A bare "unknown subcommand" would cost minutes at the worst possible
658
- // moment, so say what to do instead, and be honest that the underlying
659
- // call is currently broken rather than let someone discover that live.
660
- case 'rollback': return rollbackGuidance(args[0]);
703
+ case 'rollback': return rollback(args[0], flags);
661
704
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
662
705
  }
663
706
  }
@@ -24,7 +24,8 @@ export const SCHEMA = {
24
24
  route: 'string',
25
25
  origins: 'string',
26
26
  };
27
- const KINDS = ['bug', 'idea', 'praise', 'confusion', 'other'];
27
+ // The platform's enum, checked against the schema rather than invented.
28
+ const KINDS = ['bug', 'issue', 'suggestion'];
28
29
  export async function list(flags) {
29
30
  const config = requireConfig();
30
31
  const orgId = requireOrg(flags, config, 'myapi feedback list [--kind <k>] [--status open|resolved] [--org <id>]');
@@ -64,7 +65,7 @@ export async function create(bodyArg, flags) {
64
65
  const orgId = requireOrg(flags, config, 'myapi feedback create "<text>" --kind <k> [--org <id>]');
65
66
  const body = bodyArg ?? flags.body;
66
67
  requireArg(body, 'text', 'myapi feedback create "<text>" --kind bug');
67
- const kind = flags.kind ?? 'other';
68
+ const kind = flags.kind ?? 'issue';
68
69
  if (!KINDS.includes(kind)) {
69
70
  error(`Invalid --kind "${kind}". Use one of: ${KINDS.join(', ')}.\n\n→ Kind is what the PERSON says it is. "bug" is a claim that the product is broken; do not infer it from the wording.`);
70
71
  }
@@ -127,7 +128,7 @@ export async function widget(sub, arg, flags) {
127
128
  error('Usage: myapi feedback widget create <name> [--origins <list>]\n myapi feedback widget revoke <id>');
128
129
  }
129
130
  const SUBCOMMAND_USAGE = {
130
- 'list': `myapi feedback list [--kind bug|idea|praise|confusion|other] [--status open|resolved]
131
+ 'list': `myapi feedback list [--kind bug|issue|suggestion] [--status open|resolved]
131
132
  [--limit N] [--offset N] [--org <id>] [--json]
132
133
 
133
134
  Newest first. \`total\` is the number of matches, not the page size.`,
@@ -149,7 +150,7 @@ Collect feedback from the people using what you built. A widget key lets a
149
150
  page submit without a credential; you list, filter and resolve the results.
150
151
 
151
152
  Subcommands:
152
- create "<text>" Record one piece of feedback (--kind bug|idea|praise|confusion|other)
153
+ create "<text>" Record one piece of feedback (--kind bug|issue|suggestion)
153
154
  list List feedback, newest first (--kind, --status, --limit, --offset)
154
155
  resolve <id> Close a piece of feedback
155
156
  widget create <name> Mint a PUBLIC widget key for a site (--origins to restrict)
@@ -83,38 +83,61 @@ async function run(fn) {
83
83
  throw e;
84
84
  }
85
85
  }
86
- describe('container deploy — the flag must be refused on BOTH paths', () => {
87
- // The original bug: this branch dropped the options entirely. Now the flags
88
- // are refused platform-wide, and the refusal has to fire here too a
89
- // refusal wired to one branch is the same defect wearing a different hat.
90
- it('refuses --no-promote on the --source path, and never calls the SDK', async () => {
86
+ describe('container deploy — --no-promote must REACH both paths', () => {
87
+ // This is the original bug in its final form. --no-promote was wired to the
88
+ // image branch only, so a --source deploy accepted it and dropped it, and
89
+ // the build took 100% of traffic. The platform has since fixed its side and
90
+ // the flag works on both paths so the assertion flips from "is refused" to
91
+ // "arrives", and the bug it guards is the same one either way.
92
+ const DEPLOYED = {
93
+ container_id: 'c1', revision_id: 'r1', url: 'https://x',
94
+ status: 'active', scoped_api_key: 'k', promoted: false,
95
+ revision_url: 'https://rev---x.run.app',
96
+ };
97
+ it('sends promote:false on the image path', async () => {
98
+ sdk.container.deployContainer.mockResolvedValue(DEPLOYED);
91
99
  const { deploy } = await import('./container.js');
92
- await run(() => deploy('c1', '', { source: './app', 'no-promote': true, org: ORG }));
93
- expect(exitError).toMatch(/not honoured yet/);
94
- expect(sdk.container.deployContainerSource).not.toHaveBeenCalled();
95
- expect(sdk.container.deployContainer).not.toHaveBeenCalled();
100
+ await run(() => deploy('c1', 'img:v1', { 'no-promote': true, org: ORG }));
101
+ expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', { promote: false });
96
102
  });
97
- it('refuses --smoke on the --source path', async () => {
103
+ // The branch that shipped broken. It reads a real tarball, so the assertion
104
+ // is that the options object reaches the call — argument 6.
105
+ it('sends promote:false on the --source path', async () => {
106
+ sdk.container.deployContainerSource.mockResolvedValue({ container_id: 'c1', revision_id: 'r1', status: 'building' });
107
+ sdk.container.getContainer.mockResolvedValue({ id: 'c1', status: 'active', url: 'https://x' });
108
+ const { _isTarball } = await import('./container.js');
109
+ void _isTarball;
98
110
  const { deploy } = await import('./container.js');
99
- await run(() => deploy('c1', '', { source: './app', smoke: 'GET / contains x', org: ORG }));
100
- expect(exitError).toMatch(/not honoured yet/);
101
- expect(sdk.container.deployContainerSource).not.toHaveBeenCalled();
111
+ const fsp = await import('node:fs/promises');
112
+ const tmp = `${process.env.TMPDIR ?? '/tmp'}/reach-${Date.now()}.tar.gz`;
113
+ await fsp.writeFile(tmp, 'not-a-real-tarball');
114
+ await run(() => deploy('c1', '', { source: tmp, 'no-promote': true, org: ORG }));
115
+ await fsp.rm(tmp, { force: true });
116
+ expect(sdk.container.deployContainerSource).toHaveBeenCalled();
117
+ expect(sdk.container.deployContainerSource.mock.calls[0][5]).toEqual({ promote: false });
102
118
  });
103
- it('refuses --no-promote on the image path', async () => {
119
+ it('sends no options at all when the flag is absent', async () => {
120
+ sdk.container.deployContainer.mockResolvedValue({ ...DEPLOYED, promoted: true });
104
121
  const { deploy } = await import('./container.js');
105
- await run(() => deploy('c1', 'img:v1', { 'no-promote': true, org: ORG }));
106
- expect(exitError).toMatch(/not honoured yet/);
107
- expect(sdk.container.deployContainer).not.toHaveBeenCalled();
122
+ await run(() => deploy('c1', 'img:v1', { org: ORG }));
123
+ expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', {});
108
124
  });
109
- // The refusal must not become a blanket block on deploying at all.
110
- it('still deploys normally when neither flag is passed', async () => {
111
- sdk.container.deployContainer.mockResolvedValue({
112
- container_id: 'c1', revision_id: 'r1', url: 'https://x', status: 'active', scoped_api_key: 'k',
113
- });
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 });
114
130
  const { deploy } = await import('./container.js');
115
- await run(() => deploy('c1', 'img:v1', { org: ORG }));
116
- expect(exitError).toBeNull();
117
- expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1');
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 () => {
137
+ const { deploy } = await import('./container.js');
138
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', 'no-promote': true, org: ORG }));
139
+ expect(exitError).toMatch(/one or the other/);
140
+ expect(sdk.container.deployContainer).not.toHaveBeenCalled();
118
141
  });
119
142
  });
120
143
  describe('crm pagination — --offset must reach the SDK', () => {
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
 
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Edge-hosted asset storage. Upload a local file of any content type directly, or have the server fetch from a public URL. Each asset gets a stable public CDN URL.
6
6
  triggers: [storage, upload, ingest, asset, cdn, image hosting, file upload, get-url, download, public url]
7
- checksum: sha256-9172b0d8590a3102ce35b94ef48015ec36c624ab9bcc37fe0f7efa15bc54cf62
7
+ checksum: sha256-1f3e7f6de3435e0334a808b9d2faa978c9cb70982509a376abc0e539cb44e1ed
8
8
  ---
9
9
 
10
10
  # MyStorageAPI
@@ -93,11 +93,17 @@ Both produce identical asset records — `list` doesn't distinguish.
93
93
  fetch it; there is no private mode, no signed URL, and no revocation. The id
94
94
  being long and random is **not** access control — treat the URL as public the
95
95
  moment it exists.
96
- - **For personal or regulated data, encrypt before upload.** Storage only ever
97
- sees ciphertext. A team shipping Swiss lease documents (names, dates of
98
- birth, permit type, IBAN) used AES-256-GCM envelope encryption with a
99
- per-document data key wrapped under a master key held in function secrets.
100
- That is the pattern to copy until private assets exist.
96
+ - **Private assets exist as of 2026-07-28** upload with `visibility: private`,
97
+ or `PATCH` an existing asset to close an exposure, which takes effect
98
+ immediately. Fetch it with a signed URL (15 minutes default, 24 hours max);
99
+ `revoke-links` kills every link already handed out, including unexpired ones.
100
+ A private upload deliberately returns **no plain `url`**, because that URL
101
+ does not serve the file and would look like the answer.
102
+ - **Encrypting before upload is still worth doing for the strictest cases.**
103
+ Private assets protect against the internet; client-side encryption protects
104
+ against the platform, and those are different threat models. A team shipping
105
+ Swiss lease documents used AES-256-GCM envelope encryption with a
106
+ per-document data key wrapped under a master key in function secrets.
101
107
  - Delete is immediate and unrecoverable — run `myapi storage list` first to confirm the asset, pass `--org` explicitly, and pass `--yes` in non-interactive runs.
102
108
  - The URL is permanent until you `myapi storage delete <id>` — embed it freely.
103
109
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.8.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.8.0"
49
+ "@myapihq/sdk": "^2.10.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",