@myapihq/cli 2.6.2 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.js +1 -1
- package/dist/commands/domain.js +6 -6
- package/dist/commands/fn.js +8 -8
- 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
|
}
|
|
@@ -1,73 +1,37 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Rendering for CRM result counts.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
import { describe, it, expect
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
it('
|
|
24
|
-
expect((
|
|
25
|
-
});
|
|
26
|
-
it('
|
|
27
|
-
expect((
|
|
28
|
-
});
|
|
29
|
-
it('
|
|
30
|
-
expect((
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
describe('warnIfTruncated', () => {
|
|
38
|
-
let infoSpy;
|
|
39
|
-
beforeEach(() => { infoSpy = vi.spyOn(output, 'info').mockImplementation(() => { }); });
|
|
40
|
-
afterEach(() => infoSpy.mockRestore());
|
|
41
|
-
it('warns when the page came back exactly full', () => {
|
|
42
|
-
warnIfTruncated(10, { limit: 10 });
|
|
43
|
-
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
|
|
44
|
-
});
|
|
45
|
-
it('stays quiet when the page came back short — that answer IS complete', () => {
|
|
46
|
-
warnIfTruncated(3, { limit: 10 });
|
|
47
|
-
expect(infoSpy).not.toHaveBeenCalled();
|
|
48
|
-
});
|
|
49
|
-
it('uses the server default of 50 when no --limit was given', () => {
|
|
50
|
-
warnIfTruncated(50, {});
|
|
51
|
-
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
|
|
52
|
-
});
|
|
53
|
-
it('stays quiet below the default limit', () => {
|
|
54
|
-
warnIfTruncated(49, {});
|
|
55
|
-
expect(infoSpy).not.toHaveBeenCalled();
|
|
56
|
-
});
|
|
57
|
-
it('says a record, not "1 records"', () => {
|
|
58
|
-
warnIfTruncated(1, { limit: 1 });
|
|
59
|
-
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('exactly 1 record came back'));
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
describe('countLine', () => {
|
|
63
|
-
// The point of this helper is what it does NOT print: `N of total`, where
|
|
64
|
-
// total is the page size, reads as "you have everything".
|
|
65
|
-
it('pluralizes', () => {
|
|
66
|
-
expect(countLine(1, 'contact', 'contacts')).toBe('1 contact');
|
|
67
|
-
expect(countLine(3, 'contact', 'contacts')).toBe('3 contacts');
|
|
68
|
-
expect(countLine(0, 'company', 'companies')).toBe('0 companies');
|
|
69
|
-
});
|
|
70
|
-
it('never mentions a total', () => {
|
|
71
|
-
expect(countLine(5, 'contact', 'contacts')).not.toMatch(/of/);
|
|
3
|
+
// The line this replaces said "N of <total>" where total was the page size, so
|
|
4
|
+
// it read as a completeness claim on a truncated result. The backend fixed
|
|
5
|
+
// `total` and added `has_more` on 2026-07-28; these tests pin the rendering to
|
|
6
|
+
// the fixed contract and, in particular, to preferring `has_more` over any
|
|
7
|
+
// arithmetic against `total`.
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { pageLine } from './pagination.js';
|
|
10
|
+
describe('pageLine', () => {
|
|
11
|
+
it('states the count alone when the page IS everything', () => {
|
|
12
|
+
expect(pageLine(3, 3, false, 'contact', 'contacts')).toBe('3 contacts');
|
|
13
|
+
});
|
|
14
|
+
it('shows the true match count when the page is a subset', () => {
|
|
15
|
+
expect(pageLine(3, 128, false, 'contact', 'contacts')).toBe('3 of 128 contacts');
|
|
16
|
+
});
|
|
17
|
+
it('says more is available when has_more is set', () => {
|
|
18
|
+
expect(pageLine(50, 128, true, 'contact', 'contacts'))
|
|
19
|
+
.toBe('50 of 128 contacts (more available — raise --limit or pass --offset)');
|
|
20
|
+
});
|
|
21
|
+
// has_more is authoritative. Inferring truncation by comparing lengths is
|
|
22
|
+
// what silently agreed with the old, wrong `total`.
|
|
23
|
+
it('trusts has_more even when the numbers look complete', () => {
|
|
24
|
+
expect(pageLine(3, 3, true, 'contact', 'contacts')).toMatch(/more available/);
|
|
25
|
+
});
|
|
26
|
+
it('omits the total when the API did not send one', () => {
|
|
27
|
+
expect(pageLine(3, undefined, undefined, 'contact', 'contacts')).toBe('3 contacts');
|
|
28
|
+
});
|
|
29
|
+
it('pluralizes both nouns', () => {
|
|
30
|
+
expect(pageLine(1, 1, false, 'contact', 'contacts')).toBe('1 contact');
|
|
31
|
+
expect(pageLine(1, 9, false, 'company', 'companies')).toBe('1 of 9 companies');
|
|
32
|
+
expect(pageLine(0, 0, false, 'company', 'companies')).toBe('0 companies');
|
|
33
|
+
});
|
|
34
|
+
it('never claims a total it was not given', () => {
|
|
35
|
+
expect(pageLine(5, undefined, true, 'contact', 'contacts')).not.toMatch(/\bof\b/);
|
|
72
36
|
});
|
|
73
37
|
});
|
package/dist/commands/doctor.js
CHANGED
|
@@ -272,7 +272,7 @@ async function fetchStatus(url) {
|
|
|
272
272
|
async function httpProbeSection(apiKey, orgId) {
|
|
273
273
|
const targets = [];
|
|
274
274
|
// Containers: probe the bound custom domain when set (what customers hit),
|
|
275
|
-
// else the
|
|
275
|
+
// else the runtime-assigned URL. An undeployed container has neither.
|
|
276
276
|
try {
|
|
277
277
|
const containers = await sdkContainer.listContainers(apiKey, orgId);
|
|
278
278
|
for (const c of containers) {
|
package/dist/commands/domain.js
CHANGED
|
@@ -315,7 +315,7 @@ function renderStatus(res) {
|
|
|
315
315
|
info(`Email: ${res.email_infra}${where}`);
|
|
316
316
|
}
|
|
317
317
|
if (res.status === 'pending_ns_change') {
|
|
318
|
-
info('Waiting for you to change nameservers at your current registrar. Each poll re-checks
|
|
318
|
+
info('Waiting for you to change nameservers at your current registrar. Each poll re-checks the DNS provider.');
|
|
319
319
|
}
|
|
320
320
|
if (res.status === 'infra_error' && res.error_detail) {
|
|
321
321
|
info(` Failed step: ${res.error_detail.failed_step}`);
|
|
@@ -524,7 +524,7 @@ Name normalization:
|
|
|
524
524
|
--name="" Apex (empty == @)
|
|
525
525
|
|
|
526
526
|
Notes:
|
|
527
|
-
--ttl defaults to 1 (
|
|
527
|
+
--ttl defaults to 1 ("automatic"). Explicit range: [60, 86400].
|
|
528
528
|
--priority is required for MX (typical: 10).
|
|
529
529
|
--proxied (CF "orange-cloud") applies to A/AAAA/CNAME only.
|
|
530
530
|
|
|
@@ -596,17 +596,17 @@ DNS propagation takes a few minutes — track it with: myapi domain status <doma
|
|
|
596
596
|
'import': `myapi domain import <domain> [--org <id>] [--json]
|
|
597
597
|
|
|
598
598
|
Bring your own domain (BYOD). Snapshots existing DNS records via a best-effort
|
|
599
|
-
public probe and creates a
|
|
599
|
+
public probe and creates a DNS zone for the domain. Returns the
|
|
600
600
|
nameservers you need to set at your current registrar.
|
|
601
601
|
|
|
602
602
|
No registrar credentials needed — works with any registrar (Namecheap, GoDaddy,
|
|
603
|
-
|
|
603
|
+
the registrar of record, etc.).
|
|
604
604
|
|
|
605
605
|
After running:
|
|
606
606
|
1. Verify the preserved-records table covers your MX/SPF/DKIM/DMARC etc.
|
|
607
607
|
2. At your current registrar, change the nameservers to the values returned.
|
|
608
608
|
3. Wait for propagation. Use "myapi domain status <domain> --watch" to track
|
|
609
|
-
activation — the backend live-checks
|
|
609
|
+
activation — the backend live-checks the DNS provider each poll.`,
|
|
610
610
|
'renew': `myapi domain renew <domain> [--yes] [--org <id>]
|
|
611
611
|
|
|
612
612
|
Renews a registered domain for one more registration period (typically 1 year).
|
|
@@ -634,7 +634,7 @@ binding with: myapi domain list --filter all`,
|
|
|
634
634
|
|
|
635
635
|
--watch Poll until the domain reaches a terminal state (active / failed /
|
|
636
636
|
expired). Backoff: 10s × 30 then 30s × 60 (~35 min budget). Useful
|
|
637
|
-
after "myapi domain import" — each poll also live-checks
|
|
637
|
+
after "myapi domain import" — each poll also live-checks DNS,
|
|
638
638
|
so polling drives the flip from pending_ns_change → provisioning.`,
|
|
639
639
|
'settings': `myapi domain settings <domain> [--org <id>]
|
|
640
640
|
|
package/dist/commands/fn.js
CHANGED
|
@@ -23,7 +23,7 @@ export const SCHEMA = {
|
|
|
23
23
|
set: 'string',
|
|
24
24
|
};
|
|
25
25
|
// Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
|
|
26
|
-
// JS bundle to
|
|
26
|
+
// JS bundle to the edge runtime, list runs, set env secrets).
|
|
27
27
|
// Mirrors validateName in myapi-hq/internal/routes/function/crud.go. We
|
|
28
28
|
// pre-validate client-side so typos fail before the network call; backend
|
|
29
29
|
// runs the same regex as defence in depth.
|
|
@@ -142,7 +142,7 @@ export async function del(id, flags) {
|
|
|
142
142
|
success(`Deleted function ${id} (org ${orgId})`);
|
|
143
143
|
}
|
|
144
144
|
// deploy uploads a single-file JS bundle. The backend wraps it with the
|
|
145
|
-
// MYAPI shim and ships it to
|
|
145
|
+
// MYAPI shim and ships it to the edge runtime. The scoped API key is
|
|
146
146
|
// rotated on every deploy — the fresh value is shown once here.
|
|
147
147
|
export async function deploy(id, bundlePath, flags) {
|
|
148
148
|
const config = requireConfig();
|
|
@@ -226,7 +226,7 @@ export function _parseSetPairs(raw) {
|
|
|
226
226
|
}
|
|
227
227
|
return env;
|
|
228
228
|
}
|
|
229
|
-
// env sets
|
|
229
|
+
// env sets encrypted secret(s) (Stripe key, etc.) on a deployed function.
|
|
230
230
|
// Single form: myapi fn env <id> <name> <value>
|
|
231
231
|
// Bulk form: myapi fn env <id> --set K=V[,K2=V2 ...]
|
|
232
232
|
export async function setEnv(id, name, value, flags) {
|
|
@@ -243,7 +243,7 @@ export async function setEnv(id, name, value, flags) {
|
|
|
243
243
|
error('No secrets given. Usage: myapi fn env <id> --set KEY=VALUE');
|
|
244
244
|
const result = await sdkFn.setFunctionEnvBulk(config.api_key, orgId, id, env);
|
|
245
245
|
success(`Set ${result.set} secret${result.set === 1 ? '' : 's'} on function ${id}`);
|
|
246
|
-
info('Values are encrypted at rest
|
|
246
|
+
info('Values are encrypted at rest and never stored or echoed by MyAPI.');
|
|
247
247
|
return;
|
|
248
248
|
}
|
|
249
249
|
// Single-secret path (original form).
|
|
@@ -253,7 +253,7 @@ export async function setEnv(id, name, value, flags) {
|
|
|
253
253
|
error('Missing secret value.\nUsage: myapi fn env <id> <name> <value>');
|
|
254
254
|
await sdkFn.setFunctionEnv(config.api_key, orgId, id, name, value);
|
|
255
255
|
success(`Set ${name} on function ${id}`);
|
|
256
|
-
info('The value is encrypted at rest
|
|
256
|
+
info('The value is encrypted at rest and never stored or echoed by MyAPI.');
|
|
257
257
|
}
|
|
258
258
|
// runs lists recent invocation records, most recent first.
|
|
259
259
|
export async function runs(id, flags) {
|
|
@@ -304,7 +304,7 @@ other MyAPI slots with its own permissions (scopes=slot_call; rejected at
|
|
|
304
304
|
'deploy': `myapi fn deploy <id> <bundle.js> [--org <id>] [--json]
|
|
305
305
|
|
|
306
306
|
Uploads a single-file JavaScript bundle (≤4MB) to the edge runtime. The
|
|
307
|
-
backend wraps it with the MYAPI shim and ships it to
|
|
307
|
+
backend wraps it with the MYAPI shim and ships it to the edge runtime.
|
|
308
308
|
|
|
309
309
|
The scoped API key is rotated on every deploy — the fresh value is printed
|
|
310
310
|
once. After deploy the function has a live invocation URL.
|
|
@@ -314,7 +314,7 @@ Example:
|
|
|
314
314
|
'env': `myapi fn env <id> <name> <value> [--org <id>]
|
|
315
315
|
myapi fn env <id> --set KEY=VALUE[,KEY2=VALUE2 ...] [--org <id>]
|
|
316
316
|
|
|
317
|
-
Sets one or more secrets (Stripe key, API token, ...) as
|
|
317
|
+
Sets one or more secrets (Stripe key, API token, ...) as encrypted edge
|
|
318
318
|
Secrets on a deployed function. Values are encrypted at rest and never stored
|
|
319
319
|
in MyAPI or echoed back. The function must already be deployed.
|
|
320
320
|
|
|
@@ -337,7 +337,7 @@ Subcommands:
|
|
|
337
337
|
create Register a function and get its scoped API key (returned once)
|
|
338
338
|
delete <id> Soft-delete and revoke its scoped API key
|
|
339
339
|
deploy <id> <file> Upload a JS bundle and go live
|
|
340
|
-
env <id> <k> <v> Set
|
|
340
|
+
env <id> <k> <v> Set an encrypted secret (or --set K=V for bulk) on a deployed function
|
|
341
341
|
get <id> Inspect a function
|
|
342
342
|
list List functions in your org
|
|
343
343
|
runs <id> List recent invocation records
|
package/dist/errors.js
CHANGED
|
@@ -39,8 +39,23 @@ export const ERROR_MESSAGES = {
|
|
|
39
39
|
MX_PRIORITY_REQUIRED: 'MX records require --priority (typical value: 10).',
|
|
40
40
|
INVALID_TTL: 'Invalid TTL. Use the auto sentinel (1) or a value between 60 and 86400 seconds.',
|
|
41
41
|
INVALID_RECORD_CONTENT: 'Invalid record content for this type.',
|
|
42
|
-
RECORD_LIMIT_EXCEEDED: '
|
|
43
|
-
|
|
42
|
+
RECORD_LIMIT_EXCEEDED: 'Per-zone DNS record limit reached.',
|
|
43
|
+
// The DNS provider's codes were renamed on 2026-07-28 (CF_* → DNS_*/EDGE_*)
|
|
44
|
+
// because they named the vendor. Both spellings are handled: the rename ships
|
|
45
|
+
// on a backend deploy we do not control the timing of, and a CLI that only
|
|
46
|
+
// knows the new names would print a bare code for anyone on the old build.
|
|
47
|
+
// The old five can go once that deploy is everywhere.
|
|
48
|
+
DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
|
|
49
|
+
DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
|
|
50
|
+
DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
|
|
51
|
+
EDGE_SUBDOMAIN_UNAVAILABLE: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
|
|
52
|
+
NOT_ON_OUR_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
|
|
53
|
+
// Superseded spellings, kept so an older backend still gets a readable message.
|
|
54
|
+
CF_API_ERROR: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
|
|
55
|
+
CF_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
|
|
56
|
+
CF_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
|
|
57
|
+
CF_WORKERS_SUBDOMAIN: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
|
|
58
|
+
NOT_ON_CF_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
|
|
44
59
|
// invalid_json_response intentionally absent — the SDK's MyApiError now
|
|
45
60
|
// builds a useful detailed message for that case (status + URL + body
|
|
46
61
|
// snippet), and friendlyError(err.code) would override it.
|
|
@@ -54,9 +69,10 @@ export const ERROR_MESSAGES = {
|
|
|
54
69
|
};
|
|
55
70
|
export function friendlyError(err) {
|
|
56
71
|
const body = err.body ?? {};
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
72
|
+
// The provider's own message is already self-describing, so use it verbatim
|
|
73
|
+
// (with its status if present) instead of doubling up. Matches both the old
|
|
74
|
+
// and new code spellings for the duration of the rename.
|
|
75
|
+
if ((err.code === 'DNS_UNAVAILABLE' || err.code === 'CF_API_ERROR') && typeof body.cf_message === 'string') {
|
|
60
76
|
return typeof body.cf_status === 'number'
|
|
61
77
|
? `${body.cf_message} (HTTP ${body.cf_status})`
|
|
62
78
|
: body.cf_message;
|
|
@@ -144,13 +144,18 @@ describe('container.getContainerLogs', () => {
|
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
146
|
describe('container.EXPOSES', () => {
|
|
147
|
-
it('covers the
|
|
147
|
+
it('covers the 11 container endpoints', () => {
|
|
148
148
|
expect(container.EXPOSES).toEqual([
|
|
149
149
|
'POST /container/orgs/{org_id}/containers',
|
|
150
150
|
'GET /container/orgs/{org_id}/containers',
|
|
151
151
|
'GET /container/orgs/{org_id}/containers/{id}',
|
|
152
152
|
'DELETE /container/orgs/{org_id}/containers/{id}',
|
|
153
153
|
'POST /container/orgs/{org_id}/containers/{id}/deploy',
|
|
154
|
+
// Added 2026-07-28. Promote and rollback are ONE endpoint: naming a
|
|
155
|
+
// revision promotes it, omitting one rolls back to the previous ready
|
|
156
|
+
// revision. Modelled as a single operation so the two cannot drift.
|
|
157
|
+
'GET /container/orgs/{org_id}/containers/{id}/revisions',
|
|
158
|
+
'POST /container/orgs/{org_id}/containers/{id}/promote',
|
|
154
159
|
'GET /container/orgs/{org_id}/containers/{id}/logs',
|
|
155
160
|
'POST /container/orgs/{org_id}/containers/{id}/domain',
|
|
156
161
|
'DELETE /container/orgs/{org_id}/containers/{id}/domain',
|
|
@@ -193,3 +198,55 @@ describe('container custom domain', () => {
|
|
|
193
198
|
expect(init.method).toBe('DELETE');
|
|
194
199
|
});
|
|
195
200
|
});
|
|
201
|
+
describe('container deploy safety — promote and revisions', () => {
|
|
202
|
+
// `promote` defaults to true server-side, so the historical call must stay
|
|
203
|
+
// byte-identical. Sending promote:true explicitly would be a behaviour
|
|
204
|
+
// change dressed as a no-op.
|
|
205
|
+
it('sends no promote field when promoting normally', async () => {
|
|
206
|
+
fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
|
|
207
|
+
await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1');
|
|
208
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1' });
|
|
209
|
+
});
|
|
210
|
+
it('sends promote:false only when explicitly withheld', async () => {
|
|
211
|
+
fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k', promoted: false }));
|
|
212
|
+
await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { promote: false });
|
|
213
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1', promote: false });
|
|
214
|
+
});
|
|
215
|
+
it('passes a smoke check through unchanged', async () => {
|
|
216
|
+
fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
|
|
217
|
+
await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { smoke: { path: '/', contains: 'assets/' } });
|
|
218
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).smoke).toEqual({ path: '/', contains: 'assets/' });
|
|
219
|
+
});
|
|
220
|
+
it('unwraps the revisions envelope', async () => {
|
|
221
|
+
fetchMock.mockResolvedValueOnce(ok({ revisions: [
|
|
222
|
+
{ revision: 'r2', ready: true, serving: true, traffic_percent: 100, created_at: 't2' },
|
|
223
|
+
{ revision: 'r1', ready: true, serving: false, traffic_percent: 0, created_at: 't1' },
|
|
224
|
+
] }));
|
|
225
|
+
const revs = await container.listRevisions(API_KEY, ORG_ID, C_ID);
|
|
226
|
+
expect(revs).toHaveLength(2);
|
|
227
|
+
expect(revs[0].traffic_percent).toBe(100);
|
|
228
|
+
expect(fetchMock.mock.calls[0][1].method).toBe('GET');
|
|
229
|
+
});
|
|
230
|
+
it('returns an empty list rather than undefined when there are no revisions', async () => {
|
|
231
|
+
fetchMock.mockResolvedValueOnce(ok({}));
|
|
232
|
+
expect(await container.listRevisions(API_KEY, ORG_ID, C_ID)).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it('names the revision when promoting', async () => {
|
|
235
|
+
fetchMock.mockResolvedValueOnce(ok({ serving: 'r2' }));
|
|
236
|
+
await container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r2');
|
|
237
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
238
|
+
expect(url).toContain(`/containers/${C_ID}/promote`);
|
|
239
|
+
expect(JSON.parse(init.body)).toEqual({ revision: 'r2' });
|
|
240
|
+
});
|
|
241
|
+
// Omitting the revision is the rollback path — same endpoint, empty body.
|
|
242
|
+
it('sends an empty body to roll back', async () => {
|
|
243
|
+
fetchMock.mockResolvedValueOnce(ok({ serving: 'r1' }));
|
|
244
|
+
await container.promoteRevision(API_KEY, ORG_ID, C_ID);
|
|
245
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
|
|
246
|
+
});
|
|
247
|
+
it('surfaces REVISION_NOT_READY without implying traffic moved', async () => {
|
|
248
|
+
fetchMock.mockResolvedValueOnce(fail('REVISION_NOT_READY', 'traffic was NOT moved', 409));
|
|
249
|
+
await expect(container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r9'))
|
|
250
|
+
.rejects.toMatchObject({ code: 'REVISION_NOT_READY' });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -4,7 +4,7 @@ version: 1.0.0
|
|
|
4
4
|
description: >
|
|
5
5
|
Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
|
|
6
6
|
triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-4847ab675fa971bbd0fdd83f0039d9110ba4c38e406860fe9265dd540b7f299e
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyContainerAPI
|
|
@@ -19,6 +19,23 @@ The lifecycle is **create → deploy → (optionally) bind a custom domain**.
|
|
|
19
19
|
- `deploy` ships a pre-built image reference to the runtime and makes the container live at a generated URL.
|
|
20
20
|
- `domain` puts the container on a **custom domain** — how you serve a dynamic app at `app.yourbrand.com`.
|
|
21
21
|
|
|
22
|
+
### Deploying safely
|
|
23
|
+
|
|
24
|
+
A deploy takes 100% of traffic the moment it lands, so a broken build is live
|
|
25
|
+
before you can look at it. Two ways to avoid that:
|
|
26
|
+
|
|
27
|
+
- `--smoke 'GET / contains assets/'` — the platform deploys the revision with
|
|
28
|
+
NO traffic, runs the assertion, and promotes only if it holds. A failure
|
|
29
|
+
leaves the previous revision serving. **Assert on content, not status**:
|
|
30
|
+
"returns 200" is true of a placeholder page too.
|
|
31
|
+
- `--no-promote` — build the revision and hold it back. You get a URL to
|
|
32
|
+
exercise it, then `myapi container promote <id> <revision>`.
|
|
33
|
+
|
|
34
|
+
`--health-check /livez` at create time makes the startup probe an HTTP request
|
|
35
|
+
instead of a bare TCP connect. `/healthz` is refused: the runtime intercepts
|
|
36
|
+
it, so the probe would never reach your container and would report success
|
|
37
|
+
regardless.
|
|
38
|
+
|
|
22
39
|
### Custom domains (dynamic apps)
|
|
23
40
|
|
|
24
41
|
`myapi container domain <id> <domain>` binds a custom domain to a **deployed** container, served over HTTPS automatically. This is the path for a dynamic backend on a real domain — distinct from `my-funnel-api`, which serves static sites.
|
|
@@ -36,7 +53,9 @@ Get it right:
|
|
|
36
53
|
| Command | What it does |
|
|
37
54
|
|---|---|
|
|
38
55
|
| `myapi container create --name <name> [--type service\|worker\|job] [--cron <expr>] [--cpu <n>] [--memory <size>] [--port <n>] [--env K=V,...]` | Register a container, get its scoped API key (once) |
|
|
39
|
-
| `myapi container deploy <id> <image-ref
|
|
56
|
+
| `myapi container deploy <id> <image-ref> [--no-promote] [--smoke '<assertion>']` | Ship a pre-built image (rotates the scoped key) |
|
|
57
|
+
| `myapi container revisions <id>` | List revisions and the traffic each takes |
|
|
58
|
+
| `myapi container promote <id> <revision>` | Move all traffic to a revision (seconds, no rebuild) |
|
|
40
59
|
| `myapi container list` | List containers in your org |
|
|
41
60
|
| `myapi container get <id>` | Inspect a container (status, URL, custom domain) |
|
|
42
61
|
| `myapi container logs <id> [--tail <n>] [--scope all]` | Recent runtime logs, newest first (`--scope all` adds platform audit records) |
|
|
@@ -47,13 +66,26 @@ Get it right:
|
|
|
47
66
|
## Examples
|
|
48
67
|
<!-- llm:start -->
|
|
49
68
|
```bash
|
|
50
|
-
# 1. Register a service container
|
|
51
|
-
|
|
69
|
+
# 1. Register a service container. --health-check makes the startup probe an
|
|
70
|
+
# HTTP request instead of a bare TCP connect.
|
|
71
|
+
myapi container create --name api --type service --port 8080 --health-check /livez
|
|
52
72
|
# → prints a scoped API key ONCE — save it if your code needs it
|
|
53
73
|
|
|
54
|
-
#
|
|
74
|
+
# 2a. Deploy. Plain form takes 100% of traffic immediately.
|
|
55
75
|
myapi container deploy <id> registry.example.com/my-app:v1
|
|
56
76
|
|
|
77
|
+
# 2b. SAFER: assert before any traffic moves. The revision is deployed with no
|
|
78
|
+
# traffic, checked, and promoted only if the check holds. Assert on
|
|
79
|
+
# CONTENT — a broken build still returns 200.
|
|
80
|
+
myapi container deploy <id> registry.example.com/my-app:v1 \
|
|
81
|
+
--smoke 'GET / contains assets/'
|
|
82
|
+
|
|
83
|
+
# 2c. Or hold it back and look yourself.
|
|
84
|
+
myapi container deploy <id> registry.example.com/my-app:v1 --no-promote
|
|
85
|
+
# → prints a revision URL serving 0% of traffic
|
|
86
|
+
myapi container revisions <id>
|
|
87
|
+
myapi container promote <id> <revision>
|
|
88
|
+
|
|
57
89
|
# 3. Serve it on a custom domain. The parent domain must already be
|
|
58
90
|
# registered: myapi domain register synthesisdaily.com
|
|
59
91
|
myapi container domain <id> app.synthesisdaily.com
|
|
@@ -4,7 +4,7 @@ version: 1.0.0
|
|
|
4
4
|
description: >
|
|
5
5
|
The canonical store of engaged contacts + companies for an org. Auto-ingests from inbound webhooks via a configurable dot-path. Fixed lifecycle_stage enum (cold | warm | qualified | customer | churned). Append-only event timeline with reserved kinds. Soft delete + restore. Promote-from-Goldfox closes the discovery → engagement loop.
|
|
6
6
|
triggers: [crm, contact, company, lead, engagement, pipeline, lifecycle, qualified, customer, webhook ingest, promote]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-50cbdd28ed2a901b7a75f1a6c1df1c225d256a8d281ad86cf1e3b42511edc2fe
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyCRMAPI
|
|
@@ -91,7 +91,7 @@ A contact promoted from Goldfox carries a `goldfox_person_id`. In v2 the GET res
|
|
|
91
91
|
### Contacts
|
|
92
92
|
| Command | What it does |
|
|
93
93
|
|---|---|
|
|
94
|
-
| `myapi crm contacts list [--limit N]` | List all contacts (newest engagement first) |
|
|
94
|
+
| `myapi crm contacts list [--limit N] [--offset N]` | List all contacts (newest engagement first) |
|
|
95
95
|
| `myapi crm contacts search [--stage ...] [--source ...] [--email ...] [--min/max-last-engagement-days N]` | Filter contacts |
|
|
96
96
|
| `myapi crm contacts create <email> [--first-name ...] [--last-name ...] [--stage ...] [--custom-json ...]` | Manually create (source='manual') |
|
|
97
97
|
| `myapi crm contacts get <id>` | Fetch one contact (with embedded Goldfox enrichment when available) |
|
|
@@ -158,11 +158,10 @@ myapi crm contacts events <id> --kind webhook_received
|
|
|
158
158
|
|
|
159
159
|
## Notes
|
|
160
160
|
|
|
161
|
-
- **
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
narrow with filters.
|
|
161
|
+
- **Paginate with `--limit` + `--offset`.** `total` is the true match count
|
|
162
|
+
and the response carries `has_more`; branch on `has_more` rather than doing
|
|
163
|
+
arithmetic against `total`. (Both were broken until 2026-07-28. Cached
|
|
164
|
+
guidance saying the CRM cannot paginate is stale.)
|
|
166
165
|
- **Reserved event kinds — no custom events in v1.** If an agent needs custom state per contact, use `myapi database` keyed by contact id. The curated timeline stays the authoritative engagement record.
|
|
167
166
|
- **`external_id` on an event payload** is the backend's idempotency key — a duplicate of the action's natural id (`goldfox_person_id`, `delivery_id`). Read the semantic field instead; legacy `message_id` rows hold the same value.
|
|
168
167
|
- **Only `webhook_received` fires today.** Email and pixel auto-ingest are coming; the CLI surface will not change.
|
|
@@ -4,7 +4,7 @@ version: 1.0.0
|
|
|
4
4
|
description: >
|
|
5
5
|
Register new domains and manage edge settings. Required before a funnel can go live on a custom URL.
|
|
6
6
|
triggers: [domain, register domain, dns, custom domain, edge, cdn, security level, browser check, renew, namecheap]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-8f96d19c11c3591af71e9d73b2cea6d83f40abbfa1c58c5674dbe15db02efa3c
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyDomainAPI
|
|
@@ -88,7 +88,7 @@ myapi domain update-settings example.com \
|
|
|
88
88
|
myapi domain import example.com
|
|
89
89
|
# → Returns nameservers; set them at your current registrar.
|
|
90
90
|
myapi domain status example.com --watch
|
|
91
|
-
# → Polls until active. Backend live-checks
|
|
91
|
+
# → Polls until active. Backend live-checks the DNS provider each poll.
|
|
92
92
|
|
|
93
93
|
# Fix a record after import (e.g. clean up SPF)
|
|
94
94
|
myapi domain records list example.com --type TXT
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
name: my-function-api
|
|
3
3
|
version: 1.0.0
|
|
4
4
|
description: >
|
|
5
|
-
Deploy JavaScript functions to the MyAPI edge runtime
|
|
5
|
+
Deploy JavaScript functions to the MyAPI edge runtime. Register a function, upload a single-file JS bundle, get a live HTTP invocation URL or run it on a cron schedule. Each function gets a scoped capability key for cross-slot calls.
|
|
6
6
|
triggers: [function, deploy function, edge function, serverless, cloudflare worker, cron, scoped api key, capability key, invocation url, bundle]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-610e16c931e44d43ee6ac94a32e6d852bb900cdf6f5eea61aaeae7bb95a0814e
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyFunctionAPI
|
|
11
11
|
|
|
12
|
-
Deploy backend code without running a server. Register a function, upload a single-file JS bundle, and it goes live on the MyAPI edge runtime
|
|
12
|
+
Deploy backend code without running a server. Register a function, upload a single-file JS bundle, and it goes live on the MyAPI edge runtime with a public invocation URL — or runs on a cron schedule. Each function carries a scoped capability key so it can call other MyAPI slots with its own authority.
|
|
13
13
|
|
|
14
14
|
The full loop is live: **create → deploy → invoke → inspect runs → set secrets**.
|
|
15
15
|
|
|
16
16
|
## Capabilities
|
|
17
17
|
<!-- llm:start -->
|
|
18
|
-
**Two-step lifecycle: register, then deploy.** `myapi fn create --name <slug>` persists the function record and mints a `scoped_api_key`, returned **exactly once** (save it if you need it). `myapi fn deploy <id> <bundle.js>` uploads a single-file JavaScript bundle (≤4MB); the backend wraps it with the MYAPI shim and ships it to
|
|
18
|
+
**Two-step lifecycle: register, then deploy.** `myapi fn create --name <slug>` persists the function record and mints a `scoped_api_key`, returned **exactly once** (save it if you need it). `myapi fn deploy <id> <bundle.js>` uploads a single-file JavaScript bundle (≤4MB); the backend wraps it with the MYAPI shim and ships it to the edge runtime. Propagation is typically 4-45s, so poll the invocation URL rather than redeploying. After deploy the function has a live `invocation_url`.
|
|
19
19
|
|
|
20
20
|
**Scoped key = capability key.** It is **org-locked**. By default it inherits the deployer's slot grants; narrow it at create time with `--scope <slot>[,<slot>...]` (comma-separated, e.g. `--scope email,storage`) — the primary way to deploy a deliberately narrow function. Grants can never exceed the caller's, so a function never out-reaches the credential that created it. It is rejected with `403 SCOPE_FORBIDDEN` at `/hq/*`, `/admin/*`, `/internal/*`. **Deploy rotates this key** — the fresh value is printed once on every deploy. (Minting a narrow account key first — `myapi keys create --grant ...` — is only needed when the *deploy credential itself* must be constrained, e.g. handing deploy rights to another system.)
|
|
21
21
|
|
|
22
22
|
**HTTP or cron triggers.** Default is `http` — the function gets a public invocation URL once deployed. Pass `--cron "<expr>"` at create time to run on a schedule (e.g. `"0 8 * * *"`) instead.
|
|
23
23
|
|
|
24
|
-
**Secrets
|
|
24
|
+
**Secrets are encrypted at rest.** `myapi fn env <id> <name> <value>` sets a secret (Stripe key, API token, …) on a deployed function. The value is encrypted at rest by the edge runtime and never stored or echoed by MyAPI. The function must already be deployed.
|
|
25
25
|
|
|
26
26
|
**Inspect invocations.** `myapi fn runs <id>` lists recent invocation records (most recent first, up to 100) with status, duration, and any error message.
|
|
27
27
|
|
|
@@ -36,7 +36,7 @@ Name rules (validated client- and server-side, kept identical):
|
|
|
36
36
|
|---|---|
|
|
37
37
|
| `myapi fn create --name <name> [--cron <expr>] [--scope <slot>[,<slot>...]]` | Register a function record + receive scoped API key (returned once). `--scope` narrows the key's slot grants |
|
|
38
38
|
| `myapi fn deploy <id> <bundle.js>` | Upload a single-file JS bundle (≤4MB) and go live; rotates the scoped key |
|
|
39
|
-
| `myapi fn env <id> <name> <value>` | Set
|
|
39
|
+
| `myapi fn env <id> <name> <value>` | Set an encrypted secret on a deployed function |
|
|
40
40
|
| `myapi fn runs <id>` | List recent invocation records (status, duration, errors) |
|
|
41
41
|
| `myapi fn list` | List functions in your org |
|
|
42
42
|
| `myapi fn get <id>` | Inspect a function (name, trigger, invocation URL) |
|
|
@@ -58,7 +58,7 @@ myapi fn deploy fn_abc123 ./dist/bundle.js
|
|
|
58
58
|
# Invocation URL: https://fn-abc123.<...>.workers.dev
|
|
59
59
|
# Scoped API key was rotated. New value (returned once): hq_live_...
|
|
60
60
|
|
|
61
|
-
# Set a secret (encrypted at rest
|
|
61
|
+
# Set a secret (encrypted at rest; never echoed)
|
|
62
62
|
myapi fn env fn_abc123 STRIPE_KEY sk_live_...
|
|
63
63
|
|
|
64
64
|
# Inspect recent invocations
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.7.0",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"lint:help-order": "node scripts/lint-help-order.js",
|
|
36
36
|
"lint:exposes": "node scripts/lint-exposes.js",
|
|
37
37
|
"lint:request-fields": "node scripts/lint-request-fields.js",
|
|
38
|
+
"audit:doctor": "npm run build && node scripts/audit-doctor.js",
|
|
38
39
|
"lint:docs": "node scripts/lint-docs.js",
|
|
39
40
|
"lint:skills": "node scripts/copy-skills.js && node scripts/lint-skills.js",
|
|
40
41
|
"lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
|
-
"@myapihq/sdk": "^2.
|
|
44
|
+
"@myapihq/sdk": "^2.7.0"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/node": "^25.6.0",
|