@myapihq/cli 2.6.1 → 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.
@@ -6,7 +6,7 @@
6
6
  // OBSERVATION ("it answered 401", "the platform's counter said zero"). Each
7
7
  // test below pins the observation and refuses the conclusion.
8
8
  import { describe, it, expect } from 'vitest';
9
- import { classifyReachability, _setupSection } from './doctor.js';
9
+ import { classifyReachability, _setupSection, _rewriteQuietWebhook } from './doctor.js';
10
10
  const ENTITY = { slot: 'container', name: 'skout-engine-prod' };
11
11
  describe('classifyReachability', () => {
12
12
  // The finding: two containers behind an auth boundary were reported as
@@ -98,3 +98,59 @@ describe('setup gaps are confirmed before they become instructions', () => {
98
98
  expect(setupMessages(r, { domainCount: 0 })[0].hint).toMatch(/domain register/);
99
99
  });
100
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
+ });
@@ -18,6 +18,10 @@ export interface SetupContext {
18
18
  domainCount?: number;
19
19
  }
20
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;
21
25
  export declare function classifyReachability(probe: {
22
26
  status: number | null;
23
27
  error?: string;
@@ -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, domain as sdkDomain, email as sdkEmail } 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;
@@ -336,6 +338,85 @@ async function httpProbeSection(apiKey, orgId) {
336
338
  issues,
337
339
  };
338
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
+ }
339
420
  // What an HTTP status actually tells you about reachability.
340
421
  //
341
422
  // This check used to treat any status >= 400 as "unreachable" with the hint
@@ -460,6 +541,7 @@ export async function run(_subcommand, _args, flags) {
460
541
  const setup = _setupSection(report, { mailingAddress, mailboxCount, domainCount });
461
542
  if (setup)
462
543
  report.sections.push(setup);
544
+ await enrichQuietWebhooks(apiKey, orgId, report);
463
545
  const localSection = await dnsProbeSection(report);
464
546
  if (localSection)
465
547
  report.sections.push(localSection);
@@ -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.1",
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.1"
43
+ "@myapihq/sdk": "^2.6.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^25.6.0",