@myapihq/cli 2.5.1 → 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.
- package/dist/commands/container.js +28 -8
- package/dist/commands/crm/companies.js +11 -5
- package/dist/commands/crm/contacts.js +11 -5
- package/dist/commands/crm/pagination.d.ts +6 -0
- package/dist/commands/crm/pagination.js +60 -0
- package/dist/commands/crm/pagination.test.d.ts +1 -0
- package/dist/commands/crm/pagination.test.js +73 -0
- package/dist/commands/pixel.js +5 -1
- package/dist/exposes.test.js +1 -0
- package/dist/sdk-container.test.js +18 -0
- package/dist/sdk-envelope.test.d.ts +1 -0
- package/dist/sdk-envelope.test.js +84 -0
- package/dist/skills/my-container-api/SKILL.md +23 -2
- package/dist/skills/my-crm-api/SKILL.md +9 -4
- package/dist/skills/my-storage-api/SKILL.md +24 -6
- package/package.json +3 -2
|
@@ -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
|
|
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
|
-
|
|
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;
|
|
@@ -382,10 +398,14 @@ Examples:
|
|
|
382
398
|
myapi container deploy <id> --source ./context.tar.gz`,
|
|
383
399
|
'list': 'myapi container list [--org <id>] [--json]',
|
|
384
400
|
'get': 'myapi container get <id> [--org <id>] [--json]',
|
|
385
|
-
'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).
|
|
386
405
|
|
|
387
|
-
|
|
388
|
-
|
|
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.`,
|
|
389
409
|
'domain': `myapi container domain <id> <domain> [--org <id>] [--json]
|
|
390
410
|
myapi container domain <id> --remove [--org <id>]
|
|
391
411
|
|
|
@@ -413,7 +433,7 @@ Subcommands:
|
|
|
413
433
|
domain <id> <domain> Bind a custom domain (--remove to unbind)
|
|
414
434
|
get <id> Inspect a container
|
|
415
435
|
list List containers in your org
|
|
416
|
-
logs <id> Show recent runtime logs (--tail <n
|
|
436
|
+
logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
|
|
417
437
|
|
|
418
438
|
All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
419
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
|
-
|
|
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] [--
|
|
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] [--
|
|
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
|
-
|
|
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] [--
|
|
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] [--
|
|
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
|
+
});
|
package/dist/commands/pixel.js
CHANGED
|
@@ -86,7 +86,11 @@ export async function visits(flags) {
|
|
|
86
86
|
to_url: v.to_url,
|
|
87
87
|
ts: v.ts,
|
|
88
88
|
})));
|
|
89
|
-
|
|
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}`);
|
|
90
94
|
}
|
|
91
95
|
// Engagement events (open / click / page_visit / sent) — filterable by
|
|
92
96
|
// campaign or by domain.
|
package/dist/exposes.test.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
-
|
|
163
|
-
- **
|
|
164
|
-
- **Free in v1.** Metered later if usage
|
|
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
|
|
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-
|
|
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
|
|
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 (
|
|
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.
|
|
63
|
-
- **`ingest`** — file is at a public HTTP(S) URL. Server downloads and stores. Useful for migrating assets
|
|
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.
|
|
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.
|
|
43
|
+
"@myapihq/sdk": "^2.6.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@types/node": "^25.6.0",
|