@myapihq/cli 2.6.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/container-deploy-safety.test.d.ts +1 -0
- package/dist/commands/container-deploy-safety.test.js +60 -0
- package/dist/commands/container.d.ts +4 -0
- package/dist/commands/container.js +188 -9
- package/dist/commands/crm/companies.js +6 -11
- package/dist/commands/crm/contacts.js +6 -11
- package/dist/commands/crm/pagination.d.ts +1 -4
- package/dist/commands/crm/pagination.js +24 -52
- package/dist/commands/crm/pagination.test.js +34 -70
- package/dist/commands/doctor-findings.test.js +57 -1
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +84 -2
- package/dist/commands/domain.js +6 -6
- package/dist/commands/fn.js +8 -8
- package/dist/commands/funnel.js +58 -0
- package/dist/errors.js +21 -5
- package/dist/sdk-container.test.js +58 -1
- package/dist/skills/my-container-api/SKILL.md +37 -5
- package/dist/skills/my-crm-api/SKILL.md +6 -7
- package/dist/skills/my-domain-api/SKILL.md +2 -2
- package/dist/skills/my-function-api/SKILL.md +7 -7
- package/package.json +3 -2
|
@@ -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>,
|
|
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
|
|
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
|
|
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>.
|
|
393
|
-
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
2
|
-
// production on 2026-07-27 (build c8b30009).
|
|
1
|
+
// CRM search/list pagination.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
export function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
}
|