@myapihq/cli 2.27.3 → 2.28.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.
- package/dist/commands/storage-upload-json.test.d.ts +1 -0
- package/dist/commands/storage-upload-json.test.js +73 -0
- package/dist/commands/storage.js +27 -1
- package/dist/errors.test.js +32 -0
- package/dist/helpers.d.ts +6 -0
- package/dist/helpers.js +37 -0
- package/dist/index.js +21 -1
- package/dist/org-resolution.test.js +49 -0
- package/dist/sdk-iam.test.js +16 -2
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `storage upload --json` printed prose.
|
|
2
|
+
//
|
|
3
|
+
// Every other storage verb honours --json. Upload did not, so an agent that
|
|
4
|
+
// asked for machine output got "✓ Asset uploaded! ID: obj_…" and had to scrape
|
|
5
|
+
// the id out of a sentence to learn what it had just created — on the one verb
|
|
6
|
+
// whose RESULT it most needs, for a platform whose primary caller is an agent.
|
|
7
|
+
//
|
|
8
|
+
// Found by the probe on its first storage run: the step failed with "no JSON on
|
|
9
|
+
// stdout", which is the probe noticing something a human would have read past.
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
15
|
+
const sdk = vi.hoisted(() => ({
|
|
16
|
+
storage: { uploadAsset: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@myapihq/sdk', () => sdk);
|
|
19
|
+
vi.mock('../config.js', () => ({
|
|
20
|
+
requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
|
|
21
|
+
loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
|
|
22
|
+
CONFIG_DIR: '/tmp/nowhere',
|
|
23
|
+
}));
|
|
24
|
+
const RESULT = { asset_id: 'obj_abc', url: 'https://api.myapihq.com/storage/obj_abc' };
|
|
25
|
+
let printed;
|
|
26
|
+
// A real file rather than an fs mock: the handler reads it through whichever
|
|
27
|
+
// fs binding it imports, and a mock that misses that binding fails the test
|
|
28
|
+
// for a reason that has nothing to do with the behaviour under test.
|
|
29
|
+
const FILE = path.join(os.tmpdir(), `storage-json-${process.pid}.txt`);
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
fs.writeFileSync(FILE, 'bytes');
|
|
32
|
+
printed = [];
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
sdk.storage.uploadAsset.mockResolvedValue(RESULT);
|
|
35
|
+
const output = await import('../output.js');
|
|
36
|
+
const cap = ((m) => { printed.push(typeof m === 'string' ? m : JSON.stringify(m)); });
|
|
37
|
+
vi.spyOn(output, 'success').mockImplementation(cap);
|
|
38
|
+
vi.spyOn(output, 'info').mockImplementation(cap);
|
|
39
|
+
vi.spyOn(output, 'printJson').mockImplementation(cap);
|
|
40
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
41
|
+
printed.push(String(m));
|
|
42
|
+
throw new Error('__EXIT__');
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => { vi.restoreAllMocks(); try {
|
|
46
|
+
fs.unlinkSync(FILE);
|
|
47
|
+
}
|
|
48
|
+
catch { /* already gone */ } });
|
|
49
|
+
async function upload(flags) {
|
|
50
|
+
const { run } = await import('./storage.js');
|
|
51
|
+
try {
|
|
52
|
+
await run('upload', [FILE], { org: ORG, 'content-type': 'text/plain', ...flags });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e?.message !== '__EXIT__')
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
describe('storage upload --json', () => {
|
|
60
|
+
it('emits the asset object, not a sentence about it', async () => {
|
|
61
|
+
await upload({ json: true });
|
|
62
|
+
const out = printed.join('\n');
|
|
63
|
+
expect(out).toContain('obj_abc');
|
|
64
|
+
expect(out).not.toMatch(/Asset uploaded/);
|
|
65
|
+
expect(() => JSON.parse(out)).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
it('still prints the public-URL warning for humans', async () => {
|
|
68
|
+
// That warning is the only thing standing between a customer and a
|
|
69
|
+
// permanently public file, so --json must not be what removes it.
|
|
70
|
+
await upload({});
|
|
71
|
+
expect(printed.join('\n')).toMatch(/public forever/i);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/dist/commands/storage.js
CHANGED
|
@@ -25,7 +25,20 @@ export const SCHEMA = {
|
|
|
25
25
|
private: 'boolean',
|
|
26
26
|
ttl: 'string',
|
|
27
27
|
};
|
|
28
|
-
//
|
|
28
|
+
// Extension → content type, for inference only. The API accepts any MIME type
|
|
29
|
+
// (a customer found it taking a CSV this table rejected), so widening this
|
|
30
|
+
// costs nothing at the boundary — it only saves people from --content-type.
|
|
31
|
+
//
|
|
32
|
+
// The media types were here already. The text and data ones below are the
|
|
33
|
+
// files a platform that hosts websites and stores exports obviously handles,
|
|
34
|
+
// and a probe hit the gap on its first run trying to upload a .txt.
|
|
35
|
+
//
|
|
36
|
+
// DELIBERATELY ABSENT: .html and .js. They would upload fine and then DOWNLOAD
|
|
37
|
+
// rather than render, because storage serves anything outside its inline-safe
|
|
38
|
+
// list as an attachment — a defence against a polyglot file executing at the
|
|
39
|
+
// api.myapihq.com origin, where session cookies live. Making them one step
|
|
40
|
+
// easier to upload would mostly generate "why doesn't my page load" questions.
|
|
41
|
+
// HTML belongs in a funnel, which serves it from a domain of its own.
|
|
29
42
|
const EXT_TO_CT = {
|
|
30
43
|
'.png': 'image/png',
|
|
31
44
|
'.jpg': 'image/jpeg',
|
|
@@ -36,6 +49,11 @@ const EXT_TO_CT = {
|
|
|
36
49
|
'.pdf': 'application/pdf',
|
|
37
50
|
'.mp4': 'video/mp4',
|
|
38
51
|
'.webm': 'video/webm',
|
|
52
|
+
'.txt': 'text/plain',
|
|
53
|
+
'.md': 'text/markdown',
|
|
54
|
+
'.csv': 'text/csv',
|
|
55
|
+
'.json': 'application/json',
|
|
56
|
+
'.zip': 'application/zip',
|
|
39
57
|
};
|
|
40
58
|
function summarizeAsset(a) {
|
|
41
59
|
return {
|
|
@@ -101,6 +119,14 @@ async function upload(filePath, flags) {
|
|
|
101
119
|
const name = flags.name || basename(filePath);
|
|
102
120
|
const visibility = flags.private ? 'private' : undefined;
|
|
103
121
|
const res = await sdkStorage.uploadAsset(config.api_key, orgId, data, contentType, name, visibility);
|
|
122
|
+
// Every other storage verb honours --json; upload did not, so an agent that
|
|
123
|
+
// asked for machine output got prose and had to scrape "ID: obj_…" out of a
|
|
124
|
+
// sentence to learn what it had just created. Agents are the primary caller
|
|
125
|
+
// of this platform, and upload is the verb whose RESULT they most need.
|
|
126
|
+
if (flags.json) {
|
|
127
|
+
printJson(res);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
104
130
|
// A private upload has no URL to print, and saying nothing here would look
|
|
105
131
|
// like a partial failure. Name the next step instead.
|
|
106
132
|
if (visibility) {
|
package/dist/errors.test.js
CHANGED
|
@@ -107,3 +107,35 @@ describe('a 500 must be reportable', () => {
|
|
|
107
107
|
expect(out).not.toMatch(/our fault/i);
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
describe('a 401 that is not about credentials', () => {
|
|
111
|
+
// The platform relayed an upstream's auth status verbatim: analytics
|
|
112
|
+
// refusing US arrived as 401 ANALYTICS_ERROR, and the CLI told a customer
|
|
113
|
+
// with a valid key to run `myapi account setup`. It fixed nothing, so they
|
|
114
|
+
// ran it again and got the same sentence. Reported from a real terminal
|
|
115
|
+
// 2026-08-23; `org list` worked with that key in the same shell.
|
|
116
|
+
//
|
|
117
|
+
// The backend no longer relays it. This is the client half: do not assert a
|
|
118
|
+
// cause the error itself contradicts.
|
|
119
|
+
const AUTH_CODES = ['unauthorized', 'invalid api key', 'invalid_token', 'unknown_error'];
|
|
120
|
+
it('a real auth failure still says the key is invalid', () => {
|
|
121
|
+
for (const code of AUTH_CODES) {
|
|
122
|
+
const e = new MyApiError(code, 401);
|
|
123
|
+
expect(looksLikeBadKey(e), `${code} should read as a bad key`).toBe(true);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it('a foreign code at 401 does not', () => {
|
|
127
|
+
expect(looksLikeBadKey(new MyApiError('ANALYTICS_ERROR', 401))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it('a bare 401 with no code still does — most arrive that way', () => {
|
|
130
|
+
expect(looksLikeBadKey(new MyApiError('', 401))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// Mirrors the predicate in index.ts. Kept here so the rule is testable without
|
|
134
|
+
// importing the entrypoint, which runs main() on import.
|
|
135
|
+
function looksLikeBadKey(err) {
|
|
136
|
+
const AUTH_CODES = new Set([
|
|
137
|
+
'unauthorized', 'invalid api key', 'invalid_api_key', 'invalid_token',
|
|
138
|
+
'missing authorization header', 'invalid authorization header', 'unknown_error',
|
|
139
|
+
]);
|
|
140
|
+
return err.status === 401 && (!err.code || AUTH_CODES.has(String(err.code).toLowerCase()));
|
|
141
|
+
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface ResolvedOrg {
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function resolveOrg(flags: Flags, config: Config): ResolvedOrg | undefined;
|
|
19
19
|
export declare function requireOrg(flags: Flags, config: Config, usage: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Test seam. Both org notices fire once per PROCESS, which is right for a CLI
|
|
22
|
+
* invocation and wrong for a test file — without this the first test latches
|
|
23
|
+
* them and every later assertion passes for the wrong reason.
|
|
24
|
+
*/
|
|
25
|
+
export declare function _resetOrgNotices(): void;
|
|
20
26
|
export declare function orgLine(orgId: string, name: string | undefined, changedFrom?: string): string;
|
|
21
27
|
/** The org this invocation resolved to, for `--json` consumers. */
|
|
22
28
|
export declare function currentOrg(): {
|
package/dist/helpers.js
CHANGED
|
@@ -50,10 +50,47 @@ export function requireOrg(flags, config, usage) {
|
|
|
50
50
|
`The server would refuse this (403 SCOPE_FORBIDDEN), so nothing was sent.\n` +
|
|
51
51
|
`Use a key for ${orgId}, or drop the override to work in ${config.key_org_id}.`);
|
|
52
52
|
}
|
|
53
|
+
warnOnceIfSharedDefault(orgId, resolved, config);
|
|
53
54
|
announceOrg(orgId, config);
|
|
54
55
|
recordResolvedOrg(orgId, config);
|
|
55
56
|
return orgId;
|
|
56
57
|
}
|
|
58
|
+
// The moment the machine-wide default becomes a hazard, said once.
|
|
59
|
+
//
|
|
60
|
+
// `default_org` is one value for the whole machine. With a single project that
|
|
61
|
+
// is a convenience; the second project makes it a trap, because whichever ran
|
|
62
|
+
// `config set-org` last owns every bare command in both. Customers reported
|
|
63
|
+
// exactly that, and `myapi init` exists to end it — but only for someone who
|
|
64
|
+
// knows to run it.
|
|
65
|
+
//
|
|
66
|
+
// So: when a command falls through to the machine-wide default AND this machine
|
|
67
|
+
// has already been used with more than one org, say so. Not on every run — a
|
|
68
|
+
// line that is always there is filtered out within a day — and not at all for
|
|
69
|
+
// the single-project case, which is the majority and is genuinely fine.
|
|
70
|
+
function warnOnceIfSharedDefault(orgId, resolved, config) {
|
|
71
|
+
if (resolved.source !== 'default')
|
|
72
|
+
return; // something explicit chose it
|
|
73
|
+
if (sharedDefaultWarned)
|
|
74
|
+
return;
|
|
75
|
+
const seen = Object.keys(config.org_names ?? {});
|
|
76
|
+
if (seen.length < 2)
|
|
77
|
+
return; // one org on this machine: no ambiguity
|
|
78
|
+
sharedDefaultWarned = true;
|
|
79
|
+
const name = config.org_names?.[orgId] ?? orgId;
|
|
80
|
+
banner(`myapi: using your machine-wide default org (${name}). ` +
|
|
81
|
+
`This machine has used ${seen.length} orgs; a second project running ` +
|
|
82
|
+
'`config set-org` would silently retarget this one. Bind the directory instead: myapi init --org <id>');
|
|
83
|
+
}
|
|
84
|
+
let sharedDefaultWarned = false;
|
|
85
|
+
/**
|
|
86
|
+
* Test seam. Both org notices fire once per PROCESS, which is right for a CLI
|
|
87
|
+
* invocation and wrong for a test file — without this the first test latches
|
|
88
|
+
* them and every later assertion passes for the wrong reason.
|
|
89
|
+
*/
|
|
90
|
+
export function _resetOrgNotices() {
|
|
91
|
+
sharedDefaultWarned = false;
|
|
92
|
+
announced = false;
|
|
93
|
+
}
|
|
57
94
|
function describeSource(r) {
|
|
58
95
|
switch (r.source) {
|
|
59
96
|
case 'flag': return '--org';
|
package/dist/index.js
CHANGED
|
@@ -358,8 +358,28 @@ async function main() {
|
|
|
358
358
|
}
|
|
359
359
|
catch (err) {
|
|
360
360
|
if (err instanceof MyApiError) {
|
|
361
|
-
|
|
361
|
+
// 401 alone does not mean the key is bad. The platform relayed an
|
|
362
|
+
// upstream's auth status verbatim — analytics refusing US came back as
|
|
363
|
+
// 401 ANALYTICS_ERROR — and this told a customer with a perfectly valid
|
|
364
|
+
// key to run `myapi account setup`. It fixed nothing, so they ran it
|
|
365
|
+
// again and got the same sentence. Reported 2026-08-23; the backend no
|
|
366
|
+
// longer relays it, and this stops the CLI asserting a cause the error
|
|
367
|
+
// itself contradicts.
|
|
368
|
+
//
|
|
369
|
+
// Codes the platform actually uses for a bad credential, plus the empty
|
|
370
|
+
// case (a bare string body, which is how most 401s arrive).
|
|
371
|
+
const AUTH_CODES = new Set([
|
|
372
|
+
'unauthorized', 'invalid api key', 'invalid_api_key', 'invalid_token',
|
|
373
|
+
'missing authorization header', 'invalid authorization header', 'unknown_error',
|
|
374
|
+
]);
|
|
375
|
+
if (err.status === 401 && (!err.code || AUTH_CODES.has(String(err.code).toLowerCase()))) {
|
|
362
376
|
error('Invalid API key. Run: myapi account setup');
|
|
377
|
+
}
|
|
378
|
+
else if (err.status === 401) {
|
|
379
|
+
// A 401 carrying a code that is not about credentials. Show what the
|
|
380
|
+
// platform actually said rather than a guess it contradicts.
|
|
381
|
+
error(friendlyError(err));
|
|
382
|
+
}
|
|
363
383
|
else if (err.status === 402) {
|
|
364
384
|
const body = (err.body ?? {});
|
|
365
385
|
if (err.code === 'REGISTRATION_REQUIRED' || err.code === 'UPGRADE_REQUIRED')
|
|
@@ -155,3 +155,52 @@ describe('a locked key refuses a mismatched target', () => {
|
|
|
155
155
|
expect(exitError).toMatch(/without a value/);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
describe('the machine-wide default warns once, when it is a hazard', () => {
|
|
159
|
+
// `default_org` is one value for the whole machine. With a single project
|
|
160
|
+
// that is a convenience; the second project makes it a trap, because
|
|
161
|
+
// whichever ran `config set-org` last owns every bare command in both.
|
|
162
|
+
// Customers reported exactly that. `myapi init` ends it — for someone who
|
|
163
|
+
// knows to run it, which is what this line is for.
|
|
164
|
+
let banners;
|
|
165
|
+
beforeEach(async () => {
|
|
166
|
+
banners = [];
|
|
167
|
+
const helpers = await import('./helpers.js');
|
|
168
|
+
helpers._resetOrgNotices();
|
|
169
|
+
const output = await import('./output.js');
|
|
170
|
+
vi.spyOn(output, 'banner').mockImplementation(((m) => { banners.push(String(m)); }));
|
|
171
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
172
|
+
throw new Error('__EXIT__');
|
|
173
|
+
}));
|
|
174
|
+
});
|
|
175
|
+
afterEach(() => vi.restoreAllMocks());
|
|
176
|
+
async function resolve(config, flags = {}) {
|
|
177
|
+
const { requireOrg } = await import('./helpers.js');
|
|
178
|
+
try {
|
|
179
|
+
requireOrg(flags, config, 'usage');
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (e?.message !== '__EXIT__')
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
return banners.join('\n');
|
|
186
|
+
}
|
|
187
|
+
const manyOrgs = { [A]: 'Alpha', [B]: 'Beta' };
|
|
188
|
+
it('warns when a bare command lands on the machine default and the machine has seen several orgs', async () => {
|
|
189
|
+
const out = await resolve({ ...base, default_org: A, org_names: manyOrgs });
|
|
190
|
+
expect(out).toMatch(/machine-wide default/i);
|
|
191
|
+
expect(out).toContain('myapi init --org');
|
|
192
|
+
});
|
|
193
|
+
it('stays quiet for a single-org machine, which is genuinely fine', async () => {
|
|
194
|
+
const out = await resolve({ ...base, default_org: A, org_names: { [A]: 'Alpha' } });
|
|
195
|
+
expect(out).not.toMatch(/machine-wide default/i);
|
|
196
|
+
});
|
|
197
|
+
it('stays quiet when --org chose the org', async () => {
|
|
198
|
+
const out = await resolve({ ...base, default_org: A, org_names: manyOrgs }, { org: B });
|
|
199
|
+
expect(out).not.toMatch(/machine-wide default/i);
|
|
200
|
+
});
|
|
201
|
+
it('stays quiet when a project file chose the org', async () => {
|
|
202
|
+
writeProjectOrg(tmp, B);
|
|
203
|
+
const out = await resolve({ ...base, default_org: A, org_names: manyOrgs });
|
|
204
|
+
expect(out).not.toMatch(/machine-wide default/i);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/dist/sdk-iam.test.js
CHANGED
|
@@ -163,9 +163,23 @@ describe('hq IAM contract surface', () => {
|
|
|
163
163
|
expect(hq.EXPOSES).toContain('POST /hq/account/keys/revoke-all');
|
|
164
164
|
expect(hq.EXPOSES).toContain('PATCH /hq/account/spend-cap');
|
|
165
165
|
});
|
|
166
|
-
|
|
166
|
+
// NOT a check against the backend, whatever the previous name said. This
|
|
167
|
+
// file cannot reach `iam.GrantableSlots`; it compares one client-side copy
|
|
168
|
+
// against another written here. When the backend added `payments`, both
|
|
169
|
+
// copies stayed stale, this stayed green, and it would have FAILED anyone
|
|
170
|
+
// who tried to fix it — a guard enforcing the bug.
|
|
171
|
+
//
|
|
172
|
+
// Found the only way it could be: the API accepted `--grant payments:write`
|
|
173
|
+
// while the CLI called it an unknown slot. Until then the only way to mint a
|
|
174
|
+
// payments-scoped key was to omit --grant, which mints an UNRESTRICTED key.
|
|
175
|
+
//
|
|
176
|
+
// Kept as a pin, because a change to this list should be deliberate, and
|
|
177
|
+
// renamed so it stops claiming an authority it does not have. The real check
|
|
178
|
+
// is the API: it rejects a grant it does not know.
|
|
179
|
+
it('GRANTABLE_SLOTS is not changed by accident (a pin, not a backend check)', () => {
|
|
167
180
|
expect([...hq.GRANTABLE_SLOTS].sort()).toEqual(['audience', 'company', 'crm', 'database', 'domain', 'email', 'function',
|
|
168
|
-
'funnel', 'image', 'llm', '
|
|
181
|
+
'funnel', 'image', 'llm', 'payments', 'people', 'storage', 'url',
|
|
182
|
+
'webhook', 'workflow']);
|
|
169
183
|
});
|
|
170
184
|
it('GRANTABLE_SLOTS excludes management/non-grantable surfaces', () => {
|
|
171
185
|
for (const s of ['hq', 'admin', 'internal', 'schema', 'ops', 'pixel']) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.28.0",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@myapihq/sdk": "^2.
|
|
49
|
+
"@myapihq/sdk": "^2.28.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|