@myapihq/cli 2.6.0 → 2.6.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ // Regression cover for three doctor findings reported from a live production
2
+ // org, where two of four warnings described healthy things as broken.
3
+ //
4
+ // The shared defect: the check reported a CONCLUSION ("customers may not be
5
+ // able to reach this", "no email inbox configured") where it only had an
6
+ // OBSERVATION ("it answered 401", "the platform's counter said zero"). Each
7
+ // test below pins the observation and refuses the conclusion.
8
+ import { describe, it, expect } from 'vitest';
9
+ import { classifyReachability, _setupSection } from './doctor.js';
10
+ const ENTITY = { slot: 'container', name: 'skout-engine-prod' };
11
+ describe('classifyReachability', () => {
12
+ // The finding: two containers behind an auth boundary were reported as
13
+ // "customers may not be able to reach" them. Both were up. A 401 to an
14
+ // anonymous probe is the designed answer and proves liveness.
15
+ it('treats 401 as reachable, not as a warning', () => {
16
+ const v = classifyReachability({ status: 401 }, ENTITY);
17
+ expect(v.severity).toBe('ok');
18
+ expect(v.message).toMatch(/reachable, authentication required/);
19
+ });
20
+ it('treats 403 the same way', () => {
21
+ expect(classifyReachability({ status: 403 }, ENTITY).severity).toBe('ok');
22
+ });
23
+ it('never tells the user customers cannot reach a URL that answered', () => {
24
+ for (const status of [200, 301, 401, 403, 404, 418, 500, 503]) {
25
+ const v = classifyReachability({ status }, ENTITY);
26
+ expect(v.hint ?? '').not.toMatch(/may not be able to reach/);
27
+ }
28
+ });
29
+ // Only a probe that got no answer at all is unreachable. That is the one
30
+ // case where the old wording was right.
31
+ it('reports a network failure as unreachable, and says why', () => {
32
+ const v = classifyReachability({ status: null, error: 'ETIMEDOUT' }, ENTITY);
33
+ expect(v.severity).toBe('warn');
34
+ expect(v.message).toMatch(/unreachable/);
35
+ expect(v.hint).toBe('ETIMEDOUT');
36
+ });
37
+ it('falls back to a customer-facing hint when there is no error string', () => {
38
+ const v = classifyReachability({ status: null }, ENTITY);
39
+ expect(v.hint).toMatch(/nothing answered/);
40
+ });
41
+ // Still warns, but distinguishes "the host answered and served nothing"
42
+ // from "the host is down" — a different problem with a different fix.
43
+ it('warns on 404 while stating the host answered', () => {
44
+ const v = classifyReachability({ status: 404 }, ENTITY);
45
+ expect(v.severity).toBe('warn');
46
+ expect(v.message).toMatch(/reachable, but nothing is served/);
47
+ });
48
+ it('warns on 5xx as an application error, not an outage', () => {
49
+ const v = classifyReachability({ status: 503 }, ENTITY);
50
+ expect(v.severity).toBe('warn');
51
+ expect(v.message).toMatch(/the application is erroring/);
52
+ expect(v.hint).toMatch(/logs/);
53
+ });
54
+ it('handles an entity with no name without printing undefined', () => {
55
+ const v = classifyReachability({ status: null }, { slot: 'funnel' });
56
+ expect(v.hint ?? '').not.toMatch(/undefined/);
57
+ });
58
+ });
59
+ // ── setup gaps ──────────────────────────────────────────────────────────────
60
+ function report(sections) {
61
+ return {
62
+ org_id: '11111111-1111-4111-8111-111111111111',
63
+ sections: sections.map(s => ({ name: '', summary: '', issues: [], ...s })),
64
+ };
65
+ }
66
+ const ZERO_EMAILS = report([{ name: 'emails', resource_count: 0, issues: [] }]);
67
+ function setupMessages(r, ctx) {
68
+ return (_setupSection(r, ctx)?.issues ?? []).map(i => ({ message: i.message, hint: i.hint ?? '' }));
69
+ }
70
+ describe('setup gaps are confirmed before they become instructions', () => {
71
+ // The finding: the platform reported resource_count 0 for an org with two
72
+ // active mailboxes, one of which had received mail 14 hours earlier. The
73
+ // doctor turned that into "Create one with: myapi email mailbox create …".
74
+ it('does not tell you to create a mailbox when we counted some', () => {
75
+ const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 2 });
76
+ expect(issue.message).toMatch(/but 2 are configured/);
77
+ expect(issue.hint).toMatch(/Do not create another/);
78
+ expect(issue.hint).not.toMatch(/mailbox create/);
79
+ });
80
+ it('still reports a real gap when our own count agrees', () => {
81
+ const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 0 });
82
+ expect(issue.message).toBe('No email inbox configured');
83
+ expect(issue.hint).toMatch(/mailbox create/);
84
+ });
85
+ // A failed enumeration is not evidence of absence OR presence, so the
86
+ // backend's view stands rather than us inventing a disagreement.
87
+ it('defers to the platform when we could not count', () => {
88
+ const [issue] = setupMessages(ZERO_EMAILS, {});
89
+ expect(issue.message).toBe('No email inbox configured');
90
+ });
91
+ it('gets the singular right', () => {
92
+ const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 1 });
93
+ expect(issue.message).toMatch(/but 1 is configured/);
94
+ });
95
+ it('applies the same rule to domains', () => {
96
+ const r = report([{ name: 'domains', resource_count: 0, issues: [] }]);
97
+ expect(setupMessages(r, { domainCount: 3 })[0].message).toMatch(/but 3 are configured/);
98
+ expect(setupMessages(r, { domainCount: 0 })[0].hint).toMatch(/domain register/);
99
+ });
100
+ });
@@ -14,6 +14,19 @@ export declare function _tallyTotals(sections: sdkHq.DoctorSection[]): DoctorTot
14
14
  export declare function sanitizeHint(hint: string | undefined): string | undefined;
15
15
  export interface SetupContext {
16
16
  mailingAddress?: string | null;
17
+ mailboxCount?: number;
18
+ domainCount?: number;
17
19
  }
18
20
  export declare function _setupSection(report: sdkHq.DoctorReport, ctx?: SetupContext): sdkHq.DoctorSection | null;
21
+ export declare function classifyReachability(probe: {
22
+ status: number | null;
23
+ error?: string;
24
+ }, entity: {
25
+ slot: string;
26
+ name?: string;
27
+ }): {
28
+ severity: 'ok' | 'warn';
29
+ message: string;
30
+ hint?: string;
31
+ };
19
32
  export declare function run(_subcommand: string | undefined, _args: string[], flags: Flags): Promise<void>;
@@ -17,7 +17,7 @@
17
17
  // interleave with the backend's findings.
18
18
  import { promises as dns } from 'node:dns';
19
19
  import { createHash } from 'node:crypto';
20
- import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel } from '@myapihq/sdk';
20
+ import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel, domain as sdkDomain, email as sdkEmail } from '@myapihq/sdk';
21
21
  import { requireConfig } from '../config.js';
22
22
  import { info, error, printJson } from '../output.js';
23
23
  import { requireOrg } from '../helpers.js';
@@ -116,6 +116,32 @@ function isZeroState(section) {
116
116
  return section.resource_count === 0;
117
117
  return section.issues.length === 0;
118
118
  }
119
+ // Emits the "you have not set this up" warning ONLY when our own count agrees
120
+ // with the backend's. When we counted resources the backend says do not
121
+ // exist, the disagreement replaces the advice — it is both true and more
122
+ // useful, and it never sends anyone to recreate something that already
123
+ // exists. When we did not count (`counted` undefined), the backend's view
124
+ // stands, since a missing check is not evidence either way.
125
+ function confirmedGap(o) {
126
+ if (typeof o.counted === 'number' && o.counted > 0) {
127
+ return {
128
+ id: localIssueId(`${o.idKey}_disagreement`, o.orgId),
129
+ severity: 'warn',
130
+ scope: o.scope,
131
+ category: 'setup',
132
+ message: `Platform reports no ${o.noun} for this org, but ${o.counted} ${o.counted === 1 ? 'is' : 'are'} configured`,
133
+ hint: `This is a reporting bug, not a setup gap — your ${o.noun}${o.counted === 1 ? '' : 'es'} ${o.counted === 1 ? 'is' : 'are'} fine. Do not create another.`,
134
+ };
135
+ }
136
+ return {
137
+ id: localIssueId(o.idKey, o.orgId),
138
+ severity: 'warn',
139
+ scope: o.scope,
140
+ category: 'setup',
141
+ message: o.absent,
142
+ hint: o.hint,
143
+ };
144
+ }
119
145
  export function _setupSection(report, ctx = {}) {
120
146
  const byName = new Map();
121
147
  for (const s of report.sections)
@@ -123,25 +149,27 @@ export function _setupSection(report, ctx = {}) {
123
149
  const issues = [];
124
150
  const dom = byName.get('domains');
125
151
  if (dom && isZeroState(dom)) {
126
- issues.push({
127
- id: localIssueId('setup_no_domain', report.org_id),
128
- severity: 'warn',
152
+ issues.push(confirmedGap({
153
+ orgId: report.org_id,
154
+ idKey: 'setup_no_domain',
129
155
  scope: 'setup/domain',
130
- category: 'setup',
131
- message: 'No domain registered for this org',
156
+ counted: ctx.domainCount,
157
+ noun: 'domain',
158
+ absent: 'No domain registered for this org',
132
159
  hint: 'Register one with: myapi domain register <domain> && myapi domain assign <domain>',
133
- });
160
+ }));
134
161
  }
135
162
  const em = byName.get('emails');
136
163
  if (em && isZeroState(em)) {
137
- issues.push({
138
- id: localIssueId('setup_no_mailbox', report.org_id),
139
- severity: 'warn',
164
+ issues.push(confirmedGap({
165
+ orgId: report.org_id,
166
+ idKey: 'setup_no_mailbox',
140
167
  scope: 'setup/email-inbox',
141
- category: 'setup',
142
- message: 'No email inbox configured',
168
+ counted: ctx.mailboxCount,
169
+ noun: 'email inbox',
170
+ absent: 'No email inbox configured',
143
171
  hint: 'Create one with: myapi email mailbox create <username>@<your-domain>',
144
- });
172
+ }));
145
173
  }
146
174
  // Account-scoped (so use report.org_id only for the dedup id, not as
147
175
  // entity scope). Hard-gates every transactional `email send` —
@@ -253,17 +281,30 @@ async function httpProbeSection(apiKey, orgId) {
253
281
  }
254
282
  catch { /* augmentation is best-effort — skip the slot if enumeration fails */ }
255
283
  // Funnels: probe every published page URL.
284
+ //
285
+ // And probe the funnel's own subdomain when the page list comes back empty.
286
+ // A funnel serving a live site can report an empty inventory — verified
287
+ // 2026-07-28 on a funnel answering 200 with real content on both its
288
+ // subdomain and a bound custom domain while `GET .../pages` returned
289
+ // `{"pages":[]}`. Trusting that list meant the org's public landing page,
290
+ // the single most customer-visible URL it has, was silently dropped from
291
+ // the probe. A check that quietly narrows its own scope is worse than one
292
+ // that fails, so fall back rather than skip.
256
293
  try {
257
294
  const funnels = await sdkFunnel.listFunnels(apiKey, orgId);
258
295
  await Promise.all(funnels.map(async (f) => {
296
+ const name = f.name || f.id;
297
+ let pageUrls = [];
259
298
  try {
260
299
  const pages = await sdkFunnel.listFunnelPages(apiKey, orgId, f.id);
261
- for (const p of pages) {
262
- if (p.url)
263
- targets.push({ url: p.url, entity: { slot: 'funnel', id: f.id, name: f.name || f.id } });
264
- }
300
+ pageUrls = pages.map(p => p.url).filter((u) => !!u);
301
+ }
302
+ catch { /* fall through to the subdomain */ }
303
+ if (pageUrls.length === 0 && f.subdomain_url)
304
+ pageUrls = [f.subdomain_url];
305
+ for (const url of pageUrls) {
306
+ targets.push({ url, entity: { slot: 'funnel', id: f.id, name } });
265
307
  }
266
- catch { /* skip this funnel */ }
267
308
  }));
268
309
  }
269
310
  catch { /* skip the slot */ }
@@ -272,29 +313,84 @@ async function httpProbeSection(apiKey, orgId) {
272
313
  const issues = [];
273
314
  await Promise.all(targets.map(async ({ url, entity }) => {
274
315
  const probe = await fetchStatus(url);
275
- // 2xx/3xx = reachable. 4xx, 5xx and network failures are all `warn`.
276
- const ok = probe.status != null && probe.status < 400;
316
+ const verdict = classifyReachability(probe, entity);
277
317
  issues.push({
278
- id: localIssueId(ok ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
279
- severity: ok ? 'ok' : 'warn',
318
+ id: localIssueId(verdict.severity === 'ok' ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
319
+ severity: verdict.severity,
280
320
  scope: `local/${url}`,
281
321
  entity,
282
322
  category: 'network',
283
- message: ok
284
- ? `${url} responded ${probe.status}`
285
- : `${url} ${probe.status != null ? `returned ${probe.status}` : 'is unreachable'}`,
286
- hint: ok ? undefined : (probe.error || `customers may not be able to reach ${entity.slot} "${entity.name}"`),
323
+ message: `${url} ${verdict.message}`,
324
+ hint: verdict.hint,
287
325
  });
288
326
  }));
289
- const warns = issues.filter(i => i.severity === 'warn').length;
327
+ // Only a probe that got NO response means unreachable. Everything else
328
+ // answered, so the summary must not call it unreachable — see
329
+ // classifyReachability.
330
+ const unreachable = issues.filter(i => i.severity === 'warn').length;
290
331
  return {
291
332
  name: 'reachability',
292
- summary: warns
293
- ? `${warns} of ${issues.length} URL${issues.length === 1 ? '' : 's'} unreachable`
333
+ summary: unreachable
334
+ ? `${unreachable} of ${issues.length} URL${issues.length === 1 ? '' : 's'} need attention`
294
335
  : `${issues.length} URL${issues.length === 1 ? '' : 's'} reachable`,
295
336
  issues,
296
337
  };
297
338
  }
339
+ // What an HTTP status actually tells you about reachability.
340
+ //
341
+ // This check used to treat any status >= 400 as "unreachable" with the hint
342
+ // "customers may not be able to reach <name>". A user ran it against a
343
+ // healthy production org and got two such warnings, both for containers
344
+ // behind an auth boundary that were answering 401 exactly as designed.
345
+ //
346
+ // The advice was backwards. A 401 is positive evidence: something answered,
347
+ // and its auth boundary works. As they put it, if one of those URLs ever
348
+ // answered 200 to an unauthenticated probe, THAT would be the emergency —
349
+ // and the old check would have called it healthy.
350
+ //
351
+ // The cost of a false warning is not neutral. Two of them in one run teaches
352
+ // the reader to skim past warnings, which is where the real ones live.
353
+ //
354
+ // So: reachability is about whether anything answered. Only a network
355
+ // failure or timeout is unreachable. Statuses that answered but suggest a
356
+ // problem are still reported — as what was observed, not as a conclusion
357
+ // about customers.
358
+ export function classifyReachability(probe, entity) {
359
+ const { status } = probe;
360
+ if (status == null) {
361
+ return {
362
+ severity: 'warn',
363
+ message: 'is unreachable',
364
+ hint: probe.error || `nothing answered — customers may not be able to reach ${entity.slot} "${entity.name ?? '(unnamed)'}"`,
365
+ };
366
+ }
367
+ // Answered and served.
368
+ if (status < 400)
369
+ return { severity: 'ok', message: `responded ${status}` };
370
+ // Answered and refused the anonymous probe. This is the designed behaviour
371
+ // of anything sitting behind auth, and it proves both liveness and that the
372
+ // boundary holds.
373
+ if (status === 401 || status === 403) {
374
+ return { severity: 'ok', message: `responded ${status} — reachable, authentication required` };
375
+ }
376
+ // Answered, but nothing is published at that path.
377
+ if (status === 404 || status === 410) {
378
+ return {
379
+ severity: 'warn',
380
+ message: `returned ${status} — reachable, but nothing is served at this URL`,
381
+ hint: `the host answers, so this is a routing or publish problem rather than an outage`,
382
+ };
383
+ }
384
+ if (status >= 500) {
385
+ return {
386
+ severity: 'warn',
387
+ message: `returned ${status} — reachable, but the application is erroring`,
388
+ hint: `check \`myapi ${entity.slot} logs\``,
389
+ };
390
+ }
391
+ // Any other 4xx: report the fact, draw no conclusion.
392
+ return { severity: 'warn', message: `returned ${status} — reachable` };
393
+ }
298
394
  export async function run(_subcommand, _args, flags) {
299
395
  if (flags.help) {
300
396
  info(HELP);
@@ -330,7 +426,38 @@ export async function run(_subcommand, _args, flags) {
330
426
  catch {
331
427
  mailingAddress = undefined;
332
428
  }
333
- const setup = _setupSection(report, { mailingAddress });
429
+ // Count mailboxes and domains ourselves before any "you have not set this
430
+ // up" advice goes out. Both are cheap list calls, and both are the
431
+ // difference between a true finding and an instruction to duplicate live
432
+ // infrastructure. `undefined` on failure means "not checked" — the backend
433
+ // view then stands, because a failed check is not evidence.
434
+ //
435
+ // Mailboxes are scoped per registered domain, so this walks the org's
436
+ // domains rather than asking for a bare list.
437
+ let mailboxCount;
438
+ let domainCount;
439
+ try {
440
+ // NOT filter:'all' — that is account-wide and spans every org, which
441
+ // would count another org's mailboxes against this one. The default
442
+ // filter is org-scoped, which is the only correct basis for a claim
443
+ // about "this org".
444
+ const domains = await sdkDomain.listDomains(apiKey, orgId);
445
+ domainCount = domains.length;
446
+ const perDomain = await Promise.all(domains.map(async (d) => {
447
+ try {
448
+ return (await sdkEmail.listMailboxes(apiKey, { domain: d.domain })).length;
449
+ }
450
+ catch {
451
+ return 0;
452
+ }
453
+ }));
454
+ mailboxCount = perDomain.reduce((a, b) => a + b, 0);
455
+ }
456
+ catch {
457
+ mailboxCount = undefined;
458
+ domainCount = undefined;
459
+ }
460
+ const setup = _setupSection(report, { mailingAddress, mailboxCount, domainCount });
334
461
  if (setup)
335
462
  report.sections.push(setup);
336
463
  const localSection = await dnsProbeSection(report);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.6.0",
4
+ "version": "2.6.1",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
41
41
  },
42
42
  "dependencies": {
43
- "@myapihq/sdk": "^2.6.0"
43
+ "@myapihq/sdk": "^2.6.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^25.6.0",