@myapihq/cli 2.7.2 → 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
  }
@@ -0,0 +1,10 @@
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ import type { Exposes } from '../exposes.js';
4
+ export declare const EXPOSES: Exposes;
5
+ export declare const SCHEMA: FlagSchema;
6
+ export declare function list(flags: Flags): Promise<void>;
7
+ export declare function create(bodyArg: string | undefined, flags: Flags): Promise<void>;
8
+ export declare function resolve(id: string, flags: Flags): Promise<void>;
9
+ export declare function widget(sub: string | undefined, arg: string | undefined, flags: Flags): Promise<void>;
10
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,174 @@
1
+ // `myapi feedback` — collect what the people using your product tell you.
2
+ //
3
+ // The slot shipped on 2026-07-28 with five endpoints and no CLI. A primitive
4
+ // with no surface is one nobody finds, which is the failure this repo spent a
5
+ // week fixing: two customers concluded a shipped capability did not exist
6
+ // because nothing they read mentioned it.
7
+ import { feedback as sdkFeedback } from '@myapihq/sdk';
8
+ import { requireConfig } from '../config.js';
9
+ import { success, error, info, printTable, printJson, banner } from '../output.js';
10
+ import { formatDate } from '../utils.js';
11
+ import { requireOrg, requireArg, confirmDestructive } from '../helpers.js';
12
+ export const EXPOSES = [
13
+ 'GET /feedback/orgs/{org_id}/items',
14
+ 'POST /feedback/orgs/{org_id}/items',
15
+ 'POST /feedback/orgs/{org_id}/items/{id}/resolve',
16
+ 'POST /feedback/orgs/{org_id}/widgets',
17
+ 'DELETE /feedback/orgs/{org_id}/widgets/{id}',
18
+ ];
19
+ export const SCHEMA = {
20
+ kind: 'string',
21
+ status: 'string',
22
+ body: 'string',
23
+ 'page-url': 'string',
24
+ route: 'string',
25
+ origins: 'string',
26
+ };
27
+ // The platform's enum, checked against the schema rather than invented.
28
+ const KINDS = ['bug', 'issue', 'suggestion'];
29
+ export async function list(flags) {
30
+ const config = requireConfig();
31
+ const orgId = requireOrg(flags, config, 'myapi feedback list [--kind <k>] [--status open|resolved] [--org <id>]');
32
+ if (flags.kind !== undefined && !KINDS.includes(flags.kind)) {
33
+ error(`Invalid --kind "${flags.kind}". Use one of: ${KINDS.join(', ')}.`);
34
+ }
35
+ if (flags.status !== undefined && flags.status !== 'open' && flags.status !== 'resolved') {
36
+ error(`Invalid --status "${flags.status}". Use "open" or "resolved".`);
37
+ }
38
+ const page = await sdkFeedback.listFeedback(config.api_key, orgId, {
39
+ kind: flags.kind,
40
+ status: flags.status,
41
+ limit: typeof flags.limit === 'number' ? flags.limit : undefined,
42
+ offset: typeof flags.offset === 'number' ? flags.offset : undefined,
43
+ });
44
+ if (flags.json) {
45
+ printJson(page);
46
+ return;
47
+ }
48
+ // `total` here is the match count, not the page size — the platform states
49
+ // that explicitly, having been bitten by the opposite in CRM search.
50
+ const shown = page.items?.length ?? 0;
51
+ info(shown === page.total ? `${shown} item${shown === 1 ? '' : 's'}` : `${shown} of ${page.total} items`);
52
+ printTable((page.items ?? []).map(i => ({
53
+ id: i.id,
54
+ kind: i.kind,
55
+ status: i.status,
56
+ body: i.body.length > 60 ? `${i.body.slice(0, 57)}…` : i.body,
57
+ route: i.route ?? i.page_url ?? '',
58
+ created: i.created_at ? formatDate(i.created_at) : '',
59
+ })), { flags, empty: 'No feedback yet. Put a widget on a page: myapi feedback widget create <name>' });
60
+ if (page.has_more)
61
+ info('More available — raise --limit or pass --offset.');
62
+ }
63
+ export async function create(bodyArg, flags) {
64
+ const config = requireConfig();
65
+ const orgId = requireOrg(flags, config, 'myapi feedback create "<text>" --kind <k> [--org <id>]');
66
+ const body = bodyArg ?? flags.body;
67
+ requireArg(body, 'text', 'myapi feedback create "<text>" --kind bug');
68
+ const kind = flags.kind ?? 'issue';
69
+ if (!KINDS.includes(kind)) {
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.`);
71
+ }
72
+ const item = await sdkFeedback.createFeedback(config.api_key, orgId, {
73
+ kind: kind,
74
+ body: body,
75
+ page_url: typeof flags['page-url'] === 'string' ? flags['page-url'] : undefined,
76
+ route: typeof flags.route === 'string' ? flags.route : undefined,
77
+ });
78
+ if (flags.json) {
79
+ printJson(item);
80
+ return;
81
+ }
82
+ success(`Recorded ${item.kind}: ${item.id}`);
83
+ }
84
+ export async function resolve(id, flags) {
85
+ const config = requireConfig();
86
+ const orgId = requireOrg(flags, config, 'myapi feedback resolve <id> [--org <id>]');
87
+ if (!id)
88
+ error('Missing id.\nUsage: myapi feedback resolve <id>');
89
+ await sdkFeedback.resolveFeedback(config.api_key, orgId, id);
90
+ // Already-resolved and not-found answer identically, so this is not proof
91
+ // the id existed. Say so rather than implying a state change happened.
92
+ success(`Resolved ${id}`);
93
+ info('(An unknown id answers the same way, so this is not confirmation the item existed.)');
94
+ }
95
+ export async function widget(sub, arg, flags) {
96
+ const config = requireConfig();
97
+ const orgId = requireOrg(flags, config, 'myapi feedback widget create <name> | revoke <id>');
98
+ if (sub === 'create') {
99
+ requireArg(arg, 'name', 'myapi feedback widget create <name> [--origins a.com,b.com]');
100
+ const origins = typeof flags.origins === 'string'
101
+ ? flags.origins.split(',').map(s => s.trim()).filter(Boolean)
102
+ : undefined;
103
+ const w = await sdkFeedback.createWidget(config.api_key, orgId, arg, origins);
104
+ if (flags.json) {
105
+ printJson(w);
106
+ return;
107
+ }
108
+ success(`Widget created: ${w.id}`);
109
+ info(`Key: ${w.key}`);
110
+ info('');
111
+ // Saying this plainly matters: a value that looks like a credential and is
112
+ // not one gets treated as a secret, and then nobody puts it in the page.
113
+ info('This key is PUBLIC. It ships in your page source and authenticates nobody —');
114
+ info('it names your org so a visitor can submit without signing in.');
115
+ if (!origins?.length) {
116
+ banner('No --origins set, so any site can post through this key. Set them unless that is intended.');
117
+ }
118
+ return;
119
+ }
120
+ if (sub === 'revoke') {
121
+ requireArg(arg, 'id', 'myapi feedback widget revoke <id>');
122
+ await confirmDestructive(flags, `revoke widget ${arg} (submissions with it stop immediately)`, 'myapi feedback widget revoke <id> [--yes] [--org <id>]');
123
+ await sdkFeedback.revokeWidget(config.api_key, orgId, arg);
124
+ success(`Widget ${arg} revoked`);
125
+ info('Feedback already collected through it is kept.');
126
+ return;
127
+ }
128
+ error('Usage: myapi feedback widget create <name> [--origins <list>]\n myapi feedback widget revoke <id>');
129
+ }
130
+ const SUBCOMMAND_USAGE = {
131
+ 'list': `myapi feedback list [--kind bug|issue|suggestion] [--status open|resolved]
132
+ [--limit N] [--offset N] [--org <id>] [--json]
133
+
134
+ Newest first. \`total\` is the number of matches, not the page size.`,
135
+ 'create': `myapi feedback create "<text>" --kind <k> [--page-url <url>] [--route <path>] [--org <id>]
136
+
137
+ --kind is what the person reporting says it is, not what the text sounds like.`,
138
+ 'resolve': 'myapi feedback resolve <id> [--org <id>]',
139
+ 'widget': `myapi feedback widget create <name> [--origins a.com,b.com] [--org <id>]
140
+ myapi feedback widget revoke <id> [--yes] [--org <id>]
141
+
142
+ The key a widget mints is PUBLIC — it ships in page source and authenticates
143
+ nobody. --origins stops another site posting through it.`,
144
+ };
145
+ export async function run(subcommand, args, flags) {
146
+ if (!subcommand || (flags.help && !subcommand)) {
147
+ info(`Usage: myapi feedback <subcommand>
148
+
149
+ Collect feedback from the people using what you built. A widget key lets a
150
+ page submit without a credential; you list, filter and resolve the results.
151
+
152
+ Subcommands:
153
+ create "<text>" Record one piece of feedback (--kind bug|issue|suggestion)
154
+ list List feedback, newest first (--kind, --status, --limit, --offset)
155
+ resolve <id> Close a piece of feedback
156
+ widget create <name> Mint a PUBLIC widget key for a site (--origins to restrict)
157
+ widget revoke <id> Revoke a widget key; collected feedback is kept
158
+
159
+ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
160
+ return;
161
+ }
162
+ if (flags.help) {
163
+ const usage = SUBCOMMAND_USAGE[subcommand];
164
+ info(usage ? `Usage: ${usage}` : `Unknown subcommand: ${subcommand}. Run "myapi feedback --help" for the list.`);
165
+ return;
166
+ }
167
+ switch (subcommand) {
168
+ case 'create': return create(args[0], flags);
169
+ case 'list': return list(flags);
170
+ case 'resolve': return resolve(args[0], flags);
171
+ case 'widget': return widget(args[0], args[1], flags);
172
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi feedback --help" for a list of valid subcommands.`);
173
+ }
174
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,287 @@
1
+ // Does the flag REACH the call, or does it only parse?
2
+ //
3
+ // This file exists because of a bug we shipped and a customer found.
4
+ //
5
+ // `container deploy --no-promote` was wired to the pre-built-image path and
6
+ // not to the `--source` path. `deployContainerSource()` was called without the
7
+ // options object, so on a source build the flag was accepted and silently
8
+ // discarded. Both flags were declared in the command schema, so the
9
+ // foreign-flag check stayed quiet too. A team deployed with it, the build took
10
+ // 100% of traffic anyway, and they reported it.
11
+ //
12
+ // The tests we had at the time all passed. They tested `_parseSmoke` (a pure
13
+ // parser) and `deployContainer` (the SDK function). Nothing tested the CLI
14
+ // handler, so nothing noticed that one of its two branches never passed the
15
+ // options along. The backend hit the identical shape the same day and put it
16
+ // better than we can:
17
+ //
18
+ // "Counting call sites proves the helper is CALLED, not that it is REACHED."
19
+ //
20
+ // Ours is: testing the parser proves the flag PARSES, not that it is SENT.
21
+ //
22
+ // So this file tests HANDLERS, with the SDK mocked, asserting what actually
23
+ // arrives at the boundary. Every case below is a real field report. When you
24
+ // add a flag that changes a request, add a case here — a unit test on its
25
+ // parser is not cover.
26
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27
+ const ORG = '11111111-1111-4111-8111-111111111111';
28
+ // The SDK is mocked wholesale so we can see exactly what the handler passes.
29
+ // Typed loosely on purpose: the later describes assign whole slot objects
30
+ // (sdk.task = {...}) and TS would otherwise reject each one. The assertions
31
+ // still check real shapes.
32
+ const sdk = vi.hoisted(() => ({
33
+ container: {
34
+ deployContainer: vi.fn(),
35
+ deployContainerSource: vi.fn(),
36
+ getContainerLogs: vi.fn(),
37
+ listContainers: vi.fn(),
38
+ getContainer: vi.fn(),
39
+ listRevisions: vi.fn(),
40
+ promoteRevision: vi.fn(),
41
+ createContainer: vi.fn(),
42
+ },
43
+ crm: { searchContacts: vi.fn(), searchCompanies: vi.fn() },
44
+ fn: { listFunctions: vi.fn() },
45
+ // Top-level SDK exports the handlers reach for. Mocking the module
46
+ // wholesale drops anything not listed, and the failure reads as a missing
47
+ // export rather than a missing mock — so they are enumerated explicitly.
48
+ withFundsRetry: vi.fn(async (f) => f()),
49
+ MyApiError: class MyApiError extends Error {
50
+ code = '';
51
+ status = 0;
52
+ },
53
+ }));
54
+ vi.mock('@myapihq/sdk', () => sdk);
55
+ vi.mock('../config.js', () => ({
56
+ requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
57
+ loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
58
+ CONFIG_DIR: '/tmp/nowhere',
59
+ }));
60
+ let exitError;
61
+ beforeEach(async () => {
62
+ exitError = null;
63
+ vi.clearAllMocks();
64
+ const output = await import('../output.js');
65
+ vi.spyOn(output, 'error').mockImplementation(((m) => {
66
+ exitError = m;
67
+ throw new Error('__EXIT__');
68
+ }));
69
+ vi.spyOn(output, 'info').mockImplementation(() => { });
70
+ vi.spyOn(output, 'success').mockImplementation(() => { });
71
+ vi.spyOn(output, 'printTable').mockImplementation(() => { });
72
+ vi.spyOn(output, 'printJson').mockImplementation(() => { });
73
+ vi.spyOn(output, 'banner').mockImplementation(() => { });
74
+ });
75
+ afterEach(() => vi.restoreAllMocks());
76
+ // Runs a handler and swallows the synthetic exit thrown by a mocked error().
77
+ async function run(fn) {
78
+ try {
79
+ await fn();
80
+ }
81
+ catch (e) {
82
+ if (e?.message !== '__EXIT__')
83
+ throw e;
84
+ }
85
+ }
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);
99
+ const { deploy } = await import('./container.js');
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 });
102
+ });
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;
110
+ const { deploy } = await import('./container.js');
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 });
118
+ });
119
+ it('sends no options at all when the flag is absent', async () => {
120
+ sdk.container.deployContainer.mockResolvedValue({ ...DEPLOYED, promoted: true });
121
+ const { deploy } = await import('./container.js');
122
+ await run(() => deploy('c1', 'img:v1', { org: ORG }));
123
+ expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', {});
124
+ });
125
+ // --smoke is still not honoured upstream, and refusing beats accepting.
126
+ it('still refuses --smoke, on either path', async () => {
127
+ 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/);
130
+ expect(sdk.container.deployContainer).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+ describe('crm pagination — --offset must reach the SDK', () => {
134
+ // Reported by a customer as returning page one forever. The API ignored it
135
+ // at the time; once fixed, the CLI had to actually send it, and only a
136
+ // handler-level test proves that.
137
+ beforeEach(() => {
138
+ sdk.crm.searchContacts.mockResolvedValue({ contacts: [], total: 0, has_more: false });
139
+ sdk.crm.searchCompanies.mockResolvedValue({ companies: [], total: 0, has_more: false });
140
+ });
141
+ it('passes --offset through on contacts list', async () => {
142
+ const { run: crmRun } = await import('./crm/index.js');
143
+ await run(() => crmRun('contacts', ['list'], { offset: 25, limit: 10, org: ORG }));
144
+ expect(sdk.crm.searchContacts).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ offset: 25, limit: 10 }));
145
+ });
146
+ it('passes --offset through on contacts search, alongside filters', async () => {
147
+ const { run: crmRun } = await import('./crm/index.js');
148
+ await run(() => crmRun('contacts', ['search'], { offset: 5, origin: 'webhook', org: ORG }));
149
+ expect(sdk.crm.searchContacts).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ offset: 5 }));
150
+ });
151
+ it('passes --offset through on companies', async () => {
152
+ const { run: crmRun } = await import('./crm/index.js');
153
+ await run(() => crmRun('companies', ['list'], { offset: 7, org: ORG }));
154
+ expect(sdk.crm.searchCompanies).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ offset: 7 }));
155
+ });
156
+ // The deprecated spelling must still reach the request, or the alias is a
157
+ // promise we are not keeping.
158
+ it('still honours the deprecated --source spelling for provenance', async () => {
159
+ const { run: crmRun } = await import('./crm/index.js');
160
+ await run(() => crmRun('contacts', ['search'], { source: 'goldfox', org: ORG }));
161
+ expect(sdk.crm.searchContacts).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ source: ['goldfox'] }));
162
+ });
163
+ });
164
+ describe('container logs — --scope must reach the SDK', () => {
165
+ it('sends scope=all when asked', async () => {
166
+ sdk.container.getContainerLogs.mockResolvedValue([]);
167
+ const { logs } = await import('./container.js');
168
+ await run(() => logs('c1', { scope: 'all', tail: 500, org: ORG }));
169
+ expect(sdk.container.getContainerLogs).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 500, 'all');
170
+ });
171
+ it('sends no scope by default, rather than the string "container"', async () => {
172
+ sdk.container.getContainerLogs.mockResolvedValue([]);
173
+ const { logs } = await import('./container.js');
174
+ await run(() => logs('c1', { org: ORG }));
175
+ expect(sdk.container.getContainerLogs).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', undefined, undefined);
176
+ });
177
+ it('refuses an invalid scope instead of passing it on', async () => {
178
+ const { logs } = await import('./container.js');
179
+ await run(() => logs('c1', { scope: 'everything', org: ORG }));
180
+ expect(exitError).toMatch(/Invalid --scope/);
181
+ expect(sdk.container.getContainerLogs).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+ describe('container create — --health-check is validated before the call', () => {
185
+ it('refuses /healthz client-side and never calls the SDK', async () => {
186
+ const { create } = await import('./container.js');
187
+ await run(() => create('probe', { 'health-check': '/healthz', org: ORG }));
188
+ expect(exitError).toMatch(/intercepts \/healthz/);
189
+ expect(sdk.container.createContainer).not.toHaveBeenCalled();
190
+ });
191
+ it('refuses a path that is not a path', async () => {
192
+ const { create } = await import('./container.js');
193
+ await run(() => create('probe', { 'health-check': 'livez', org: ORG }));
194
+ expect(exitError).toMatch(/must be a path/);
195
+ expect(sdk.container.createContainer).not.toHaveBeenCalled();
196
+ });
197
+ });
198
+ // ── Wider coverage ──────────────────────────────────────────────────────────
199
+ //
200
+ // The cases above are the ones a customer found. These are the same class of
201
+ // risk elsewhere: a flag that changes WHAT GETS WRITTEN, where losing it in
202
+ // transit produces a wrong record rather than an error.
203
+ //
204
+ // Coverage is partial and worth stating: this file exercises 8 commands of the
205
+ // 32 that take flags. It covers the ones where a dropped flag is silent and
206
+ // consequential. `list`/`get` verbs are omitted deliberately — a dropped
207
+ // filter there is visible in the output, which is a different and much
208
+ // cheaper failure.
209
+ describe('task create — flags that change the stored record', () => {
210
+ beforeEach(() => { sdk.task = { createTask: vi.fn().mockResolvedValue({ id: 't1' }), listTasks: vi.fn().mockResolvedValue([]) }; });
211
+ it('sends --dedup-key, which is what makes creation idempotent', async () => {
212
+ const { create } = await import('./task.js');
213
+ await run(() => create('do a thing', { 'dedup-key': 'evt-123', org: ORG }));
214
+ expect(sdk.task.createTask).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ dedupKey: 'evt-123' }));
215
+ });
216
+ it('sends --origin, and still honours the deprecated --source', async () => {
217
+ const { create } = await import('./task.js');
218
+ await run(() => create('x', { origin: 'agent', org: ORG }));
219
+ expect(sdk.task.createTask).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ source: 'agent' }));
220
+ vi.clearAllMocks();
221
+ await run(() => create('x', { source: 'legacy', org: ORG }));
222
+ expect(sdk.task.createTask).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ source: 'legacy' }));
223
+ });
224
+ });
225
+ describe('webhook create — the CRM ingest path must survive', () => {
226
+ beforeEach(() => { sdk.webhook = { createEndpoint: vi.fn().mockResolvedValue({ id: 'w1', url: 'u' }) }; });
227
+ // Losing this silently means submissions stop becoming contacts, with no
228
+ // error anywhere — the endpoint keeps accepting deliveries.
229
+ it('sends --crm-email-path', async () => {
230
+ const { create } = await import('./webhook.js');
231
+ await run(() => create('stripe', { 'crm-email-path': 'data.object.customer_email', org: ORG }));
232
+ const [, , , opts] = sdk.webhook.createEndpoint.mock.calls[0];
233
+ expect(opts).toMatchObject({ crm_email_path: 'data.object.customer_email' });
234
+ });
235
+ it('sends --forward-url', async () => {
236
+ const { create } = await import('./webhook.js');
237
+ await run(() => create('gh', { 'forward-url': 'https://example.com/hook', org: ORG }));
238
+ const [, , , opts] = sdk.webhook.createEndpoint.mock.calls[0];
239
+ expect(opts).toMatchObject({ forward_url: 'https://example.com/hook' });
240
+ });
241
+ });
242
+ describe('audience create — --from selects the dataset', () => {
243
+ beforeEach(() => { sdk.audience = { createAudience: vi.fn().mockResolvedValue({ id: 'a1', member_count: 0 }) }; });
244
+ // Picking the wrong dataset builds an audience of the wrong KIND of record.
245
+ // Nothing errors; the list is simply of companies when you wanted people.
246
+ it('sends --from', async () => {
247
+ const { run: audRun } = await import('./audience.js');
248
+ await run(() => audRun('create', ['my-list'], { from: 'company', filter: '{}', org: ORG }));
249
+ expect(sdk.audience.createAudience).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ source: 'company' }));
250
+ });
251
+ it('still honours the deprecated --source spelling', async () => {
252
+ const { run: audRun } = await import('./audience.js');
253
+ await run(() => audRun('create', ['my-list'], { source: 'people', filter: '{}', org: ORG }));
254
+ expect(sdk.audience.createAudience).toHaveBeenCalledWith('hq_live_test', ORG, expect.objectContaining({ source: 'people' }));
255
+ });
256
+ });
257
+ describe('git commit — authorship must not silently default', () => {
258
+ beforeEach(() => {
259
+ sdk.git = {
260
+ commit: vi.fn().mockResolvedValue({ sha: 'abc1234' }),
261
+ // The handler resolves the branch tip before committing; without this it
262
+ // refuses rather than guessing a base, which is the right behaviour and
263
+ // has to be satisfied to reach the call we are testing.
264
+ listRefs: vi.fn().mockResolvedValue({ branches: [{ name: 'main', sha: 'base123' }] }),
265
+ };
266
+ });
267
+ // Without these every agent-written commit is attributed to the key's
268
+ // account — wrong quietly rather than loudly.
269
+ it('sends --author-name and --author-email', async () => {
270
+ const { commit } = await import('./git.js');
271
+ await run(() => commit('repo', {
272
+ branch: 'main', message: 'm',
273
+ changes: '[{"path":"a.txt","content":"hi"}]',
274
+ 'author-name': 'Ada', 'author-email': 'ada@example.com', org: ORG,
275
+ }));
276
+ expect(sdk.git.commit).toHaveBeenCalled();
277
+ const payload = sdk.git.commit.mock.calls[0][3];
278
+ expect(payload.author).toMatchObject({ name: 'Ada', email: 'ada@example.com' });
279
+ });
280
+ it('omits author entirely when neither flag is given', async () => {
281
+ const { commit } = await import('./git.js');
282
+ await run(() => commit('repo', {
283
+ branch: 'main', message: 'm', changes: '[{"path":"a.txt","content":"hi"}]', org: ORG,
284
+ }));
285
+ expect(sdk.git.commit.mock.calls[0][3].author).toBeUndefined();
286
+ });
287
+ });
@@ -3,4 +3,5 @@ import { type Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
+ export declare function _parseJsonObjectFlag(raw: unknown, flagName: string): Record<string, unknown> | undefined;
6
7
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -24,6 +24,9 @@ export const SCHEMA = {
24
24
  schema: 'string',
25
25
  style: 'string',
26
26
  kind: 'string',
27
+ facts: 'string',
28
+ directives: 'string',
29
+ // Deprecated alias for --facts; undocumented, removable next minor.
27
30
  context: 'string',
28
31
  prompt: 'string',
29
32
  tier: 'string',
@@ -103,21 +106,33 @@ function parseSchemaFlag(flags) {
103
106
  return null;
104
107
  }
105
108
  }
106
- function parseContextFlag(flags) {
107
- if (typeof flags.context !== 'string' || !flags.context)
109
+ // --facts is referent data quoted into the prompt as reference; --directives
110
+ // are writer controls (tone, max_words, format). The platform split them
111
+ // because they are trusted differently, and `--context` predated the split.
112
+ export function _parseJsonObjectFlag(raw, flagName) {
113
+ if (typeof raw !== 'string' || !raw)
108
114
  return undefined;
109
115
  try {
110
- const parsed = JSON.parse(flags.context);
111
- if (typeof parsed === 'object' && parsed !== null)
116
+ const parsed = JSON.parse(raw);
117
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
112
118
  return parsed;
113
- error('--context must be a JSON object');
119
+ }
120
+ error(`${flagName} must be a JSON object`);
114
121
  return undefined;
115
122
  }
116
123
  catch {
117
- error('--context must be valid JSON');
124
+ error(`${flagName} must be valid JSON`);
118
125
  return undefined;
119
126
  }
120
127
  }
128
+ function parseContextFlag(flags) {
129
+ // --facts wins; --context is the old spelling and still works.
130
+ return _parseJsonObjectFlag(flags.facts, '--facts')
131
+ ?? _parseJsonObjectFlag(flags.context, '--context');
132
+ }
133
+ function parseDirectivesFlag(flags) {
134
+ return _parseJsonObjectFlag(flags.directives, '--directives');
135
+ }
121
136
  function tierFromFlag(flags) {
122
137
  if (typeof flags.tier !== 'string' || !flags.tier)
123
138
  return undefined;
@@ -269,7 +284,7 @@ async function summarize(inputArg, flags) {
269
284
  }
270
285
  async function draft(inputArg, flags) {
271
286
  const config = requireConfig();
272
- const orgId = requireOrg(flags, config, 'myapi llm draft --kind <what> [--prompt "<instructions>"] [--context <json>] ["<source text>"] [--tier <t>] [--org <id>]');
287
+ const orgId = requireOrg(flags, config, 'myapi llm draft --kind <what> [--prompt "<instructions>"] [--facts <json>] ["<source text>"] [--tier <t>] [--org <id>]');
273
288
  if (typeof flags.kind !== 'string' || !flags.kind) {
274
289
  error('Missing required flag: --kind <email|message|reply|...>');
275
290
  return;
@@ -281,12 +296,13 @@ async function draft(inputArg, flags) {
281
296
  const promptText = typeof flags.prompt === 'string' ? flags.prompt : '';
282
297
  const ctx = parseContextFlag(flags);
283
298
  if (!input.trim() && !promptText.trim() && (!ctx || Object.keys(ctx).length === 0)) {
284
- error('draft needs at least one of: <source text> (arg or --file), --prompt, or --context.');
299
+ error('draft needs at least one of: <source text> (arg or --file), --prompt, or --facts.');
285
300
  }
286
301
  const res = await retryFunds(() => sdkLlm.draft(config.api_key, orgId, {
287
302
  input: input || undefined,
288
303
  kind,
289
- context: ctx,
304
+ facts: ctx,
305
+ directives: parseDirectivesFlag(flags),
290
306
  prompt: promptText || undefined,
291
307
  tier: tierFromFlag(flags),
292
308
  }));
@@ -29,7 +29,7 @@ export const COMMANDS = [
29
29
  'account', 'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
30
30
  'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
31
31
  'doctor', 'install-skills', 'keys', 'llm', 'login', 'org', 'payments', 'people', 'pixel',
32
- 'queue', 'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
32
+ 'feedback', 'queue', 'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
33
33
  'workflow',
34
34
  ];
35
35
  // command → subcommands, for `myapi <command> <TAB>`. Mirrors each
@@ -61,6 +61,7 @@ export const SUBCOMMANDS = {
61
61
  payments: ['connect', 'status', 'charge', 'list', 'get', 'refund'],
62
62
  container: ['create', 'deploy', 'list', 'get', 'logs', 'domain', 'delete'],
63
63
  git: ['create', 'list', 'get', 'delete', 'refs', 'log', 'show', 'tree', 'blob', 'diff', 'commit', 'create-branch', 'delete-branch', 'tag', 'merge', 'repack'],
64
+ feedback: ['create', 'list', 'resolve', 'widget'],
64
65
  queue: ['create', 'list', 'get', 'delete', 'enqueue', 'jobs', 'job'],
65
66
  task: ['create', 'list', 'get', 'claim', 'extend', 'resolve', 'fail', 'cancel'],
66
67
  completion: ['install', 'uninstall'],
@@ -42,6 +42,7 @@ const COMMAND_MODULES = [
42
42
  './commands/payments.js',
43
43
  './commands/container.js',
44
44
  './commands/git.js',
45
+ './commands/feedback.js',
45
46
  './commands/queue.js',
46
47
  './commands/task.js',
47
48
  './commands/doctor.js',
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ import * as paymentsCmd from './commands/payments.js';
41
41
  import * as containerCmd from './commands/container.js';
42
42
  import * as gitCmd from './commands/git.js';
43
43
  import * as queueCmd from './commands/queue.js';
44
+ import * as feedbackCmd from './commands/feedback.js';
44
45
  import * as taskCmd from './commands/task.js';
45
46
  import * as doctorCmd from './commands/doctor.js';
46
47
  import * as loginCmd from './commands/login.js';
@@ -85,6 +86,7 @@ const COMMAND_SCHEMAS = {
85
86
  people: peopleCmd.SCHEMA,
86
87
  pixel: pixelCmd.SCHEMA,
87
88
  queue: queueCmd.SCHEMA,
89
+ feedback: feedbackCmd.SCHEMA,
88
90
  status: statusCmd.SCHEMA,
89
91
  storage: storageCmd.SCHEMA,
90
92
  task: taskCmd.SCHEMA,
@@ -134,6 +136,7 @@ const COMBINED_SCHEMA = {
134
136
  ...containerCmd.SCHEMA,
135
137
  ...gitCmd.SCHEMA,
136
138
  ...queueCmd.SCHEMA,
139
+ ...feedbackCmd.SCHEMA,
137
140
  ...taskCmd.SCHEMA,
138
141
  ...doctorCmd.SCHEMA,
139
142
  ...loginCmd.SCHEMA,
@@ -258,6 +261,9 @@ async function main() {
258
261
  case 'queue':
259
262
  await queueCmd.run(subcommand, restArgs, flags);
260
263
  break;
264
+ case 'feedback':
265
+ await feedbackCmd.run(subcommand, restArgs, flags);
266
+ break;
261
267
  case 'task':
262
268
  await taskCmd.run(subcommand, restArgs, flags);
263
269
  break;
@@ -463,6 +469,7 @@ Commands:
463
469
  doctor Org-wide consistency check — funnels, webhooks, domains, containers
464
470
  domain Manage domain configurations
465
471
  email Manage mailboxes, send/read email, templates, and campaigns
472
+ feedback Collect feedback from the people using what you built
466
473
  fn Create and deploy functions on the edge runtime
467
474
  funnel Manage websites (publish pages, custom domains, funnels)
468
475
  git Hosted git repositories — repos, commits, branches, history
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Deploy JavaScript functions to the MyAPI edge runtime. Register a function, upload a single-file JS bundle, get a live HTTP invocation URL or run it on a cron schedule. Each function gets a scoped capability key for cross-slot calls.
6
6
  triggers: [function, deploy function, edge function, serverless, cloudflare worker, cron, scoped api key, capability key, invocation url, bundle]
7
- checksum: sha256-1ef76e07c9fb4f295b491224261b5719314f15d3645c32e6e7b443a7fdaf95c6
7
+ checksum: sha256-a29b3c96645c59317c0896c9a886d9904bdb2ebde9eb8582d0cb7071d0b9ac2c
8
8
  ---
9
9
 
10
10
  # MyFunctionAPI
@@ -129,4 +129,10 @@ curl -H "Authorization: Bearer $SCOPED_KEY" \
129
129
  - Deploy rotates the scoped API key on every call — re-capture the printed value if other systems use it.
130
130
  - `myapi fn env <id> --set KEY=VALUE,OTHER=VALUE` sets several secrets in one call instead of one command each.
131
131
 
132
+ **`--scope` is create-only, so treat it as permanent.** There is no
133
+ `fn scope --add`. Adding a slot later means delete + recreate, which mints a
134
+ **new function id and a new invocation URL**, breaking every reference already
135
+ handed out — docs, front-end config, webhooks, anything given to a third
136
+ party. Decide the full slot list before you publish the URL.
137
+
132
138
  Run `myapi fn --help` or `myapi fn <subcommand> --help` for full flag reference.
@@ -7,7 +7,7 @@ description: >
7
7
  (classify / extract / summarize / draft) that hide the model behind a
8
8
  task. Pricing in cents per 1M tokens; charged from your MyAPI balance.
9
9
  triggers: [llm, completion, chat, embed, embedding, inference, classify, extract, summarize, draft, qwen]
10
- checksum: sha256-6c399da099802637c68eda61ba85a23286ff3dca8f50c391e80ab442777c17c4
10
+ checksum: sha256-2a9070e118134aea98a1be986d29f9c4e3a969aa960e7dfc87137bbc490ac04e
11
11
  ---
12
12
 
13
13
  # MyLLMAPI
@@ -21,9 +21,9 @@ Pricing is cents per 1M tokens at the actual upstream rate, debited from your My
21
21
 
22
22
  ## Capabilities
23
23
  <!-- llm:start -->
24
- Use this for workflow tasks — summarize a doc, classify an inbound email, extract structured data, draft a reply. The reply goes to stdout; a one-line usage footer (tokens + cost + finish reason) goes to stderr, so `myapi llm complete ... | jq` and similar pipelines work as expected.
24
+ For workflow tasks — summarize, classify, extract, draft. The reply goes to stdout and the usage footer (tokens, cost, finish reason) to stderr, so `myapi llm complete ... | jq` works.
25
25
 
26
- **Don't use this as your own model.** If you're an agent reading this, you already have a more capable model than what's exposed here. Reach for the LLM verbs when you're scripting a recurring step where a small/cheap model is the right tool — not for one-shot reasoning you can just do yourself.
26
+ **Don't use this as your own model.** If you are an agent reading this, you already have a more capable model. Reach for the LLM verbs when scripting a recurring step where a small, cheap model is the right tool — not for one-shot reasoning you can do yourself.
27
27
 
28
28
  Reach for raw `complete` when shape matters (you build the `messages` array and set `max_tokens`/`temperature`/`stop`); reach for a verb when you want a *result* and don't care which model produced it.
29
29
 
@@ -88,7 +88,7 @@ The model/provider is **never** named in the verb response — the verb is the c
88
88
 
89
89
  ### OpenAI-compatible drop-in
90
90
 
91
- `POST /llm/orgs/{org_id}/chat/completions` (and `/v1/chat/completions` alias) accepts the OpenAI request shape and returns the OpenAI response shape — **no envelope**. Same catalog and pricing as raw `complete`. Use it when existing OpenAI SDK / LangChain / any `base_url`-configurable tooling should point at MyAPI without rewriting.
91
+ `POST /llm/orgs/{org_id}/chat/completions` (alias `/v1/chat/completions`) takes and returns the OpenAI shape — **no envelope**. Same catalog and pricing as `complete`. Use it when an existing OpenAI SDK or LangChain integration should point at MyAPI unchanged.
92
92
 
93
93
  ```python
94
94
  from openai import OpenAI
@@ -160,3 +160,16 @@ INTENT=$(printf '%s' "$BODY" | myapi llm classify - \
160
160
  - **Self-hosted raw, server-picked verbs.** Raw runs on MyAPI's TPU; verbs route wherever the server picks.
161
161
  - **Cost + latency.** `usage.cost_cents` is authoritative — no markup. Varies by tier: 200–600 ms to first token, 1–3 s end-to-end.
162
162
  - **Live catalog, no streaming, no BYOK.** Don't hard-code ids — `models` is truth (CLI auto-picks if `--model` omitted). Full reply only.
163
+
164
+ ## `--facts` vs `--directives` on draft
165
+
166
+ `--facts '<json>'` is referent data, quoted as reference and never as
167
+ instructions (recipient, dates, amounts). `--directives '<json>'` is writer
168
+ controls only: tone, max_words, format, style. They are trusted differently.
169
+
170
+ ```bash
171
+ myapi llm draft --kind email --prompt "the invoice is due" \
172
+ --facts '{"to":"Ada"}' --directives '{"tone":"warm"}'
173
+ ```
174
+
175
+ `--context` is the old name for `--facts`; accepted, deprecated upstream.
@@ -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.7.2",
4
+ "version": "2.9.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,11 +38,15 @@
38
38
  "audit:doctor": "npm run build && node scripts/audit-doctor.js",
39
39
  "lint:docs": "node scripts/lint-docs.js",
40
40
  "lint:skill-coverage": "node scripts/lint-skill-coverage.js",
41
+ "bench:discoverability": "node scripts/bench-discoverability.js",
42
+ "lint:claims": "node scripts/verify-claims.js --lint",
43
+ "verify:claims": "npm run build && node scripts/verify-claims.js",
44
+ "audit:fields": "npm run build && node scripts/audit-field-honoured.js",
41
45
  "lint:skills": "node scripts/copy-skills.js && node scripts/lint-skills.js",
42
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
43
47
  },
44
48
  "dependencies": {
45
- "@myapihq/sdk": "^2.7.2"
49
+ "@myapihq/sdk": "^2.9.0"
46
50
  },
47
51
  "devDependencies": {
48
52
  "@types/node": "^25.6.0",