@myapihq/cli 2.6.1 → 2.7.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.
@@ -1,73 +1,37 @@
1
- // Regression cover for the CRM pagination guards.
1
+ // Rendering for CRM result counts.
2
2
  //
3
- // The bug being fenced off: the API accepts `offset`, ignores it, and reports
4
- // `total` as the page size so an agent paging a contact list re-read page
5
- // one forever and had no signal that anything was missing. Verified against
6
- // production 2026-07-27. These tests assert the CLI never silently accepts
7
- // that flag again, and never claims a result is complete when it cannot know.
8
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
- import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
10
- import * as output from '../../output.js';
11
- describe('rejectOffset', () => {
12
- let errSpy;
13
- beforeEach(() => {
14
- // error() exits the process; make it throw so the test can catch it.
15
- errSpy = vi.spyOn(output, 'error').mockImplementation(((msg) => {
16
- throw new Error(msg);
17
- }));
18
- });
19
- afterEach(() => errSpy.mockRestore());
20
- it('passes through when --offset is absent', () => {
21
- expect(() => rejectOffset({}, 'usage')).not.toThrow();
22
- });
23
- it('rejects --offset 0 it is as broken as any other value', () => {
24
- expect(() => rejectOffset({ offset: 0 }, 'usage')).toThrow(/ignores it/);
25
- });
26
- it('rejects a non-zero --offset', () => {
27
- expect(() => rejectOffset({ offset: 25 }, 'usage')).toThrow(/ignores it/);
28
- });
29
- it('explains that no cursor exists either, so the user does not go looking', () => {
30
- expect(() => rejectOffset({ offset: 1 }, 'usage')).toThrow(/no cursor parameter/);
31
- });
32
- it('includes the usage line so the error is actionable', () => {
33
- expect(() => rejectOffset({ offset: 1 }, 'myapi crm contacts list [--limit N]'))
34
- .toThrow(/myapi crm contacts list/);
35
- });
36
- });
37
- describe('warnIfTruncated', () => {
38
- let infoSpy;
39
- beforeEach(() => { infoSpy = vi.spyOn(output, 'info').mockImplementation(() => { }); });
40
- afterEach(() => infoSpy.mockRestore());
41
- it('warns when the page came back exactly full', () => {
42
- warnIfTruncated(10, { limit: 10 });
43
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
44
- });
45
- it('stays quiet when the page came back short — that answer IS complete', () => {
46
- warnIfTruncated(3, { limit: 10 });
47
- expect(infoSpy).not.toHaveBeenCalled();
48
- });
49
- it('uses the server default of 50 when no --limit was given', () => {
50
- warnIfTruncated(50, {});
51
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
52
- });
53
- it('stays quiet below the default limit', () => {
54
- warnIfTruncated(49, {});
55
- expect(infoSpy).not.toHaveBeenCalled();
56
- });
57
- it('says a record, not "1 records"', () => {
58
- warnIfTruncated(1, { limit: 1 });
59
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('exactly 1 record came back'));
60
- });
61
- });
62
- describe('countLine', () => {
63
- // The point of this helper is what it does NOT print: `N of total`, where
64
- // total is the page size, reads as "you have everything".
65
- it('pluralizes', () => {
66
- expect(countLine(1, 'contact', 'contacts')).toBe('1 contact');
67
- expect(countLine(3, 'contact', 'contacts')).toBe('3 contacts');
68
- expect(countLine(0, 'company', 'companies')).toBe('0 companies');
69
- });
70
- it('never mentions a total', () => {
71
- expect(countLine(5, 'contact', 'contacts')).not.toMatch(/of/);
3
+ // The line this replaces said "N of <total>" where total was the page size, so
4
+ // it read as a completeness claim on a truncated result. The backend fixed
5
+ // `total` and added `has_more` on 2026-07-28; these tests pin the rendering to
6
+ // the fixed contract and, in particular, to preferring `has_more` over any
7
+ // arithmetic against `total`.
8
+ import { describe, it, expect } from 'vitest';
9
+ import { pageLine } from './pagination.js';
10
+ describe('pageLine', () => {
11
+ it('states the count alone when the page IS everything', () => {
12
+ expect(pageLine(3, 3, false, 'contact', 'contacts')).toBe('3 contacts');
13
+ });
14
+ it('shows the true match count when the page is a subset', () => {
15
+ expect(pageLine(3, 128, false, 'contact', 'contacts')).toBe('3 of 128 contacts');
16
+ });
17
+ it('says more is available when has_more is set', () => {
18
+ expect(pageLine(50, 128, true, 'contact', 'contacts'))
19
+ .toBe('50 of 128 contacts (more available — raise --limit or pass --offset)');
20
+ });
21
+ // has_more is authoritative. Inferring truncation by comparing lengths is
22
+ // what silently agreed with the old, wrong `total`.
23
+ it('trusts has_more even when the numbers look complete', () => {
24
+ expect(pageLine(3, 3, true, 'contact', 'contacts')).toMatch(/more available/);
25
+ });
26
+ it('omits the total when the API did not send one', () => {
27
+ expect(pageLine(3, undefined, undefined, 'contact', 'contacts')).toBe('3 contacts');
28
+ });
29
+ it('pluralizes both nouns', () => {
30
+ expect(pageLine(1, 1, false, 'contact', 'contacts')).toBe('1 contact');
31
+ expect(pageLine(1, 9, false, 'company', 'companies')).toBe('1 of 9 companies');
32
+ expect(pageLine(0, 0, false, 'company', 'companies')).toBe('0 companies');
33
+ });
34
+ it('never claims a total it was not given', () => {
35
+ expect(pageLine(5, undefined, true, 'contact', 'contacts')).not.toMatch(/\bof\b/);
72
36
  });
73
37
  });
@@ -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;
@@ -270,7 +272,7 @@ async function fetchStatus(url) {
270
272
  async function httpProbeSection(apiKey, orgId) {
271
273
  const targets = [];
272
274
  // Containers: probe the bound custom domain when set (what customers hit),
273
- // else the Cloud Run URL. An undeployed container has neither — skip it.
275
+ // else the runtime-assigned URL. An undeployed container has neither.
274
276
  try {
275
277
  const containers = await sdkContainer.listContainers(apiKey, orgId);
276
278
  for (const c of containers) {
@@ -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);
@@ -315,7 +315,7 @@ function renderStatus(res) {
315
315
  info(`Email: ${res.email_infra}${where}`);
316
316
  }
317
317
  if (res.status === 'pending_ns_change') {
318
- info('Waiting for you to change nameservers at your current registrar. Each poll re-checks Cloudflare.');
318
+ info('Waiting for you to change nameservers at your current registrar. Each poll re-checks the DNS provider.');
319
319
  }
320
320
  if (res.status === 'infra_error' && res.error_detail) {
321
321
  info(` Failed step: ${res.error_detail.failed_step}`);
@@ -524,7 +524,7 @@ Name normalization:
524
524
  --name="" Apex (empty == @)
525
525
 
526
526
  Notes:
527
- --ttl defaults to 1 (Cloudflare "automatic"). Explicit range: [60, 86400].
527
+ --ttl defaults to 1 ("automatic"). Explicit range: [60, 86400].
528
528
  --priority is required for MX (typical: 10).
529
529
  --proxied (CF "orange-cloud") applies to A/AAAA/CNAME only.
530
530
 
@@ -596,17 +596,17 @@ DNS propagation takes a few minutes — track it with: myapi domain status <doma
596
596
  'import': `myapi domain import <domain> [--org <id>] [--json]
597
597
 
598
598
  Bring your own domain (BYOD). Snapshots existing DNS records via a best-effort
599
- public probe and creates a Cloudflare zone for the domain. Returns the
599
+ public probe and creates a DNS zone for the domain. Returns the
600
600
  nameservers you need to set at your current registrar.
601
601
 
602
602
  No registrar credentials needed — works with any registrar (Namecheap, GoDaddy,
603
- Cloudflare Registrar, etc.).
603
+ the registrar of record, etc.).
604
604
 
605
605
  After running:
606
606
  1. Verify the preserved-records table covers your MX/SPF/DKIM/DMARC etc.
607
607
  2. At your current registrar, change the nameservers to the values returned.
608
608
  3. Wait for propagation. Use "myapi domain status <domain> --watch" to track
609
- activation — the backend live-checks Cloudflare each time you poll.`,
609
+ activation — the backend live-checks the DNS provider each poll.`,
610
610
  'renew': `myapi domain renew <domain> [--yes] [--org <id>]
611
611
 
612
612
  Renews a registered domain for one more registration period (typically 1 year).
@@ -634,7 +634,7 @@ binding with: myapi domain list --filter all`,
634
634
 
635
635
  --watch Poll until the domain reaches a terminal state (active / failed /
636
636
  expired). Backoff: 10s × 30 then 30s × 60 (~35 min budget). Useful
637
- after "myapi domain import" — each poll also live-checks Cloudflare,
637
+ after "myapi domain import" — each poll also live-checks DNS,
638
638
  so polling drives the flip from pending_ns_change → provisioning.`,
639
639
  'settings': `myapi domain settings <domain> [--org <id>]
640
640
 
@@ -23,7 +23,7 @@ export const SCHEMA = {
23
23
  set: 'string',
24
24
  };
25
25
  // Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
26
- // JS bundle to Cloudflare Workers, list runs, set env secrets).
26
+ // JS bundle to the edge runtime, list runs, set env secrets).
27
27
  // Mirrors validateName in myapi-hq/internal/routes/function/crud.go. We
28
28
  // pre-validate client-side so typos fail before the network call; backend
29
29
  // runs the same regex as defence in depth.
@@ -142,7 +142,7 @@ export async function del(id, flags) {
142
142
  success(`Deleted function ${id} (org ${orgId})`);
143
143
  }
144
144
  // deploy uploads a single-file JS bundle. The backend wraps it with the
145
- // MYAPI shim and ships it to Cloudflare Workers. The scoped API key is
145
+ // MYAPI shim and ships it to the edge runtime. The scoped API key is
146
146
  // rotated on every deploy — the fresh value is shown once here.
147
147
  export async function deploy(id, bundlePath, flags) {
148
148
  const config = requireConfig();
@@ -226,7 +226,7 @@ export function _parseSetPairs(raw) {
226
226
  }
227
227
  return env;
228
228
  }
229
- // env sets Worker Secret(s) (Stripe key, etc.) on a deployed function.
229
+ // env sets encrypted secret(s) (Stripe key, etc.) on a deployed function.
230
230
  // Single form: myapi fn env <id> <name> <value>
231
231
  // Bulk form: myapi fn env <id> --set K=V[,K2=V2 ...]
232
232
  export async function setEnv(id, name, value, flags) {
@@ -243,7 +243,7 @@ export async function setEnv(id, name, value, flags) {
243
243
  error('No secrets given. Usage: myapi fn env <id> --set KEY=VALUE');
244
244
  const result = await sdkFn.setFunctionEnvBulk(config.api_key, orgId, id, env);
245
245
  success(`Set ${result.set} secret${result.set === 1 ? '' : 's'} on function ${id}`);
246
- info('Values are encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
246
+ info('Values are encrypted at rest and never stored or echoed by MyAPI.');
247
247
  return;
248
248
  }
249
249
  // Single-secret path (original form).
@@ -253,7 +253,7 @@ export async function setEnv(id, name, value, flags) {
253
253
  error('Missing secret value.\nUsage: myapi fn env <id> <name> <value>');
254
254
  await sdkFn.setFunctionEnv(config.api_key, orgId, id, name, value);
255
255
  success(`Set ${name} on function ${id}`);
256
- info('The value is encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
256
+ info('The value is encrypted at rest and never stored or echoed by MyAPI.');
257
257
  }
258
258
  // runs lists recent invocation records, most recent first.
259
259
  export async function runs(id, flags) {
@@ -304,7 +304,7 @@ other MyAPI slots with its own permissions (scopes=slot_call; rejected at
304
304
  'deploy': `myapi fn deploy <id> <bundle.js> [--org <id>] [--json]
305
305
 
306
306
  Uploads a single-file JavaScript bundle (≤4MB) to the edge runtime. The
307
- backend wraps it with the MYAPI shim and ships it to Cloudflare Workers.
307
+ backend wraps it with the MYAPI shim and ships it to the edge runtime.
308
308
 
309
309
  The scoped API key is rotated on every deploy — the fresh value is printed
310
310
  once. After deploy the function has a live invocation URL.
@@ -314,7 +314,7 @@ Example:
314
314
  'env': `myapi fn env <id> <name> <value> [--org <id>]
315
315
  myapi fn env <id> --set KEY=VALUE[,KEY2=VALUE2 ...] [--org <id>]
316
316
 
317
- Sets one or more secrets (Stripe key, API token, ...) as Cloudflare Worker
317
+ Sets one or more secrets (Stripe key, API token, ...) as encrypted edge
318
318
  Secrets on a deployed function. Values are encrypted at rest and never stored
319
319
  in MyAPI or echoed back. The function must already be deployed.
320
320
 
@@ -337,7 +337,7 @@ Subcommands:
337
337
  create Register a function and get its scoped API key (returned once)
338
338
  delete <id> Soft-delete and revoke its scoped API key
339
339
  deploy <id> <file> Upload a JS bundle and go live
340
- env <id> <k> <v> Set a Worker Secret (or --set K=V for bulk) on a deployed function
340
+ env <id> <k> <v> Set an encrypted secret (or --set K=V for bulk) on a deployed function
341
341
  get <id> Inspect a function
342
342
  list List functions in your org
343
343
  runs <id> List recent invocation records
@@ -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/dist/errors.js CHANGED
@@ -39,8 +39,23 @@ export const ERROR_MESSAGES = {
39
39
  MX_PRIORITY_REQUIRED: 'MX records require --priority (typical value: 10).',
40
40
  INVALID_TTL: 'Invalid TTL. Use the auto sentinel (1) or a value between 60 and 86400 seconds.',
41
41
  INVALID_RECORD_CONTENT: 'Invalid record content for this type.',
42
- RECORD_LIMIT_EXCEEDED: 'Cloudflare per-zone record limit reached.',
43
- CF_API_ERROR: 'Cloudflare API error.',
42
+ RECORD_LIMIT_EXCEEDED: 'Per-zone DNS record limit reached.',
43
+ // The DNS provider's codes were renamed on 2026-07-28 (CF_* → DNS_*/EDGE_*)
44
+ // because they named the vendor. Both spellings are handled: the rename ships
45
+ // on a backend deploy we do not control the timing of, and a CLI that only
46
+ // knows the new names would print a bare code for anyone on the old build.
47
+ // The old five can go once that deploy is everywhere.
48
+ DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
49
+ DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
50
+ DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
51
+ EDGE_SUBDOMAIN_UNAVAILABLE: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
52
+ NOT_ON_OUR_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
53
+ // Superseded spellings, kept so an older backend still gets a readable message.
54
+ CF_API_ERROR: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
55
+ CF_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
56
+ CF_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
57
+ CF_WORKERS_SUBDOMAIN: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
58
+ NOT_ON_CF_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
44
59
  // invalid_json_response intentionally absent — the SDK's MyApiError now
45
60
  // builds a useful detailed message for that case (status + URL + body
46
61
  // snippet), and friendlyError(err.code) would override it.
@@ -54,9 +69,10 @@ export const ERROR_MESSAGES = {
54
69
  };
55
70
  export function friendlyError(err) {
56
71
  const body = err.body ?? {};
57
- // CF_API_ERROR: the cf_message already includes a "Cloudflare API error"
58
- // prefix, so use it verbatim (with cf_status if present) instead of doubling.
59
- if (err.code === 'CF_API_ERROR' && typeof body.cf_message === 'string') {
72
+ // The provider's own message is already self-describing, so use it verbatim
73
+ // (with its status if present) instead of doubling up. Matches both the old
74
+ // and new code spellings for the duration of the rename.
75
+ if ((err.code === 'DNS_UNAVAILABLE' || err.code === 'CF_API_ERROR') && typeof body.cf_message === 'string') {
60
76
  return typeof body.cf_status === 'number'
61
77
  ? `${body.cf_message} (HTTP ${body.cf_status})`
62
78
  : body.cf_message;
@@ -144,13 +144,18 @@ describe('container.getContainerLogs', () => {
144
144
  });
145
145
  });
146
146
  describe('container.EXPOSES', () => {
147
- it('covers the 9 container endpoints', () => {
147
+ it('covers the 11 container endpoints', () => {
148
148
  expect(container.EXPOSES).toEqual([
149
149
  'POST /container/orgs/{org_id}/containers',
150
150
  'GET /container/orgs/{org_id}/containers',
151
151
  'GET /container/orgs/{org_id}/containers/{id}',
152
152
  'DELETE /container/orgs/{org_id}/containers/{id}',
153
153
  'POST /container/orgs/{org_id}/containers/{id}/deploy',
154
+ // Added 2026-07-28. Promote and rollback are ONE endpoint: naming a
155
+ // revision promotes it, omitting one rolls back to the previous ready
156
+ // revision. Modelled as a single operation so the two cannot drift.
157
+ 'GET /container/orgs/{org_id}/containers/{id}/revisions',
158
+ 'POST /container/orgs/{org_id}/containers/{id}/promote',
154
159
  'GET /container/orgs/{org_id}/containers/{id}/logs',
155
160
  'POST /container/orgs/{org_id}/containers/{id}/domain',
156
161
  'DELETE /container/orgs/{org_id}/containers/{id}/domain',
@@ -193,3 +198,55 @@ describe('container custom domain', () => {
193
198
  expect(init.method).toBe('DELETE');
194
199
  });
195
200
  });
201
+ describe('container deploy safety — promote and revisions', () => {
202
+ // `promote` defaults to true server-side, so the historical call must stay
203
+ // byte-identical. Sending promote:true explicitly would be a behaviour
204
+ // change dressed as a no-op.
205
+ it('sends no promote field when promoting normally', async () => {
206
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
207
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1');
208
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1' });
209
+ });
210
+ it('sends promote:false only when explicitly withheld', async () => {
211
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k', promoted: false }));
212
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { promote: false });
213
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1', promote: false });
214
+ });
215
+ it('passes a smoke check through unchanged', async () => {
216
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
217
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { smoke: { path: '/', contains: 'assets/' } });
218
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).smoke).toEqual({ path: '/', contains: 'assets/' });
219
+ });
220
+ it('unwraps the revisions envelope', async () => {
221
+ fetchMock.mockResolvedValueOnce(ok({ revisions: [
222
+ { revision: 'r2', ready: true, serving: true, traffic_percent: 100, created_at: 't2' },
223
+ { revision: 'r1', ready: true, serving: false, traffic_percent: 0, created_at: 't1' },
224
+ ] }));
225
+ const revs = await container.listRevisions(API_KEY, ORG_ID, C_ID);
226
+ expect(revs).toHaveLength(2);
227
+ expect(revs[0].traffic_percent).toBe(100);
228
+ expect(fetchMock.mock.calls[0][1].method).toBe('GET');
229
+ });
230
+ it('returns an empty list rather than undefined when there are no revisions', async () => {
231
+ fetchMock.mockResolvedValueOnce(ok({}));
232
+ expect(await container.listRevisions(API_KEY, ORG_ID, C_ID)).toEqual([]);
233
+ });
234
+ it('names the revision when promoting', async () => {
235
+ fetchMock.mockResolvedValueOnce(ok({ serving: 'r2' }));
236
+ await container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r2');
237
+ const [url, init] = fetchMock.mock.calls[0];
238
+ expect(url).toContain(`/containers/${C_ID}/promote`);
239
+ expect(JSON.parse(init.body)).toEqual({ revision: 'r2' });
240
+ });
241
+ // Omitting the revision is the rollback path — same endpoint, empty body.
242
+ it('sends an empty body to roll back', async () => {
243
+ fetchMock.mockResolvedValueOnce(ok({ serving: 'r1' }));
244
+ await container.promoteRevision(API_KEY, ORG_ID, C_ID);
245
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
246
+ });
247
+ it('surfaces REVISION_NOT_READY without implying traffic moved', async () => {
248
+ fetchMock.mockResolvedValueOnce(fail('REVISION_NOT_READY', 'traffic was NOT moved', 409));
249
+ await expect(container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r9'))
250
+ .rejects.toMatchObject({ code: 'REVISION_NOT_READY' });
251
+ });
252
+ });