@myapihq/cli 2.9.0 → 2.11.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.
Files changed (50) hide show
  1. package/dist/commands/container.js +35 -14
  2. package/dist/commands/flag-reachability.test.js +14 -4
  3. package/dist/commands/import-key.test.d.ts +1 -0
  4. package/dist/commands/import-key.test.js +69 -0
  5. package/dist/commands/org.js +5 -1
  6. package/dist/commands/setup.d.ts +5 -1
  7. package/dist/commands/setup.js +67 -6
  8. package/dist/commands/storage.js +151 -14
  9. package/dist/config.d.ts +2 -0
  10. package/dist/errors.d.ts +4 -0
  11. package/dist/errors.js +36 -3
  12. package/dist/helpers.d.ts +20 -1
  13. package/dist/helpers.js +79 -1
  14. package/dist/index.js +6 -1
  15. package/dist/org-not-found.test.d.ts +1 -0
  16. package/dist/org-not-found.test.js +42 -0
  17. package/dist/org-notice.test.d.ts +1 -0
  18. package/dist/org-notice.test.js +37 -0
  19. package/dist/output.d.ts +6 -0
  20. package/dist/output.js +8 -1
  21. package/dist/skills/my-api-hq/SKILL.md +34 -14
  22. package/dist/skills/my-audience-api/SKILL.md +16 -1
  23. package/dist/skills/my-auth-api/SKILL.md +17 -2
  24. package/dist/skills/my-company-api/SKILL.md +16 -1
  25. package/dist/skills/my-container-api/SKILL.md +45 -24
  26. package/dist/skills/my-crm-api/SKILL.md +16 -1
  27. package/dist/skills/my-database-api/SKILL.md +13 -17
  28. package/dist/skills/my-domain-api/SKILL.md +16 -1
  29. package/dist/skills/my-email-api/SKILL.md +12 -16
  30. package/dist/skills/my-email-verify-api/SKILL.md +16 -1
  31. package/dist/skills/my-feedback-api/README.md +33 -0
  32. package/dist/skills/my-feedback-api/SKILL.md +118 -0
  33. package/dist/skills/my-feedback-api/claude/.claude-plugin/plugin.json +7 -0
  34. package/dist/skills/my-function-api/README.md +34 -0
  35. package/dist/skills/my-function-api/SKILL.md +16 -1
  36. package/dist/skills/my-funnel-api/SKILL.md +16 -1
  37. package/dist/skills/my-git-api/SKILL.md +17 -1
  38. package/dist/skills/my-image-api/SKILL.md +17 -1
  39. package/dist/skills/my-llm-api/SKILL.md +20 -6
  40. package/dist/skills/my-payments-api/README.md +33 -0
  41. package/dist/skills/my-payments-api/SKILL.md +16 -1
  42. package/dist/skills/my-people-api/SKILL.md +16 -1
  43. package/dist/skills/my-pixel-api/SKILL.md +26 -3
  44. package/dist/skills/my-queue-api/SKILL.md +35 -2
  45. package/dist/skills/my-storage-api/SKILL.md +37 -29
  46. package/dist/skills/my-task-api/SKILL.md +35 -2
  47. package/dist/skills/my-url-to/SKILL.md +16 -1
  48. package/dist/skills/my-webhook-api/SKILL.md +23 -3
  49. package/dist/skills/my-workflow-api/SKILL.md +32 -6
  50. package/package.json +2 -2
@@ -239,17 +239,6 @@ export async function deploy(id, image, flags) {
239
239
  // write their own; a guard that silently passes makes them stop. Restore
240
240
  // these the moment the upstream fix lands — see
241
241
  // docs/cross-repo-prompts/backend-consolidated-2026-07-28.md.
242
- // --smoke is still not honoured; --no-promote was fixed upstream on
243
- // 2026-07-28 and works on both deploy paths. Refusing what does not work
244
- // beats accepting it, and un-refusing what does beats obstructing.
245
- if (typeof flags.smoke === 'string') {
246
- error('--smoke is not honoured yet, so this CLI refuses it rather than letting you believe a deploy was checked.\n\n' +
247
- 'Use --no-promote instead, which now works:\n' +
248
- ' 1. myapi container deploy <id> <image> --no-promote\n' +
249
- ' 2. curl the revision URL it prints, for a string only a real build emits\n' +
250
- ' 3. myapi container promote <id> <revision>\n\n' +
251
- 'That is the same verify-then-promote, done by you rather than the platform.');
252
- }
253
242
  const source = typeof flags.source === 'string' ? flags.source : undefined;
254
243
  // --image is an alias for the positional image ref.
255
244
  if (!image && typeof flags.image === 'string')
@@ -262,6 +251,28 @@ export async function deploy(id, image, flags) {
262
251
  const deployOpts = {};
263
252
  if (flags['no-promote'] === true)
264
253
  deployOpts.promote = false;
254
+ if (typeof flags.smoke === 'string')
255
+ deployOpts.smoke = _parseSmoke(flags.smoke);
256
+ // On a container's FIRST deploy there is no earlier revision to hold
257
+ // traffic, so the platform runs the assertion against the LIVE url — a
258
+ // failing build is already serving when you are told it failed. Say so
259
+ // before the deploy rather than after, because the remedy differs: on any
260
+ // later deploy a failure is contained, and here it is not.
261
+ if (deployOpts.smoke || deployOpts.promote === false) {
262
+ try {
263
+ const existing = await sdkContainer.listRevisions(config.api_key, orgId, id);
264
+ if (existing.length === 0) {
265
+ info('Note: this container has no earlier revision, so there is nothing to hold');
266
+ info('traffic while the new one is checked. A failing build WILL be serving.');
267
+ info('Deploy to a non-production container first if that matters.');
268
+ info('');
269
+ }
270
+ }
271
+ catch { /* advisory only — never block a deploy on it */ }
272
+ }
273
+ if (deployOpts.promote === false && deployOpts.smoke) {
274
+ error('--smoke already withholds traffic until the assertion passes, then promotes.\nUse one or the other: --smoke to verify-and-promote, --no-promote to hold the revision back.');
275
+ }
265
276
  // ── Source-build path (async) ───────────────────────────────────────────
266
277
  if (source) {
267
278
  let tarball;
@@ -593,13 +604,21 @@ Options:
593
604
  On a container's FIRST deploy there is nothing already
594
605
  serving, so traffic is NOT withheld and the output says so.
595
606
 
596
- --smoke Still refused — the platform accepts it and does nothing.
597
- --no-promote gives you the same verify-then-promote by hand.
607
+ --smoke '<assertion>'
608
+ Deploy, assert against the new revision, and promote it ONLY
609
+ if the assertion holds. A failure leaves the previous
610
+ revision serving and returns SMOKE_FAILED with a revision URL
611
+ to inspect. Works on both deploy paths.
612
+
613
+ Grammar: [GET|HEAD] [/path] [status N] [contains TEXT]
614
+ Assert on CONTENT — "returns 200" is true of a placeholder
615
+ page too, which is how one reached production and stayed.
598
616
 
599
617
  Examples:
600
618
  myapi container deploy <id> registry.example.com/my-app:v2
601
619
  myapi container deploy <id> --source ./my-app
602
- myapi container deploy <id> <image> --no-promote`,
620
+ myapi container deploy <id> <image> --no-promote
621
+ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'`,
603
622
  'rollback': `myapi container rollback <id> [--org <id>] [--json]
604
623
 
605
624
  Move traffic back to the previous ready revision. Seconds, no rebuild.
@@ -647,8 +666,10 @@ dependencies and long execution.
647
666
  Subcommands:
648
667
  build-logs <id> Why the last --source build failed (--tail N; default 100)
649
668
  create Register a container and get its scoped API key (returned once)
669
+ (--health-check /livez for an HTTP startup probe)
650
670
  delete <id> Soft-delete and revoke its scoped API key
651
671
  deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
672
+ (--smoke to verify before promoting; --no-promote to hold it back)
652
673
  domain <id> <domain> Bind a custom domain (--remove to unbind)
653
674
  get <id> Inspect a container
654
675
  list List containers in your org
@@ -122,11 +122,21 @@ describe('container deploy — --no-promote must REACH both paths', () => {
122
122
  await run(() => deploy('c1', 'img:v1', { org: ORG }));
123
123
  expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', {});
124
124
  });
125
- // --smoke is still not honoured upstream, and refusing beats accepting.
126
- it('still refuses --smoke, on either path', async () => {
125
+ // --smoke was refused while the platform ignored it, and is honoured now.
126
+ // The assertion has flipped twice; what has not changed is that it must
127
+ // REACH the call, on whichever path.
128
+ it('sends the parsed assertion on the image path', async () => {
129
+ sdk.container.deployContainer.mockResolvedValue({ ...DEPLOYED, promoted: true });
130
+ const { deploy } = await import('./container.js');
131
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains assets/', org: ORG }));
132
+ expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', { smoke: { method: 'GET', path: '/', contains: 'assets/' } });
133
+ });
134
+ // Combining them is a contradiction: --smoke promotes on success, and
135
+ // --no-promote withholds regardless. Refused rather than silently picking.
136
+ it('refuses --smoke together with --no-promote', async () => {
127
137
  const { deploy } = await import('./container.js');
128
- await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', org: ORG }));
129
- expect(exitError).toMatch(/--smoke is not honoured yet/);
138
+ await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', 'no-promote': true, org: ORG }));
139
+ expect(exitError).toMatch(/one or the other/);
130
140
  expect(sdk.container.deployContainer).not.toHaveBeenCalled();
131
141
  });
132
142
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import { resolveImportKey } from './setup.js';
4
+ // Where `myapi account import-key` is allowed to take the key from.
5
+ //
6
+ // A secret in argv is readable by any user on the machine — /proc/<pid>/cmdline
7
+ // on Linux, `ps` anywhere — and the shell records it in history. A 2026-07-31
8
+ // security audit confirmed it with an OS-level probe against the syntax the CLI
9
+ // itself documented, so the docs were instructing people to leak their key.
10
+ //
11
+ // The argument still works: breaking every CI pipeline that already calls it
12
+ // would be worse than the exposure. But it is no longer the documented path, and
13
+ // it announces itself.
14
+ const ORIGINAL_STDIN = process.stdin;
15
+ function fakeStdin(piped) {
16
+ const stream = new EventEmitter();
17
+ stream.setEncoding = () => { };
18
+ stream.isTTY = piped === null;
19
+ if (piped !== null) {
20
+ setImmediate(() => { stream.emit('data', piped); stream.emit('end'); });
21
+ }
22
+ Object.defineProperty(process, 'stdin', { value: stream, configurable: true });
23
+ }
24
+ describe('import-key key sources', () => {
25
+ beforeEach(() => {
26
+ delete process.env.MYAPI_API_KEY;
27
+ delete process.env.MYAPI_KEY;
28
+ fakeStdin(null); // a TTY with nothing piped, unless a test says otherwise
29
+ });
30
+ afterEach(() => {
31
+ Object.defineProperty(process, 'stdin', { value: ORIGINAL_STDIN, configurable: true });
32
+ });
33
+ test('stdin is accepted, and is not flagged as argv exposure', async () => {
34
+ fakeStdin('hq_live_frompipe\n');
35
+ const got = await resolveImportKey('');
36
+ expect(got.key).toBe('hq_live_frompipe');
37
+ expect(got.fromArgv).toBe(false);
38
+ });
39
+ test('MYAPI_API_KEY is accepted', async () => {
40
+ process.env.MYAPI_API_KEY = 'hq_live_fromenv';
41
+ const got = await resolveImportKey('');
42
+ expect(got.key).toBe('hq_live_fromenv');
43
+ expect(got.fromArgv).toBe(false);
44
+ });
45
+ test('MYAPI_KEY is accepted as the older name', async () => {
46
+ process.env.MYAPI_KEY = 'hq_live_fromoldenv';
47
+ expect((await resolveImportKey('')).key).toBe('hq_live_fromoldenv');
48
+ });
49
+ test('an argument still works, and is reported as argv so it can be warned about', async () => {
50
+ // The warning is the whole point: silently accepting it would leave the
51
+ // caller believing the documented-unsafe form is fine.
52
+ const got = await resolveImportKey('hq_live_fromargv');
53
+ expect(got.key).toBe('hq_live_fromargv');
54
+ expect(got.fromArgv).toBe(true);
55
+ });
56
+ test('nothing anywhere yields no key rather than hanging on a TTY', async () => {
57
+ // Reading stdin unconditionally would make the CLI look frozen when a human
58
+ // runs it with no input, which is how a safe default becomes one people
59
+ // route around.
60
+ const got = await resolveImportKey('');
61
+ expect(got.key).toBe('');
62
+ });
63
+ test('surrounding whitespace is stripped', async () => {
64
+ // `echo "$KEY" |` appends a newline, and a key with a trailing \n fails
65
+ // validation against the API for a reason nobody can see.
66
+ fakeStdin(' hq_live_padded \n');
67
+ expect((await resolveImportKey('')).key).toBe('hq_live_padded');
68
+ });
69
+ });
@@ -3,7 +3,7 @@ import { requireConfig, saveConfig } from '../config.js';
3
3
  import { success, error, printTable, printJson, info } from '../output.js';
4
4
  import { confirm, isNonInteractive } from '../prompt.js';
5
5
  import { formatDate, pollJob } from '../utils.js';
6
- import { requireOrg, requireArg } from '../helpers.js';
6
+ import { requireOrg, requireArg, rememberOrgNames } from '../helpers.js';
7
7
  export const EXPOSES = [
8
8
  'POST /hq/orgs',
9
9
  'GET /hq/orgs',
@@ -74,6 +74,10 @@ export async function create(restArgs, flags) {
74
74
  export async function list(flags) {
75
75
  const config = requireConfig();
76
76
  const orgs = await hq.listOrgs(config.api_key);
77
+ // The only place the full id → name mapping is in hand. Caching it here is
78
+ // what lets every later "org changed" notice name the org instead of showing
79
+ // a UUID nobody can verify at a glance.
80
+ rememberOrgNames(config, orgs);
77
81
  if (flags.json) {
78
82
  if (flags.verbose) {
79
83
  printJson(orgs);
@@ -3,5 +3,9 @@ import type { Exposes } from '../exposes.js';
3
3
  export declare const EXPOSES: Exposes;
4
4
  export declare function _isLocallyModified(src: string): boolean;
5
5
  export declare function installSkills(): Promise<void>;
6
- export declare function importKey(apiKey: string, flags: Flags): Promise<void>;
6
+ export declare function resolveImportKey(positional: string): Promise<{
7
+ key: string;
8
+ fromArgv: boolean;
9
+ }>;
10
+ export declare function importKey(positional: string, flags: Flags): Promise<void>;
7
11
  export declare function setup(flags?: Flags): Promise<void>;
@@ -221,11 +221,15 @@ async function anonymousFlow() {
221
221
  // ---------------------------------------------------------------------------
222
222
  // Main setup command
223
223
  // ---------------------------------------------------------------------------
224
- const IMPORT_KEY_HELP = `Usage: myapi account import-key <api_key> [--install-skills] [--no-skills]
224
+ const IMPORT_KEY_HELP = `Usage: myapi account import-key [--install-skills] [--no-skills]
225
225
 
226
226
  Imports an existing API key non-interactively. Use this in CI, Docker, or any
227
227
  environment where the interactive "myapi account setup" flow is not practical.
228
228
 
229
+ Give the key on stdin, or in MYAPI_API_KEY. Do NOT pass it as an argument:
230
+ arguments are visible to every user on the machine through the process list
231
+ (ps, /proc/<pid>/cmdline) and are written to your shell history.
232
+
229
233
  The key is validated against the API before being saved. Your default org and
230
234
  funnel are auto-detected from the account and written to the local config.
231
235
 
@@ -234,8 +238,9 @@ Flags:
234
238
  --no-skills Skip skills installation even if previously installed
235
239
 
236
240
  Examples:
237
- myapi account import-key hq_live_xxxxxxxxxxxxxxxxxxxx
238
- myapi account import-key hq_live_xxxxxxxxxxxxxxxxxxxx --install-skills`;
241
+ echo "$MY_KEY" | myapi account import-key
242
+ MYAPI_API_KEY="$MY_KEY" myapi account import-key --install-skills
243
+ myapi account import-key < key.txt`;
239
244
  const SETUP_HELP = `Usage: myapi account setup [--anonymous] [--yes] [--install-skills|--no-skills]
240
245
 
241
246
  Configures your account and stores your default org and funnel so you don't
@@ -246,12 +251,56 @@ Flags:
246
251
  --yes Skip confirmation prompts
247
252
  --install-skills Auto-install skills pack
248
253
  --no-skills Skip skills installation`;
249
- // myapi account import-key <key> non-interactively import a raw API key.
250
- export async function importKey(apiKey, flags) {
251
- if (flags.help || !apiKey) {
254
+ // Where the key may come from, safest first.
255
+ //
256
+ // A secret passed as a command-line argument is readable by any user on the
257
+ // machine for as long as the process lives — /proc/<pid>/cmdline on Linux, `ps`
258
+ // anywhere — and the shell writes it to history on the way in. A security audit
259
+ // on 2026-07-31 confirmed this with an OS-level probe against the documented
260
+ // syntax, so it was not theoretical: the docs told people to do the unsafe
261
+ // thing.
262
+ //
263
+ // stdin and the environment both avoid the process list. The argument still
264
+ // works, because breaking every CI pipeline that calls it would be a worse
265
+ // outcome than the exposure, but it warns and the help no longer shows it.
266
+ export async function resolveImportKey(positional) {
267
+ if (positional)
268
+ return { key: positional, fromArgv: true };
269
+ const fromEnv = (process.env.MYAPI_API_KEY || process.env.MYAPI_KEY || '').trim();
270
+ if (fromEnv)
271
+ return { key: fromEnv, fromArgv: false };
272
+ // Only read stdin when something is actually piped in. On a TTY this would
273
+ // hang looking like the CLI had frozen.
274
+ if (!process.stdin.isTTY) {
275
+ const data = await new Promise((resolve, reject) => {
276
+ let buf = '';
277
+ process.stdin.setEncoding('utf-8');
278
+ process.stdin.on('data', c => { buf += c; });
279
+ process.stdin.on('end', () => resolve(buf));
280
+ process.stdin.on('error', reject);
281
+ });
282
+ const key = data.trim();
283
+ if (key)
284
+ return { key, fromArgv: false };
285
+ }
286
+ return { key: '', fromArgv: false };
287
+ }
288
+ // myapi account import-key — non-interactively import a raw API key.
289
+ export async function importKey(positional, flags) {
290
+ if (flags.help) {
252
291
  info(IMPORT_KEY_HELP);
253
292
  return;
254
293
  }
294
+ const { key: apiKey, fromArgv } = await resolveImportKey(positional);
295
+ if (!apiKey) {
296
+ info(IMPORT_KEY_HELP);
297
+ return;
298
+ }
299
+ if (fromArgv) {
300
+ info('Warning: passing the key as an argument exposes it in the process list ' +
301
+ '(ps, /proc) and your shell history. Pipe it instead: ' +
302
+ 'echo "$MY_KEY" | myapi account import-key');
303
+ }
255
304
  let accountId = '';
256
305
  let email;
257
306
  try {
@@ -383,4 +432,16 @@ export async function setup(flags = {}) {
383
432
  else {
384
433
  success(`› Setup complete. Org ${defaultOrg} ready · $5.00 free credits added.`);
385
434
  }
435
+ // The key just saved is account-wide, and it has to be: an org-locked key
436
+ // cannot create orgs or manage billing, so locking the operator's own
437
+ // credential would break the next thing they do. The scoping belongs on the
438
+ // credential handed to a WORKLOAD — an agent loop or CI job that should only
439
+ // ever touch one org. That is the only defence that survives a stale default,
440
+ // because it moves the org from an ambient setting to a property of the key.
441
+ if (defaultOrg) {
442
+ info('');
443
+ info('› Handing this to an agent or CI? Give it an org-locked key, so a stale');
444
+ info(' default can never reach another org:');
445
+ info(` myapi keys create --name agent --org ${defaultOrg} --grant '*'`);
446
+ }
386
447
  }
@@ -14,11 +14,16 @@ export const EXPOSES = [
14
14
  'POST /storage/orgs/{org_id}/assets/upload',
15
15
  'GET /storage/orgs/{org_id}/assets',
16
16
  'DELETE /storage/orgs/{org_id}/assets/{asset_id}',
17
+ 'PATCH /storage/orgs/{org_id}/assets/{asset_id}',
18
+ 'POST /storage/orgs/{org_id}/assets/{asset_id}/signed-url',
19
+ 'POST /storage/orgs/{org_id}/assets/{asset_id}/revoke-links',
17
20
  ];
18
21
  export const SCHEMA = {
19
22
  org: 'string',
20
23
  name: 'string',
21
24
  'content-type': 'string',
25
+ private: 'boolean',
26
+ ttl: 'string',
22
27
  };
23
28
  // Mirrors UploadContentType in @myapihq/sdk + backend allowlist.
24
29
  const EXT_TO_CT = {
@@ -36,7 +41,10 @@ function summarizeAsset(a) {
36
41
  return {
37
42
  asset_id: a.asset_id,
38
43
  name: a.name || '(unnamed)',
39
- url: a.url,
44
+ // A private asset has no plain URL — the backend deliberately returns none,
45
+ // because that URL does not serve the file and would read as the answer.
46
+ url: a.url || (a.visibility === 'private' ? '— (private: myapi storage sign <id>)' : ''),
47
+ visibility: a.visibility ?? 'public',
40
48
  created_at: a.created_at ? formatDate(a.created_at) : '',
41
49
  };
42
50
  }
@@ -91,8 +99,103 @@ async function upload(filePath, flags) {
91
99
  error(`Could not read ${filePath}: ${e?.message ?? e}`);
92
100
  }
93
101
  const name = flags.name || basename(filePath);
94
- const res = await sdkStorage.uploadAsset(config.api_key, orgId, data, contentType, name);
102
+ const visibility = flags.private ? 'private' : undefined;
103
+ const res = await sdkStorage.uploadAsset(config.api_key, orgId, data, contentType, name, visibility);
104
+ // A private upload has no URL to print, and saying nothing here would look
105
+ // like a partial failure. Name the next step instead.
106
+ if (visibility) {
107
+ success(`Asset uploaded (private)! ID: ${res.asset_id}`);
108
+ info('');
109
+ info('Private assets have no public URL. Get a time-limited link with:');
110
+ info(` myapi storage sign ${res.asset_id} --ttl 900`);
111
+ return;
112
+ }
95
113
  success(`Asset uploaded! ID: ${res.asset_id}\nHosted URL: ${res.url}`);
114
+ info('');
115
+ info('This URL is public forever and needs no credential. Holding personal');
116
+ info(`data? Close it now: myapi storage visibility ${res.asset_id} private`);
117
+ }
118
+ // ── Private assets ───────────────────────────────────────────────────────────
119
+ async function visibility(id, value, flags) {
120
+ const usage = 'myapi storage visibility <asset_id> <public|private> [--org <id>]';
121
+ const config = requireConfig();
122
+ const orgId = requireOrg(flags, config, usage);
123
+ requireArg(id, 'asset_id', usage);
124
+ if (value !== 'public' && value !== 'private') {
125
+ error(`Missing or invalid visibility "${value ?? ''}".\nUsage: ${usage}\n\n` +
126
+ `→ private: the plain URL stops serving at once; only signed links work.\n` +
127
+ ` public: anyone holding the URL can read it, forever.`);
128
+ }
129
+ const res = await sdkStorage.setAssetVisibility(config.api_key, orgId, id, value);
130
+ if (flags.json) {
131
+ printJson(res);
132
+ return;
133
+ }
134
+ if (value === 'private') {
135
+ success(`Asset ${id} is now private — the plain URL no longer serves it.`);
136
+ info('');
137
+ info('Links you handed out before this still work until they expire.');
138
+ info(`Kill them too: myapi storage revoke-links ${id}`);
139
+ }
140
+ else {
141
+ success(`Asset ${id} is now public — anyone with the URL can read it, forever.`);
142
+ }
143
+ }
144
+ async function sign(id, flags) {
145
+ const usage = 'myapi storage sign <asset_id> [--ttl <seconds>] [--org <id>]';
146
+ const config = requireConfig();
147
+ const orgId = requireOrg(flags, config, usage);
148
+ requireArg(id, 'asset_id', usage);
149
+ let ttl;
150
+ if (flags.ttl !== undefined) {
151
+ ttl = Number(flags.ttl);
152
+ // Bounds are the backend's (default 900, max 86400). Checking here turns
153
+ // a round-trip and an EXPIRES_TOO_LONG into an instant, specific message.
154
+ if (!Number.isInteger(ttl) || ttl <= 0 || ttl > 86400) {
155
+ error(`--ttl must be a whole number of seconds between 1 and 86400 (24h). Got "${flags.ttl}".`);
156
+ }
157
+ }
158
+ const res = await sdkStorage.createSignedUrl(config.api_key, orgId, id, ttl);
159
+ if (flags.json) {
160
+ printJson(res);
161
+ return;
162
+ }
163
+ const url = sdkStorage.signedUrlOf(res);
164
+ if (!url) {
165
+ // Better to hand back what arrived than to claim nothing came: the schema
166
+ // does not describe this response, so an unexpected field name is possible.
167
+ info('Signed link created, but no recognised URL field was in the response:');
168
+ printJson(res);
169
+ return;
170
+ }
171
+ // Report what the backend returned, not what we asked for — they can differ,
172
+ // and stating the request as if it were the outcome is how a link gets
173
+ // handed out with the wrong lifetime believed of it.
174
+ const lifetime = typeof res.expires_in === 'number' ? `${res.expires_in}s`
175
+ : typeof res.expires_at === 'string' ? `until ${res.expires_at}`
176
+ : ttl !== undefined ? `${ttl}s (requested; backend did not confirm)` : 'the backend default';
177
+ success(`Signed link (expires ${lifetime.startsWith('until') ? '' : 'in '}${lifetime}):`);
178
+ info(url);
179
+ }
180
+ async function revokeLinks(id, flags) {
181
+ const usage = 'myapi storage revoke-links <asset_id> [--org <id>]';
182
+ const config = requireConfig();
183
+ const orgId = requireOrg(flags, config, usage);
184
+ requireArg(id, 'asset_id', usage);
185
+ // Revoking on a public asset succeeds and changes nothing — the plain URL
186
+ // still serves it. Reporting that as done would be the wrong answer during
187
+ // an exposure, which is exactly when this gets run. Only refuse on a
188
+ // definite 'public'; an absent field means proceed rather than guess.
189
+ const all = await sdkStorage.listAssets(config.api_key, orgId);
190
+ const asset = all.find(a => a.asset_id === id);
191
+ if (asset?.visibility === 'public') {
192
+ error(`Asset ${id} is public, so revoking signed links changes nothing —\n` +
193
+ `anyone holding the plain URL can still read it.\n\n` +
194
+ `→ Make it private first: myapi storage visibility ${id} private`);
195
+ }
196
+ await sdkStorage.revokeAssetLinks(config.api_key, orgId, id);
197
+ success(`Every signed link for ${id} is dead, including unexpired ones.`);
198
+ info(`Issue a fresh one for anyone who should still have access: myapi storage sign ${id}`);
96
199
  }
97
200
  // `get`: round-trip metadata for one asset. Backend has no single-asset
98
201
  // GET endpoint today, so we list + filter locally. Switch to a direct
@@ -109,11 +212,16 @@ async function get(id, flags) {
109
212
  printJson(asset);
110
213
  return;
111
214
  }
112
- info(`ID: ${asset.asset_id}`);
113
- info(`Name: ${asset.name || '(unnamed)'}`);
114
- info(`URL: ${asset.url}`);
215
+ const vis = asset.visibility ?? 'public';
216
+ info(`ID: ${asset.asset_id}`);
217
+ info(`Name: ${asset.name || '(unnamed)'}`);
218
+ info(`Visibility: ${vis}`);
219
+ if (asset.url)
220
+ info(`URL: ${asset.url}`);
221
+ else if (vis === 'private')
222
+ info(`URL: — private; sign one: myapi storage sign ${asset.asset_id}`);
115
223
  if (asset.created_at)
116
- info(`Created: ${formatDate(asset.created_at)}`);
224
+ info(`Created: ${formatDate(asset.created_at)}`);
117
225
  }
118
226
  // `get-url`: cheapest possible — print the public URL pattern. No API
119
227
  // call. Curl-friendly. Doesn't even verify the asset exists; that's a
@@ -136,10 +244,29 @@ const SUBCOMMAND_USAGE = {
136
244
 
137
245
  Server fetches the URL and stores the file. Useful for migrating assets that
138
246
  already live on a public URL (e.g. importing brand assets from another host).`,
139
- 'upload': `myapi storage upload <file> [--name <name>] [--org <id>]
247
+ 'upload': `myapi storage upload <file> [--private] [--name <name>] [--org <id>]
140
248
 
141
249
  Direct multipart upload of a local file. Supported: ${Object.keys(EXT_TO_CT).join(', ')}.
142
- The display name defaults to the file's basename — override with --name.`,
250
+ The display name defaults to the file's basename — override with --name.
251
+
252
+ --private stores it with no public URL; read it back with a signed link
253
+ (myapi storage sign <id>). Use it for anything holding personal data — the
254
+ default is a permanent, unauthenticated URL.`,
255
+ 'visibility': `myapi storage visibility <asset_id> <public|private> [--org <id>]
256
+
257
+ Flip an asset between public and private. Going private takes effect at once:
258
+ the plain URL stops serving and only signed links work. This is the
259
+ close-an-exposure path — follow it with revoke-links to kill links already
260
+ handed out.`,
261
+ 'sign': `myapi storage sign <asset_id> [--ttl <seconds>] [--org <id>]
262
+
263
+ Make a time-limited link to a private asset. The link carries its own
264
+ authorisation, so it can go to someone with no MyAPI account.
265
+ --ttl defaults to 900 (15 min), maximum 86400 (24h). Signing is billable.`,
266
+ 'revoke-links': `myapi storage revoke-links <asset_id> [--org <id>]
267
+
268
+ Kill every signed link for an asset, including ones that had not expired yet.
269
+ Refused on a public asset, where it would change nothing.`,
143
270
  'get': `myapi storage get <asset_id> [--org <id>] [--json]
144
271
 
145
272
  Round-trips the API to fetch the asset's metadata (name, URL, created_at).`,
@@ -154,12 +281,19 @@ export async function run(subcommand, args, flags) {
154
281
  info(`Usage: myapi storage <subcommand>
155
282
 
156
283
  Subcommands:
157
- delete <asset_id> Permanently delete a stored asset
158
- get <asset_id> Fetch asset metadata (name, URL, created_at)
159
- get-url <asset_id> Print the public CDN URL (no API call)
160
- ingest <url> Server pulls a public URL into storage
161
- list List all your stored assets
162
- upload <file> Direct upload of a local file
284
+ delete <asset_id> Permanently delete a stored asset
285
+ get <asset_id> Fetch asset metadata (name, URL, visibility)
286
+ get-url <asset_id> Print the public CDN URL (no API call)
287
+ ingest <url> Server pulls a public URL into storage
288
+ list List all your stored assets
289
+ revoke-links <asset_id> Kill every signed link for a private asset
290
+ sign <asset_id> Time-limited link to a private asset
291
+ upload <file> Direct upload of a local file (--private supported)
292
+ visibility <asset_id> Flip an asset public|private
293
+
294
+ Assets are PUBLIC by default — a permanent URL that needs no credential.
295
+ Storing personal data? Upload with --private, or close an existing exposure
296
+ with: myapi storage visibility <asset_id> private
163
297
 
164
298
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
165
299
  return;
@@ -179,6 +313,9 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
179
313
  case 'get': return get(args[0], flags);
180
314
  case 'get-url': return getUrl(args[0], flags);
181
315
  case 'delete': return del(args[0], flags);
316
+ case 'visibility': return visibility(args[0], args[1], flags);
317
+ case 'sign': return sign(args[0], flags);
318
+ case 'revoke-links': return revokeLinks(args[0], flags);
182
319
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for available subcommands.`);
183
320
  }
184
321
  }
package/dist/config.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface AccountEntry {
9
9
  registrant?: sdkDomain.Registrant;
10
10
  is_anonymous?: boolean;
11
11
  skills_installed?: boolean;
12
+ last_org_used?: string;
13
+ org_names?: Record<string, string>;
12
14
  }
13
15
  export interface Config extends AccountEntry {
14
16
  autocomplete_setup?: boolean;
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,7 @@
1
1
  import { MyApiError } from '@myapihq/sdk';
2
2
  export declare const ERROR_MESSAGES: Record<string, string>;
3
3
  export declare function friendlyError(err: MyApiError): string;
4
+ export declare function withOrgContext(message: string, err: Pick<MyApiError, 'status' | 'code'>, org?: {
5
+ org_id: string;
6
+ org_name?: string;
7
+ } | undefined): string;
package/dist/errors.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { currentOrg } from './helpers.js';
1
2
  // Per-code friendly messages for MyApiError.code. Kept in its own module (not
2
3
  // index.ts) so it can be unit-tested without importing the CLI entrypoint,
3
4
  // which runs main() on import.
@@ -45,6 +46,12 @@ export const ERROR_MESSAGES = {
45
46
  // on a backend deploy we do not control the timing of, and a CLI that only
46
47
  // knows the new names would print a bare code for anyone on the old build.
47
48
  // The old five can go once that deploy is everywhere.
49
+ // Deploy-guard validation, added when the platform started refusing bad
50
+ // assertions BEFORE building — a source build costs minutes you pay for, so
51
+ // catching a typo up front matters more than it looks.
52
+ 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.',
53
+ 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.',
54
+ 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
55
  DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
49
56
  DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
50
57
  DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
@@ -89,7 +96,33 @@ export function friendlyError(err) {
89
96
  const base = ERROR_MESSAGES[err.code] || err.code;
90
97
  // Generic fallback: append the backend's `message` when it adds info beyond
91
98
  // the friendly mapping. Future per-service detail fields can be added here.
92
- if (err.detail && err.detail !== base)
93
- return `${base} — ${err.detail}`;
94
- return base;
99
+ const message = err.detail && err.detail !== base ? `${base} — ${err.detail}` : base;
100
+ return withOrgContext(message, err);
101
+ }
102
+ // A resource that exists in another org answers exactly like a typo'd id, and
103
+ // "not found" reads as the id being wrong. So the reader checks the id — which
104
+ // is correct — and never questions the org, which is the actual fault. Naming
105
+ // the org that was searched turns the most common org-drift symptom into its
106
+ // own diagnosis.
107
+ //
108
+ // Only on not-found, and only when an org was actually resolved: appending
109
+ // this to every error would be the always-on banner in a different costume.
110
+ const NOT_FOUND_CODE = /NOT_FOUND|not_found/;
111
+ export function withOrgContext(message, err, org = currentOrg()) {
112
+ if (err.status !== 404 && !NOT_FOUND_CODE.test(err.code))
113
+ return message;
114
+ // ORG_NOT_FOUND is excluded deliberately: there the org id IS the thing that
115
+ // could not be found, so "looked in org <that same id>" restates the failure
116
+ // as if it were a clue.
117
+ if (/ORG_NOT_FOUND|org_not_found/.test(err.code))
118
+ return message;
119
+ if (!org)
120
+ return message;
121
+ const label = org.org_name ? `${org.org_name} (${shortId(org.org_id)})` : org.org_id;
122
+ if (message.includes(org.org_id) || (org.org_name && message.includes(org.org_name)))
123
+ return message;
124
+ return `${message}\n→ Looked in org ${label}. If it lives elsewhere, \`myapi org list\` shows the rest — pass --org <id>.`;
125
+ }
126
+ function shortId(id) {
127
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
95
128
  }
package/dist/helpers.d.ts CHANGED
@@ -1,6 +1,25 @@
1
- import type { Config } from './config.js';
1
+ import { type Config } from './config.js';
2
2
  export type Flags = Record<string, string | boolean | number>;
3
3
  export declare function requireOrg(flags: Flags, config: Config, usage: string): string;
4
+ /** The notice for a move, or undefined when there is nothing to say.
5
+ *
6
+ * Pure and exported because both failure directions are silent in production:
7
+ * return a string always and it becomes noise everyone filters out; return
8
+ * undefined always and the wrong-org write it exists to catch goes through
9
+ * unannounced. Neither shows up as a broken build, so it is pinned by a test.
10
+ */
11
+ export declare function orgChangeNotice(previous: string | undefined, next: string, names?: Record<string, string>): string | undefined;
12
+ /** The org this invocation resolved to, for `--json` consumers. */
13
+ export declare function currentOrg(): {
14
+ org_id: string;
15
+ org_name?: string;
16
+ } | undefined;
17
+ /** Cache id → name so later output can name an org instead of printing a UUID.
18
+ * Called by the commands that already hold the mapping (`org list`, `status`). */
19
+ export declare function rememberOrgNames(config: Config, orgs: Array<{
20
+ id?: string;
21
+ name?: string;
22
+ }>): void;
4
23
  export declare function requireDomain(arg: string | undefined, flags: Flags, config: Config, usage: string): string;
5
24
  export declare function requireArg(value: string | undefined, name: string, usage: string): string;
6
25
  export declare function confirmDestructive(flags: Flags, description: string, usage: string): Promise<void>;