@myapihq/cli 2.6.2 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,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')
@@ -117,7 +133,7 @@ export async function create(nameArg, flags) {
117
133
  info(`Name: ${result.container.name}`);
118
134
  info(`Type: ${result.container.type}${result.container.cron_schedule ? ` (${result.container.cron_schedule})` : ''}`);
119
135
  info(`Resources: ${result.container.cpu} CPU, ${result.container.memory}, instances ${result.container.min_instances}-${result.container.max_instances}`);
120
- // The scoped key is returned ONCE — it's delivered to the running
136
+ // The scoped API key is returned ONCE — it's delivered to the running
121
137
  // container as the MYAPI_KEY env var. Deploy rotates it.
122
138
  info('');
123
139
  info(`Scoped API key (returned once — save it if you need it):`);
@@ -198,13 +214,43 @@ 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();
205
221
  const orgId = requireOrg(flags, config, 'myapi container deploy <id> <image-ref> | --source <dir|tar> [--org <id>]');
206
222
  if (!id)
207
223
  error('Missing id.\nUsage: myapi container deploy <id> <image-ref> (or --source <dir|tar>)');
224
+ // --no-promote and --smoke are REFUSED, not honoured, as of 2026-07-28.
225
+ //
226
+ // They shipped in 2.7.0 and do not work on either deploy path. A customer
227
+ // found it by testing rather than trusting the flags, which is the outcome
228
+ // this CLI spends most of its error messages trying to prevent:
229
+ //
230
+ // --source: the multipart body accepts only `source`. The flags were
231
+ // silently dropped — accepted by our flag parser, never sent. That was
232
+ // our bug, and it is the exact failure these flags exist to stop.
233
+ //
234
+ // image ref: the API accepts promote/smoke, then the deploy fails with a
235
+ // runtime error setting traffic to the revision it just created. The
236
+ // previous revision keeps serving, so it is safe, but it does not work.
237
+ //
238
+ // Refusing is strictly better than accepting. A missing guard makes people
239
+ // write their own; a guard that silently passes makes them stop. Restore
240
+ // these the moment the upstream fix lands — see
241
+ // docs/cross-repo-prompts/backend-consolidated-2026-07-28.md.
242
+ if (flags['no-promote'] === true || typeof flags.smoke === 'string') {
243
+ const which = flags['no-promote'] === true ? '--no-promote' : '--smoke';
244
+ error(`${which} is not honoured yet, so this CLI refuses it rather than letting you believe a deploy was guarded.\n\n` +
245
+ 'It shipped in 2.7.0 and does not work end to end on either deploy path:\n' +
246
+ ' --source the API accepts only the tarball on that path; the flag never reaches it\n' +
247
+ ' <image-ref> the API accepts the flag, then fails while moving traffic\n\n' +
248
+ 'Until it lands, the safe sequence is:\n' +
249
+ ` 1. deploy to a non-production container first\n` +
250
+ ` 2. check it yourself (curl for a string only a real build emits)\n` +
251
+ ` 3. deploy the same image to production\n\n` +
252
+ 'Reported upstream; this message goes away when the flag works.');
253
+ }
208
254
  const source = typeof flags.source === 'string' ? flags.source : undefined;
209
255
  // --image is an alias for the positional image ref.
210
256
  if (!image && typeof flags.image === 'string')
@@ -242,7 +288,7 @@ export async function deploy(id, image, flags) {
242
288
  }
243
289
  // Async: poll the container until it leaves the building state.
244
290
  if (start.status === 'building') {
245
- info(`Build started (revision ${start.revision_id}). Building from source via Cloud Build…`);
291
+ info(`Build started (revision ${start.revision_id}). Building from source typically ~4 minutes…`);
246
292
  const final = await pollJob({
247
293
  label: 'Building & deploying',
248
294
  check: () => sdkContainer.getContainer(config.api_key, orgId, id),
@@ -274,11 +320,28 @@ export async function deploy(id, image, flags) {
274
320
  // ── Pre-built image path (sync) ─────────────────────────────────────────
275
321
  if (!image)
276
322
  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).');
323
+ // No DeployOptions built here: --no-promote and --smoke are refused above
324
+ // until the upstream fix lands. The SDK still carries them so the wiring is
325
+ // one commit away, and sdk-container.test.ts keeps them covered.
277
326
  const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image);
278
327
  if (flags.json) {
279
328
  printJson(result);
280
329
  return;
281
330
  }
331
+ // An unpromoted revision must NOT read like a completed deploy. A response
332
+ // that looked the same either way is how an agent concludes it has shipped
333
+ // when it has not — the original outage in miniature.
334
+ // Unreachable while the flags are refused above. Kept because it is the
335
+ // render we want the moment they are restored, and deleting it would mean
336
+ // rewriting it from memory later.
337
+ if (result.promoted === false) {
338
+ success(`Revision ${result.revision_id} built — NOT serving traffic`);
339
+ info(`Test it: ${result.revision_url ?? '(no revision URL returned)'}`);
340
+ info(`Still live: ${result.url || '(previous revision)'}`);
341
+ info('');
342
+ banner(`When it looks right: myapi container promote ${id} ${result.revision_id}`);
343
+ return;
344
+ }
282
345
  success(`Deployed container ${id} (revision ${result.revision_id})`);
283
346
  info(`Status: ${result.status}`);
284
347
  info(`URL: ${result.url}`);
@@ -286,6 +349,115 @@ export async function deploy(id, image, flags) {
286
349
  info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
287
350
  info(` ${result.scoped_api_key}`);
288
351
  }
352
+ // Parses `--smoke 'GET / contains assets/'` into the API's structured check.
353
+ //
354
+ // The shape is the one the reporting user proposed, because it reads like a
355
+ // sentence under incident pressure. Grammar:
356
+ //
357
+ // [METHOD] [PATH] [status N] [contains TEXT]
358
+ //
359
+ // Everything is optional except that something must be asserted — a smoke
360
+ // check that asserts nothing would pass on the broken build it exists to
361
+ // catch. Returns the object or calls error().
362
+ export function _parseSmoke(raw) {
363
+ const out = {};
364
+ let rest = raw.trim();
365
+ const m = /^(GET|HEAD)\s+/i.exec(rest);
366
+ if (m) {
367
+ out.method = m[1].toUpperCase();
368
+ rest = rest.slice(m[0].length);
369
+ }
370
+ const p = /^(\/\S*)\s*/.exec(rest);
371
+ if (p) {
372
+ out.path = p[1];
373
+ rest = rest.slice(p[0].length);
374
+ }
375
+ const st = /\bstatus\s+(\d{3})\b/i.exec(rest);
376
+ if (st) {
377
+ out.status = Number(st[1]);
378
+ rest = rest.replace(st[0], ' ');
379
+ }
380
+ const c = /\bcontains\s+(.+)$/i.exec(rest.trim());
381
+ if (c)
382
+ out.contains = c[1].trim().replace(/^['"]|['"]$/g, '');
383
+ if (out.status === undefined && out.contains === undefined) {
384
+ error(`--smoke "${raw}" asserts nothing.\n\n` +
385
+ 'Add `contains <text>` or `status <code>`. A check that only requests a path\n' +
386
+ 'passes on any response, including the placeholder page this flag exists to catch.\n\n' +
387
+ "Example: --smoke 'GET / contains assets/'");
388
+ }
389
+ return out;
390
+ }
391
+ // revisions lists what the runtime actually has, newest first.
392
+ export async function revisions(id, flags) {
393
+ const config = requireConfig();
394
+ const orgId = requireOrg(flags, config, 'myapi container revisions <id> [--org <id>]');
395
+ if (!id)
396
+ error('Missing id.\nUsage: myapi container revisions <id>');
397
+ const [revs, container] = await Promise.all([
398
+ sdkContainer.listRevisions(config.api_key, orgId, id),
399
+ sdkContainer.getContainer(config.api_key, orgId, id).catch(() => undefined),
400
+ ]);
401
+ if (flags.json) {
402
+ printJson(revs);
403
+ return;
404
+ }
405
+ printTable(revs.map(r => ({
406
+ revision: r.revision,
407
+ ready: r.ready ? '✓' : '',
408
+ traffic: `${r.traffic_percent}%`,
409
+ serving: r.serving ? '✓' : '',
410
+ created: r.created_at ? formatDate(r.created_at) : '',
411
+ })), { flags, empty: 'No revisions — this container has never been deployed.' });
412
+ // Verified 2026-07-28 across every service container in two orgs: the API
413
+ // reports 0% traffic on every revision while the containers demonstrably
414
+ // serve. The backend's own note explains it — Traffic was never set on the
415
+ // service, so the runtime applies an implicit "latest takes 100%" that this
416
+ // endpoint cannot see. Containers deployed before that fix are all in this
417
+ // state.
418
+ //
419
+ // Say so rather than render a table that reads as "nothing is live".
420
+ if (revs.length > 0 && revs.every(r => !r.serving && !r.traffic_percent) && container?.status === 'active') {
421
+ info('');
422
+ info('Note: every revision reports 0% traffic while this container is active and serving.');
423
+ info('The traffic column is wrong, not the container. Verified on a container created');
424
+ info('today, so this is not limited to older ones — an earlier version of this message');
425
+ info('said redeploying fixes it, which was wrong.');
426
+ info('promote depends on this data and currently fails. Reported upstream.');
427
+ }
428
+ }
429
+ // promote moves all traffic to one revision. Omitting the revision rolls back
430
+ // to the previous ready one — the API models promote and rollback as a single
431
+ // operation with a different target.
432
+ export async function promote(id, revision, flags) {
433
+ const config = requireConfig();
434
+ const orgId = requireOrg(flags, config, 'myapi container promote <id> <revision> [--org <id>]');
435
+ if (!id)
436
+ error('Missing id.\nUsage: myapi container promote <id> <revision>');
437
+ if (!revision) {
438
+ error('Missing revision.\nUsage: myapi container promote <id> <revision>\n\n' +
439
+ 'List them with: myapi container revisions ' + id);
440
+ }
441
+ const res = await sdkContainer.promoteRevision(config.api_key, orgId, id, revision);
442
+ if (flags.json) {
443
+ printJson(res);
444
+ return;
445
+ }
446
+ success(`Traffic moved to ${revision}`);
447
+ if (res.message)
448
+ info(res.message);
449
+ }
450
+ // Not a command — guidance for a word that is not one. See the dispatch note.
451
+ function rollbackGuidance(id) {
452
+ const ref = id || '<id>';
453
+ error('There is no `rollback` subcommand — rolling back is promoting an older revision.\n\n' +
454
+ ` myapi container revisions ${ref} # find the last good revision\n` +
455
+ ` myapi container promote ${ref} <revision> # traffic moves in seconds\n\n` +
456
+ 'Heads up: the platform currently reports 0% traffic on every revision of any\n' +
457
+ 'container deployed before 2026-07-28, and its own roll-back-to-previous path\n' +
458
+ 'returns a gateway error. Promoting a revision BY NAME works and is unaffected —\n' +
459
+ 'use that. Reported upstream.');
460
+ }
289
461
  // logs prints the container's recent runtime logs, newest first. By default
290
462
  // this is the container's own stdout/stderr; --scope all adds the platform
291
463
  // audit records that share the stream.
@@ -389,13 +561,34 @@ Two ways to deploy:
389
561
  image Ship a pre-built image (positional ref or --image). Synchronous;
390
562
  the scoped API key is rotated and printed once.
391
563
  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.
564
+ pre-built --source <archive.tar.gz>. The context is
565
+ built server-side (typically ~4 minutes), then deployed.
566
+ Asynchronous — the CLI polls until it's live.
567
+
568
+ TEMPORARILY REFUSED: --no-promote and --smoke
569
+
570
+ Both shipped in 2.7.0 and do not work end to end. Rather than accept a flag
571
+ and deploy anyway, the CLI now refuses them and explains what to do instead.
572
+ A guard that silently passes is worse than no guard.
573
+
574
+ Until they land: deploy to a non-production container, verify it yourself,
575
+ then deploy the same image to production.
394
576
 
395
577
  Examples:
396
578
  myapi container deploy <id> registry.example.com/my-app:v2
397
579
  myapi container deploy <id> --source ./my-app
398
580
  myapi container deploy <id> --source ./context.tar.gz`,
581
+ 'revisions': `myapi container revisions <id> [--org <id>] [--json]
582
+
583
+ Every revision the runtime currently holds, newest first, with the traffic
584
+ each takes. Read from the runtime rather than our records, so a revision
585
+ missing here genuinely no longer exists and cannot be promoted.`,
586
+ 'promote': `myapi container promote <id> <revision> [--org <id>] [--json]
587
+
588
+ Move all traffic to one revision. A traffic shift only — no build, no new
589
+ revision — so it completes in seconds.
590
+
591
+ List candidates with: myapi container revisions <id>`,
399
592
  'list': 'myapi container list [--org <id>] [--json]',
400
593
  'get': 'myapi container get <id> [--org <id>] [--json]',
401
594
  'logs': `myapi container logs <id> [--tail <n>] [--scope all] [--org <id>] [--json]
@@ -409,9 +602,9 @@ same stream and share the --tail budget, so raise --tail when you use it.`,
409
602
  'domain': `myapi container domain <id> <domain> [--org <id>] [--json]
410
603
  myapi container domain <id> --remove [--org <id>]
411
604
 
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.
605
+ Binds a custom domain to a deployed container, served over HTTPS. The
606
+ domain's MyAPI-managed parent domain must already be registered.
607
+ --remove unbinds it.
415
608
 
416
609
  Example:
417
610
  myapi container domain <id> app.synthesisdaily.com`,
@@ -434,6 +627,8 @@ Subcommands:
434
627
  get <id> Inspect a container
435
628
  list List containers in your org
436
629
  logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
630
+ promote <id> <rev> Move all traffic to a revision (seconds, no rebuild)
631
+ revisions <id> List revisions and the traffic each takes
437
632
 
438
633
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
439
634
  return;
@@ -453,8 +648,16 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
453
648
  case 'list': return list(flags);
454
649
  case 'get': return get(args[0], flags);
455
650
  case 'logs': return logs(args[0], flags);
651
+ case 'revisions': return revisions(args[0], flags);
652
+ case 'promote': return promote(args[0], args[1], flags);
456
653
  case 'domain': return domain(args[0], args[1], flags);
457
654
  case 'delete': return del(args[0], flags);
655
+ // `rollback` is the word people reach for during an incident, and it is
656
+ // NOT a verb here — the API models rollback as `promote` with no revision.
657
+ // A bare "unknown subcommand" would cost minutes at the worst possible
658
+ // moment, so say what to do instead, and be honest that the underlying
659
+ // call is currently broken rather than let someone discover that live.
660
+ case 'rollback': return rollbackGuidance(args[0]);
458
661
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
459
662
  }
460
663
  }
@@ -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
  }