@myapihq/cli 2.10.0 → 2.11.1

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 (49) hide show
  1. package/dist/commands/email/mailbox.js +11 -0
  2. package/dist/commands/import-key.test.d.ts +1 -0
  3. package/dist/commands/import-key.test.js +69 -0
  4. package/dist/commands/org.js +5 -1
  5. package/dist/commands/setup.d.ts +5 -1
  6. package/dist/commands/setup.js +67 -6
  7. package/dist/commands/storage.js +151 -14
  8. package/dist/config.d.ts +2 -0
  9. package/dist/errors.d.ts +4 -0
  10. package/dist/errors.js +30 -3
  11. package/dist/helpers.d.ts +20 -1
  12. package/dist/helpers.js +79 -1
  13. package/dist/index.js +6 -1
  14. package/dist/org-not-found.test.d.ts +1 -0
  15. package/dist/org-not-found.test.js +42 -0
  16. package/dist/org-notice.test.d.ts +1 -0
  17. package/dist/org-notice.test.js +37 -0
  18. package/dist/output.d.ts +6 -0
  19. package/dist/output.js +8 -1
  20. package/dist/skills/my-api-hq/SKILL.md +34 -14
  21. package/dist/skills/my-audience-api/SKILL.md +16 -1
  22. package/dist/skills/my-auth-api/SKILL.md +17 -2
  23. package/dist/skills/my-company-api/SKILL.md +16 -1
  24. package/dist/skills/my-container-api/SKILL.md +22 -7
  25. package/dist/skills/my-crm-api/SKILL.md +16 -1
  26. package/dist/skills/my-database-api/SKILL.md +13 -17
  27. package/dist/skills/my-domain-api/SKILL.md +16 -1
  28. package/dist/skills/my-email-api/SKILL.md +12 -16
  29. package/dist/skills/my-email-verify-api/SKILL.md +16 -1
  30. package/dist/skills/my-feedback-api/README.md +33 -0
  31. package/dist/skills/my-feedback-api/SKILL.md +118 -0
  32. package/dist/skills/my-feedback-api/claude/.claude-plugin/plugin.json +7 -0
  33. package/dist/skills/my-function-api/README.md +34 -0
  34. package/dist/skills/my-function-api/SKILL.md +16 -1
  35. package/dist/skills/my-funnel-api/SKILL.md +16 -1
  36. package/dist/skills/my-git-api/SKILL.md +17 -1
  37. package/dist/skills/my-image-api/SKILL.md +17 -1
  38. package/dist/skills/my-llm-api/SKILL.md +20 -6
  39. package/dist/skills/my-payments-api/README.md +33 -0
  40. package/dist/skills/my-payments-api/SKILL.md +16 -1
  41. package/dist/skills/my-people-api/SKILL.md +16 -1
  42. package/dist/skills/my-pixel-api/SKILL.md +26 -3
  43. package/dist/skills/my-queue-api/SKILL.md +35 -2
  44. package/dist/skills/my-storage-api/SKILL.md +37 -29
  45. package/dist/skills/my-task-api/SKILL.md +35 -2
  46. package/dist/skills/my-url-to/SKILL.md +16 -1
  47. package/dist/skills/my-webhook-api/SKILL.md +23 -3
  48. package/dist/skills/my-workflow-api/SKILL.md +32 -6
  49. package/package.json +2 -2
@@ -53,6 +53,17 @@ async function activateSending(flags) {
53
53
  if (!address)
54
54
  error('Missing required arguments.\nUsage: myapi email mailbox activate-sending --address <email>');
55
55
  const res = await sdkEmail.activateSending(config.api_key, address);
56
+ // A repaired account was broken a moment ago: it held a mailbox that reported
57
+ // sending_enabled while the ACCOUNT had no credential behind it, so every send
58
+ // failed with nothing in the mailbox or domain to explain why. Reporting that
59
+ // as an ordinary activation hides the one fact the caller needs — that the
60
+ // thing which was failing is now fixed, and that they should retry.
61
+ if (res.repaired) {
62
+ success(`Sending repaired: ${address} — the account had no sending credential; it does now.`);
63
+ info('No charge was made — the mailbox was paid for at its first activation.');
64
+ info('Sending should work immediately. Retry the send that was failing.');
65
+ return;
66
+ }
56
67
  success(`Sending activated: ${address} (${res.emails_quota_remaining} emails/day quota)`);
57
68
  }
58
69
  async function setForwarding(address, forwardTo, _flags) {
@@ -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.
@@ -95,7 +96,33 @@ export function friendlyError(err) {
95
96
  const base = ERROR_MESSAGES[err.code] || err.code;
96
97
  // Generic fallback: append the backend's `message` when it adds info beyond
97
98
  // the friendly mapping. Future per-service detail fields can be added here.
98
- if (err.detail && err.detail !== base)
99
- return `${base} — ${err.detail}`;
100
- 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;
101
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>;
package/dist/helpers.js CHANGED
@@ -1,4 +1,5 @@
1
- import { error } from './output.js';
1
+ import { error, banner } from './output.js';
2
+ import { saveConfig } from './config.js';
2
3
  import { confirm, isNonInteractive } from './prompt.js';
3
4
  // error() returns `never`, so after `if (!x) error(...)` TS narrows x to a
4
5
  // non-falsy value and the casts disappear.
@@ -13,8 +14,85 @@ export function requireOrg(flags, config, usage) {
13
14
  if (!orgId) {
14
15
  error(`Missing required arguments.\nUsage: ${usage}\n(Or set default: myapi config set-org <id>)`);
15
16
  }
17
+ noticeOrgChanged(orgId, config);
18
+ recordResolvedOrg(orgId, config);
16
19
  return orgId;
17
20
  }
21
+ // ── Which org am I in? ───────────────────────────────────────────────────────
22
+ //
23
+ // The org is ambient, sticky and invisible: it comes from a saved default that
24
+ // survives across turns, tasks and days, and a stale one looks exactly like a
25
+ // correct one. Agents lose whole writes to this — a demo pushed onto a live
26
+ // site, a domain reassigned away from the org that was serving it.
27
+ //
28
+ // The fix is NOT to announce the org on every command. A line that is always
29
+ // there carries no information, gets filtered out within a day, and costs
30
+ // tokens on every call in an agent loop. It only carries signal when it could
31
+ // have been different — so it fires on CHANGE and is silent otherwise. Steady
32
+ // single-org work never sees it; the moment the target moves, it says so once.
33
+ //
34
+ // Goes to stderr, like the spinner, so `--json | jq` and piped output stay
35
+ // clean.
36
+ let resolvedOrgId;
37
+ let resolvedOrgName;
38
+ /** The notice for a move, or undefined when there is nothing to say.
39
+ *
40
+ * Pure and exported because both failure directions are silent in production:
41
+ * return a string always and it becomes noise everyone filters out; return
42
+ * undefined always and the wrong-org write it exists to catch goes through
43
+ * unannounced. Neither shows up as a broken build, so it is pinned by a test.
44
+ */
45
+ export function orgChangeNotice(previous, next, names = {}) {
46
+ if (!previous || previous === next)
47
+ return undefined;
48
+ const label = (id) => (names[id] ? `${names[id]} ` : '') + short(id);
49
+ return `→ org changed: ${label(previous)} → ${label(next)}`;
50
+ }
51
+ function noticeOrgChanged(orgId, config) {
52
+ const msg = orgChangeNotice(config.last_org_used, orgId, config.org_names ?? {});
53
+ if (msg)
54
+ banner(msg);
55
+ }
56
+ function recordResolvedOrg(orgId, config) {
57
+ resolvedOrgId = orgId;
58
+ resolvedOrgName = config.org_names?.[orgId];
59
+ if (config.last_org_used === orgId)
60
+ return;
61
+ // Written only on change, so the common path does no disk I/O. Failure here
62
+ // must never break the command the user actually asked for — the worst case
63
+ // is that the next run re-notices the same change.
64
+ try {
65
+ config.last_org_used = orgId;
66
+ saveConfig(config);
67
+ }
68
+ catch { /* advisory only */ }
69
+ }
70
+ function short(id) {
71
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
72
+ }
73
+ /** The org this invocation resolved to, for `--json` consumers. */
74
+ export function currentOrg() {
75
+ return resolvedOrgId ? { org_id: resolvedOrgId, org_name: resolvedOrgName } : undefined;
76
+ }
77
+ /** Cache id → name so later output can name an org instead of printing a UUID.
78
+ * Called by the commands that already hold the mapping (`org list`, `status`). */
79
+ export function rememberOrgNames(config, orgs) {
80
+ const names = { ...(config.org_names ?? {}) };
81
+ let changed = false;
82
+ for (const o of orgs) {
83
+ if (!o?.id || !o?.name || names[o.id] === o.name)
84
+ continue;
85
+ names[o.id] = o.name;
86
+ changed = true;
87
+ }
88
+ if (!changed)
89
+ return;
90
+ try {
91
+ config.org_names = names;
92
+ saveConfig(config);
93
+ }
94
+ catch { /* advisory only */ }
95
+ }
18
96
  export function requireDomain(arg, flags, config, usage) {
19
97
  const fromFlag = typeof flags.domain === 'string' ? flags.domain : '';
20
98
  const fromConfig = typeof config.default_domain === 'string' ? config.default_domain : '';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { error, info, success, banner } from './output.js';
2
+ import { error, info, success, banner, setResolvedContextSource } from './output.js';
3
3
  import { loadConfig } from './config.js';
4
4
  import { MyApiError, setUserAgent } from '@myapihq/sdk';
5
5
  import { friendlyError } from './errors.js';
6
+ import { currentOrg } from './helpers.js';
6
7
  import * as fs from 'fs';
7
8
  import { parseFlags } from './flags.js';
8
9
  const pkgPath = new URL('../package.json', import.meta.url);
@@ -12,6 +13,10 @@ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
12
13
  // tell which client versions are still in the wild — which is what you need
13
14
  // before you can deprecate anything server-side.
14
15
  setUserAgent(`myapi-cli/${pkg.version} node/${process.versions.node}`);
16
+ // Let `--json` output carry the org the call actually resolved to. Wired here
17
+ // rather than imported inside output.ts so that module stays free of command
18
+ // and config state. See printJson / requireOrg.
19
+ setResolvedContextSource(currentOrg);
15
20
  import * as accountCmd from './commands/account.js';
16
21
  import * as keysCmd from './commands/keys.js';
17
22
  import * as billingCmd from './commands/billing.js';
@@ -0,0 +1 @@
1
+ export {};