@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.
- package/dist/commands/email/mailbox.js +11 -0
- package/dist/commands/import-key.test.d.ts +1 -0
- package/dist/commands/import-key.test.js +69 -0
- package/dist/commands/org.js +5 -1
- package/dist/commands/setup.d.ts +5 -1
- package/dist/commands/setup.js +67 -6
- package/dist/commands/storage.js +151 -14
- package/dist/config.d.ts +2 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +30 -3
- package/dist/helpers.d.ts +20 -1
- package/dist/helpers.js +79 -1
- package/dist/index.js +6 -1
- package/dist/org-not-found.test.d.ts +1 -0
- package/dist/org-not-found.test.js +42 -0
- package/dist/org-notice.test.d.ts +1 -0
- package/dist/org-notice.test.js +37 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +8 -1
- package/dist/skills/my-api-hq/SKILL.md +34 -14
- package/dist/skills/my-audience-api/SKILL.md +16 -1
- package/dist/skills/my-auth-api/SKILL.md +17 -2
- package/dist/skills/my-company-api/SKILL.md +16 -1
- package/dist/skills/my-container-api/SKILL.md +22 -7
- package/dist/skills/my-crm-api/SKILL.md +16 -1
- package/dist/skills/my-database-api/SKILL.md +13 -17
- package/dist/skills/my-domain-api/SKILL.md +16 -1
- package/dist/skills/my-email-api/SKILL.md +12 -16
- package/dist/skills/my-email-verify-api/SKILL.md +16 -1
- package/dist/skills/my-feedback-api/README.md +33 -0
- package/dist/skills/my-feedback-api/SKILL.md +118 -0
- package/dist/skills/my-feedback-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-function-api/README.md +34 -0
- package/dist/skills/my-function-api/SKILL.md +16 -1
- package/dist/skills/my-funnel-api/SKILL.md +16 -1
- package/dist/skills/my-git-api/SKILL.md +17 -1
- package/dist/skills/my-image-api/SKILL.md +17 -1
- package/dist/skills/my-llm-api/SKILL.md +20 -6
- package/dist/skills/my-payments-api/README.md +33 -0
- package/dist/skills/my-payments-api/SKILL.md +16 -1
- package/dist/skills/my-people-api/SKILL.md +16 -1
- package/dist/skills/my-pixel-api/SKILL.md +26 -3
- package/dist/skills/my-queue-api/SKILL.md +35 -2
- package/dist/skills/my-storage-api/SKILL.md +37 -29
- package/dist/skills/my-task-api/SKILL.md +35 -2
- package/dist/skills/my-url-to/SKILL.md +16 -1
- package/dist/skills/my-webhook-api/SKILL.md +23 -3
- package/dist/skills/my-workflow-api/SKILL.md +32 -6
- 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
|
+
});
|
package/dist/commands/org.js
CHANGED
|
@@ -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);
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
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
|
|
238
|
-
myapi account import-key
|
|
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
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
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
|
}
|
package/dist/commands/storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
info(`
|
|
114
|
-
info(`
|
|
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:
|
|
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>
|
|
158
|
-
get <asset_id>
|
|
159
|
-
get-url <asset_id>
|
|
160
|
-
ingest <url>
|
|
161
|
-
list
|
|
162
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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 {};
|