@myapihq/cli 2.8.0 → 2.9.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,17 +239,16 @@ 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.');
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.');
253
252
  }
254
253
  const source = typeof flags.source === 'string' ? flags.source : undefined;
255
254
  // --image is an alias for the positional image ref.
@@ -258,6 +257,11 @@ export async function deploy(id, image, flags) {
258
257
  if (source && image) {
259
258
  error('Pass either an image ref or --source, not both.');
260
259
  }
260
+ // Built once, passed to BOTH branches. The original bug was building these
261
+ // inside the image branch only, so a --source deploy dropped them silently.
262
+ const deployOpts = {};
263
+ if (flags['no-promote'] === true)
264
+ deployOpts.promote = false;
261
265
  // ── Source-build path (async) ───────────────────────────────────────────
262
266
  if (source) {
263
267
  let tarball;
@@ -281,7 +285,7 @@ export async function deploy(id, image, flags) {
281
285
  else {
282
286
  error(`--source must be a directory or a .tar/.tar.gz/.tgz archive — got ${source}`);
283
287
  }
284
- const start = await sdkContainer.deployContainerSource(config.api_key, orgId, id, tarball, filename);
288
+ const start = await sdkContainer.deployContainerSource(config.api_key, orgId, id, tarball, filename, deployOpts);
285
289
  if (flags.json && start.status !== 'building') {
286
290
  printJson(start);
287
291
  return;
@@ -320,10 +324,7 @@ export async function deploy(id, image, flags) {
320
324
  // ── Pre-built image path (sync) ─────────────────────────────────────────
321
325
  if (!image)
322
326
  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);
327
+ const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image, deployOpts);
327
328
  if (flags.json) {
328
329
  printJson(result);
329
330
  return;
@@ -331,9 +332,18 @@ export async function deploy(id, image, flags) {
331
332
  // An unpromoted revision must NOT read like a completed deploy. A response
332
333
  // that looked the same either way is how an agent concludes it has shipped
333
334
  // 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.
335
+ // Read `promoted` rather than assuming --no-promote was honoured. On a
336
+ // container's FIRST deploy there is nothing already serving to hold traffic,
337
+ // so the platform promotes anyway and says so — reporting "not serving"
338
+ // there would be the lie this flag exists to prevent.
339
+ if (flags['no-promote'] === true && result.promoted !== false) {
340
+ success(`Deployed container ${id} (revision ${result.revision_id})`);
341
+ info('');
342
+ info('Note: --no-promote was NOT applied. This container had nothing already');
343
+ info('serving, so withholding traffic would have left it answering nothing.');
344
+ info(`URL: ${result.url}`);
345
+ return;
346
+ }
337
347
  if (result.promoted === false) {
338
348
  success(`Revision ${result.revision_id} built — NOT serving traffic`);
339
349
  info(`Test it: ${result.revision_url ?? '(no revision URL returned)'}`);
@@ -417,13 +427,14 @@ export async function revisions(id, flags) {
417
427
  // state.
418
428
  //
419
429
  // Say so rather than render a table that reads as "nothing is live".
430
+ // The 0%-everywhere reporting bug was fixed upstream on 2026-07-28 (a v2
431
+ // traffic target of type LATEST carries no revision name, so 100% was filed
432
+ // under ""). Keeping a narrower check: all-zero on an active container is
433
+ // still worth flagging, it is just no longer expected.
420
434
  if (revs.length > 0 && revs.every(r => !r.serving && !r.traffic_percent) && container?.status === 'active') {
421
435
  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.');
436
+ info('Note: no revision reports any traffic while this container is active.');
437
+ info('That should not happen report it rather than trusting the column.');
427
438
  }
428
439
  }
429
440
  // promote moves all traffic to one revision. Omitting the revision rolls back
@@ -447,16 +458,25 @@ export async function promote(id, revision, flags) {
447
458
  if (res.message)
448
459
  info(res.message);
449
460
  }
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.');
461
+ // rollback moves traffic to the previous ready revision. The API models it as
462
+ // `promote` with no revision named — one operation, two targets — but
463
+ // `rollback` is the word someone types during an incident, so it is a verb
464
+ // here even though the SDK has a single function.
465
+ export async function rollback(id, flags) {
466
+ const config = requireConfig();
467
+ const orgId = requireOrg(flags, config, 'myapi container rollback <id> [--org <id>]');
468
+ if (!id)
469
+ error('Missing id.\nUsage: myapi container rollback <id>');
470
+ const res = await sdkContainer.promoteRevision(config.api_key, orgId, id);
471
+ if (flags.json) {
472
+ printJson(res);
473
+ return;
474
+ }
475
+ success(`Rolled back ${id} to the previous ready revision`);
476
+ if (res.serving)
477
+ info(`Now serving: ${res.serving}`);
478
+ if (res.message)
479
+ info(res.message);
460
480
  }
461
481
  // logs prints the container's recent runtime logs, newest first. By default
462
482
  // this is the container's own stdout/stderr; --scope all adds the platform
@@ -565,19 +585,25 @@ Two ways to deploy:
565
585
  built server-side (typically ~4 minutes), then deployed.
566
586
  Asynchronous — the CLI polls until it's live.
567
587
 
568
- TEMPORARILY REFUSED: --no-promote and --smoke
588
+ Options:
589
+ --no-promote Build the revision without giving it traffic, on either
590
+ deploy path. Prints a revision URL to test at, then:
591
+ myapi container promote <id> <revision>
569
592
 
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.
593
+ On a container's FIRST deploy there is nothing already
594
+ serving, so traffic is NOT withheld and the output says so.
573
595
 
574
- Until they land: deploy to a non-production container, verify it yourself,
575
- then deploy the same image to production.
596
+ --smoke Still refused the platform accepts it and does nothing.
597
+ --no-promote gives you the same verify-then-promote by hand.
576
598
 
577
599
  Examples:
578
600
  myapi container deploy <id> registry.example.com/my-app:v2
579
601
  myapi container deploy <id> --source ./my-app
580
- myapi container deploy <id> --source ./context.tar.gz`,
602
+ myapi container deploy <id> <image> --no-promote`,
603
+ 'rollback': `myapi container rollback <id> [--org <id>] [--json]
604
+
605
+ Move traffic back to the previous ready revision. Seconds, no rebuild.
606
+ Refuses rather than guessing when it cannot tell what is serving.`,
581
607
  'revisions': `myapi container revisions <id> [--org <id>] [--json]
582
608
 
583
609
  Every revision the runtime currently holds, newest first, with the traffic
@@ -629,6 +655,7 @@ Subcommands:
629
655
  logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
630
656
  promote <id> <rev> Move all traffic to a revision (seconds, no rebuild)
631
657
  revisions <id> List revisions and the traffic each takes
658
+ rollback <id> Move traffic back to the previous ready revision
632
659
 
633
660
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
634
661
  return;
@@ -652,12 +679,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
652
679
  case 'promote': return promote(args[0], args[1], flags);
653
680
  case 'domain': return domain(args[0], args[1], flags);
654
681
  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]);
682
+ case 'rollback': return rollback(args[0], flags);
661
683
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
662
684
  }
663
685
  }
@@ -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,51 @@ 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 is still not honoured upstream, and refusing beats accepting.
126
+ it('still refuses --smoke, on either path', async () => {
114
127
  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');
128
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', org: ORG }));
129
+ expect(exitError).toMatch(/--smoke is not honoured yet/);
130
+ expect(sdk.container.deployContainer).not.toHaveBeenCalled();
118
131
  });
119
132
  });
120
133
  describe('crm pagination — --offset must reach the SDK', () => {
@@ -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.9.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.9.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",