@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.
- 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 +211 -8
- 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/database.js +21 -14
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/domain.js +6 -6
- package/dist/commands/fn.js +11 -11
- package/dist/errors.js +21 -5
- package/dist/sdk-container.test.js +58 -1
- package/dist/skills/my-container-api/SKILL.md +31 -3
- package/dist/skills/my-crm-api/SKILL.md +6 -7
- package/dist/skills/my-database-api/README.md +1 -1
- package/dist/skills/my-database-api/SKILL.md +4 -4
- package/dist/skills/my-domain-api/SKILL.md +2 -2
- package/dist/skills/my-function-api/SKILL.md +16 -16
- package/dist/skills/my-git-api/SKILL.md +4 -4
- 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')
|
|
@@ -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>,
|
|
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
|
|
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>.
|
|
393
|
-
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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 {
|
|
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
|
}
|