@myapihq/cli 2.5.1 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/container.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/doctor-findings.test.d.ts +1 -0
- package/dist/commands/doctor-findings.test.js +100 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +157 -30
- 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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Regression cover for three doctor findings reported from a live production
|
|
2
|
+
// org, where two of four warnings described healthy things as broken.
|
|
3
|
+
//
|
|
4
|
+
// The shared defect: the check reported a CONCLUSION ("customers may not be
|
|
5
|
+
// able to reach this", "no email inbox configured") where it only had an
|
|
6
|
+
// OBSERVATION ("it answered 401", "the platform's counter said zero"). Each
|
|
7
|
+
// test below pins the observation and refuses the conclusion.
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { classifyReachability, _setupSection } from './doctor.js';
|
|
10
|
+
const ENTITY = { slot: 'container', name: 'skout-engine-prod' };
|
|
11
|
+
describe('classifyReachability', () => {
|
|
12
|
+
// The finding: two containers behind an auth boundary were reported as
|
|
13
|
+
// "customers may not be able to reach" them. Both were up. A 401 to an
|
|
14
|
+
// anonymous probe is the designed answer and proves liveness.
|
|
15
|
+
it('treats 401 as reachable, not as a warning', () => {
|
|
16
|
+
const v = classifyReachability({ status: 401 }, ENTITY);
|
|
17
|
+
expect(v.severity).toBe('ok');
|
|
18
|
+
expect(v.message).toMatch(/reachable, authentication required/);
|
|
19
|
+
});
|
|
20
|
+
it('treats 403 the same way', () => {
|
|
21
|
+
expect(classifyReachability({ status: 403 }, ENTITY).severity).toBe('ok');
|
|
22
|
+
});
|
|
23
|
+
it('never tells the user customers cannot reach a URL that answered', () => {
|
|
24
|
+
for (const status of [200, 301, 401, 403, 404, 418, 500, 503]) {
|
|
25
|
+
const v = classifyReachability({ status }, ENTITY);
|
|
26
|
+
expect(v.hint ?? '').not.toMatch(/may not be able to reach/);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
// Only a probe that got no answer at all is unreachable. That is the one
|
|
30
|
+
// case where the old wording was right.
|
|
31
|
+
it('reports a network failure as unreachable, and says why', () => {
|
|
32
|
+
const v = classifyReachability({ status: null, error: 'ETIMEDOUT' }, ENTITY);
|
|
33
|
+
expect(v.severity).toBe('warn');
|
|
34
|
+
expect(v.message).toMatch(/unreachable/);
|
|
35
|
+
expect(v.hint).toBe('ETIMEDOUT');
|
|
36
|
+
});
|
|
37
|
+
it('falls back to a customer-facing hint when there is no error string', () => {
|
|
38
|
+
const v = classifyReachability({ status: null }, ENTITY);
|
|
39
|
+
expect(v.hint).toMatch(/nothing answered/);
|
|
40
|
+
});
|
|
41
|
+
// Still warns, but distinguishes "the host answered and served nothing"
|
|
42
|
+
// from "the host is down" — a different problem with a different fix.
|
|
43
|
+
it('warns on 404 while stating the host answered', () => {
|
|
44
|
+
const v = classifyReachability({ status: 404 }, ENTITY);
|
|
45
|
+
expect(v.severity).toBe('warn');
|
|
46
|
+
expect(v.message).toMatch(/reachable, but nothing is served/);
|
|
47
|
+
});
|
|
48
|
+
it('warns on 5xx as an application error, not an outage', () => {
|
|
49
|
+
const v = classifyReachability({ status: 503 }, ENTITY);
|
|
50
|
+
expect(v.severity).toBe('warn');
|
|
51
|
+
expect(v.message).toMatch(/the application is erroring/);
|
|
52
|
+
expect(v.hint).toMatch(/logs/);
|
|
53
|
+
});
|
|
54
|
+
it('handles an entity with no name without printing undefined', () => {
|
|
55
|
+
const v = classifyReachability({ status: null }, { slot: 'funnel' });
|
|
56
|
+
expect(v.hint ?? '').not.toMatch(/undefined/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
// ── setup gaps ──────────────────────────────────────────────────────────────
|
|
60
|
+
function report(sections) {
|
|
61
|
+
return {
|
|
62
|
+
org_id: '11111111-1111-4111-8111-111111111111',
|
|
63
|
+
sections: sections.map(s => ({ name: '', summary: '', issues: [], ...s })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const ZERO_EMAILS = report([{ name: 'emails', resource_count: 0, issues: [] }]);
|
|
67
|
+
function setupMessages(r, ctx) {
|
|
68
|
+
return (_setupSection(r, ctx)?.issues ?? []).map(i => ({ message: i.message, hint: i.hint ?? '' }));
|
|
69
|
+
}
|
|
70
|
+
describe('setup gaps are confirmed before they become instructions', () => {
|
|
71
|
+
// The finding: the platform reported resource_count 0 for an org with two
|
|
72
|
+
// active mailboxes, one of which had received mail 14 hours earlier. The
|
|
73
|
+
// doctor turned that into "Create one with: myapi email mailbox create …".
|
|
74
|
+
it('does not tell you to create a mailbox when we counted some', () => {
|
|
75
|
+
const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 2 });
|
|
76
|
+
expect(issue.message).toMatch(/but 2 are configured/);
|
|
77
|
+
expect(issue.hint).toMatch(/Do not create another/);
|
|
78
|
+
expect(issue.hint).not.toMatch(/mailbox create/);
|
|
79
|
+
});
|
|
80
|
+
it('still reports a real gap when our own count agrees', () => {
|
|
81
|
+
const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 0 });
|
|
82
|
+
expect(issue.message).toBe('No email inbox configured');
|
|
83
|
+
expect(issue.hint).toMatch(/mailbox create/);
|
|
84
|
+
});
|
|
85
|
+
// A failed enumeration is not evidence of absence OR presence, so the
|
|
86
|
+
// backend's view stands rather than us inventing a disagreement.
|
|
87
|
+
it('defers to the platform when we could not count', () => {
|
|
88
|
+
const [issue] = setupMessages(ZERO_EMAILS, {});
|
|
89
|
+
expect(issue.message).toBe('No email inbox configured');
|
|
90
|
+
});
|
|
91
|
+
it('gets the singular right', () => {
|
|
92
|
+
const [issue] = setupMessages(ZERO_EMAILS, { mailboxCount: 1 });
|
|
93
|
+
expect(issue.message).toMatch(/but 1 is configured/);
|
|
94
|
+
});
|
|
95
|
+
it('applies the same rule to domains', () => {
|
|
96
|
+
const r = report([{ name: 'domains', resource_count: 0, issues: [] }]);
|
|
97
|
+
expect(setupMessages(r, { domainCount: 3 })[0].message).toMatch(/but 3 are configured/);
|
|
98
|
+
expect(setupMessages(r, { domainCount: 0 })[0].hint).toMatch(/domain register/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -14,6 +14,19 @@ export declare function _tallyTotals(sections: sdkHq.DoctorSection[]): DoctorTot
|
|
|
14
14
|
export declare function sanitizeHint(hint: string | undefined): string | undefined;
|
|
15
15
|
export interface SetupContext {
|
|
16
16
|
mailingAddress?: string | null;
|
|
17
|
+
mailboxCount?: number;
|
|
18
|
+
domainCount?: number;
|
|
17
19
|
}
|
|
18
20
|
export declare function _setupSection(report: sdkHq.DoctorReport, ctx?: SetupContext): sdkHq.DoctorSection | null;
|
|
21
|
+
export declare function classifyReachability(probe: {
|
|
22
|
+
status: number | null;
|
|
23
|
+
error?: string;
|
|
24
|
+
}, entity: {
|
|
25
|
+
slot: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
}): {
|
|
28
|
+
severity: 'ok' | 'warn';
|
|
29
|
+
message: string;
|
|
30
|
+
hint?: string;
|
|
31
|
+
};
|
|
19
32
|
export declare function run(_subcommand: string | undefined, _args: string[], flags: Flags): Promise<void>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// interleave with the backend's findings.
|
|
18
18
|
import { promises as dns } from 'node:dns';
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
|
-
import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel } from '@myapihq/sdk';
|
|
20
|
+
import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel, domain as sdkDomain, email as sdkEmail } from '@myapihq/sdk';
|
|
21
21
|
import { requireConfig } from '../config.js';
|
|
22
22
|
import { info, error, printJson } from '../output.js';
|
|
23
23
|
import { requireOrg } from '../helpers.js';
|
|
@@ -116,6 +116,32 @@ function isZeroState(section) {
|
|
|
116
116
|
return section.resource_count === 0;
|
|
117
117
|
return section.issues.length === 0;
|
|
118
118
|
}
|
|
119
|
+
// Emits the "you have not set this up" warning ONLY when our own count agrees
|
|
120
|
+
// with the backend's. When we counted resources the backend says do not
|
|
121
|
+
// exist, the disagreement replaces the advice — it is both true and more
|
|
122
|
+
// useful, and it never sends anyone to recreate something that already
|
|
123
|
+
// exists. When we did not count (`counted` undefined), the backend's view
|
|
124
|
+
// stands, since a missing check is not evidence either way.
|
|
125
|
+
function confirmedGap(o) {
|
|
126
|
+
if (typeof o.counted === 'number' && o.counted > 0) {
|
|
127
|
+
return {
|
|
128
|
+
id: localIssueId(`${o.idKey}_disagreement`, o.orgId),
|
|
129
|
+
severity: 'warn',
|
|
130
|
+
scope: o.scope,
|
|
131
|
+
category: 'setup',
|
|
132
|
+
message: `Platform reports no ${o.noun} for this org, but ${o.counted} ${o.counted === 1 ? 'is' : 'are'} configured`,
|
|
133
|
+
hint: `This is a reporting bug, not a setup gap — your ${o.noun}${o.counted === 1 ? '' : 'es'} ${o.counted === 1 ? 'is' : 'are'} fine. Do not create another.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
id: localIssueId(o.idKey, o.orgId),
|
|
138
|
+
severity: 'warn',
|
|
139
|
+
scope: o.scope,
|
|
140
|
+
category: 'setup',
|
|
141
|
+
message: o.absent,
|
|
142
|
+
hint: o.hint,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
119
145
|
export function _setupSection(report, ctx = {}) {
|
|
120
146
|
const byName = new Map();
|
|
121
147
|
for (const s of report.sections)
|
|
@@ -123,25 +149,27 @@ export function _setupSection(report, ctx = {}) {
|
|
|
123
149
|
const issues = [];
|
|
124
150
|
const dom = byName.get('domains');
|
|
125
151
|
if (dom && isZeroState(dom)) {
|
|
126
|
-
issues.push({
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
issues.push(confirmedGap({
|
|
153
|
+
orgId: report.org_id,
|
|
154
|
+
idKey: 'setup_no_domain',
|
|
129
155
|
scope: 'setup/domain',
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
counted: ctx.domainCount,
|
|
157
|
+
noun: 'domain',
|
|
158
|
+
absent: 'No domain registered for this org',
|
|
132
159
|
hint: 'Register one with: myapi domain register <domain> && myapi domain assign <domain>',
|
|
133
|
-
});
|
|
160
|
+
}));
|
|
134
161
|
}
|
|
135
162
|
const em = byName.get('emails');
|
|
136
163
|
if (em && isZeroState(em)) {
|
|
137
|
-
issues.push({
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
issues.push(confirmedGap({
|
|
165
|
+
orgId: report.org_id,
|
|
166
|
+
idKey: 'setup_no_mailbox',
|
|
140
167
|
scope: 'setup/email-inbox',
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
counted: ctx.mailboxCount,
|
|
169
|
+
noun: 'email inbox',
|
|
170
|
+
absent: 'No email inbox configured',
|
|
143
171
|
hint: 'Create one with: myapi email mailbox create <username>@<your-domain>',
|
|
144
|
-
});
|
|
172
|
+
}));
|
|
145
173
|
}
|
|
146
174
|
// Account-scoped (so use report.org_id only for the dedup id, not as
|
|
147
175
|
// entity scope). Hard-gates every transactional `email send` —
|
|
@@ -253,17 +281,30 @@ async function httpProbeSection(apiKey, orgId) {
|
|
|
253
281
|
}
|
|
254
282
|
catch { /* augmentation is best-effort — skip the slot if enumeration fails */ }
|
|
255
283
|
// Funnels: probe every published page URL.
|
|
284
|
+
//
|
|
285
|
+
// And probe the funnel's own subdomain when the page list comes back empty.
|
|
286
|
+
// A funnel serving a live site can report an empty inventory — verified
|
|
287
|
+
// 2026-07-28 on a funnel answering 200 with real content on both its
|
|
288
|
+
// subdomain and a bound custom domain while `GET .../pages` returned
|
|
289
|
+
// `{"pages":[]}`. Trusting that list meant the org's public landing page,
|
|
290
|
+
// the single most customer-visible URL it has, was silently dropped from
|
|
291
|
+
// the probe. A check that quietly narrows its own scope is worse than one
|
|
292
|
+
// that fails, so fall back rather than skip.
|
|
256
293
|
try {
|
|
257
294
|
const funnels = await sdkFunnel.listFunnels(apiKey, orgId);
|
|
258
295
|
await Promise.all(funnels.map(async (f) => {
|
|
296
|
+
const name = f.name || f.id;
|
|
297
|
+
let pageUrls = [];
|
|
259
298
|
try {
|
|
260
299
|
const pages = await sdkFunnel.listFunnelPages(apiKey, orgId, f.id);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
300
|
+
pageUrls = pages.map(p => p.url).filter((u) => !!u);
|
|
301
|
+
}
|
|
302
|
+
catch { /* fall through to the subdomain */ }
|
|
303
|
+
if (pageUrls.length === 0 && f.subdomain_url)
|
|
304
|
+
pageUrls = [f.subdomain_url];
|
|
305
|
+
for (const url of pageUrls) {
|
|
306
|
+
targets.push({ url, entity: { slot: 'funnel', id: f.id, name } });
|
|
265
307
|
}
|
|
266
|
-
catch { /* skip this funnel */ }
|
|
267
308
|
}));
|
|
268
309
|
}
|
|
269
310
|
catch { /* skip the slot */ }
|
|
@@ -272,29 +313,84 @@ async function httpProbeSection(apiKey, orgId) {
|
|
|
272
313
|
const issues = [];
|
|
273
314
|
await Promise.all(targets.map(async ({ url, entity }) => {
|
|
274
315
|
const probe = await fetchStatus(url);
|
|
275
|
-
|
|
276
|
-
const ok = probe.status != null && probe.status < 400;
|
|
316
|
+
const verdict = classifyReachability(probe, entity);
|
|
277
317
|
issues.push({
|
|
278
|
-
id: localIssueId(ok ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
|
|
279
|
-
severity:
|
|
318
|
+
id: localIssueId(verdict.severity === 'ok' ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
|
|
319
|
+
severity: verdict.severity,
|
|
280
320
|
scope: `local/${url}`,
|
|
281
321
|
entity,
|
|
282
322
|
category: 'network',
|
|
283
|
-
message:
|
|
284
|
-
|
|
285
|
-
: `${url} ${probe.status != null ? `returned ${probe.status}` : 'is unreachable'}`,
|
|
286
|
-
hint: ok ? undefined : (probe.error || `customers may not be able to reach ${entity.slot} "${entity.name}"`),
|
|
323
|
+
message: `${url} ${verdict.message}`,
|
|
324
|
+
hint: verdict.hint,
|
|
287
325
|
});
|
|
288
326
|
}));
|
|
289
|
-
|
|
327
|
+
// Only a probe that got NO response means unreachable. Everything else
|
|
328
|
+
// answered, so the summary must not call it unreachable — see
|
|
329
|
+
// classifyReachability.
|
|
330
|
+
const unreachable = issues.filter(i => i.severity === 'warn').length;
|
|
290
331
|
return {
|
|
291
332
|
name: 'reachability',
|
|
292
|
-
summary:
|
|
293
|
-
? `${
|
|
333
|
+
summary: unreachable
|
|
334
|
+
? `${unreachable} of ${issues.length} URL${issues.length === 1 ? '' : 's'} need attention`
|
|
294
335
|
: `${issues.length} URL${issues.length === 1 ? '' : 's'} reachable`,
|
|
295
336
|
issues,
|
|
296
337
|
};
|
|
297
338
|
}
|
|
339
|
+
// What an HTTP status actually tells you about reachability.
|
|
340
|
+
//
|
|
341
|
+
// This check used to treat any status >= 400 as "unreachable" with the hint
|
|
342
|
+
// "customers may not be able to reach <name>". A user ran it against a
|
|
343
|
+
// healthy production org and got two such warnings, both for containers
|
|
344
|
+
// behind an auth boundary that were answering 401 exactly as designed.
|
|
345
|
+
//
|
|
346
|
+
// The advice was backwards. A 401 is positive evidence: something answered,
|
|
347
|
+
// and its auth boundary works. As they put it, if one of those URLs ever
|
|
348
|
+
// answered 200 to an unauthenticated probe, THAT would be the emergency —
|
|
349
|
+
// and the old check would have called it healthy.
|
|
350
|
+
//
|
|
351
|
+
// The cost of a false warning is not neutral. Two of them in one run teaches
|
|
352
|
+
// the reader to skim past warnings, which is where the real ones live.
|
|
353
|
+
//
|
|
354
|
+
// So: reachability is about whether anything answered. Only a network
|
|
355
|
+
// failure or timeout is unreachable. Statuses that answered but suggest a
|
|
356
|
+
// problem are still reported — as what was observed, not as a conclusion
|
|
357
|
+
// about customers.
|
|
358
|
+
export function classifyReachability(probe, entity) {
|
|
359
|
+
const { status } = probe;
|
|
360
|
+
if (status == null) {
|
|
361
|
+
return {
|
|
362
|
+
severity: 'warn',
|
|
363
|
+
message: 'is unreachable',
|
|
364
|
+
hint: probe.error || `nothing answered — customers may not be able to reach ${entity.slot} "${entity.name ?? '(unnamed)'}"`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Answered and served.
|
|
368
|
+
if (status < 400)
|
|
369
|
+
return { severity: 'ok', message: `responded ${status}` };
|
|
370
|
+
// Answered and refused the anonymous probe. This is the designed behaviour
|
|
371
|
+
// of anything sitting behind auth, and it proves both liveness and that the
|
|
372
|
+
// boundary holds.
|
|
373
|
+
if (status === 401 || status === 403) {
|
|
374
|
+
return { severity: 'ok', message: `responded ${status} — reachable, authentication required` };
|
|
375
|
+
}
|
|
376
|
+
// Answered, but nothing is published at that path.
|
|
377
|
+
if (status === 404 || status === 410) {
|
|
378
|
+
return {
|
|
379
|
+
severity: 'warn',
|
|
380
|
+
message: `returned ${status} — reachable, but nothing is served at this URL`,
|
|
381
|
+
hint: `the host answers, so this is a routing or publish problem rather than an outage`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (status >= 500) {
|
|
385
|
+
return {
|
|
386
|
+
severity: 'warn',
|
|
387
|
+
message: `returned ${status} — reachable, but the application is erroring`,
|
|
388
|
+
hint: `check \`myapi ${entity.slot} logs\``,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
// Any other 4xx: report the fact, draw no conclusion.
|
|
392
|
+
return { severity: 'warn', message: `returned ${status} — reachable` };
|
|
393
|
+
}
|
|
298
394
|
export async function run(_subcommand, _args, flags) {
|
|
299
395
|
if (flags.help) {
|
|
300
396
|
info(HELP);
|
|
@@ -330,7 +426,38 @@ export async function run(_subcommand, _args, flags) {
|
|
|
330
426
|
catch {
|
|
331
427
|
mailingAddress = undefined;
|
|
332
428
|
}
|
|
333
|
-
|
|
429
|
+
// Count mailboxes and domains ourselves before any "you have not set this
|
|
430
|
+
// up" advice goes out. Both are cheap list calls, and both are the
|
|
431
|
+
// difference between a true finding and an instruction to duplicate live
|
|
432
|
+
// infrastructure. `undefined` on failure means "not checked" — the backend
|
|
433
|
+
// view then stands, because a failed check is not evidence.
|
|
434
|
+
//
|
|
435
|
+
// Mailboxes are scoped per registered domain, so this walks the org's
|
|
436
|
+
// domains rather than asking for a bare list.
|
|
437
|
+
let mailboxCount;
|
|
438
|
+
let domainCount;
|
|
439
|
+
try {
|
|
440
|
+
// NOT filter:'all' — that is account-wide and spans every org, which
|
|
441
|
+
// would count another org's mailboxes against this one. The default
|
|
442
|
+
// filter is org-scoped, which is the only correct basis for a claim
|
|
443
|
+
// about "this org".
|
|
444
|
+
const domains = await sdkDomain.listDomains(apiKey, orgId);
|
|
445
|
+
domainCount = domains.length;
|
|
446
|
+
const perDomain = await Promise.all(domains.map(async (d) => {
|
|
447
|
+
try {
|
|
448
|
+
return (await sdkEmail.listMailboxes(apiKey, { domain: d.domain })).length;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
}));
|
|
454
|
+
mailboxCount = perDomain.reduce((a, b) => a + b, 0);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
mailboxCount = undefined;
|
|
458
|
+
domainCount = undefined;
|
|
459
|
+
}
|
|
460
|
+
const setup = _setupSection(report, { mailingAddress, mailboxCount, domainCount });
|
|
334
461
|
if (setup)
|
|
335
462
|
report.sections.push(setup);
|
|
336
463
|
const localSection = await dnsProbeSection(report);
|
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.1",
|
|
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.1"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@types/node": "^25.6.0",
|