@myapihq/cli 2.6.2 → 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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ // The deploy-safety surface: --smoke parsing, and the rules around
2
+ // --no-promote.
3
+ //
4
+ // Context: a user shipped a build whose frontend was a placeholder. It bound
5
+ // its port and answered every request with 200, so the platform called the
6
+ // deploy healthy and sent it 100% of traffic. Fifteen minutes of dead
7
+ // production followed, with no way back.
8
+ //
9
+ // The one assertion that would have caught it is content, not status —
10
+ // "returns 200" was true of the broken build. That is why a smoke check with
11
+ // no `contains` and no `status` is refused rather than accepted as a no-op.
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import { _parseSmoke } from './container.js';
14
+ import * as output from '../output.js';
15
+ let errSpy;
16
+ beforeEach(() => {
17
+ errSpy = vi.spyOn(output, 'error').mockImplementation(((m) => { throw new Error(m); }));
18
+ });
19
+ afterEach(() => errSpy.mockRestore());
20
+ describe('_parseSmoke', () => {
21
+ it('parses the shape the reporting user proposed', () => {
22
+ expect(_parseSmoke('GET / contains assets/')).toEqual({
23
+ method: 'GET', path: '/', contains: 'assets/',
24
+ });
25
+ });
26
+ it('parses a status assertion', () => {
27
+ expect(_parseSmoke('GET /livez status 200')).toEqual({
28
+ method: 'GET', path: '/livez', status: 200,
29
+ });
30
+ });
31
+ it('parses both assertions together', () => {
32
+ const s = _parseSmoke('HEAD /health status 204 contains ok');
33
+ expect(s).toEqual({ method: 'HEAD', path: '/health', status: 204, contains: 'ok' });
34
+ });
35
+ it('defaults method and path when only an assertion is given', () => {
36
+ expect(_parseSmoke('contains assets/')).toEqual({ contains: 'assets/' });
37
+ });
38
+ it('strips quotes around the contains value', () => {
39
+ expect(_parseSmoke("GET / contains 'assets/'").contains).toBe('assets/');
40
+ expect(_parseSmoke('GET / contains "assets/"').contains).toBe('assets/');
41
+ });
42
+ it('keeps spaces inside the contains value', () => {
43
+ expect(_parseSmoke('GET / contains <div id="app">').contains).toBe('<div id="app">');
44
+ });
45
+ // The whole point. A check that asserts nothing passes on the placeholder
46
+ // page it exists to catch, which is worse than no check because it reads as
47
+ // verification.
48
+ it('REFUSES a check that asserts nothing', () => {
49
+ expect(() => _parseSmoke('GET /')).toThrow(/asserts nothing/);
50
+ expect(() => _parseSmoke('/')).toThrow(/asserts nothing/);
51
+ expect(() => _parseSmoke('')).toThrow(/asserts nothing/);
52
+ });
53
+ it('explains why, rather than just rejecting', () => {
54
+ expect(() => _parseSmoke('GET /')).toThrow(/placeholder/);
55
+ });
56
+ it('is case-insensitive on the keywords', () => {
57
+ const s = _parseSmoke('get / CONTAINS assets/');
58
+ expect(s.contains).toBe('assets/');
59
+ });
60
+ });
@@ -1,3 +1,4 @@
1
+ import { container as sdkContainer } from '@myapihq/sdk';
1
2
  import type { FlagSchema } from '../flags.js';
2
3
  import { type Flags } from '../helpers.js';
3
4
  import type { Exposes } from '../exposes.js';
@@ -13,6 +14,9 @@ export declare function get(id: string, flags: Flags): Promise<void>;
13
14
  export declare function del(id: string, flags: Flags): Promise<void>;
14
15
  export declare function _isTarball(p: string): boolean;
15
16
  export declare function deploy(id: string, image: string, flags: Flags): Promise<void>;
17
+ export declare function _parseSmoke(raw: string): sdkContainer.SmokeCheck;
18
+ export declare function revisions(id: string, flags: Flags): Promise<void>;
19
+ export declare function promote(id: string, revision: string | undefined, flags: Flags): Promise<void>;
16
20
  export declare function logs(id: string, flags: Flags): Promise<void>;
17
21
  export declare function domain(id: string, domainArg: string | undefined, flags: Flags): Promise<void>;
18
22
  export declare function buildLogs(id: string | undefined, flags: Flags): Promise<void>;
@@ -13,6 +13,8 @@ export const EXPOSES = [
13
13
  'DELETE /container/orgs/{org_id}/containers/{id}',
14
14
  'POST /container/orgs/{org_id}/containers/{id}/deploy',
15
15
  'GET /container/orgs/{org_id}/containers/{id}/logs',
16
+ 'GET /container/orgs/{org_id}/containers/{id}/revisions',
17
+ 'POST /container/orgs/{org_id}/containers/{id}/promote',
16
18
  'POST /container/orgs/{org_id}/containers/{id}/domain',
17
19
  'DELETE /container/orgs/{org_id}/containers/{id}/domain',
18
20
  ];
@@ -28,6 +30,9 @@ export const SCHEMA = {
28
30
  env: 'string',
29
31
  tail: 'number',
30
32
  scope: 'string',
33
+ 'no-promote': 'boolean',
34
+ 'health-check': 'string',
35
+ smoke: 'string',
31
36
  remove: 'boolean',
32
37
  source: 'string',
33
38
  image: 'string',
@@ -106,6 +111,17 @@ export async function create(nameArg, flags) {
106
111
  payload.max_instances = flags['max-instances'];
107
112
  if (typeof flags.port === 'number')
108
113
  payload.port = flags.port;
114
+ if (typeof flags['health-check'] === 'string') {
115
+ const hc = flags['health-check'];
116
+ // The API refuses /healthz, but say so here rather than round-tripping:
117
+ // the reason is specific and worth stating where the user typed it.
118
+ if (/^\/?healthz\/?$/i.test(hc)) {
119
+ error('The runtime intercepts /healthz, so a probe against it never reaches your container —\nit would report healthy no matter what your code does. Use /livez, or any other path.');
120
+ }
121
+ if (!hc.startsWith('/'))
122
+ error(`--health-check must be a path starting with "/" — got "${hc}".`);
123
+ payload.health_check = hc;
124
+ }
109
125
  if (typeof flags.env === 'string') {
110
126
  const env = _parseEnv(flags.env);
111
127
  if (typeof env === 'string')
@@ -198,7 +214,7 @@ async function tarDirectory(dir) {
198
214
  return res.stdout;
199
215
  }
200
216
  // deploy ships either a pre-built image (--image / positional ref, synchronous)
201
- // or a source build context (--source <dir-or-tar>, asynchronous Cloud Build).
217
+ // or a source build context (--source <dir-or-tar>, built server-side, async).
202
218
  // The scoped API key is rotated on every (image) deploy and shown once.
203
219
  export async function deploy(id, image, flags) {
204
220
  const config = requireConfig();
@@ -242,7 +258,7 @@ export async function deploy(id, image, flags) {
242
258
  }
243
259
  // Async: poll the container until it leaves the building state.
244
260
  if (start.status === 'building') {
245
- info(`Build started (revision ${start.revision_id}). Building from source via Cloud Build…`);
261
+ info(`Build started (revision ${start.revision_id}). Building from source typically ~4 minutes…`);
246
262
  const final = await pollJob({
247
263
  label: 'Building & deploying',
248
264
  check: () => sdkContainer.getContainer(config.api_key, orgId, id),
@@ -274,11 +290,30 @@ export async function deploy(id, image, flags) {
274
290
  // ── Pre-built image path (sync) ─────────────────────────────────────────
275
291
  if (!image)
276
292
  error('Missing image ref.\nUsage: myapi container deploy <id> <image-ref>\n or: myapi container deploy <id> --source <dir|tar>\n\n→ <image-ref> is a pre-built container image (e.g. a registry path).');
277
- const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image);
293
+ const opts = {};
294
+ if (flags['no-promote'] === true)
295
+ opts.promote = false;
296
+ if (typeof flags.smoke === 'string')
297
+ opts.smoke = _parseSmoke(flags.smoke);
298
+ if (opts.promote === false && opts.smoke) {
299
+ error('--smoke already withholds traffic until the check passes, then promotes.\nUse one or the other: --smoke to verify-and-promote, --no-promote to hold the revision back.');
300
+ }
301
+ const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image, opts);
278
302
  if (flags.json) {
279
303
  printJson(result);
280
304
  return;
281
305
  }
306
+ // An unpromoted revision must NOT read like a completed deploy. A response
307
+ // that looked the same either way is how an agent concludes it has shipped
308
+ // when it has not — the original outage in miniature.
309
+ if (result.promoted === false) {
310
+ success(`Revision ${result.revision_id} built — NOT serving traffic`);
311
+ info(`Test it: ${result.revision_url ?? '(no revision URL returned)'}`);
312
+ info(`Still live: ${result.url || '(previous revision)'}`);
313
+ info('');
314
+ banner(`When it looks right: myapi container promote ${id} ${result.revision_id}`);
315
+ return;
316
+ }
282
317
  success(`Deployed container ${id} (revision ${result.revision_id})`);
283
318
  info(`Status: ${result.status}`);
284
319
  info(`URL: ${result.url}`);
@@ -286,6 +321,113 @@ export async function deploy(id, image, flags) {
286
321
  info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
287
322
  info(` ${result.scoped_api_key}`);
288
323
  }
324
+ // Parses `--smoke 'GET / contains assets/'` into the API's structured check.
325
+ //
326
+ // The shape is the one the reporting user proposed, because it reads like a
327
+ // sentence under incident pressure. Grammar:
328
+ //
329
+ // [METHOD] [PATH] [status N] [contains TEXT]
330
+ //
331
+ // Everything is optional except that something must be asserted — a smoke
332
+ // check that asserts nothing would pass on the broken build it exists to
333
+ // catch. Returns the object or calls error().
334
+ export function _parseSmoke(raw) {
335
+ const out = {};
336
+ let rest = raw.trim();
337
+ const m = /^(GET|HEAD)\s+/i.exec(rest);
338
+ if (m) {
339
+ out.method = m[1].toUpperCase();
340
+ rest = rest.slice(m[0].length);
341
+ }
342
+ const p = /^(\/\S*)\s*/.exec(rest);
343
+ if (p) {
344
+ out.path = p[1];
345
+ rest = rest.slice(p[0].length);
346
+ }
347
+ const st = /\bstatus\s+(\d{3})\b/i.exec(rest);
348
+ if (st) {
349
+ out.status = Number(st[1]);
350
+ rest = rest.replace(st[0], ' ');
351
+ }
352
+ const c = /\bcontains\s+(.+)$/i.exec(rest.trim());
353
+ if (c)
354
+ out.contains = c[1].trim().replace(/^['"]|['"]$/g, '');
355
+ if (out.status === undefined && out.contains === undefined) {
356
+ error(`--smoke "${raw}" asserts nothing.\n\n` +
357
+ 'Add `contains <text>` or `status <code>`. A check that only requests a path\n' +
358
+ 'passes on any response, including the placeholder page this flag exists to catch.\n\n' +
359
+ "Example: --smoke 'GET / contains assets/'");
360
+ }
361
+ return out;
362
+ }
363
+ // revisions lists what the runtime actually has, newest first.
364
+ export async function revisions(id, flags) {
365
+ const config = requireConfig();
366
+ const orgId = requireOrg(flags, config, 'myapi container revisions <id> [--org <id>]');
367
+ if (!id)
368
+ error('Missing id.\nUsage: myapi container revisions <id>');
369
+ const [revs, container] = await Promise.all([
370
+ sdkContainer.listRevisions(config.api_key, orgId, id),
371
+ sdkContainer.getContainer(config.api_key, orgId, id).catch(() => undefined),
372
+ ]);
373
+ if (flags.json) {
374
+ printJson(revs);
375
+ return;
376
+ }
377
+ printTable(revs.map(r => ({
378
+ revision: r.revision,
379
+ ready: r.ready ? '✓' : '',
380
+ traffic: `${r.traffic_percent}%`,
381
+ serving: r.serving ? '✓' : '',
382
+ created: r.created_at ? formatDate(r.created_at) : '',
383
+ })), { flags, empty: 'No revisions — this container has never been deployed.' });
384
+ // Verified 2026-07-28 across every service container in two orgs: the API
385
+ // reports 0% traffic on every revision while the containers demonstrably
386
+ // serve. The backend's own note explains it — Traffic was never set on the
387
+ // service, so the runtime applies an implicit "latest takes 100%" that this
388
+ // endpoint cannot see. Containers deployed before that fix are all in this
389
+ // state.
390
+ //
391
+ // Say so rather than render a table that reads as "nothing is live".
392
+ if (revs.length > 0 && revs.every(r => !r.serving && !r.traffic_percent) && container?.status === 'active') {
393
+ info('');
394
+ info('Note: every revision reports 0% traffic while this container is active and serving.');
395
+ info('That is a known reporting bug for containers deployed before 2026-07-28, not an outage.');
396
+ info('Rollback depends on this data, so it is unreliable here until the container is redeployed.');
397
+ }
398
+ }
399
+ // promote moves all traffic to one revision. Omitting the revision rolls back
400
+ // to the previous ready one — the API models promote and rollback as a single
401
+ // operation with a different target.
402
+ export async function promote(id, revision, flags) {
403
+ const config = requireConfig();
404
+ const orgId = requireOrg(flags, config, 'myapi container promote <id> <revision> [--org <id>]');
405
+ if (!id)
406
+ error('Missing id.\nUsage: myapi container promote <id> <revision>');
407
+ if (!revision) {
408
+ error('Missing revision.\nUsage: myapi container promote <id> <revision>\n\n' +
409
+ 'List them with: myapi container revisions ' + id);
410
+ }
411
+ const res = await sdkContainer.promoteRevision(config.api_key, orgId, id, revision);
412
+ if (flags.json) {
413
+ printJson(res);
414
+ return;
415
+ }
416
+ success(`Traffic moved to ${revision}`);
417
+ if (res.message)
418
+ info(res.message);
419
+ }
420
+ // Not a command — guidance for a word that is not one. See the dispatch note.
421
+ function rollbackGuidance(id) {
422
+ const ref = id || '<id>';
423
+ error('There is no `rollback` subcommand — rolling back is promoting an older revision.\n\n' +
424
+ ` myapi container revisions ${ref} # find the last good revision\n` +
425
+ ` myapi container promote ${ref} <revision> # traffic moves in seconds\n\n` +
426
+ 'Heads up: the platform currently reports 0% traffic on every revision of any\n' +
427
+ 'container deployed before 2026-07-28, and its own roll-back-to-previous path\n' +
428
+ 'returns a gateway error. Promoting a revision BY NAME works and is unaffected —\n' +
429
+ 'use that. Reported upstream.');
430
+ }
289
431
  // logs prints the container's recent runtime logs, newest first. By default
290
432
  // this is the container's own stdout/stderr; --scope all adds the platform
291
433
  // audit records that share the stream.
@@ -389,13 +531,38 @@ Two ways to deploy:
389
531
  image Ship a pre-built image (positional ref or --image). Synchronous;
390
532
  the scoped API key is rotated and printed once.
391
533
  source Upload a build context with --source <dir> (tarred locally) or a
392
- pre-built --source <archive.tar.gz>. MyAPI builds it via Cloud
393
- Build, then deploys. Asynchronous the CLI polls until it's live.
534
+ pre-built --source <archive.tar.gz>. The context is
535
+ built server-side (typically ~4 minutes), then deployed.
536
+ Asynchronous — the CLI polls until it's live.
537
+
538
+ Options:
539
+ --no-promote Build the revision without giving it traffic. Test it at
540
+ the returned URL, then: myapi container promote <id> <rev>
541
+ --smoke '<assertion>' Deploy, assert against the new revision, and promote it
542
+ ONLY if the assertion holds. A failure leaves the
543
+ previous revision serving.
544
+
545
+ Grammar: [GET|HEAD] [/path] [status N] [contains TEXT]
546
+ Assert on content, not just status — "returns 200" is
547
+ true of a placeholder page too.
394
548
 
395
549
  Examples:
396
550
  myapi container deploy <id> registry.example.com/my-app:v2
397
551
  myapi container deploy <id> --source ./my-app
398
- myapi container deploy <id> --source ./context.tar.gz`,
552
+ myapi container deploy <id> --source ./context.tar.gz
553
+ myapi container deploy <id> <image> --no-promote
554
+ myapi container deploy <id> <image> --smoke 'GET / contains assets/'`,
555
+ 'revisions': `myapi container revisions <id> [--org <id>] [--json]
556
+
557
+ Every revision the runtime currently holds, newest first, with the traffic
558
+ each takes. Read from the runtime rather than our records, so a revision
559
+ missing here genuinely no longer exists and cannot be promoted.`,
560
+ 'promote': `myapi container promote <id> <revision> [--org <id>] [--json]
561
+
562
+ Move all traffic to one revision. A traffic shift only — no build, no new
563
+ revision — so it completes in seconds.
564
+
565
+ List candidates with: myapi container revisions <id>`,
399
566
  'list': 'myapi container list [--org <id>] [--json]',
400
567
  'get': 'myapi container get <id> [--org <id>] [--json]',
401
568
  'logs': `myapi container logs <id> [--tail <n>] [--scope all] [--org <id>] [--json]
@@ -409,9 +576,9 @@ same stream and share the --tail budget, so raise --tail when you use it.`,
409
576
  'domain': `myapi container domain <id> <domain> [--org <id>] [--json]
410
577
  myapi container domain <id> --remove [--org <id>]
411
578
 
412
- Binds a custom domain to a deployed container, served over HTTPS via
413
- Cloudflare. The domain's MyAPI-managed parent domain must already be
414
- registered. --remove unbinds it.
579
+ Binds a custom domain to a deployed container, served over HTTPS. The
580
+ domain's MyAPI-managed parent domain must already be registered.
581
+ --remove unbinds it.
415
582
 
416
583
  Example:
417
584
  myapi container domain <id> app.synthesisdaily.com`,
@@ -428,12 +595,16 @@ dependencies and long execution.
428
595
  Subcommands:
429
596
  build-logs <id> Why the last --source build failed (--tail N; default 100)
430
597
  create Register a container and get its scoped API key (returned once)
598
+ (--health-check /livez to probe with HTTP, not a bare TCP connect)
431
599
  delete <id> Soft-delete and revoke its scoped API key
432
600
  deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
601
+ (--no-promote to hold it back; --smoke to verify before promoting)
433
602
  domain <id> <domain> Bind a custom domain (--remove to unbind)
434
603
  get <id> Inspect a container
435
604
  list List containers in your org
436
605
  logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
606
+ promote <id> <rev> Move all traffic to a revision (seconds, no rebuild)
607
+ revisions <id> List revisions and the traffic each takes
437
608
 
438
609
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
439
610
  return;
@@ -453,8 +624,16 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
453
624
  case 'list': return list(flags);
454
625
  case 'get': return get(args[0], flags);
455
626
  case 'logs': return logs(args[0], flags);
627
+ case 'revisions': return revisions(args[0], flags);
628
+ case 'promote': return promote(args[0], args[1], flags);
456
629
  case 'domain': return domain(args[0], args[1], flags);
457
630
  case 'delete': return del(args[0], flags);
631
+ // `rollback` is the word people reach for during an incident, and it is
632
+ // NOT a verb here — the API models rollback as `promote` with no revision.
633
+ // A bare "unknown subcommand" would cost minutes at the worst possible
634
+ // moment, so say what to do instead, and be honest that the underlying
635
+ // call is currently broken rather than let someone discover that live.
636
+ case 'rollback': return rollbackGuidance(args[0]);
458
637
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
459
638
  }
460
639
  }
@@ -3,7 +3,7 @@ import { crm } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../../config.js';
4
4
  import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
- import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
6
+ import { pageLine } from './pagination.js';
7
7
  export const EXPOSES = [
8
8
  'POST /crm/orgs/{org_id}/companies',
9
9
  'POST /crm/orgs/{org_id}/companies/promote',
@@ -36,6 +36,7 @@ function buildSearchFilter(flags) {
36
36
  domain: typeof flags.domain === 'string' ? flags.domain : undefined,
37
37
  include_deleted: flags['include-deleted'] === true || undefined,
38
38
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
39
+ offset: typeof flags.offset === 'number' ? flags.offset : undefined,
39
40
  };
40
41
  }
41
42
  function renderCompanies(res, flags) {
@@ -43,8 +44,7 @@ function renderCompanies(res, flags) {
43
44
  printJson(res);
44
45
  return;
45
46
  }
46
- // NOT `N of res.total` total is the page size, not the match count.
47
- info(countLine(res.companies.length, 'company', 'companies'));
47
+ info(pageLine(res.companies.length, res.total, res.has_more, 'company', 'companies'));
48
48
  printTable(res.companies.map(c => ({
49
49
  id: c.id,
50
50
  domain: c.domain ?? '',
@@ -58,22 +58,17 @@ function renderCompanies(res, flags) {
58
58
  async function search(flags) {
59
59
  const config = requireConfig();
60
60
  const orgId = requireOrg(flags, config, 'myapi crm companies search [filters...] [--org <id>]');
61
- rejectOffset(flags, 'myapi crm companies search [filters...] [--limit N] [--org <id>]');
62
61
  const res = await crm.searchCompanies(config.api_key, orgId, buildSearchFilter(flags));
63
62
  renderCompanies(res, flags);
64
- if (!flags.json)
65
- warnIfTruncated(res.companies.length, flags);
66
63
  }
67
64
  async function list(flags) {
68
65
  const config = requireConfig();
69
66
  const orgId = requireOrg(flags, config, 'myapi crm companies list [--limit N] [--org <id>]');
70
- rejectOffset(flags, 'myapi crm companies list [--limit N] [--org <id>]');
71
67
  const res = await crm.searchCompanies(config.api_key, orgId, {
72
68
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
69
+ offset: typeof flags.offset === 'number' ? flags.offset : undefined,
73
70
  });
74
71
  renderCompanies(res, flags);
75
- if (!flags.json)
76
- warnIfTruncated(res.companies.length, flags);
77
72
  }
78
73
  async function create(domainArg, flags) {
79
74
  const config = requireConfig();
@@ -148,9 +143,9 @@ async function promote(domain, flags) {
148
143
  }
149
144
  // ── Dispatcher ──────────────────────────────────────────────────────────
150
145
  const SUBCOMMAND_USAGE = {
151
- list: 'myapi crm companies list [--limit N] [--org <id>] [--json]',
146
+ list: 'myapi crm companies list [--limit N] [--offset N] [--org <id>] [--json]',
152
147
  search: `myapi crm companies search [--stage <csv>] [--source <csv>] [--domain <d>]
153
- [--include-deleted] [--limit N] [--org <id>] [--json]
148
+ [--include-deleted] [--limit N] [--offset N] [--org <id>] [--json]
154
149
 
155
150
  --stage cold, warm, qualified, customer, churned
156
151
  --source goldfox, email, pixel, webhook, manual`,
@@ -3,7 +3,7 @@ import { crm } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../../config.js';
4
4
  import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
- import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
6
+ import { pageLine } from './pagination.js';
7
7
  export const EXPOSES = [
8
8
  'POST /crm/orgs/{org_id}/contacts',
9
9
  'POST /crm/orgs/{org_id}/contacts/promote',
@@ -42,6 +42,7 @@ function buildSearchFilter(flags) {
42
42
  max_last_engagement_days: typeof flags['max-last-engagement-days'] === 'number' ? flags['max-last-engagement-days'] : undefined,
43
43
  include_deleted: flags['include-deleted'] === true || undefined,
44
44
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
45
+ offset: typeof flags.offset === 'number' ? flags.offset : undefined,
45
46
  };
46
47
  }
47
48
  function renderContacts(res, flags) {
@@ -49,8 +50,7 @@ function renderContacts(res, flags) {
49
50
  printJson(res);
50
51
  return;
51
52
  }
52
- // NOT `N of res.total` total is the page size, not the match count.
53
- info(countLine(res.contacts.length, 'contact', 'contacts'));
53
+ info(pageLine(res.contacts.length, res.total, res.has_more, 'contact', 'contacts'));
54
54
  printTable(res.contacts.map(c => ({
55
55
  id: c.id,
56
56
  email: c.email ?? '',
@@ -65,23 +65,18 @@ function renderContacts(res, flags) {
65
65
  async function search(flags) {
66
66
  const config = requireConfig();
67
67
  const orgId = requireOrg(flags, config, 'myapi crm contacts search [filters...] [--org <id>]');
68
- rejectOffset(flags, 'myapi crm contacts search [filters...] [--limit N] [--org <id>]');
69
68
  const res = await crm.searchContacts(config.api_key, orgId, buildSearchFilter(flags));
70
69
  renderContacts(res, flags);
71
- if (!flags.json)
72
- warnIfTruncated(res.contacts.length, flags);
73
70
  }
74
71
  // `list` is `search` with no filters — separate verb for discoverability.
75
72
  async function list(flags) {
76
73
  const config = requireConfig();
77
74
  const orgId = requireOrg(flags, config, 'myapi crm contacts list [--limit N] [--org <id>]');
78
- rejectOffset(flags, 'myapi crm contacts list [--limit N] [--org <id>]');
79
75
  const res = await crm.searchContacts(config.api_key, orgId, {
80
76
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
77
+ offset: typeof flags.offset === 'number' ? flags.offset : undefined,
81
78
  });
82
79
  renderContacts(res, flags);
83
- if (!flags.json)
84
- warnIfTruncated(res.contacts.length, flags);
85
80
  }
86
81
  async function create(emailArg, flags) {
87
82
  const config = requireConfig();
@@ -183,10 +178,10 @@ async function events(id, flags) {
183
178
  }
184
179
  // ── Dispatcher ──────────────────────────────────────────────────────────
185
180
  const SUBCOMMAND_USAGE = {
186
- list: 'myapi crm contacts list [--limit N] [--org <id>] [--json]',
181
+ list: 'myapi crm contacts list [--limit N] [--offset N] [--org <id>] [--json]',
187
182
  search: `myapi crm contacts search [--stage <csv>] [--source <csv>] [--email <e>]
188
183
  [--company-id <id>] [--min-last-engagement-days N] [--max-last-engagement-days N]
189
- [--include-deleted] [--limit N] [--org <id>] [--json]
184
+ [--include-deleted] [--limit N] [--offset N] [--org <id>] [--json]
190
185
 
191
186
  --stage cold, warm, qualified, customer, churned
192
187
  --source goldfox, email, pixel, webhook, manual
@@ -1,6 +1,3 @@
1
- import { type Flags } from '../../helpers.js';
2
1
  import type { Exposes } from '../../exposes.js';
3
2
  export declare const EXPOSES: Exposes;
4
- export declare function rejectOffset(flags: Flags, usage: string): void;
5
- export declare function warnIfTruncated(returned: number, flags: Flags): void;
6
- export declare function countLine(returned: number, singular: string, plural: string): string;
3
+ export declare function pageLine(returned: number, total: number | undefined, hasMore: boolean | undefined, singular: string, plural: string): string;
@@ -1,60 +1,32 @@
1
- // CRM search/list pagination — what the API actually does, verified against
2
- // production on 2026-07-27 (build c8b30009).
1
+ // CRM search/list pagination.
3
2
  //
4
- // Two facts, both discovered by lint-request-fields.js flagging that the SDK
5
- // sends an `offset` the OpenAPI schema does not declare:
3
+ // HISTORY, because it is short and the lesson outlived the bug. For one day
4
+ // this file did the opposite of what it does now: it REFUSED `--offset`,
5
+ // because the API accepted the parameter and silently ignored it, and `total`
6
+ // was `len(rows)` rather than the match count. An agent asking for 100 of 500
7
+ // contacts received 100 records and a `total` of 100 — a complete-looking
8
+ // answer that was short by 400, with nothing in the response to say so.
6
9
  //
7
- // 1. `offset` is ACCEPTED AND IGNORED. Against an org with three contacts,
8
- // {limit:1, offset:0}, {limit:1, offset:1} and {limit:1, offset:2} all
9
- // return the same first contact. This holds for contacts and companies,
10
- // on both the POST /search and GET /contacts paths. There is no cursor
11
- // parameter either.
10
+ // Fixed backend-side on 2026-07-28 and re-verified here against an org with
11
+ // three contacts: offsets 0/1/2 at limit 1 return three different records,
12
+ // `total` reads 3 throughout, and `has_more` goes true, true, false.
12
13
  //
13
- // 2. `total` is the SIZE OF THE PAGE, not the number of matches. With three
14
- // contacts in the org: limit=1 total=1, limit=2 total=2, limit=3
15
- // total=3, limit=10 total=3.
16
- //
17
- // Together those make the result set silently lossy. An agent with 500
18
- // contacts running `--limit 100` receives 100 records and a `total` of 100,
19
- // and has no signal that 400 more exist. This is the worst shape a data bug
20
- // can take for an unattended caller: it looks like a complete answer.
21
- //
22
- // Filed upstream. Until it is fixed the CLI refuses `--offset` rather than
23
- // sending a parameter that does nothing, and never prints `total` as though
24
- // it were a match count.
25
- import { error, info } from '../../output.js';
14
+ // So the guard is gone and this file is now just honest rendering. Prefer
15
+ // `has_more` over arithmetic against `total`: it stays correct even if the
16
+ // meaning of `total` moves again, which is exactly what went wrong before.
26
17
  // A helper, not a command: it calls nothing. Declared empty rather than
27
18
  // exempted, so the coverage gate's "every module states its surface" rule
28
19
  // stays absolute.
29
20
  export const EXPOSES = [];
30
- // Server-side default page size when no --limit is given. The API applies
31
- // this; we only need it to know whether a result may have been truncated.
32
- const DEFAULT_LIMIT = 50;
33
- // Rejects --offset outright. Silently dropping it would reproduce the bug we
34
- // are protecting against, and passing it through means duplicate pages.
35
- export function rejectOffset(flags, usage) {
36
- if (flags.offset === undefined)
37
- return;
38
- error('The CRM API accepts --offset and ignores it, so every page would return the same records.\n' +
39
- 'Verified against production: with --limit 1, offsets 0, 1 and 2 all return the first match.\n\n' +
40
- 'There is no cursor parameter either, so the CRM has no working pagination today.\n' +
41
- 'Raise --limit to fetch more in one call, and narrow the result set with filters\n' +
42
- '(--stage, --source, --company-id, --email) rather than paging through it.\n\n' +
43
- `Usage: ${usage}`);
44
- }
45
- // A page that came back exactly full is indistinguishable from a truncated
46
- // one, and `total` cannot tell them apart. Say so, rather than letting the
47
- // caller assume the answer is complete.
48
- export function warnIfTruncated(returned, flags) {
49
- const limit = typeof flags.limit === 'number' ? flags.limit : DEFAULT_LIMIT;
50
- if (returned < limit)
51
- return;
52
- info(`Note: exactly ${returned} record${returned === 1 ? '' : 's'} came back, which is the page limit. ` +
53
- 'There may be more, and the API reports no match count and offers no way to fetch the next page. ' +
54
- 'Raise --limit or add filters.');
55
- }
56
- // The API returns `total`, but it equals the page size, so rendering "N of
57
- // total" states something false. Callers use this instead.
58
- export function countLine(returned, singular, plural) {
59
- return `${returned} ${returned === 1 ? singular : plural}`;
21
+ // "3 contacts" · "3 of 128 contacts" · "3 of 128 contacts (more available)".
22
+ //
23
+ // `total` is only rendered when the API supplied it, and the more-available
24
+ // note comes from `has_more` rather than from comparing lengths a comparison
25
+ // is what silently agreed with a wrong `total` before.
26
+ export function pageLine(returned, total, hasMore, singular, plural) {
27
+ const noun = returned === 1 ? singular : plural;
28
+ const head = (typeof total === 'number' && total !== returned)
29
+ ? `${returned} of ${total} ${total === 1 ? singular : plural}`
30
+ : `${returned} ${noun}`;
31
+ return hasMore ? `${head} (more available raise --limit or pass --offset)` : head;
60
32
  }
@@ -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
  });
@@ -272,7 +272,7 @@ async function fetchStatus(url) {
272
272
  async function httpProbeSection(apiKey, orgId) {
273
273
  const targets = [];
274
274
  // Containers: probe the bound custom domain when set (what customers hit),
275
- // else the Cloud Run URL. An undeployed container has neither — skip it.
275
+ // else the runtime-assigned URL. An undeployed container has neither.
276
276
  try {
277
277
  const containers = await sdkContainer.listContainers(apiKey, orgId);
278
278
  for (const c of containers) {
@@ -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
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
+ });
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
6
6
  triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
7
- checksum: sha256-d25fff35df7bf67bebabb6cc228f322b108d8472035cbda70756d720b8db502d
7
+ checksum: sha256-4847ab675fa971bbd0fdd83f0039d9110ba4c38e406860fe9265dd540b7f299e
8
8
  ---
9
9
 
10
10
  # MyContainerAPI
@@ -19,6 +19,23 @@ The lifecycle is **create → deploy → (optionally) bind a custom domain**.
19
19
  - `deploy` ships a pre-built image reference to the runtime and makes the container live at a generated URL.
20
20
  - `domain` puts the container on a **custom domain** — how you serve a dynamic app at `app.yourbrand.com`.
21
21
 
22
+ ### Deploying safely
23
+
24
+ A deploy takes 100% of traffic the moment it lands, so a broken build is live
25
+ before you can look at it. Two ways to avoid that:
26
+
27
+ - `--smoke 'GET / contains assets/'` — the platform deploys the revision with
28
+ NO traffic, runs the assertion, and promotes only if it holds. A failure
29
+ leaves the previous revision serving. **Assert on content, not status**:
30
+ "returns 200" is true of a placeholder page too.
31
+ - `--no-promote` — build the revision and hold it back. You get a URL to
32
+ exercise it, then `myapi container promote <id> <revision>`.
33
+
34
+ `--health-check /livez` at create time makes the startup probe an HTTP request
35
+ instead of a bare TCP connect. `/healthz` is refused: the runtime intercepts
36
+ it, so the probe would never reach your container and would report success
37
+ regardless.
38
+
22
39
  ### Custom domains (dynamic apps)
23
40
 
24
41
  `myapi container domain <id> <domain>` binds a custom domain to a **deployed** container, served over HTTPS automatically. This is the path for a dynamic backend on a real domain — distinct from `my-funnel-api`, which serves static sites.
@@ -36,7 +53,9 @@ Get it right:
36
53
  | Command | What it does |
37
54
  |---|---|
38
55
  | `myapi container create --name <name> [--type service\|worker\|job] [--cron <expr>] [--cpu <n>] [--memory <size>] [--port <n>] [--env K=V,...]` | Register a container, get its scoped API key (once) |
39
- | `myapi container deploy <id> <image-ref>` | Ship a pre-built image and go live (rotates the scoped key) |
56
+ | `myapi container deploy <id> <image-ref> [--no-promote] [--smoke '<assertion>']` | Ship a pre-built image (rotates the scoped key) |
57
+ | `myapi container revisions <id>` | List revisions and the traffic each takes |
58
+ | `myapi container promote <id> <revision>` | Move all traffic to a revision (seconds, no rebuild) |
40
59
  | `myapi container list` | List containers in your org |
41
60
  | `myapi container get <id>` | Inspect a container (status, URL, custom domain) |
42
61
  | `myapi container logs <id> [--tail <n>] [--scope all]` | Recent runtime logs, newest first (`--scope all` adds platform audit records) |
@@ -47,13 +66,26 @@ Get it right:
47
66
  ## Examples
48
67
  <!-- llm:start -->
49
68
  ```bash
50
- # 1. Register a service container
51
- myapi container create --name api --type service --port 8080
69
+ # 1. Register a service container. --health-check makes the startup probe an
70
+ # HTTP request instead of a bare TCP connect.
71
+ myapi container create --name api --type service --port 8080 --health-check /livez
52
72
  # → prints a scoped API key ONCE — save it if your code needs it
53
73
 
54
- # 2. Deploy a pre-built image
74
+ # 2a. Deploy. Plain form takes 100% of traffic immediately.
55
75
  myapi container deploy <id> registry.example.com/my-app:v1
56
76
 
77
+ # 2b. SAFER: assert before any traffic moves. The revision is deployed with no
78
+ # traffic, checked, and promoted only if the check holds. Assert on
79
+ # CONTENT — a broken build still returns 200.
80
+ myapi container deploy <id> registry.example.com/my-app:v1 \
81
+ --smoke 'GET / contains assets/'
82
+
83
+ # 2c. Or hold it back and look yourself.
84
+ myapi container deploy <id> registry.example.com/my-app:v1 --no-promote
85
+ # → prints a revision URL serving 0% of traffic
86
+ myapi container revisions <id>
87
+ myapi container promote <id> <revision>
88
+
57
89
  # 3. Serve it on a custom domain. The parent domain must already be
58
90
  # registered: myapi domain register synthesisdaily.com
59
91
  myapi container domain <id> app.synthesisdaily.com
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  The canonical store of engaged contacts + companies for an org. Auto-ingests from inbound webhooks via a configurable dot-path. Fixed lifecycle_stage enum (cold | warm | qualified | customer | churned). Append-only event timeline with reserved kinds. Soft delete + restore. Promote-from-Goldfox closes the discovery → engagement loop.
6
6
  triggers: [crm, contact, company, lead, engagement, pipeline, lifecycle, qualified, customer, webhook ingest, promote]
7
- checksum: sha256-085cb990a292ba815d7c59a58fb25816d52bf33ad5019a7d90abf4123383a67c
7
+ checksum: sha256-50cbdd28ed2a901b7a75f1a6c1df1c225d256a8d281ad86cf1e3b42511edc2fe
8
8
  ---
9
9
 
10
10
  # MyCRMAPI
@@ -91,7 +91,7 @@ A contact promoted from Goldfox carries a `goldfox_person_id`. In v2 the GET res
91
91
  ### Contacts
92
92
  | Command | What it does |
93
93
  |---|---|
94
- | `myapi crm contacts list [--limit N]` | List all contacts (newest engagement first) |
94
+ | `myapi crm contacts list [--limit N] [--offset N]` | List all contacts (newest engagement first) |
95
95
  | `myapi crm contacts search [--stage ...] [--source ...] [--email ...] [--min/max-last-engagement-days N]` | Filter contacts |
96
96
  | `myapi crm contacts create <email> [--first-name ...] [--last-name ...] [--stage ...] [--custom-json ...]` | Manually create (source='manual') |
97
97
  | `myapi crm contacts get <id>` | Fetch one contact (with embedded Goldfox enrichment when available) |
@@ -158,11 +158,10 @@ myapi crm contacts events <id> --kind webhook_received
158
158
 
159
159
  ## Notes
160
160
 
161
- - **No pagination. Do not build a paging loop.** The API ignores `offset`
162
- (every page repeats page one), there is no cursor, and `total` is the page
163
- size, not the match count. The CLI rejects `--offset` rather than lie. If
164
- exactly `--limit` rows come back, assume more exist: raise `--limit` or
165
- narrow with filters.
161
+ - **Paginate with `--limit` + `--offset`.** `total` is the true match count
162
+ and the response carries `has_more`; branch on `has_more` rather than doing
163
+ arithmetic against `total`. (Both were broken until 2026-07-28. Cached
164
+ guidance saying the CRM cannot paginate is stale.)
166
165
  - **Reserved event kinds — no custom events in v1.** If an agent needs custom state per contact, use `myapi database` keyed by contact id. The curated timeline stays the authoritative engagement record.
167
166
  - **`external_id` on an event payload** is the backend's idempotency key — a duplicate of the action's natural id (`goldfox_person_id`, `delivery_id`). Read the semantic field instead; legacy `message_id` rows hold the same value.
168
167
  - **Only `webhook_received` fires today.** Email and pixel auto-ingest are coming; the CLI surface will not change.
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Register new domains and manage edge settings. Required before a funnel can go live on a custom URL.
6
6
  triggers: [domain, register domain, dns, custom domain, edge, cdn, security level, browser check, renew, namecheap]
7
- checksum: sha256-067ab2cc412c1f1f34aaeeec5890f9eb77f680f5b2d1c847e376336702cd8344
7
+ checksum: sha256-8f96d19c11c3591af71e9d73b2cea6d83f40abbfa1c58c5674dbe15db02efa3c
8
8
  ---
9
9
 
10
10
  # MyDomainAPI
@@ -88,7 +88,7 @@ myapi domain update-settings example.com \
88
88
  myapi domain import example.com
89
89
  # → Returns nameservers; set them at your current registrar.
90
90
  myapi domain status example.com --watch
91
- # → Polls until active. Backend live-checks Cloudflare each poll.
91
+ # → Polls until active. Backend live-checks the DNS provider each poll.
92
92
 
93
93
  # Fix a record after import (e.g. clean up SPF)
94
94
  myapi domain records list example.com --type TXT
@@ -2,26 +2,26 @@
2
2
  name: my-function-api
3
3
  version: 1.0.0
4
4
  description: >
5
- Deploy JavaScript functions to the MyAPI edge runtime (Cloudflare Workers). Register a function, upload a single-file JS bundle, get a live HTTP invocation URL or run it on a cron schedule. Each function gets a scoped capability key for cross-slot calls.
5
+ Deploy JavaScript functions to the MyAPI edge runtime. Register a function, upload a single-file JS bundle, get a live HTTP invocation URL or run it on a cron schedule. Each function gets a scoped capability key for cross-slot calls.
6
6
  triggers: [function, deploy function, edge function, serverless, cloudflare worker, cron, scoped api key, capability key, invocation url, bundle]
7
- checksum: sha256-ada0a52fdd9c0ba14ca86496e1c5c42e8b018155c3e8c83edba071fa6bd0b7e9
7
+ checksum: sha256-610e16c931e44d43ee6ac94a32e6d852bb900cdf6f5eea61aaeae7bb95a0814e
8
8
  ---
9
9
 
10
10
  # MyFunctionAPI
11
11
 
12
- Deploy backend code without running a server. Register a function, upload a single-file JS bundle, and it goes live on the MyAPI edge runtime (Cloudflare Workers) with a public invocation URL — or runs on a cron schedule. Each function carries a scoped capability key so it can call other MyAPI slots with its own authority.
12
+ Deploy backend code without running a server. Register a function, upload a single-file JS bundle, and it goes live on the MyAPI edge runtime with a public invocation URL — or runs on a cron schedule. Each function carries a scoped capability key so it can call other MyAPI slots with its own authority.
13
13
 
14
14
  The full loop is live: **create → deploy → invoke → inspect runs → set secrets**.
15
15
 
16
16
  ## Capabilities
17
17
  <!-- llm:start -->
18
- **Two-step lifecycle: register, then deploy.** `myapi fn create --name <slug>` persists the function record and mints a `scoped_api_key`, returned **exactly once** (save it if you need it). `myapi fn deploy <id> <bundle.js>` uploads a single-file JavaScript bundle (≤4MB); the backend wraps it with the MYAPI shim and ships it to Cloudflare Workers. After deploy the function has a live `invocation_url`.
18
+ **Two-step lifecycle: register, then deploy.** `myapi fn create --name <slug>` persists the function record and mints a `scoped_api_key`, returned **exactly once** (save it if you need it). `myapi fn deploy <id> <bundle.js>` uploads a single-file JavaScript bundle (≤4MB); the backend wraps it with the MYAPI shim and ships it to the edge runtime. Propagation is typically 4-45s, so poll the invocation URL rather than redeploying. After deploy the function has a live `invocation_url`.
19
19
 
20
20
  **Scoped key = capability key.** It is **org-locked**. By default it inherits the deployer's slot grants; narrow it at create time with `--scope <slot>[,<slot>...]` (comma-separated, e.g. `--scope email,storage`) — the primary way to deploy a deliberately narrow function. Grants can never exceed the caller's, so a function never out-reaches the credential that created it. It is rejected with `403 SCOPE_FORBIDDEN` at `/hq/*`, `/admin/*`, `/internal/*`. **Deploy rotates this key** — the fresh value is printed once on every deploy. (Minting a narrow account key first — `myapi keys create --grant ...` — is only needed when the *deploy credential itself* must be constrained, e.g. handing deploy rights to another system.)
21
21
 
22
22
  **HTTP or cron triggers.** Default is `http` — the function gets a public invocation URL once deployed. Pass `--cron "<expr>"` at create time to run on a schedule (e.g. `"0 8 * * *"`) instead.
23
23
 
24
- **Secrets via Worker Secrets.** `myapi fn env <id> <name> <value>` sets a secret (Stripe key, API token, …) as a Cloudflare Worker Secret on a deployed function. The value is encrypted at rest by Cloudflare and never stored or echoed by MyAPI. The function must already be deployed.
24
+ **Secrets are encrypted at rest.** `myapi fn env <id> <name> <value>` sets a secret (Stripe key, API token, …) on a deployed function. The value is encrypted at rest by the edge runtime and never stored or echoed by MyAPI. The function must already be deployed.
25
25
 
26
26
  **Inspect invocations.** `myapi fn runs <id>` lists recent invocation records (most recent first, up to 100) with status, duration, and any error message.
27
27
 
@@ -36,7 +36,7 @@ Name rules (validated client- and server-side, kept identical):
36
36
  |---|---|
37
37
  | `myapi fn create --name <name> [--cron <expr>] [--scope <slot>[,<slot>...]]` | Register a function record + receive scoped API key (returned once). `--scope` narrows the key's slot grants |
38
38
  | `myapi fn deploy <id> <bundle.js>` | Upload a single-file JS bundle (≤4MB) and go live; rotates the scoped key |
39
- | `myapi fn env <id> <name> <value>` | Set a Worker Secret on a deployed function |
39
+ | `myapi fn env <id> <name> <value>` | Set an encrypted secret on a deployed function |
40
40
  | `myapi fn runs <id>` | List recent invocation records (status, duration, errors) |
41
41
  | `myapi fn list` | List functions in your org |
42
42
  | `myapi fn get <id>` | Inspect a function (name, trigger, invocation URL) |
@@ -58,7 +58,7 @@ myapi fn deploy fn_abc123 ./dist/bundle.js
58
58
  # Invocation URL: https://fn-abc123.<...>.workers.dev
59
59
  # Scoped API key was rotated. New value (returned once): hq_live_...
60
60
 
61
- # Set a secret (encrypted at rest by Cloudflare; never echoed)
61
+ # Set a secret (encrypted at rest; never echoed)
62
62
  myapi fn env fn_abc123 STRIPE_KEY sk_live_...
63
63
 
64
64
  # Inspect recent invocations
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.2",
4
+ "version": "2.7.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -35,12 +35,13 @@
35
35
  "lint:help-order": "node scripts/lint-help-order.js",
36
36
  "lint:exposes": "node scripts/lint-exposes.js",
37
37
  "lint:request-fields": "node scripts/lint-request-fields.js",
38
+ "audit:doctor": "npm run build && node scripts/audit-doctor.js",
38
39
  "lint:docs": "node scripts/lint-docs.js",
39
40
  "lint:skills": "node scripts/copy-skills.js && node scripts/lint-skills.js",
40
41
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
41
42
  },
42
43
  "dependencies": {
43
- "@myapihq/sdk": "^2.6.2"
44
+ "@myapihq/sdk": "^2.7.0"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^25.6.0",