@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.
@@ -1,73 +1,37 @@
1
- // Regression cover for the CRM pagination guards.
1
+ // Rendering for CRM result counts.
2
2
  //
3
- // The bug being fenced off: the API accepts `offset`, ignores it, and reports
4
- // `total` as the page size so an agent paging a contact list re-read page
5
- // one forever and had no signal that anything was missing. Verified against
6
- // production 2026-07-27. These tests assert the CLI never silently accepts
7
- // that flag again, and never claims a result is complete when it cannot know.
8
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
- import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
10
- import * as output from '../../output.js';
11
- describe('rejectOffset', () => {
12
- let errSpy;
13
- beforeEach(() => {
14
- // error() exits the process; make it throw so the test can catch it.
15
- errSpy = vi.spyOn(output, 'error').mockImplementation(((msg) => {
16
- throw new Error(msg);
17
- }));
18
- });
19
- afterEach(() => errSpy.mockRestore());
20
- it('passes through when --offset is absent', () => {
21
- expect(() => rejectOffset({}, 'usage')).not.toThrow();
22
- });
23
- it('rejects --offset 0 it is as broken as any other value', () => {
24
- expect(() => rejectOffset({ offset: 0 }, 'usage')).toThrow(/ignores it/);
25
- });
26
- it('rejects a non-zero --offset', () => {
27
- expect(() => rejectOffset({ offset: 25 }, 'usage')).toThrow(/ignores it/);
28
- });
29
- it('explains that no cursor exists either, so the user does not go looking', () => {
30
- expect(() => rejectOffset({ offset: 1 }, 'usage')).toThrow(/no cursor parameter/);
31
- });
32
- it('includes the usage line so the error is actionable', () => {
33
- expect(() => rejectOffset({ offset: 1 }, 'myapi crm contacts list [--limit N]'))
34
- .toThrow(/myapi crm contacts list/);
35
- });
36
- });
37
- describe('warnIfTruncated', () => {
38
- let infoSpy;
39
- beforeEach(() => { infoSpy = vi.spyOn(output, 'info').mockImplementation(() => { }); });
40
- afterEach(() => infoSpy.mockRestore());
41
- it('warns when the page came back exactly full', () => {
42
- warnIfTruncated(10, { limit: 10 });
43
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
44
- });
45
- it('stays quiet when the page came back short — that answer IS complete', () => {
46
- warnIfTruncated(3, { limit: 10 });
47
- expect(infoSpy).not.toHaveBeenCalled();
48
- });
49
- it('uses the server default of 50 when no --limit was given', () => {
50
- warnIfTruncated(50, {});
51
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('page limit'));
52
- });
53
- it('stays quiet below the default limit', () => {
54
- warnIfTruncated(49, {});
55
- expect(infoSpy).not.toHaveBeenCalled();
56
- });
57
- it('says a record, not "1 records"', () => {
58
- warnIfTruncated(1, { limit: 1 });
59
- expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('exactly 1 record came back'));
60
- });
61
- });
62
- describe('countLine', () => {
63
- // The point of this helper is what it does NOT print: `N of total`, where
64
- // total is the page size, reads as "you have everything".
65
- it('pluralizes', () => {
66
- expect(countLine(1, 'contact', 'contacts')).toBe('1 contact');
67
- expect(countLine(3, 'contact', 'contacts')).toBe('3 contacts');
68
- expect(countLine(0, 'company', 'companies')).toBe('0 companies');
69
- });
70
- it('never mentions a total', () => {
71
- expect(countLine(5, 'contact', 'contacts')).not.toMatch(/of/);
3
+ // The line this replaces said "N of <total>" where total was the page size, so
4
+ // it read as a completeness claim on a truncated result. The backend fixed
5
+ // `total` and added `has_more` on 2026-07-28; these tests pin the rendering to
6
+ // the fixed contract and, in particular, to preferring `has_more` over any
7
+ // arithmetic against `total`.
8
+ import { describe, it, expect } from 'vitest';
9
+ import { pageLine } from './pagination.js';
10
+ describe('pageLine', () => {
11
+ it('states the count alone when the page IS everything', () => {
12
+ expect(pageLine(3, 3, false, 'contact', 'contacts')).toBe('3 contacts');
13
+ });
14
+ it('shows the true match count when the page is a subset', () => {
15
+ expect(pageLine(3, 128, false, 'contact', 'contacts')).toBe('3 of 128 contacts');
16
+ });
17
+ it('says more is available when has_more is set', () => {
18
+ expect(pageLine(50, 128, true, 'contact', 'contacts'))
19
+ .toBe('50 of 128 contacts (more available — raise --limit or pass --offset)');
20
+ });
21
+ // has_more is authoritative. Inferring truncation by comparing lengths is
22
+ // what silently agreed with the old, wrong `total`.
23
+ it('trusts has_more even when the numbers look complete', () => {
24
+ expect(pageLine(3, 3, true, 'contact', 'contacts')).toMatch(/more available/);
25
+ });
26
+ it('omits the total when the API did not send one', () => {
27
+ expect(pageLine(3, undefined, undefined, 'contact', 'contacts')).toBe('3 contacts');
28
+ });
29
+ it('pluralizes both nouns', () => {
30
+ expect(pageLine(1, 1, false, 'contact', 'contacts')).toBe('1 contact');
31
+ expect(pageLine(1, 9, false, 'company', 'companies')).toBe('1 of 9 companies');
32
+ expect(pageLine(0, 0, false, 'company', 'companies')).toBe('0 companies');
33
+ });
34
+ it('never claims a total it was not given', () => {
35
+ expect(pageLine(5, undefined, true, 'contact', 'contacts')).not.toMatch(/\bof\b/);
72
36
  });
73
37
  });
@@ -106,8 +106,8 @@ async function nsDelete(name, flags) {
106
106
  // ── Keys ─────────────────────────────────────────────────────────────────
107
107
  async function keysList(flags) {
108
108
  const config = requireConfig();
109
- const orgId = requireOrg(flags, config, 'myapi database keys --ns <ns> [--prefix <p>] [--limit N] [--values] [--cursor <c>] [--org <id>]');
110
- const ns = requireNs(flags, 'myapi database keys --ns <ns> [--prefix <p>] [--limit N] [--values]');
109
+ const orgId = requireOrg(flags, config, 'myapi database entries --ns <ns> [--prefix <p>] [--limit N] [--values] [--cursor <c>] [--org <id>]');
110
+ const ns = requireNs(flags, 'myapi database entries --ns <ns> [--prefix <p>] [--limit N] [--values]');
111
111
  const opts = {};
112
112
  if (typeof flags.prefix === 'string')
113
113
  opts.prefix = flags.prefix;
@@ -197,12 +197,16 @@ const SUBCOMMAND_USAGE = {
197
197
  'create': 'myapi database create <name> [--org <id>]',
198
198
  'delete-namespace': `myapi database delete-namespace <name> [--org <id>]
199
199
 
200
- Deletes the namespace AND all keys in it. Irreversible.`,
201
- 'keys': `myapi database keys --ns <namespace> [--prefix <p>] [--limit N] [--values] [--cursor <c>] [--org <id>] [--json]
200
+ Deletes the namespace AND all entries in it. Irreversible.`,
201
+ 'entries': `myapi database entries --ns <namespace> [--prefix <p>] [--limit N] [--values] [--cursor <c>] [--org <id>] [--json]
202
202
 
203
- Default render is one key per line. --values inlines each value alongside
204
- its key + etag. --cursor takes the next_cursor printed at the end of a
205
- previous page.`,
203
+ Default render is one entry key per line. --values inlines each value
204
+ alongside its key + etag. --cursor takes the next_cursor printed at the end
205
+ of a previous page.
206
+
207
+ (\`myapi database keys\` still works. It was renamed because "key" also means
208
+ an API credential everywhere else on the platform, and an agent reading
209
+ "list keys" could not tell which was meant.)`,
206
210
  'get': `myapi database get <key> --ns <namespace> [--org <id>] [--json]
207
211
 
208
212
  Prints the value as compact JSON on stdout; etag + updated_at go to
@@ -226,17 +230,17 @@ export async function run(subcommand, args, flags) {
226
230
 
227
231
  Namespaces:
228
232
  create <name> Create a namespace
229
- delete-namespace <name> Delete a namespace AND all its keys (irreversible)
233
+ delete-namespace <name> Delete a namespace AND all its entries (irreversible)
230
234
  namespaces List namespaces in the org
231
235
 
232
- Keys (require --ns <namespace>):
233
- keys List keys in a namespace
234
- get <key> Get a key's value + etag
235
- set <key> <value-json> Set a key. Value is JSON; CAS via --if-match
236
- del <key> Delete a key (CAS via --if-match)
236
+ Entries (require --ns <namespace>):
237
+ entries List entries in a namespace
238
+ get <key> Get an entry's value + etag
239
+ set <key> <value-json> Set an entry. Value is JSON; CAS via --if-match
240
+ del <key> Delete an entry (CAS via --if-match)
237
241
 
238
242
  All commands accept --org <id> (or set default: myapi config set-org <id>).
239
- Values are JSON; per-key size cap is 256 KB. Use myapi storage for binary.`);
243
+ Values are JSON; per-entry size cap is 256 KB. Use myapi storage for binary.`);
240
244
  return;
241
245
  }
242
246
  if (flags.help) {
@@ -251,6 +255,9 @@ Values are JSON; per-key size cap is 256 KB. Use myapi storage for binary.`);
251
255
  case 'namespaces': return nsList(flags);
252
256
  case 'create': return nsCreate(args[0], flags);
253
257
  case 'delete-namespace': return nsDelete(args[0], flags);
258
+ // `keys` kept as an undocumented alias: renaming a verb people have in
259
+ // scripts should not break them. Dropped no earlier than the next minor.
260
+ case 'entries':
254
261
  case 'keys': return keysList(flags);
255
262
  case 'get': return keyGet(args[0], flags);
256
263
  case 'set': return keySet(args[0], args[1], flags);
@@ -272,7 +272,7 @@ async function fetchStatus(url) {
272
272
  async function httpProbeSection(apiKey, orgId) {
273
273
  const targets = [];
274
274
  // Containers: probe the bound custom domain when set (what customers hit),
275
- // else the Cloud Run URL. An undeployed container has neither — skip it.
275
+ // else the runtime-assigned URL. An undeployed container has neither.
276
276
  try {
277
277
  const containers = await sdkContainer.listContainers(apiKey, orgId);
278
278
  for (const c of containers) {
@@ -315,7 +315,7 @@ function renderStatus(res) {
315
315
  info(`Email: ${res.email_infra}${where}`);
316
316
  }
317
317
  if (res.status === 'pending_ns_change') {
318
- info('Waiting for you to change nameservers at your current registrar. Each poll re-checks Cloudflare.');
318
+ info('Waiting for you to change nameservers at your current registrar. Each poll re-checks the DNS provider.');
319
319
  }
320
320
  if (res.status === 'infra_error' && res.error_detail) {
321
321
  info(` Failed step: ${res.error_detail.failed_step}`);
@@ -524,7 +524,7 @@ Name normalization:
524
524
  --name="" Apex (empty == @)
525
525
 
526
526
  Notes:
527
- --ttl defaults to 1 (Cloudflare "automatic"). Explicit range: [60, 86400].
527
+ --ttl defaults to 1 ("automatic"). Explicit range: [60, 86400].
528
528
  --priority is required for MX (typical: 10).
529
529
  --proxied (CF "orange-cloud") applies to A/AAAA/CNAME only.
530
530
 
@@ -596,17 +596,17 @@ DNS propagation takes a few minutes — track it with: myapi domain status <doma
596
596
  'import': `myapi domain import <domain> [--org <id>] [--json]
597
597
 
598
598
  Bring your own domain (BYOD). Snapshots existing DNS records via a best-effort
599
- public probe and creates a Cloudflare zone for the domain. Returns the
599
+ public probe and creates a DNS zone for the domain. Returns the
600
600
  nameservers you need to set at your current registrar.
601
601
 
602
602
  No registrar credentials needed — works with any registrar (Namecheap, GoDaddy,
603
- Cloudflare Registrar, etc.).
603
+ the registrar of record, etc.).
604
604
 
605
605
  After running:
606
606
  1. Verify the preserved-records table covers your MX/SPF/DKIM/DMARC etc.
607
607
  2. At your current registrar, change the nameservers to the values returned.
608
608
  3. Wait for propagation. Use "myapi domain status <domain> --watch" to track
609
- activation — the backend live-checks Cloudflare each time you poll.`,
609
+ activation — the backend live-checks the DNS provider each poll.`,
610
610
  'renew': `myapi domain renew <domain> [--yes] [--org <id>]
611
611
 
612
612
  Renews a registered domain for one more registration period (typically 1 year).
@@ -634,7 +634,7 @@ binding with: myapi domain list --filter all`,
634
634
 
635
635
  --watch Poll until the domain reaches a terminal state (active / failed /
636
636
  expired). Backoff: 10s × 30 then 30s × 60 (~35 min budget). Useful
637
- after "myapi domain import" — each poll also live-checks Cloudflare,
637
+ after "myapi domain import" — each poll also live-checks DNS,
638
638
  so polling drives the flip from pending_ns_change → provisioning.`,
639
639
  'settings': `myapi domain settings <domain> [--org <id>]
640
640
 
@@ -22,8 +22,8 @@ export const SCHEMA = {
22
22
  scope: 'list',
23
23
  set: 'string',
24
24
  };
25
- // Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
26
- // JS bundle to Cloudflare Workers, list runs, set env secrets).
25
+ // Backend: Story 1 (function CRUD + scoped API key) and Story 2/4/5 (deploy a
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.
@@ -89,7 +89,7 @@ export async function create(nameArg, flags) {
89
89
  success(`Function created: ${result.function.id}`);
90
90
  info(`Name: ${result.function.name}`);
91
91
  info(`Trigger: ${result.function.trigger_type}${result.function.cron_schedule ? ` (${result.function.cron_schedule})` : ''}`);
92
- // The scoped key is returned ONCE — surface it prominently. It's used by
92
+ // The scoped API key is returned ONCE — surface it prominently. It's used by
93
93
  // the function runtime shim to call other slot endpoints without a
94
94
  // baked-in auth token. Deploy rotates this key.
95
95
  info('');
@@ -142,7 +142,7 @@ export async function del(id, flags) {
142
142
  success(`Deleted function ${id} (org ${orgId})`);
143
143
  }
144
144
  // deploy uploads a single-file JS bundle. The backend wraps it with the
145
- // MYAPI shim and ships it to Cloudflare Workers. The scoped API key is
145
+ // MYAPI shim and ships it to the edge runtime. The scoped API key is
146
146
  // rotated on every deploy — the fresh value is shown once here.
147
147
  export async function deploy(id, bundlePath, flags) {
148
148
  const config = requireConfig();
@@ -205,7 +205,7 @@ async function waitForLive(url, budgetMs = 75_000) {
205
205
  clearLine();
206
206
  info(`Status: not serving yet after ${Math.round(budgetMs / 1000)}s.`);
207
207
  info(` The deploy itself succeeded — give it another minute.`);
208
- info(` Do NOT redeploy to "fix" it: that rotates the scoped key again.`);
208
+ info(` Do NOT redeploy to "fix" it: that rotates the scoped API key again.`);
209
209
  }
210
210
  // _parseSetPairs parses `--set K=V` entries into a map. Accepts a single
211
211
  // string (comma-joined: K=V,K2=V2) or an array of strings (when the flag is
@@ -226,7 +226,7 @@ export function _parseSetPairs(raw) {
226
226
  }
227
227
  return env;
228
228
  }
229
- // env sets Worker Secret(s) (Stripe key, etc.) on a deployed function.
229
+ // env sets encrypted secret(s) (Stripe key, etc.) on a deployed function.
230
230
  // Single form: myapi fn env <id> <name> <value>
231
231
  // Bulk form: myapi fn env <id> --set K=V[,K2=V2 ...]
232
232
  export async function setEnv(id, name, value, flags) {
@@ -243,7 +243,7 @@ export async function setEnv(id, name, value, flags) {
243
243
  error('No secrets given. Usage: myapi fn env <id> --set KEY=VALUE');
244
244
  const result = await sdkFn.setFunctionEnvBulk(config.api_key, orgId, id, env);
245
245
  success(`Set ${result.set} secret${result.set === 1 ? '' : 's'} on function ${id}`);
246
- info('Values are encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
246
+ info('Values are encrypted at rest and never stored or echoed by MyAPI.');
247
247
  return;
248
248
  }
249
249
  // Single-secret path (original form).
@@ -253,7 +253,7 @@ export async function setEnv(id, name, value, flags) {
253
253
  error('Missing secret value.\nUsage: myapi fn env <id> <name> <value>');
254
254
  await sdkFn.setFunctionEnv(config.api_key, orgId, id, name, value);
255
255
  success(`Set ${name} on function ${id}`);
256
- info('The value is encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
256
+ info('The value is encrypted at rest and never stored or echoed by MyAPI.');
257
257
  }
258
258
  // runs lists recent invocation records, most recent first.
259
259
  export async function runs(id, flags) {
@@ -304,7 +304,7 @@ other MyAPI slots with its own permissions (scopes=slot_call; rejected at
304
304
  'deploy': `myapi fn deploy <id> <bundle.js> [--org <id>] [--json]
305
305
 
306
306
  Uploads a single-file JavaScript bundle (≤4MB) to the edge runtime. The
307
- backend wraps it with the MYAPI shim and ships it to Cloudflare Workers.
307
+ backend wraps it with the MYAPI shim and ships it to the edge runtime.
308
308
 
309
309
  The scoped API key is rotated on every deploy — the fresh value is printed
310
310
  once. After deploy the function has a live invocation URL.
@@ -314,7 +314,7 @@ Example:
314
314
  'env': `myapi fn env <id> <name> <value> [--org <id>]
315
315
  myapi fn env <id> --set KEY=VALUE[,KEY2=VALUE2 ...] [--org <id>]
316
316
 
317
- Sets one or more secrets (Stripe key, API token, ...) as Cloudflare Worker
317
+ Sets one or more secrets (Stripe key, API token, ...) as encrypted edge
318
318
  Secrets on a deployed function. Values are encrypted at rest and never stored
319
319
  in MyAPI or echoed back. The function must already be deployed.
320
320
 
@@ -337,7 +337,7 @@ Subcommands:
337
337
  create Register a function and get its scoped API key (returned once)
338
338
  delete <id> Soft-delete and revoke its scoped API key
339
339
  deploy <id> <file> Upload a JS bundle and go live
340
- env <id> <k> <v> Set a Worker Secret (or --set K=V for bulk) on a deployed function
340
+ env <id> <k> <v> Set an encrypted secret (or --set K=V for bulk) on a deployed function
341
341
  get <id> Inspect a function
342
342
  list List functions in your org
343
343
  runs <id> List recent invocation records
package/dist/errors.js CHANGED
@@ -39,8 +39,23 @@ export const ERROR_MESSAGES = {
39
39
  MX_PRIORITY_REQUIRED: 'MX records require --priority (typical value: 10).',
40
40
  INVALID_TTL: 'Invalid TTL. Use the auto sentinel (1) or a value between 60 and 86400 seconds.',
41
41
  INVALID_RECORD_CONTENT: 'Invalid record content for this type.',
42
- RECORD_LIMIT_EXCEEDED: 'Cloudflare per-zone record limit reached.',
43
- CF_API_ERROR: 'Cloudflare API error.',
42
+ RECORD_LIMIT_EXCEEDED: 'Per-zone DNS record limit reached.',
43
+ // The DNS provider's codes were renamed on 2026-07-28 (CF_* → DNS_*/EDGE_*)
44
+ // because they named the vendor. Both spellings are handled: the rename ships
45
+ // on a backend deploy we do not control the timing of, and a CLI that only
46
+ // knows the new names would print a bare code for anyone on the old build.
47
+ // The old five can go once that deploy is everywhere.
48
+ DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
49
+ DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
50
+ DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
51
+ EDGE_SUBDOMAIN_UNAVAILABLE: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
52
+ NOT_ON_OUR_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
53
+ // Superseded spellings, kept so an older backend still gets a readable message.
54
+ CF_API_ERROR: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
55
+ CF_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
56
+ CF_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
57
+ CF_WORKERS_SUBDOMAIN: 'The edge subdomain could not be provisioned. Platform-side — retry, and report it if it persists.',
58
+ NOT_ON_CF_DNS: 'This domain is not on MyAPI DNS, so records here cannot be managed. Move its nameservers or import it: myapi domain import <domain>.',
44
59
  // invalid_json_response intentionally absent — the SDK's MyApiError now
45
60
  // builds a useful detailed message for that case (status + URL + body
46
61
  // snippet), and friendlyError(err.code) would override it.
@@ -54,9 +69,10 @@ export const ERROR_MESSAGES = {
54
69
  };
55
70
  export function friendlyError(err) {
56
71
  const body = err.body ?? {};
57
- // CF_API_ERROR: the cf_message already includes a "Cloudflare API error"
58
- // prefix, so use it verbatim (with cf_status if present) instead of doubling.
59
- if (err.code === 'CF_API_ERROR' && typeof body.cf_message === 'string') {
72
+ // The provider's own message is already self-describing, so use it verbatim
73
+ // (with its status if present) instead of doubling up. Matches both the old
74
+ // and new code spellings for the duration of the rename.
75
+ if ((err.code === 'DNS_UNAVAILABLE' || err.code === 'CF_API_ERROR') && typeof body.cf_message === 'string') {
60
76
  return typeof body.cf_status === 'number'
61
77
  ? `${body.cf_message} (HTTP ${body.cf_status})`
62
78
  : body.cf_message;
@@ -144,13 +144,18 @@ describe('container.getContainerLogs', () => {
144
144
  });
145
145
  });
146
146
  describe('container.EXPOSES', () => {
147
- it('covers the 9 container endpoints', () => {
147
+ it('covers the 11 container endpoints', () => {
148
148
  expect(container.EXPOSES).toEqual([
149
149
  'POST /container/orgs/{org_id}/containers',
150
150
  'GET /container/orgs/{org_id}/containers',
151
151
  'GET /container/orgs/{org_id}/containers/{id}',
152
152
  'DELETE /container/orgs/{org_id}/containers/{id}',
153
153
  'POST /container/orgs/{org_id}/containers/{id}/deploy',
154
+ // Added 2026-07-28. Promote and rollback are ONE endpoint: naming a
155
+ // revision promotes it, omitting one rolls back to the previous ready
156
+ // revision. Modelled as a single operation so the two cannot drift.
157
+ 'GET /container/orgs/{org_id}/containers/{id}/revisions',
158
+ 'POST /container/orgs/{org_id}/containers/{id}/promote',
154
159
  'GET /container/orgs/{org_id}/containers/{id}/logs',
155
160
  'POST /container/orgs/{org_id}/containers/{id}/domain',
156
161
  'DELETE /container/orgs/{org_id}/containers/{id}/domain',
@@ -193,3 +198,55 @@ describe('container custom domain', () => {
193
198
  expect(init.method).toBe('DELETE');
194
199
  });
195
200
  });
201
+ describe('container deploy safety — promote and revisions', () => {
202
+ // `promote` defaults to true server-side, so the historical call must stay
203
+ // byte-identical. Sending promote:true explicitly would be a behaviour
204
+ // change dressed as a no-op.
205
+ it('sends no promote field when promoting normally', async () => {
206
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
207
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1');
208
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1' });
209
+ });
210
+ it('sends promote:false only when explicitly withheld', async () => {
211
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k', promoted: false }));
212
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { promote: false });
213
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ image: 'img:v1', promote: false });
214
+ });
215
+ it('passes a smoke check through unchanged', async () => {
216
+ fetchMock.mockResolvedValueOnce(ok({ container_id: C_ID, revision_id: 'r1', url: 'u', status: 'active', scoped_api_key: 'k' }));
217
+ await container.deployContainer(API_KEY, ORG_ID, C_ID, 'img:v1', { smoke: { path: '/', contains: 'assets/' } });
218
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).smoke).toEqual({ path: '/', contains: 'assets/' });
219
+ });
220
+ it('unwraps the revisions envelope', async () => {
221
+ fetchMock.mockResolvedValueOnce(ok({ revisions: [
222
+ { revision: 'r2', ready: true, serving: true, traffic_percent: 100, created_at: 't2' },
223
+ { revision: 'r1', ready: true, serving: false, traffic_percent: 0, created_at: 't1' },
224
+ ] }));
225
+ const revs = await container.listRevisions(API_KEY, ORG_ID, C_ID);
226
+ expect(revs).toHaveLength(2);
227
+ expect(revs[0].traffic_percent).toBe(100);
228
+ expect(fetchMock.mock.calls[0][1].method).toBe('GET');
229
+ });
230
+ it('returns an empty list rather than undefined when there are no revisions', async () => {
231
+ fetchMock.mockResolvedValueOnce(ok({}));
232
+ expect(await container.listRevisions(API_KEY, ORG_ID, C_ID)).toEqual([]);
233
+ });
234
+ it('names the revision when promoting', async () => {
235
+ fetchMock.mockResolvedValueOnce(ok({ serving: 'r2' }));
236
+ await container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r2');
237
+ const [url, init] = fetchMock.mock.calls[0];
238
+ expect(url).toContain(`/containers/${C_ID}/promote`);
239
+ expect(JSON.parse(init.body)).toEqual({ revision: 'r2' });
240
+ });
241
+ // Omitting the revision is the rollback path — same endpoint, empty body.
242
+ it('sends an empty body to roll back', async () => {
243
+ fetchMock.mockResolvedValueOnce(ok({ serving: 'r1' }));
244
+ await container.promoteRevision(API_KEY, ORG_ID, C_ID);
245
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
246
+ });
247
+ it('surfaces REVISION_NOT_READY without implying traffic moved', async () => {
248
+ fetchMock.mockResolvedValueOnce(fail('REVISION_NOT_READY', 'traffic was NOT moved', 409));
249
+ await expect(container.promoteRevision(API_KEY, ORG_ID, C_ID, 'r9'))
250
+ .rejects.toMatchObject({ code: 'REVISION_NOT_READY' });
251
+ });
252
+ });
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
6
6
  triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
7
- checksum: sha256-d25fff35df7bf67bebabb6cc228f322b108d8472035cbda70756d720b8db502d
7
+ checksum: sha256-3864bbb98fab89ba9e937c38bed072e22f33663ec7cbc23a43f24192275bcffd
8
8
  ---
9
9
 
10
10
  # MyContainerAPI
@@ -19,6 +19,27 @@ 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 — NOT YET POSSIBLE ON THIS PLATFORM
23
+
24
+ A deploy takes 100% of traffic the moment it lands. There is no dry run, no
25
+ definition of correct beyond "something is listening on the port", and no way
26
+ back. Plan for that.
27
+
28
+ `--no-promote` and `--smoke` exist as flags and **the CLI refuses them**: they
29
+ shipped before the platform could honour them, and a guard that silently
30
+ passes is worse than no guard. `--health-check` is accepted at create but does
31
+ not appear on the container afterwards, so do not rely on it either.
32
+
33
+ Until they work, the only safe sequence is:
34
+
35
+ 1. deploy to a **non-production** container
36
+ 2. verify it yourself — `curl` for a string only a real build emits, not just
37
+ a 200, because a broken build returns 200 too
38
+ 3. deploy the same image to production
39
+
40
+ `myapi container promote <id> <revision>` currently fails, so a bad deploy
41
+ must be fixed by deploying forward. Keep a known-good image reference to hand.
42
+
22
43
  ### Custom domains (dynamic apps)
23
44
 
24
45
  `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 +57,9 @@ Get it right:
36
57
  | Command | What it does |
37
58
  |---|---|
38
59
  | `myapi container create --name <name> [--type service\|worker\|job] [--cron <expr>] [--cpu <n>] [--memory <size>] [--port <n>] [--env K=V,...]` | Register a container, get its scoped API key (once) |
39
- | `myapi container deploy <id> <image-ref>` | Ship a pre-built image and go live (rotates the scoped key) |
60
+ | `myapi container deploy <id> <image-ref> [--no-promote] [--smoke '<assertion>']` | Ship a pre-built image (rotates the scoped API key) |
61
+ | `myapi container revisions <id>` | List revisions and the traffic each takes |
62
+ | `myapi container promote <id> <revision>` | Move all traffic to a revision (seconds, no rebuild) |
40
63
  | `myapi container list` | List containers in your org |
41
64
  | `myapi container get <id>` | Inspect a container (status, URL, custom domain) |
42
65
  | `myapi container logs <id> [--tail <n>] [--scope all]` | Recent runtime logs, newest first (`--scope all` adds platform audit records) |
@@ -51,9 +74,14 @@ Get it right:
51
74
  myapi container create --name api --type service --port 8080
52
75
  # → prints a scoped API key ONCE — save it if your code needs it
53
76
 
54
- # 2. Deploy a pre-built image
77
+ # 2. Deploy. This takes 100% of traffic immediately — there is no staging
78
+ # step, so verify on a non-production container FIRST.
55
79
  myapi container deploy <id> registry.example.com/my-app:v1
56
80
 
81
+ # 3. Check what you actually shipped. Assert on content: a build whose
82
+ # frontend never bundled still binds its port and returns 200.
83
+ curl -s https://<your-domain>/ | grep -q 'assets/' || echo "BROKEN BUILD"
84
+
57
85
  # 3. Serve it on a custom domain. The parent domain must already be
58
86
  # registered: myapi domain register synthesisdaily.com
59
87
  myapi container domain <id> app.synthesisdaily.com
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  The canonical store of engaged contacts + companies for an org. Auto-ingests from inbound webhooks via a configurable dot-path. Fixed lifecycle_stage enum (cold | warm | qualified | customer | churned). Append-only event timeline with reserved kinds. Soft delete + restore. Promote-from-Goldfox closes the discovery → engagement loop.
6
6
  triggers: [crm, contact, company, lead, engagement, pipeline, lifecycle, qualified, customer, webhook ingest, promote]
7
- checksum: sha256-085cb990a292ba815d7c59a58fb25816d52bf33ad5019a7d90abf4123383a67c
7
+ checksum: sha256-50cbdd28ed2a901b7a75f1a6c1df1c225d256a8d281ad86cf1e3b42511edc2fe
8
8
  ---
9
9
 
10
10
  # MyCRMAPI
@@ -91,7 +91,7 @@ A contact promoted from Goldfox carries a `goldfox_person_id`. In v2 the GET res
91
91
  ### Contacts
92
92
  | Command | What it does |
93
93
  |---|---|
94
- | `myapi crm contacts list [--limit N]` | List all contacts (newest engagement first) |
94
+ | `myapi crm contacts list [--limit N] [--offset N]` | List all contacts (newest engagement first) |
95
95
  | `myapi crm contacts search [--stage ...] [--source ...] [--email ...] [--min/max-last-engagement-days N]` | Filter contacts |
96
96
  | `myapi crm contacts create <email> [--first-name ...] [--last-name ...] [--stage ...] [--custom-json ...]` | Manually create (source='manual') |
97
97
  | `myapi crm contacts get <id>` | Fetch one contact (with embedded Goldfox enrichment when available) |
@@ -158,11 +158,10 @@ myapi crm contacts events <id> --kind webhook_received
158
158
 
159
159
  ## Notes
160
160
 
161
- - **No pagination. Do not build a paging loop.** The API ignores `offset`
162
- (every page repeats page one), there is no cursor, and `total` is the page
163
- size, not the match count. The CLI rejects `--offset` rather than lie. If
164
- exactly `--limit` rows come back, assume more exist: raise `--limit` or
165
- narrow with filters.
161
+ - **Paginate with `--limit` + `--offset`.** `total` is the true match count
162
+ and the response carries `has_more`; branch on `has_more` rather than doing
163
+ arithmetic against `total`. (Both were broken until 2026-07-28. Cached
164
+ guidance saying the CRM cannot paginate is stale.)
166
165
  - **Reserved event kinds — no custom events in v1.** If an agent needs custom state per contact, use `myapi database` keyed by contact id. The curated timeline stays the authoritative engagement record.
167
166
  - **`external_id` on an event payload** is the backend's idempotency key — a duplicate of the action's natural id (`goldfox_person_id`, `delivery_id`). Read the semantic field instead; legacy `message_id` rows hold the same value.
168
167
  - **Only `webhook_received` fires today.** Email and pixel auto-ingest are coming; the CLI surface will not change.
@@ -15,7 +15,7 @@ Per-org KV store with namespaces. JSON values up to 256 KB per key. Compare-and-
15
15
  myapi database create my-app
16
16
  myapi database set users '{"alice":{"plan":"pro"}}' --ns my-app
17
17
  myapi database get users --ns my-app
18
- myapi database keys --ns my-app --values
18
+ myapi database entries --ns my-app --values
19
19
  ```
20
20
 
21
21
  ## Authentication
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Per-org KV store with named namespaces, JSON values up to 256 KB, prefix-scan listing, and compare-and-swap via etag. The substrate for any stateful agent-built app on MyAPI — user tables, session stores, idempotency keys, per-user lookup maps.
6
6
  triggers: [database, kv, key value, namespace, store, state, etag, cas, session, idempotency]
7
- checksum: sha256-b66f7ebdff5bce05eaab90c6812ffc0f9ec729f6df1dc4a8bca90368406b4c21
7
+ checksum: sha256-f098cb574a1773135b09cbe79bb75eb33e34b1dc90737aa80da2452bb53e972e
8
8
  ---
9
9
 
10
10
  # MyDatabaseAPI
@@ -51,7 +51,7 @@ Pass `--if-match <etag>` from a previous `get` to make `set` or `del` conditiona
51
51
  | `myapi database namespaces [--json]` | List namespaces in the org |
52
52
  | `myapi database create <name>` | Create a namespace |
53
53
  | `myapi database delete-namespace <name>` | Delete namespace AND all its keys (irreversible) |
54
- | `myapi database keys --ns <ns> [--prefix <p>] [--limit N] [--values] [--cursor <c>]` | List keys, optionally with inline values |
54
+ | `myapi database entries --ns <ns> [--prefix <p>] [--limit N] [--values] [--cursor <c>]` | List keys, optionally with inline values |
55
55
  | `myapi database get <key> --ns <ns>` | Get value + etag (etag printed to stderr) |
56
56
  | `myapi database set <key> <value-json> --ns <ns> [--if-match <etag>] [--file <path>]` | Set key. CAS via --if-match |
57
57
  | `myapi database del <key> --ns <ns> [--if-match <etag>]` | Delete key. CAS via --if-match |
@@ -62,7 +62,7 @@ Pass `-` as `<value-json>` to read the value from stdin, or `--file <path>` to r
62
62
  ## Examples
63
63
  <!-- llm:start -->
64
64
  ```bash
65
- # Create a namespace, set a key, read it back
65
+ # Create a namespace, set an entry, read it back
66
66
  myapi database create my-app
67
67
  myapi database set user:alice '{"plan":"pro","trial_ends":"2026-06-01"}' --ns my-app
68
68
  myapi database get user:alice --ns my-app
@@ -70,7 +70,7 @@ myapi database get user:alice --ns my-app
70
70
  # stderr: — etag=A1B2C3 · updated=2026-05-12T17:00:00Z
71
71
 
72
72
  # List keys with a prefix
73
- myapi database keys --ns my-app --prefix user: --values
73
+ myapi database entries --ns my-app --prefix user: --values
74
74
 
75
75
  # Compare-and-swap update
76
76
  ETAG=$(myapi database get user:alice --ns my-app --json | jq -r .etag)
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Register new domains and manage edge settings. Required before a funnel can go live on a custom URL.
6
6
  triggers: [domain, register domain, dns, custom domain, edge, cdn, security level, browser check, renew, namecheap]
7
- checksum: sha256-067ab2cc412c1f1f34aaeeec5890f9eb77f680f5b2d1c847e376336702cd8344
7
+ checksum: sha256-8f96d19c11c3591af71e9d73b2cea6d83f40abbfa1c58c5674dbe15db02efa3c
8
8
  ---
9
9
 
10
10
  # MyDomainAPI
@@ -88,7 +88,7 @@ myapi domain update-settings example.com \
88
88
  myapi domain import example.com
89
89
  # → Returns nameservers; set them at your current registrar.
90
90
  myapi domain status example.com --watch
91
- # → Polls until active. Backend live-checks Cloudflare each poll.
91
+ # → Polls until active. Backend live-checks the DNS provider each poll.
92
92
 
93
93
  # Fix a record after import (e.g. clean up SPF)
94
94
  myapi domain records list example.com --type TXT