@myapihq/cli 2.6.0 → 2.6.2
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,156 @@
|
|
|
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, _rewriteQuietWebhook } 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
|
+
});
|
|
101
|
+
// ── quiet webhooks ──────────────────────────────────────────────────────────
|
|
102
|
+
const QUIET = {
|
|
103
|
+
id: 'webhook_quiet/332660b11afb07c8',
|
|
104
|
+
severity: 'warn',
|
|
105
|
+
scope: 'webhook/7a56765f164d2db5',
|
|
106
|
+
entity: { slot: 'webhook', id: '0737f583', name: 'funnel-skout-app' },
|
|
107
|
+
category: 'activity',
|
|
108
|
+
message: 'no deliveries in the last 30 days',
|
|
109
|
+
hint: 'confirm the form/page is reachable and wired to this endpoint',
|
|
110
|
+
};
|
|
111
|
+
describe('quiet-webhook advice is replaced by delivery history', () => {
|
|
112
|
+
// The reported near-miss: this hint reads as a cleanup instruction, and the
|
|
113
|
+
// endpoint it fired on was the ingest path for a live marketing site.
|
|
114
|
+
it('never tells you to go check whether the page is wired up', () => {
|
|
115
|
+
for (const lifetime of [{ count: 0 }, { count: 3, mostRecent: '2026-07-20T10:00:00Z' }]) {
|
|
116
|
+
const out = _rewriteQuietWebhook(QUIET, lifetime);
|
|
117
|
+
expect(out.hint ?? '').not.toMatch(/confirm the form/);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// "No submissions in 30 days" is the expected state for anything
|
|
121
|
+
// pre-launch, so it must not imply abandonment.
|
|
122
|
+
it('reports zero lifetime deliveries as a plain observation, with no hint', () => {
|
|
123
|
+
const out = _rewriteQuietWebhook(QUIET, { count: 0 });
|
|
124
|
+
expect(out.message).toBe('no submissions since created');
|
|
125
|
+
expect(out.hint).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
it('says quiet-not-unused when deliveries exist', () => {
|
|
128
|
+
const out = _rewriteQuietWebhook(QUIET, { count: 3, mostRecent: '2026-07-20T10:00:00Z' });
|
|
129
|
+
expect(out.message).toMatch(/3 received in total, most recent 2026-07-20/);
|
|
130
|
+
expect(out.hint).toMatch(/quiet, not unused/);
|
|
131
|
+
});
|
|
132
|
+
it('omits the date when the API did not supply one', () => {
|
|
133
|
+
const out = _rewriteQuietWebhook(QUIET, { count: 1 });
|
|
134
|
+
expect(out.message).toMatch(/1 received in total$/);
|
|
135
|
+
});
|
|
136
|
+
// Never invent a cleanup recommendation. The ask was to stop implying
|
|
137
|
+
// cleanup on a signal that cannot support it — adding a confident orphan
|
|
138
|
+
// verdict would repeat the mistake in the other direction.
|
|
139
|
+
it('never suggests deleting anything', () => {
|
|
140
|
+
for (const lifetime of [{ count: 0 }, { count: 5 }, undefined]) {
|
|
141
|
+
const out = _rewriteQuietWebhook(QUIET, lifetime);
|
|
142
|
+
expect(`${out.message} ${out.hint ?? ''}`).not.toMatch(/delet|remov|clean/i);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
// A failed lookup is not evidence, so the backend's finding stands rather
|
|
146
|
+
// than being softened on no information.
|
|
147
|
+
it('leaves the finding untouched when the count could not be fetched', () => {
|
|
148
|
+
expect(_rewriteQuietWebhook(QUIET, undefined)).toEqual(QUIET);
|
|
149
|
+
});
|
|
150
|
+
it('preserves id, severity and entity so dedup and rendering still work', () => {
|
|
151
|
+
const out = _rewriteQuietWebhook(QUIET, { count: 2 });
|
|
152
|
+
expect(out.id).toBe(QUIET.id);
|
|
153
|
+
expect(out.severity).toBe('warn');
|
|
154
|
+
expect(out.entity).toEqual(QUIET.entity);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -14,6 +14,23 @@ 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 _rewriteQuietWebhook(issue: sdkHq.DoctorIssue, lifetime: {
|
|
22
|
+
count: number;
|
|
23
|
+
mostRecent?: string;
|
|
24
|
+
} | undefined): sdkHq.DoctorIssue;
|
|
25
|
+
export declare function classifyReachability(probe: {
|
|
26
|
+
status: number | null;
|
|
27
|
+
error?: string;
|
|
28
|
+
}, entity: {
|
|
29
|
+
slot: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
}): {
|
|
32
|
+
severity: 'ok' | 'warn';
|
|
33
|
+
message: string;
|
|
34
|
+
hint?: string;
|
|
35
|
+
};
|
|
19
36
|
export declare function run(_subcommand: string | undefined, _args: string[], flags: Flags): Promise<void>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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, webhook as sdkWebhook } 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';
|
|
@@ -29,6 +29,8 @@ export const EXPOSES = [
|
|
|
29
29
|
'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/pages',
|
|
30
30
|
// Best-effort read for the setup-gap mailing_address check (CAN-SPAM).
|
|
31
31
|
'GET /hq/account/mailing-address',
|
|
32
|
+
// Lifetime delivery counts, to replace the 30-day 'quiet webhook' advice.
|
|
33
|
+
'GET /webhook/orgs/{org_id}/deliveries',
|
|
32
34
|
];
|
|
33
35
|
export const SCHEMA = {};
|
|
34
36
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -116,6 +118,32 @@ function isZeroState(section) {
|
|
|
116
118
|
return section.resource_count === 0;
|
|
117
119
|
return section.issues.length === 0;
|
|
118
120
|
}
|
|
121
|
+
// Emits the "you have not set this up" warning ONLY when our own count agrees
|
|
122
|
+
// with the backend's. When we counted resources the backend says do not
|
|
123
|
+
// exist, the disagreement replaces the advice — it is both true and more
|
|
124
|
+
// useful, and it never sends anyone to recreate something that already
|
|
125
|
+
// exists. When we did not count (`counted` undefined), the backend's view
|
|
126
|
+
// stands, since a missing check is not evidence either way.
|
|
127
|
+
function confirmedGap(o) {
|
|
128
|
+
if (typeof o.counted === 'number' && o.counted > 0) {
|
|
129
|
+
return {
|
|
130
|
+
id: localIssueId(`${o.idKey}_disagreement`, o.orgId),
|
|
131
|
+
severity: 'warn',
|
|
132
|
+
scope: o.scope,
|
|
133
|
+
category: 'setup',
|
|
134
|
+
message: `Platform reports no ${o.noun} for this org, but ${o.counted} ${o.counted === 1 ? 'is' : 'are'} configured`,
|
|
135
|
+
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.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
id: localIssueId(o.idKey, o.orgId),
|
|
140
|
+
severity: 'warn',
|
|
141
|
+
scope: o.scope,
|
|
142
|
+
category: 'setup',
|
|
143
|
+
message: o.absent,
|
|
144
|
+
hint: o.hint,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
119
147
|
export function _setupSection(report, ctx = {}) {
|
|
120
148
|
const byName = new Map();
|
|
121
149
|
for (const s of report.sections)
|
|
@@ -123,25 +151,27 @@ export function _setupSection(report, ctx = {}) {
|
|
|
123
151
|
const issues = [];
|
|
124
152
|
const dom = byName.get('domains');
|
|
125
153
|
if (dom && isZeroState(dom)) {
|
|
126
|
-
issues.push({
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
issues.push(confirmedGap({
|
|
155
|
+
orgId: report.org_id,
|
|
156
|
+
idKey: 'setup_no_domain',
|
|
129
157
|
scope: 'setup/domain',
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
counted: ctx.domainCount,
|
|
159
|
+
noun: 'domain',
|
|
160
|
+
absent: 'No domain registered for this org',
|
|
132
161
|
hint: 'Register one with: myapi domain register <domain> && myapi domain assign <domain>',
|
|
133
|
-
});
|
|
162
|
+
}));
|
|
134
163
|
}
|
|
135
164
|
const em = byName.get('emails');
|
|
136
165
|
if (em && isZeroState(em)) {
|
|
137
|
-
issues.push({
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
issues.push(confirmedGap({
|
|
167
|
+
orgId: report.org_id,
|
|
168
|
+
idKey: 'setup_no_mailbox',
|
|
140
169
|
scope: 'setup/email-inbox',
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
counted: ctx.mailboxCount,
|
|
171
|
+
noun: 'email inbox',
|
|
172
|
+
absent: 'No email inbox configured',
|
|
143
173
|
hint: 'Create one with: myapi email mailbox create <username>@<your-domain>',
|
|
144
|
-
});
|
|
174
|
+
}));
|
|
145
175
|
}
|
|
146
176
|
// Account-scoped (so use report.org_id only for the dedup id, not as
|
|
147
177
|
// entity scope). Hard-gates every transactional `email send` —
|
|
@@ -253,17 +283,30 @@ async function httpProbeSection(apiKey, orgId) {
|
|
|
253
283
|
}
|
|
254
284
|
catch { /* augmentation is best-effort — skip the slot if enumeration fails */ }
|
|
255
285
|
// Funnels: probe every published page URL.
|
|
286
|
+
//
|
|
287
|
+
// And probe the funnel's own subdomain when the page list comes back empty.
|
|
288
|
+
// A funnel serving a live site can report an empty inventory — verified
|
|
289
|
+
// 2026-07-28 on a funnel answering 200 with real content on both its
|
|
290
|
+
// subdomain and a bound custom domain while `GET .../pages` returned
|
|
291
|
+
// `{"pages":[]}`. Trusting that list meant the org's public landing page,
|
|
292
|
+
// the single most customer-visible URL it has, was silently dropped from
|
|
293
|
+
// the probe. A check that quietly narrows its own scope is worse than one
|
|
294
|
+
// that fails, so fall back rather than skip.
|
|
256
295
|
try {
|
|
257
296
|
const funnels = await sdkFunnel.listFunnels(apiKey, orgId);
|
|
258
297
|
await Promise.all(funnels.map(async (f) => {
|
|
298
|
+
const name = f.name || f.id;
|
|
299
|
+
let pageUrls = [];
|
|
259
300
|
try {
|
|
260
301
|
const pages = await sdkFunnel.listFunnelPages(apiKey, orgId, f.id);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
302
|
+
pageUrls = pages.map(p => p.url).filter((u) => !!u);
|
|
303
|
+
}
|
|
304
|
+
catch { /* fall through to the subdomain */ }
|
|
305
|
+
if (pageUrls.length === 0 && f.subdomain_url)
|
|
306
|
+
pageUrls = [f.subdomain_url];
|
|
307
|
+
for (const url of pageUrls) {
|
|
308
|
+
targets.push({ url, entity: { slot: 'funnel', id: f.id, name } });
|
|
265
309
|
}
|
|
266
|
-
catch { /* skip this funnel */ }
|
|
267
310
|
}));
|
|
268
311
|
}
|
|
269
312
|
catch { /* skip the slot */ }
|
|
@@ -272,29 +315,163 @@ async function httpProbeSection(apiKey, orgId) {
|
|
|
272
315
|
const issues = [];
|
|
273
316
|
await Promise.all(targets.map(async ({ url, entity }) => {
|
|
274
317
|
const probe = await fetchStatus(url);
|
|
275
|
-
|
|
276
|
-
const ok = probe.status != null && probe.status < 400;
|
|
318
|
+
const verdict = classifyReachability(probe, entity);
|
|
277
319
|
issues.push({
|
|
278
|
-
id: localIssueId(ok ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
|
|
279
|
-
severity:
|
|
320
|
+
id: localIssueId(verdict.severity === 'ok' ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
|
|
321
|
+
severity: verdict.severity,
|
|
280
322
|
scope: `local/${url}`,
|
|
281
323
|
entity,
|
|
282
324
|
category: 'network',
|
|
283
|
-
message:
|
|
284
|
-
|
|
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}"`),
|
|
325
|
+
message: `${url} ${verdict.message}`,
|
|
326
|
+
hint: verdict.hint,
|
|
287
327
|
});
|
|
288
328
|
}));
|
|
289
|
-
|
|
329
|
+
// Only a probe that got NO response means unreachable. Everything else
|
|
330
|
+
// answered, so the summary must not call it unreachable — see
|
|
331
|
+
// classifyReachability.
|
|
332
|
+
const unreachable = issues.filter(i => i.severity === 'warn').length;
|
|
290
333
|
return {
|
|
291
334
|
name: 'reachability',
|
|
292
|
-
summary:
|
|
293
|
-
? `${
|
|
335
|
+
summary: unreachable
|
|
336
|
+
? `${unreachable} of ${issues.length} URL${issues.length === 1 ? '' : 's'} need attention`
|
|
294
337
|
: `${issues.length} URL${issues.length === 1 ? '' : 's'} reachable`,
|
|
295
338
|
issues,
|
|
296
339
|
};
|
|
297
340
|
}
|
|
341
|
+
// The backend flags a webhook that has had "no deliveries in the last 30 days"
|
|
342
|
+
// and hints "confirm the form/page is reachable and wired to this endpoint".
|
|
343
|
+
//
|
|
344
|
+
// That hint reads as a cleanup instruction, and a user came within one command
|
|
345
|
+
// of deleting the endpoint behind their live marketing site because of it. The
|
|
346
|
+
// 30-day window cannot support the implication: a pre-launch product has no
|
|
347
|
+
// traffic by definition, which is the normal state for most forms on a
|
|
348
|
+
// platform aimed at people building something new. The signal cannot tell
|
|
349
|
+
// "abandoned" from "not launched".
|
|
350
|
+
//
|
|
351
|
+
// It is also not correlated with the thing it implies. In the reported org the
|
|
352
|
+
// endpoint holding three real waitlist signups was NOT flagged, while the one
|
|
353
|
+
// serving the live landing page was.
|
|
354
|
+
//
|
|
355
|
+
// Lifetime deliveries can tell those apart, and we can count them. So the
|
|
356
|
+
// window stays as the observation, and the count replaces the advice:
|
|
357
|
+
//
|
|
358
|
+
// received > 0 → quiet, not abandoned. Never imply cleanup.
|
|
359
|
+
// received = 0 → "no submissions since created" — the expected pre-launch
|
|
360
|
+
// state. Still no directive.
|
|
361
|
+
//
|
|
362
|
+
// Deliberately absent: any suggestion to DELETE. The ask was to stop implying
|
|
363
|
+
// cleanup on a signal that cannot support it, and inventing a
|
|
364
|
+
// confident-sounding orphan verdict would repeat the mistake in the other
|
|
365
|
+
// direction. State what is true and let the operator decide.
|
|
366
|
+
export function _rewriteQuietWebhook(issue, lifetime) {
|
|
367
|
+
// No evidence gathered — leave the backend's finding untouched rather than
|
|
368
|
+
// soften something we did not check.
|
|
369
|
+
if (!lifetime)
|
|
370
|
+
return issue;
|
|
371
|
+
if (lifetime.count > 0) {
|
|
372
|
+
const when = lifetime.mostRecent ? `, most recent ${lifetime.mostRecent.slice(0, 10)}` : '';
|
|
373
|
+
return {
|
|
374
|
+
...issue,
|
|
375
|
+
message: `no deliveries in the last 30 days — ${lifetime.count} received in total${when}`,
|
|
376
|
+
hint: 'quiet, not unused — this endpoint has received real submissions',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
...issue,
|
|
381
|
+
message: 'no submissions since created',
|
|
382
|
+
hint: undefined,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
// Walks the report's `webhook_quiet` findings and replaces the directive with
|
|
386
|
+
// what the delivery history actually shows. Best-effort: any failure leaves
|
|
387
|
+
// the backend's original finding in place.
|
|
388
|
+
async function enrichQuietWebhooks(apiKey, orgId, report) {
|
|
389
|
+
const quiet = [];
|
|
390
|
+
for (const s of report.sections) {
|
|
391
|
+
for (const i of s.issues) {
|
|
392
|
+
if (i.id?.startsWith('webhook_quiet/') && i.entity?.id)
|
|
393
|
+
quiet.push(i);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (quiet.length === 0)
|
|
397
|
+
return;
|
|
398
|
+
const byEndpoint = new Map();
|
|
399
|
+
await Promise.all([...new Set(quiet.map(i => i.entity.id))].map(async (endpointId) => {
|
|
400
|
+
try {
|
|
401
|
+
const page = await sdkWebhook.listDeliveries(apiKey, orgId, { endpointId, limit: 100 });
|
|
402
|
+
const items = page.deliveries ?? [];
|
|
403
|
+
byEndpoint.set(endpointId, {
|
|
404
|
+
// `has_more` means we counted a floor, not a total — which is fine,
|
|
405
|
+
// because every use of this number only needs "more than zero".
|
|
406
|
+
count: items.length,
|
|
407
|
+
mostRecent: items[0]?.received_at,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
byEndpoint.set(endpointId, undefined);
|
|
412
|
+
}
|
|
413
|
+
}));
|
|
414
|
+
for (const s of report.sections) {
|
|
415
|
+
s.issues = s.issues.map(i => (i.id?.startsWith('webhook_quiet/') && i.entity?.id)
|
|
416
|
+
? _rewriteQuietWebhook(i, byEndpoint.get(i.entity.id))
|
|
417
|
+
: i);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// What an HTTP status actually tells you about reachability.
|
|
421
|
+
//
|
|
422
|
+
// This check used to treat any status >= 400 as "unreachable" with the hint
|
|
423
|
+
// "customers may not be able to reach <name>". A user ran it against a
|
|
424
|
+
// healthy production org and got two such warnings, both for containers
|
|
425
|
+
// behind an auth boundary that were answering 401 exactly as designed.
|
|
426
|
+
//
|
|
427
|
+
// The advice was backwards. A 401 is positive evidence: something answered,
|
|
428
|
+
// and its auth boundary works. As they put it, if one of those URLs ever
|
|
429
|
+
// answered 200 to an unauthenticated probe, THAT would be the emergency —
|
|
430
|
+
// and the old check would have called it healthy.
|
|
431
|
+
//
|
|
432
|
+
// The cost of a false warning is not neutral. Two of them in one run teaches
|
|
433
|
+
// the reader to skim past warnings, which is where the real ones live.
|
|
434
|
+
//
|
|
435
|
+
// So: reachability is about whether anything answered. Only a network
|
|
436
|
+
// failure or timeout is unreachable. Statuses that answered but suggest a
|
|
437
|
+
// problem are still reported — as what was observed, not as a conclusion
|
|
438
|
+
// about customers.
|
|
439
|
+
export function classifyReachability(probe, entity) {
|
|
440
|
+
const { status } = probe;
|
|
441
|
+
if (status == null) {
|
|
442
|
+
return {
|
|
443
|
+
severity: 'warn',
|
|
444
|
+
message: 'is unreachable',
|
|
445
|
+
hint: probe.error || `nothing answered — customers may not be able to reach ${entity.slot} "${entity.name ?? '(unnamed)'}"`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
// Answered and served.
|
|
449
|
+
if (status < 400)
|
|
450
|
+
return { severity: 'ok', message: `responded ${status}` };
|
|
451
|
+
// Answered and refused the anonymous probe. This is the designed behaviour
|
|
452
|
+
// of anything sitting behind auth, and it proves both liveness and that the
|
|
453
|
+
// boundary holds.
|
|
454
|
+
if (status === 401 || status === 403) {
|
|
455
|
+
return { severity: 'ok', message: `responded ${status} — reachable, authentication required` };
|
|
456
|
+
}
|
|
457
|
+
// Answered, but nothing is published at that path.
|
|
458
|
+
if (status === 404 || status === 410) {
|
|
459
|
+
return {
|
|
460
|
+
severity: 'warn',
|
|
461
|
+
message: `returned ${status} — reachable, but nothing is served at this URL`,
|
|
462
|
+
hint: `the host answers, so this is a routing or publish problem rather than an outage`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (status >= 500) {
|
|
466
|
+
return {
|
|
467
|
+
severity: 'warn',
|
|
468
|
+
message: `returned ${status} — reachable, but the application is erroring`,
|
|
469
|
+
hint: `check \`myapi ${entity.slot} logs\``,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
// Any other 4xx: report the fact, draw no conclusion.
|
|
473
|
+
return { severity: 'warn', message: `returned ${status} — reachable` };
|
|
474
|
+
}
|
|
298
475
|
export async function run(_subcommand, _args, flags) {
|
|
299
476
|
if (flags.help) {
|
|
300
477
|
info(HELP);
|
|
@@ -330,9 +507,41 @@ export async function run(_subcommand, _args, flags) {
|
|
|
330
507
|
catch {
|
|
331
508
|
mailingAddress = undefined;
|
|
332
509
|
}
|
|
333
|
-
|
|
510
|
+
// Count mailboxes and domains ourselves before any "you have not set this
|
|
511
|
+
// up" advice goes out. Both are cheap list calls, and both are the
|
|
512
|
+
// difference between a true finding and an instruction to duplicate live
|
|
513
|
+
// infrastructure. `undefined` on failure means "not checked" — the backend
|
|
514
|
+
// view then stands, because a failed check is not evidence.
|
|
515
|
+
//
|
|
516
|
+
// Mailboxes are scoped per registered domain, so this walks the org's
|
|
517
|
+
// domains rather than asking for a bare list.
|
|
518
|
+
let mailboxCount;
|
|
519
|
+
let domainCount;
|
|
520
|
+
try {
|
|
521
|
+
// NOT filter:'all' — that is account-wide and spans every org, which
|
|
522
|
+
// would count another org's mailboxes against this one. The default
|
|
523
|
+
// filter is org-scoped, which is the only correct basis for a claim
|
|
524
|
+
// about "this org".
|
|
525
|
+
const domains = await sdkDomain.listDomains(apiKey, orgId);
|
|
526
|
+
domainCount = domains.length;
|
|
527
|
+
const perDomain = await Promise.all(domains.map(async (d) => {
|
|
528
|
+
try {
|
|
529
|
+
return (await sdkEmail.listMailboxes(apiKey, { domain: d.domain })).length;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
}));
|
|
535
|
+
mailboxCount = perDomain.reduce((a, b) => a + b, 0);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
mailboxCount = undefined;
|
|
539
|
+
domainCount = undefined;
|
|
540
|
+
}
|
|
541
|
+
const setup = _setupSection(report, { mailingAddress, mailboxCount, domainCount });
|
|
334
542
|
if (setup)
|
|
335
543
|
report.sections.push(setup);
|
|
544
|
+
await enrichQuietWebhooks(apiKey, orgId, report);
|
|
336
545
|
const localSection = await dnsProbeSection(report);
|
|
337
546
|
if (localSection)
|
|
338
547
|
report.sections.push(localSection);
|
package/dist/commands/funnel.js
CHANGED
|
@@ -173,6 +173,39 @@ export async function del(id, flags) {
|
|
|
173
173
|
await sdkFunnel.deleteFunnel(config.api_key, orgId, id);
|
|
174
174
|
success(`Funnel ${id} deleted (org ${orgId})`);
|
|
175
175
|
}
|
|
176
|
+
// Returns the funnel's own URL when it answers with a non-empty body, else
|
|
177
|
+
// null. Used only to contradict an empty page inventory, so every failure
|
|
178
|
+
// mode — no URL, network error, timeout, 404, empty body — resolves to null
|
|
179
|
+
// and lets the normal "no pages" message stand. A probe that cannot reach the
|
|
180
|
+
// funnel is not evidence that the funnel is serving.
|
|
181
|
+
async function probeFunnelOrigin(apiKey, orgId, funnelId) {
|
|
182
|
+
let url;
|
|
183
|
+
try {
|
|
184
|
+
const funnels = await sdkFunnel.listFunnels(apiKey, orgId);
|
|
185
|
+
const f = funnels.find(x => x.id === funnelId);
|
|
186
|
+
url = f?.domain_url || f?.subdomain_url;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (!url)
|
|
192
|
+
return null;
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
|
|
197
|
+
if (!res.ok)
|
|
198
|
+
return null;
|
|
199
|
+
const body = await res.text();
|
|
200
|
+
return body.trim().length > 0 ? url : null;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
176
209
|
// List the pages currently published to a funnel. Resolves the funnel id
|
|
177
210
|
// from positional arg, --funnel flag, or the user's default funnel.
|
|
178
211
|
export async function pages(funnelArg, flags) {
|
|
@@ -182,6 +215,31 @@ export async function pages(funnelArg, flags) {
|
|
|
182
215
|
if (!funnelId)
|
|
183
216
|
error('Missing funnel id. Pass it as a positional arg, --funnel <id>, or set: myapi config set-funnel <id>');
|
|
184
217
|
const list = await sdkFunnel.listFunnelPages(config.api_key, orgId, funnelId);
|
|
218
|
+
// An empty inventory is not proof the funnel is empty. Verified 2026-07-28:
|
|
219
|
+
// a funnel answering 200 with real content on both its subdomain and a bound
|
|
220
|
+
// custom domain reported `{"pages":[]}`. Someone auditing what is deployed
|
|
221
|
+
// reads "No pages published" as "safe to remove", and that is how a live
|
|
222
|
+
// site gets deleted by a person being careful.
|
|
223
|
+
//
|
|
224
|
+
// So when the list is empty, ask the funnel itself before agreeing it is
|
|
225
|
+
// empty. Only on the empty path — the common case costs nothing extra.
|
|
226
|
+
//
|
|
227
|
+
// This runs BEFORE the --json branch on purpose. An agent is more likely to
|
|
228
|
+
// use --json than a human is, and handing it `[]` with exit 0 is precisely
|
|
229
|
+
// the silent wrong answer. The JSON shape stays an array so existing parsers
|
|
230
|
+
// keep working; the contradiction goes to stderr and the exit code turns
|
|
231
|
+
// non-zero, so anything checking either one is protected.
|
|
232
|
+
const serving = list.length === 0
|
|
233
|
+
? await probeFunnelOrigin(config.api_key, orgId, funnelId)
|
|
234
|
+
: null;
|
|
235
|
+
if (serving) {
|
|
236
|
+
if (flags.json)
|
|
237
|
+
printJson(list);
|
|
238
|
+
error(`Inventory reports no pages, but ${serving} is serving content right now.\n\n` +
|
|
239
|
+
'This is a reporting bug, not an empty funnel. Do NOT delete this funnel on the\n' +
|
|
240
|
+
'strength of an empty page list — confirm with curl first.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
185
243
|
if (flags.json) {
|
|
186
244
|
printJson(list);
|
|
187
245
|
return;
|
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.
|
|
4
|
+
"version": "2.6.2",
|
|
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.
|
|
43
|
+
"@myapihq/sdk": "^2.6.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^25.6.0",
|