@myapihq/cli 2.27.4 → 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.
@@ -25,7 +25,20 @@ export const SCHEMA = {
25
25
  private: 'boolean',
26
26
  ttl: 'string',
27
27
  };
28
- // Mirrors UploadContentType in @myapihq/sdk + backend allowlist.
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 {
@@ -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
- if (err.status === 401)
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.27.4",
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.27.4"
49
+ "@myapihq/sdk": "^2.28.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",