@myapihq/cli 2.5.0 → 2.6.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.
@@ -27,6 +27,7 @@ export const SCHEMA = {
27
27
  port: 'number',
28
28
  env: 'string',
29
29
  tail: 'number',
30
+ scope: 'string',
30
31
  remove: 'boolean',
31
32
  source: 'string',
32
33
  image: 'string',
@@ -285,14 +286,29 @@ export async function deploy(id, image, flags) {
285
286
  info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
286
287
  info(` ${result.scoped_api_key}`);
287
288
  }
288
- // logs prints the container's recent Cloud Run runtime logs, newest first.
289
+ // logs prints the container's recent runtime logs, newest first. By default
290
+ // this is the container's own stdout/stderr; --scope all adds the platform
291
+ // audit records that share the stream.
289
292
  export async function logs(id, flags) {
290
293
  const config = requireConfig();
291
- const orgId = requireOrg(flags, config, 'myapi container logs <id> [--tail <n>] [--org <id>]');
294
+ const orgId = requireOrg(flags, config, 'myapi container logs <id> [--tail <n>] [--scope all] [--org <id>]');
292
295
  if (!id)
293
- error('Missing id.\nUsage: myapi container logs <id> [--tail <n>]');
296
+ error('Missing id.\nUsage: myapi container logs <id> [--tail <n>] [--scope all]');
294
297
  const tail = typeof flags.tail === 'number' ? flags.tail : undefined;
295
- const entries = await sdkContainer.getContainerLogs(config.api_key, orgId, id, tail);
298
+ let scope;
299
+ if (flags.scope !== undefined) {
300
+ if (flags.scope !== 'container' && flags.scope !== 'all') {
301
+ error(`Invalid --scope "${flags.scope}". Use "container" (default) or "all".`);
302
+ }
303
+ scope = flags.scope;
304
+ }
305
+ // Audit records are multi-kilobyte JSON blobs on the same stream and share
306
+ // the tail budget, so at the default 100 they can push out every
307
+ // application line — which reads as "my container logged nothing".
308
+ if (scope === 'all' && tail === undefined) {
309
+ info('Note: --scope all includes platform audit records, which are large and share the --tail budget. Raise --tail if application lines are missing.');
310
+ }
311
+ const entries = await sdkContainer.getContainerLogs(config.api_key, orgId, id, tail, scope);
296
312
  if (flags.json) {
297
313
  printJson(entries);
298
314
  return;
@@ -336,12 +352,11 @@ export async function buildLogs(id, flags) {
336
352
  if (!id)
337
353
  error('Missing id.\nUsage: myapi container build-logs <id> [--tail N]');
338
354
  const tail = typeof flags.tail === 'number' ? flags.tail : undefined;
339
- const res = await sdkContainer.buildLogs(config.api_key, orgId, id, tail);
355
+ const lines = await sdkContainer.buildLogs(config.api_key, orgId, id, tail);
340
356
  if (flags.json) {
341
- printJson(res);
357
+ printJson(lines);
342
358
  return;
343
359
  }
344
- const lines = res.logs ?? [];
345
360
  if (lines.length === 0) {
346
361
  info('No build logs yet — has this container been deployed with --source?');
347
362
  return;
@@ -383,10 +398,14 @@ Examples:
383
398
  myapi container deploy <id> --source ./context.tar.gz`,
384
399
  'list': 'myapi container list [--org <id>] [--json]',
385
400
  'get': 'myapi container get <id> [--org <id>] [--json]',
386
- 'logs': `myapi container logs <id> [--tail <n>] [--org <id>] [--json]
401
+ 'logs': `myapi container logs <id> [--tail <n>] [--scope all] [--org <id>] [--json]
402
+
403
+ Recent runtime logs, newest first. --tail caps the count (default 100,
404
+ max 1000).
387
405
 
388
- Recent Cloud Run runtime logs, newest first. --tail caps the count
389
- (default 100, max 1000).`,
406
+ By default this shows the container's own stdout/stderr. --scope all also
407
+ returns platform audit records. They are multi-kilobyte JSON blobs on the
408
+ same stream and share the --tail budget, so raise --tail when you use it.`,
390
409
  'domain': `myapi container domain <id> <domain> [--org <id>] [--json]
391
410
  myapi container domain <id> --remove [--org <id>]
392
411
 
@@ -414,7 +433,7 @@ Subcommands:
414
433
  domain <id> <domain> Bind a custom domain (--remove to unbind)
415
434
  get <id> Inspect a container
416
435
  list List containers in your org
417
- logs <id> Show recent runtime logs (--tail <n>) — see build-logs for build failures
436
+ logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
418
437
 
419
438
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
420
439
  return;
@@ -3,6 +3,7 @@ import { crm } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../../config.js';
4
4
  import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
+ import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
6
7
  export const EXPOSES = [
7
8
  'POST /crm/orgs/{org_id}/companies',
8
9
  'POST /crm/orgs/{org_id}/companies/promote',
@@ -35,7 +36,6 @@ function buildSearchFilter(flags) {
35
36
  domain: typeof flags.domain === 'string' ? flags.domain : undefined,
36
37
  include_deleted: flags['include-deleted'] === true || undefined,
37
38
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
38
- offset: typeof flags.offset === 'number' ? flags.offset : undefined,
39
39
  };
40
40
  }
41
41
  function renderCompanies(res, flags) {
@@ -43,7 +43,8 @@ function renderCompanies(res, flags) {
43
43
  printJson(res);
44
44
  return;
45
45
  }
46
- info(`${res.companies.length} of ${res.total} compan${res.total === 1 ? 'y' : 'ies'}`);
46
+ // NOT `N of res.total` total is the page size, not the match count.
47
+ info(countLine(res.companies.length, 'company', 'companies'));
47
48
  printTable(res.companies.map(c => ({
48
49
  id: c.id,
49
50
  domain: c.domain ?? '',
@@ -57,17 +58,22 @@ function renderCompanies(res, flags) {
57
58
  async function search(flags) {
58
59
  const config = requireConfig();
59
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>]');
60
62
  const res = await crm.searchCompanies(config.api_key, orgId, buildSearchFilter(flags));
61
63
  renderCompanies(res, flags);
64
+ if (!flags.json)
65
+ warnIfTruncated(res.companies.length, flags);
62
66
  }
63
67
  async function list(flags) {
64
68
  const config = requireConfig();
65
69
  const orgId = requireOrg(flags, config, 'myapi crm companies list [--limit N] [--org <id>]');
70
+ rejectOffset(flags, 'myapi crm companies list [--limit N] [--org <id>]');
66
71
  const res = await crm.searchCompanies(config.api_key, orgId, {
67
72
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
68
- offset: typeof flags.offset === 'number' ? flags.offset : undefined,
69
73
  });
70
74
  renderCompanies(res, flags);
75
+ if (!flags.json)
76
+ warnIfTruncated(res.companies.length, flags);
71
77
  }
72
78
  async function create(domainArg, flags) {
73
79
  const config = requireConfig();
@@ -142,9 +148,9 @@ async function promote(domain, flags) {
142
148
  }
143
149
  // ── Dispatcher ──────────────────────────────────────────────────────────
144
150
  const SUBCOMMAND_USAGE = {
145
- list: 'myapi crm companies list [--limit N] [--offset N] [--org <id>] [--json]',
151
+ list: 'myapi crm companies list [--limit N] [--org <id>] [--json]',
146
152
  search: `myapi crm companies search [--stage <csv>] [--source <csv>] [--domain <d>]
147
- [--include-deleted] [--limit N] [--offset N] [--org <id>] [--json]
153
+ [--include-deleted] [--limit N] [--org <id>] [--json]
148
154
 
149
155
  --stage cold, warm, qualified, customer, churned
150
156
  --source goldfox, email, pixel, webhook, manual`,
@@ -3,6 +3,7 @@ import { crm } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../../config.js';
4
4
  import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
+ import { rejectOffset, warnIfTruncated, countLine } from './pagination.js';
6
7
  export const EXPOSES = [
7
8
  'POST /crm/orgs/{org_id}/contacts',
8
9
  'POST /crm/orgs/{org_id}/contacts/promote',
@@ -41,7 +42,6 @@ function buildSearchFilter(flags) {
41
42
  max_last_engagement_days: typeof flags['max-last-engagement-days'] === 'number' ? flags['max-last-engagement-days'] : undefined,
42
43
  include_deleted: flags['include-deleted'] === true || undefined,
43
44
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
44
- offset: typeof flags.offset === 'number' ? flags.offset : undefined,
45
45
  };
46
46
  }
47
47
  function renderContacts(res, flags) {
@@ -49,7 +49,8 @@ function renderContacts(res, flags) {
49
49
  printJson(res);
50
50
  return;
51
51
  }
52
- info(`${res.contacts.length} of ${res.total} contact${res.total === 1 ? '' : 's'}`);
52
+ // NOT `N of res.total` total is the page size, not the match count.
53
+ info(countLine(res.contacts.length, 'contact', 'contacts'));
53
54
  printTable(res.contacts.map(c => ({
54
55
  id: c.id,
55
56
  email: c.email ?? '',
@@ -64,18 +65,23 @@ function renderContacts(res, flags) {
64
65
  async function search(flags) {
65
66
  const config = requireConfig();
66
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>]');
67
69
  const res = await crm.searchContacts(config.api_key, orgId, buildSearchFilter(flags));
68
70
  renderContacts(res, flags);
71
+ if (!flags.json)
72
+ warnIfTruncated(res.contacts.length, flags);
69
73
  }
70
74
  // `list` is `search` with no filters — separate verb for discoverability.
71
75
  async function list(flags) {
72
76
  const config = requireConfig();
73
77
  const orgId = requireOrg(flags, config, 'myapi crm contacts list [--limit N] [--org <id>]');
78
+ rejectOffset(flags, 'myapi crm contacts list [--limit N] [--org <id>]');
74
79
  const res = await crm.searchContacts(config.api_key, orgId, {
75
80
  limit: typeof flags.limit === 'number' ? flags.limit : undefined,
76
- offset: typeof flags.offset === 'number' ? flags.offset : undefined,
77
81
  });
78
82
  renderContacts(res, flags);
83
+ if (!flags.json)
84
+ warnIfTruncated(res.contacts.length, flags);
79
85
  }
80
86
  async function create(emailArg, flags) {
81
87
  const config = requireConfig();
@@ -177,10 +183,10 @@ async function events(id, flags) {
177
183
  }
178
184
  // ── Dispatcher ──────────────────────────────────────────────────────────
179
185
  const SUBCOMMAND_USAGE = {
180
- list: 'myapi crm contacts list [--limit N] [--offset N] [--org <id>] [--json]',
186
+ list: 'myapi crm contacts list [--limit N] [--org <id>] [--json]',
181
187
  search: `myapi crm contacts search [--stage <csv>] [--source <csv>] [--email <e>]
182
188
  [--company-id <id>] [--min-last-engagement-days N] [--max-last-engagement-days N]
183
- [--include-deleted] [--limit N] [--offset N] [--org <id>] [--json]
189
+ [--include-deleted] [--limit N] [--org <id>] [--json]
184
190
 
185
191
  --stage cold, warm, qualified, customer, churned
186
192
  --source goldfox, email, pixel, webhook, manual
@@ -0,0 +1,6 @@
1
+ import { type Flags } from '../../helpers.js';
2
+ import type { Exposes } from '../../exposes.js';
3
+ export declare const EXPOSES: Exposes;
4
+ export declare function rejectOffset(flags: Flags, usage: string): void;
5
+ export declare function warnIfTruncated(returned: number, flags: Flags): void;
6
+ export declare function countLine(returned: number, singular: string, plural: string): string;
@@ -0,0 +1,60 @@
1
+ // CRM search/list pagination — what the API actually does, verified against
2
+ // production on 2026-07-27 (build c8b30009).
3
+ //
4
+ // Two facts, both discovered by lint-request-fields.js flagging that the SDK
5
+ // sends an `offset` the OpenAPI schema does not declare:
6
+ //
7
+ // 1. `offset` is ACCEPTED AND IGNORED. Against an org with three contacts,
8
+ // {limit:1, offset:0}, {limit:1, offset:1} and {limit:1, offset:2} all
9
+ // return the same first contact. This holds for contacts and companies,
10
+ // on both the POST /search and GET /contacts paths. There is no cursor
11
+ // parameter either.
12
+ //
13
+ // 2. `total` is the SIZE OF THE PAGE, not the number of matches. With three
14
+ // contacts in the org: limit=1 → total=1, limit=2 → total=2, limit=3 →
15
+ // total=3, limit=10 → total=3.
16
+ //
17
+ // Together those make the result set silently lossy. An agent with 500
18
+ // contacts running `--limit 100` receives 100 records and a `total` of 100,
19
+ // and has no signal that 400 more exist. This is the worst shape a data bug
20
+ // can take for an unattended caller: it looks like a complete answer.
21
+ //
22
+ // Filed upstream. Until it is fixed the CLI refuses `--offset` rather than
23
+ // sending a parameter that does nothing, and never prints `total` as though
24
+ // it were a match count.
25
+ import { error, info } from '../../output.js';
26
+ // A helper, not a command: it calls nothing. Declared empty rather than
27
+ // exempted, so the coverage gate's "every module states its surface" rule
28
+ // stays absolute.
29
+ export const EXPOSES = [];
30
+ // Server-side default page size when no --limit is given. The API applies
31
+ // this; we only need it to know whether a result may have been truncated.
32
+ const DEFAULT_LIMIT = 50;
33
+ // Rejects --offset outright. Silently dropping it would reproduce the bug we
34
+ // are protecting against, and passing it through means duplicate pages.
35
+ export function rejectOffset(flags, usage) {
36
+ if (flags.offset === undefined)
37
+ return;
38
+ error('The CRM API accepts --offset and ignores it, so every page would return the same records.\n' +
39
+ 'Verified against production: with --limit 1, offsets 0, 1 and 2 all return the first match.\n\n' +
40
+ 'There is no cursor parameter either, so the CRM has no working pagination today.\n' +
41
+ 'Raise --limit to fetch more in one call, and narrow the result set with filters\n' +
42
+ '(--stage, --source, --company-id, --email) rather than paging through it.\n\n' +
43
+ `Usage: ${usage}`);
44
+ }
45
+ // A page that came back exactly full is indistinguishable from a truncated
46
+ // one, and `total` cannot tell them apart. Say so, rather than letting the
47
+ // caller assume the answer is complete.
48
+ export function warnIfTruncated(returned, flags) {
49
+ const limit = typeof flags.limit === 'number' ? flags.limit : DEFAULT_LIMIT;
50
+ if (returned < limit)
51
+ return;
52
+ info(`Note: exactly ${returned} record${returned === 1 ? '' : 's'} came back, which is the page limit. ` +
53
+ 'There may be more, and the API reports no match count and offers no way to fetch the next page. ' +
54
+ 'Raise --limit or add filters.');
55
+ }
56
+ // The API returns `total`, but it equals the page size, so rendering "N of
57
+ // total" states something false. Callers use this instead.
58
+ export function countLine(returned, singular, plural) {
59
+ return `${returned} ${returned === 1 ? singular : plural}`;
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ // Regression cover for the CRM pagination guards.
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/);
72
+ });
73
+ });
@@ -4,6 +4,7 @@ import { success, error, printTable, info, printJson } from '../output.js';
4
4
  import { requireOrg } from '../helpers.js';
5
5
  export const SCHEMA = {
6
6
  'external-id': 'string',
7
+ email: 'string',
7
8
  org: 'string',
8
9
  website: 'string',
9
10
  'campaign-id': 'string',
@@ -85,7 +86,11 @@ export async function visits(flags) {
85
86
  to_url: v.to_url,
86
87
  ts: v.ts,
87
88
  })));
88
- info(`Total: ${res.total} | Showing: ${res.limit} | Offset: ${res.offset}`);
89
+ // /visits returns only {visits, total} it does NOT echo limit/offset the
90
+ // way /events and /interactions do, so this used to print
91
+ // "Showing: undefined | Offset: undefined". Report what the response
92
+ // actually carries.
93
+ info(`Total: ${res.total} | Showing: ${res.visits.length}`);
89
94
  }
90
95
  // Engagement events (open / click / page_visit / sent) — filterable by
91
96
  // campaign or by domain.
@@ -13,6 +13,7 @@ const COMMAND_MODULES = [
13
13
  './commands/crm/index.js',
14
14
  './commands/crm/contacts.js',
15
15
  './commands/crm/companies.js',
16
+ './commands/crm/pagination.js',
16
17
  './commands/database.js',
17
18
  './commands/domain.js',
18
19
  './commands/email/index.js',
@@ -124,6 +124,24 @@ describe('container.getContainerLogs', () => {
124
124
  fetchMock.mockResolvedValueOnce(ok([]));
125
125
  expect(await container.getContainerLogs(API_KEY, ORG_ID, C_ID)).toEqual([]);
126
126
  });
127
+ it('appends ?scope=all only for scope="all"', async () => {
128
+ fetchMock.mockResolvedValueOnce(ok([]));
129
+ await container.getContainerLogs(API_KEY, ORG_ID, C_ID, undefined, 'all');
130
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/logs\?scope=all$/);
131
+ });
132
+ // 'container' is the server default, so sending it explicitly would be
133
+ // noise — and an undeclared query param is the kind of thing that starts
134
+ // being validated later.
135
+ it('omits scope entirely for the default scope', async () => {
136
+ fetchMock.mockResolvedValueOnce(ok([]));
137
+ await container.getContainerLogs(API_KEY, ORG_ID, C_ID, undefined, 'container');
138
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/logs$/);
139
+ });
140
+ it('combines tail and scope', async () => {
141
+ fetchMock.mockResolvedValueOnce(ok([]));
142
+ await container.getContainerLogs(API_KEY, ORG_ID, C_ID, 500, 'all');
143
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/logs\?tail=500&scope=all$/);
144
+ });
127
145
  });
128
146
  describe('container.EXPOSES', () => {
129
147
  it('covers the 9 container endpoints', () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ // Envelope tolerance in the SDK's response parser (packages/sdk/src/client.ts).
2
+ //
3
+ // Most of the platform answers {success, data, error, meta}. A few routes
4
+ // answer 2xx with a bare object instead. Requiring `success` meant those
5
+ // routes surfaced as a bare `unknown_error` while their data sat unread in the
6
+ // response body — `pixel identity` and `pixel events` both shipped broken that
7
+ // way, and the second was only found by calling it with real parameters.
8
+ //
9
+ // The rule these tests pin down: the HTTP STATUS decides success. When the
10
+ // body carries no envelope, the body is the data. When it does carry one, the
11
+ // envelope still wins, so a {success:false} 200 is still an error.
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13
+ import { request, MyApiError } from '@myapihq/sdk';
14
+ const API_KEY = 'myapi_test_abc';
15
+ const URL = 'https://api.example.com/thing';
16
+ let fetchMock;
17
+ function res(body, status = 200) {
18
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
19
+ status,
20
+ headers: { 'content-type': 'application/json' },
21
+ });
22
+ }
23
+ beforeEach(() => {
24
+ fetchMock = vi.fn();
25
+ vi.stubGlobal('fetch', fetchMock);
26
+ });
27
+ afterEach(() => vi.unstubAllGlobals());
28
+ describe('enveloped responses keep their existing behaviour', () => {
29
+ it('unwraps data', async () => {
30
+ fetchMock.mockResolvedValueOnce(res({ success: true, data: { id: 7 }, meta: {} }));
31
+ expect(await request('GET', URL, API_KEY)).toEqual({ id: 7 });
32
+ });
33
+ it('still throws on {success:false} even with a 200', async () => {
34
+ fetchMock.mockResolvedValueOnce(res({ success: false, error: { code: 'nope' } }));
35
+ await expect(request('GET', URL, API_KEY)).rejects.toThrow(MyApiError);
36
+ });
37
+ it('still throws on a non-2xx', async () => {
38
+ fetchMock.mockResolvedValueOnce(res({ success: false, error: { code: 'bad' } }, 400));
39
+ await expect(request('GET', URL, API_KEY)).rejects.toThrow(MyApiError);
40
+ });
41
+ });
42
+ describe('bare responses are treated as data', () => {
43
+ // The exact shape /pixel/orgs/{org}/events returns.
44
+ it('returns the whole body when there is no success key', async () => {
45
+ fetchMock.mockResolvedValueOnce(res({ events: [], limit: 1, offset: 0, total: 0 }));
46
+ expect(await request('GET', URL, API_KEY)).toEqual({ events: [], limit: 1, offset: 0, total: 0 });
47
+ });
48
+ it('does not mistake a bare body for a failure', async () => {
49
+ fetchMock.mockResolvedValueOnce(res({ pixel_id: 'abc', emails: [] }));
50
+ await expect(request('GET', URL, API_KEY)).resolves.toBeTruthy();
51
+ });
52
+ it('handles a bare array', async () => {
53
+ fetchMock.mockResolvedValueOnce(res([1, 2, 3]));
54
+ expect(await request('GET', URL, API_KEY)).toEqual([1, 2, 3]);
55
+ });
56
+ // A bare body on an ERROR status must still raise — the status is the
57
+ // authority, so tolerance must not swallow failures.
58
+ it('still throws when a bare body arrives with a non-2xx', async () => {
59
+ fetchMock.mockResolvedValueOnce(res({ message: 'nope' }, 500));
60
+ await expect(request('GET', URL, API_KEY)).rejects.toThrow(MyApiError);
61
+ });
62
+ // A field literally named `success` on a bare payload would be
63
+ // indistinguishable from an envelope. Documented as a known limit rather
64
+ // than silently mishandled: an un-enveloped body carrying success:false
65
+ // will be read as an error.
66
+ it('KNOWN LIMIT: a bare body with a success field is read as an envelope', async () => {
67
+ fetchMock.mockResolvedValueOnce(res({ success: false, name: 'a real record' }));
68
+ await expect(request('GET', URL, API_KEY)).rejects.toThrow(MyApiError);
69
+ });
70
+ });
71
+ describe('empty and edge bodies', () => {
72
+ it('204 stays null', async () => {
73
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
74
+ expect(await request('DELETE', URL, API_KEY)).toBeNull();
75
+ });
76
+ it('an empty 200 body parses to an empty object, not an error', async () => {
77
+ fetchMock.mockResolvedValueOnce(new Response('', { status: 200, headers: { 'content-type': 'application/json' } }));
78
+ expect(await request('GET', URL, API_KEY)).toEqual({});
79
+ });
80
+ it('non-JSON still raises invalid_json_response', async () => {
81
+ fetchMock.mockResolvedValueOnce(new Response('<html>502</html>', { status: 200, headers: { 'content-type': 'text/html' } }));
82
+ await expect(request('GET', URL, API_KEY)).rejects.toThrow(/invalid_json_response/);
83
+ });
84
+ });
@@ -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-0bd2fdee306f944702faf18b0cbb5d976a2bdc39c1f940c037600b0c0f6a4691
7
+ checksum: sha256-d25fff35df7bf67bebabb6cc228f322b108d8472035cbda70756d720b8db502d
8
8
  ---
9
9
 
10
10
  # MyContainerAPI
@@ -39,7 +39,7 @@ Get it right:
39
39
  | `myapi container deploy <id> <image-ref>` | Ship a pre-built image and go live (rotates the scoped key) |
40
40
  | `myapi container list` | List containers in your org |
41
41
  | `myapi container get <id>` | Inspect a container (status, URL, custom domain) |
42
- | `myapi container logs <id> [--tail <n>]` | Recent runtime logs, newest first |
42
+ | `myapi container logs <id> [--tail <n>] [--scope all]` | Recent runtime logs, newest first (`--scope all` adds platform audit records) |
43
43
  | `myapi container domain <id> <domain>` | Bind a custom domain (`--remove` to unbind) |
44
44
  | `myapi container delete <id>` | Soft-delete and revoke its scoped API key |
45
45
  <!-- generated:end -->
@@ -67,10 +67,31 @@ myapi container domain <id> --remove
67
67
  ```
68
68
  <!-- llm:end -->
69
69
 
70
+ ## Runtime constraints
71
+
72
+ These are properties of the platform, not of your code. Each one fails in a
73
+ way that looks like an application bug.
74
+
75
+ - **A `service` is stateless.** It scales to zero *and* up to `--max-instances`
76
+ (default 3). Each instance holds its own memory, so in-process state returns
77
+ wrong answers under concurrency instead of erroring. Put state in
78
+ `my-database-api`.
79
+ - **`/healthz` never reaches your container** — the runtime intercepts it. Use
80
+ `/livez`, or any other path.
81
+ - **Request bodies are capped at 32MB.**
82
+ - **Any 5xx from your code is replaced by an edge HTML error page.** A JSON
83
+ error envelope will not reach the caller. Return a 4xx if the reason has to
84
+ survive.
85
+ - **`--env`, `--cpu`, `--memory`, `--max-instances` and `--cron` are set at
86
+ `create` and cannot be changed by `deploy`.** Passing them to `deploy` does
87
+ nothing. Recreate the container to change them.
88
+
70
89
  ## Notes
71
90
 
72
91
  - The scoped API key is shown **once** at create, and again (rotated) on every deploy. Save it if your code needs it.
73
92
  - A `worker` is forced to ≥1 instance; a `service` scales to zero; only a `job` accepts `--cron`.
93
+ - `logs --scope all` adds platform audit records. They are large JSON blobs on
94
+ the same stream and share the `--tail` budget, so raise `--tail` with it.
74
95
  - Custom domains need a deployed container **and** a MyAPI-registered parent domain — see `my-domain-api`.
75
96
  - Containers are for dynamic apps and native deps. For static sites use `my-funnel-api`; for edge functions use `my-function-api`.
76
97
 
@@ -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-1e1e7a8c4674f7a109f5bb0b5e9f053e938d2ee36d56c6ce8a4276826f7d9609
7
+ checksum: sha256-085cb990a292ba815d7c59a58fb25816d52bf33ad5019a7d90abf4123383a67c
8
8
  ---
9
9
 
10
10
  # MyCRMAPI
@@ -158,9 +158,14 @@ 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
166
  - **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.
162
- - **Event payloads carry an `external_id`** the backend's idempotency key, equal to the underlying action's natural id (`goldfox_person_id` for `promoted`, `delivery_id` for `webhook_received`). Read the semantic field; `external_id` is a backend-internal duplicate. A legacy `message_id` on old rows holds the same value — safe to ignore.
163
- - **Email + Pixel auto-ingest not yet wired**. Only `webhook_received` events fire today. Email and pixel ingest are coming the CLI surface stays unchanged when they land.
164
- - **Free in v1.** Metered later if usage shows a need.
167
+ - **`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
+ - **Only `webhook_received` fires today.** Email and pixel auto-ingest are coming; the CLI surface will not change.
169
+ - **Free in v1.** Metered later if usage warrants.
165
170
 
166
171
  Run `myapi crm --help` or `myapi crm <namespace> --help` for inline reference.
@@ -2,9 +2,9 @@
2
2
  name: my-storage-api
3
3
  version: 1.0.0
4
4
  description: >
5
- Edge-hosted asset storage. Upload local files (.png/.jpg/.gif/.webp) directly, or have the server fetch from a public URL. Each asset gets a stable public CDN URL.
5
+ Edge-hosted asset storage. Upload a local file of any content type directly, or have the server fetch from a public URL. Each asset gets a stable public CDN URL.
6
6
  triggers: [storage, upload, ingest, asset, cdn, image hosting, file upload, get-url, download, public url]
7
- checksum: sha256-80f78e83d086ec5e312abbb2fc7c85a2c9a87043a94b7e4fccc13902d5455df1
7
+ checksum: sha256-b55e3f0aa592330cadfdc2b87e730333300bfffb08866b3bc59cb0ce15d2f953
8
8
  ---
9
9
 
10
10
  # MyStorageAPI
@@ -15,7 +15,25 @@ Per-org asset storage with edge CDN delivery. Two ways in: direct upload (local
15
15
  <!-- llm:start -->
16
16
  Storage is the asset layer. Anything you upload or ingest is served from the edge under a permanent public URL — embed it in funnel pages, email templates, anywhere.
17
17
 
18
- `upload` accepts `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.pdf`, `.mp4`, `.webm` from the local filesystem. For any other extension, host the file somewhere public and use `ingest` instead — the server fetches it for you.
18
+ `upload` accepts **any content type** from the local filesystem. There is no
19
+ extension allowlist — an unrecognized type is stored as an opaque blob and
20
+ served back as it was sent. Pass `--content-type` when the extension does not
21
+ imply the type you want.
22
+
23
+ Size caps depend on the detected type, not on the extension:
24
+
25
+ | Detected type | Cap |
26
+ |---|---|
27
+ | Images (`jpeg`, `png`, `gif`, `webp`, `svg`) | 10MB |
28
+ | Video (`mp4`, `webm`) | 200MB |
29
+ | PDF and everything else | 25MB |
30
+
31
+ Use `upload` (multipart) to get the full per-type cap. A raw-body upload is
32
+ capped at 10MB regardless of type and is refused above it with
33
+ `FILE_TOO_LARGE`.
34
+
35
+ Use `ingest` when the file is already at a public URL and you would rather the
36
+ server fetch it than upload it yourself — not because `upload` refuses the type.
19
37
 
20
38
  Generated images from **myimageapi** automatically land here; they appear in `storage list` under their job id.
21
39
 
@@ -27,7 +45,7 @@ Use `get` for a round-trip metadata fetch. Use `get-url` when you just need the
27
45
  | Command | What it does |
28
46
  |---|---|
29
47
  | `myapi storage list` | List all stored assets |
30
- | `myapi storage upload <file>` | Direct multipart upload of a local file (image/svg/pdf/mp4/webm) |
48
+ | `myapi storage upload <file>` | Direct multipart upload of a local file (any content type) |
31
49
  | `myapi storage ingest <url>` | Server fetches a public URL into storage |
32
50
  | `myapi storage get <asset_id>` | Round-trip the API for full metadata (`--json` or human block) |
33
51
  | `myapi storage get-url <asset_id>` | Pure local URL constructor — no API call, no auth |
@@ -59,8 +77,8 @@ myapi storage delete <asset_id>
59
77
 
60
78
  ## Upload vs Ingest
61
79
 
62
- - **`upload`** — file is on your machine. Multipart POST. Supported extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.pdf`, `.mp4`, `.webm`.
63
- - **`ingest`** — file is at a public HTTP(S) URL. Server downloads and stores. Useful for migrating assets, pulling in third-party files you have rights to, or for extensions `upload` doesn't accept.
80
+ - **`upload`** — file is on your machine. Multipart POST. Any content type. The CLI infers `Content-Type` from the extension for the common ones; for anything else pass `--content-type` or accept `application/octet-stream`.
81
+ - **`ingest`** — file is at a public HTTP(S) URL. Server downloads and stores. Useful for migrating assets or pulling in third-party files you have rights to.
64
82
 
65
83
  Both produce identical asset records — `list` doesn't distinguish.
66
84
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -34,12 +34,13 @@
34
34
  "lint:changelog": "node ../../scripts/lint-changelog.js",
35
35
  "lint:help-order": "node scripts/lint-help-order.js",
36
36
  "lint:exposes": "node scripts/lint-exposes.js",
37
+ "lint:request-fields": "node scripts/lint-request-fields.js",
37
38
  "lint:docs": "node scripts/lint-docs.js",
38
39
  "lint:skills": "node scripts/copy-skills.js && node scripts/lint-skills.js",
39
40
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
40
41
  },
41
42
  "dependencies": {
42
- "@myapihq/sdk": "^2.5.0"
43
+ "@myapihq/sdk": "^2.6.0"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@types/node": "^25.6.0",