@myapihq/cli 1.3.2 → 1.3.5
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/doctor.js +138 -11
- package/dist/commands/domain.js +32 -11
- package/dist/skills/my-crm-api/SKILL.md +2 -3
- package/package.json +2 -2
package/dist/commands/doctor.js
CHANGED
|
@@ -4,15 +4,29 @@
|
|
|
4
4
|
// checks across slots and returns a structured report. The CLI's job is:
|
|
5
5
|
// 1. fetch the report,
|
|
6
6
|
// 2. augment with *customer-perspective* probes the backend structurally
|
|
7
|
-
// can't run
|
|
7
|
+
// can't run from its own egress: DNS resolution and HTTP reachability
|
|
8
|
+
// from the user's network,
|
|
8
9
|
// 3. render with section grouping, color, and exit codes for CI.
|
|
10
|
+
//
|
|
11
|
+
// Division of labour (a contract, not an accident): the backend owns
|
|
12
|
+
// configuration / reference-integrity / orphan checks; the client owns
|
|
13
|
+
// liveness probes (DNS, HTTP). The backend must NOT HTTP-probe — it has no
|
|
14
|
+
// customer-vantage egress — and the client must not re-derive config state.
|
|
15
|
+
// Augmented sections are therefore appended *last*: client-observed checks
|
|
16
|
+
// are a distinct epistemic class (observed now, from here) and shouldn't
|
|
17
|
+
// interleave with the backend's findings.
|
|
9
18
|
import { promises as dns } from 'node:dns';
|
|
10
|
-
import {
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel } from '@myapihq/sdk';
|
|
11
21
|
import { requireConfig } from '../config.js';
|
|
12
22
|
import { info, error, printJson } from '../output.js';
|
|
13
23
|
import { requireOrg } from '../helpers.js';
|
|
14
24
|
export const EXPOSES = [
|
|
15
25
|
'GET /hq/orgs/{org_id}/doctor',
|
|
26
|
+
// Read-only enumeration for the HTTP reachability augmentation pass.
|
|
27
|
+
'GET /container/orgs/{org_id}/containers',
|
|
28
|
+
'GET /funnel/orgs/{org_id}/funnels',
|
|
29
|
+
'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/pages',
|
|
16
30
|
];
|
|
17
31
|
export const SCHEMA = {};
|
|
18
32
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -28,8 +42,20 @@ const MARK = {
|
|
|
28
42
|
function rule(width = 60) {
|
|
29
43
|
return `${C.dim}${'─'.repeat(width)}${C.reset}`;
|
|
30
44
|
}
|
|
45
|
+
// Stable, dedupable id for a client-generated issue, matching the backend's
|
|
46
|
+
// opaque `kind/<hash>` shape. The human-readable form lives in scope/message;
|
|
47
|
+
// the id stays a single-slash key so consumers can split it the same way
|
|
48
|
+
// they split backend ids (a raw URL in the id would break that).
|
|
49
|
+
function localIssueId(kind, key) {
|
|
50
|
+
return `${kind}/${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
51
|
+
}
|
|
31
52
|
function fmtIssue(i) {
|
|
32
|
-
|
|
53
|
+
// Prefix the entity name so sibling issues with identical messages
|
|
54
|
+
// (e.g. four "domain is active" rows) are distinguishable. Skip it when
|
|
55
|
+
// the message already names the entity — the local DNS section does.
|
|
56
|
+
const name = i.entity?.name;
|
|
57
|
+
const label = name && !i.message.includes(name) ? `${C.bold}${name}${C.reset} — ` : '';
|
|
58
|
+
const head = ` ${MARK[i.severity] ?? '·'} ${label}${i.message}`;
|
|
33
59
|
return i.hint ? `${head}\n ${C.dim}→ ${i.hint}${C.reset}` : head;
|
|
34
60
|
}
|
|
35
61
|
// Local DNS-resolution probe for every distinct domain the report names.
|
|
@@ -51,7 +77,7 @@ async function dnsProbeSection(report) {
|
|
|
51
77
|
try {
|
|
52
78
|
const ips = await dns.resolve4(name);
|
|
53
79
|
issues.push({
|
|
54
|
-
id:
|
|
80
|
+
id: localIssueId('dns_local_ok', name),
|
|
55
81
|
severity: 'ok',
|
|
56
82
|
scope: `local/${name}`,
|
|
57
83
|
entity: { slot: 'domain', id: '', name },
|
|
@@ -61,7 +87,7 @@ async function dnsProbeSection(report) {
|
|
|
61
87
|
}
|
|
62
88
|
catch (e) {
|
|
63
89
|
issues.push({
|
|
64
|
-
id:
|
|
90
|
+
id: localIssueId('dns_local_fail', name),
|
|
65
91
|
severity: 'warn',
|
|
66
92
|
scope: `local/${name}`,
|
|
67
93
|
entity: { slot: 'domain', id: '', name },
|
|
@@ -78,6 +104,91 @@ async function dnsProbeSection(report) {
|
|
|
78
104
|
issues,
|
|
79
105
|
};
|
|
80
106
|
}
|
|
107
|
+
// One HTTP GET with an 8s ceiling. Returns the final status code (after
|
|
108
|
+
// redirects) or, on a network/timeout failure, a null status + error string.
|
|
109
|
+
async function fetchStatus(url) {
|
|
110
|
+
const ctrl = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal });
|
|
114
|
+
// Drain the body so the socket can be released — we only want the status.
|
|
115
|
+
await res.body?.cancel().catch(() => { });
|
|
116
|
+
return { status: res.status };
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
const error = e?.name === 'AbortError'
|
|
120
|
+
? 'timed out after 8s'
|
|
121
|
+
: (e?.cause?.code || e?.code || e?.message || String(e));
|
|
122
|
+
return { status: null, error };
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Customer-perspective HTTP reachability probe. The backend reports whether a
|
|
129
|
+
// funnel/container is *configured* consistently; it can't tell from its own
|
|
130
|
+
// egress whether the published URL actually answers. This fetches every
|
|
131
|
+
// container URL and funnel page the org exposes and surfaces the status.
|
|
132
|
+
//
|
|
133
|
+
// Like the DNS probe, a failed fetch is `warn`, never `crit`: a single
|
|
134
|
+
// request from one machine at one instant isn't authoritative enough to fail
|
|
135
|
+
// CI — it's a signal to act on, layered over the backend's config view.
|
|
136
|
+
async function httpProbeSection(apiKey, orgId) {
|
|
137
|
+
const targets = [];
|
|
138
|
+
// Containers: probe the bound custom domain when set (what customers hit),
|
|
139
|
+
// else the Cloud Run URL. An undeployed container has neither — skip it.
|
|
140
|
+
try {
|
|
141
|
+
const containers = await sdkContainer.listContainers(apiKey, orgId);
|
|
142
|
+
for (const c of containers) {
|
|
143
|
+
const url = c.custom_domain ? `https://${c.custom_domain}` : c.url;
|
|
144
|
+
if (url)
|
|
145
|
+
targets.push({ url, entity: { slot: 'container', id: c.id, name: c.name } });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { /* augmentation is best-effort — skip the slot if enumeration fails */ }
|
|
149
|
+
// Funnels: probe every published page URL.
|
|
150
|
+
try {
|
|
151
|
+
const funnels = await sdkFunnel.listFunnels(apiKey, orgId);
|
|
152
|
+
await Promise.all(funnels.map(async (f) => {
|
|
153
|
+
try {
|
|
154
|
+
const pages = await sdkFunnel.listFunnelPages(apiKey, orgId, f.id);
|
|
155
|
+
for (const p of pages) {
|
|
156
|
+
if (p.url)
|
|
157
|
+
targets.push({ url: p.url, entity: { slot: 'funnel', id: f.id, name: f.name || f.id } });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* skip this funnel */ }
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
catch { /* skip the slot */ }
|
|
164
|
+
if (targets.length === 0)
|
|
165
|
+
return null;
|
|
166
|
+
const issues = [];
|
|
167
|
+
await Promise.all(targets.map(async ({ url, entity }) => {
|
|
168
|
+
const probe = await fetchStatus(url);
|
|
169
|
+
// 2xx/3xx = reachable. 4xx, 5xx and network failures are all `warn`.
|
|
170
|
+
const ok = probe.status != null && probe.status < 400;
|
|
171
|
+
issues.push({
|
|
172
|
+
id: localIssueId(ok ? 'http_ok' : 'http_unreachable', `${entity.slot}/${entity.id}/${url}`),
|
|
173
|
+
severity: ok ? 'ok' : 'warn',
|
|
174
|
+
scope: `local/${url}`,
|
|
175
|
+
entity,
|
|
176
|
+
category: 'network',
|
|
177
|
+
message: ok
|
|
178
|
+
? `${url} responded ${probe.status}`
|
|
179
|
+
: `${url} ${probe.status != null ? `returned ${probe.status}` : 'is unreachable'}`,
|
|
180
|
+
hint: ok ? undefined : (probe.error || `customers may not be able to reach ${entity.slot} "${entity.name}"`),
|
|
181
|
+
});
|
|
182
|
+
}));
|
|
183
|
+
const warns = issues.filter(i => i.severity === 'warn').length;
|
|
184
|
+
return {
|
|
185
|
+
name: 'reachability',
|
|
186
|
+
summary: warns
|
|
187
|
+
? `${warns} of ${issues.length} URL${issues.length === 1 ? '' : 's'} unreachable`
|
|
188
|
+
: `${issues.length} URL${issues.length === 1 ? '' : 's'} reachable`,
|
|
189
|
+
issues,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
81
192
|
export async function run(_subcommand, _args, flags) {
|
|
82
193
|
if (flags.help) {
|
|
83
194
|
info(HELP);
|
|
@@ -95,14 +206,28 @@ export async function run(_subcommand, _args, flags) {
|
|
|
95
206
|
catch (e) {
|
|
96
207
|
error(`doctor endpoint failed: ${e?.message ?? String(e)}`);
|
|
97
208
|
}
|
|
209
|
+
// Local augmentation, appended after the backend's sections by design —
|
|
210
|
+
// see the file header on why client-observed checks stay grouped at the end.
|
|
98
211
|
const localSection = await dnsProbeSection(report);
|
|
99
212
|
if (localSection)
|
|
100
213
|
report.sections.push(localSection);
|
|
214
|
+
const reachSection = await httpProbeSection(apiKey, orgId);
|
|
215
|
+
if (reachSection)
|
|
216
|
+
report.sections.push(reachSection);
|
|
101
217
|
// Re-tally totals after local augmentation.
|
|
102
218
|
const totals = { ok: 0, warn: 0, crit: 0 };
|
|
103
|
-
for (const s of report.sections)
|
|
104
|
-
for (const i of s.issues)
|
|
105
|
-
|
|
219
|
+
for (const s of report.sections) {
|
|
220
|
+
for (const i of s.issues) {
|
|
221
|
+
// Ignore any severity the backend invents that we don't model — better
|
|
222
|
+
// a missed count than a NaN poisoning the whole tally.
|
|
223
|
+
if (i.severity in totals)
|
|
224
|
+
totals[i.severity]++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Set the exit code before any output branch — CI relies on it in both
|
|
228
|
+
// the human and the --json path.
|
|
229
|
+
if (totals.crit)
|
|
230
|
+
process.exitCode = 1;
|
|
106
231
|
if (wantJson) {
|
|
107
232
|
printJson({ ...report, totals });
|
|
108
233
|
return;
|
|
@@ -121,7 +246,6 @@ export async function run(_subcommand, _args, flags) {
|
|
|
121
246
|
info(rule(60));
|
|
122
247
|
if (totals.crit) {
|
|
123
248
|
info(`${MARK.crit} ${totals.crit} critical, ${MARK.warn} ${totals.warn} warning${totals.warn === 1 ? '' : 's'}`);
|
|
124
|
-
process.exitCode = 1;
|
|
125
249
|
}
|
|
126
250
|
else if (totals.warn) {
|
|
127
251
|
info(`${MARK.warn} ${totals.warn} warning${totals.warn === 1 ? '' : 's'}, no critical issues`);
|
|
@@ -136,13 +260,16 @@ const HELP = `Usage: myapi doctor [--verbose] [--json] [--org <id>]
|
|
|
136
260
|
|
|
137
261
|
Org-wide consistency check. Fetches the structured report from the backend
|
|
138
262
|
(GET /hq/orgs/{org_id}/doctor) and augments it with customer-perspective
|
|
139
|
-
probes
|
|
263
|
+
probes run from this machine's network.
|
|
140
264
|
|
|
141
265
|
Sections returned by the backend today:
|
|
142
266
|
funnels, webhooks, workflows, domains, containers, emails, payments
|
|
143
267
|
|
|
144
268
|
Local additions:
|
|
145
|
-
network
|
|
269
|
+
local network — DNS resolution from your egress for each domain mentioned.
|
|
270
|
+
reachability — HTTP GET to every container URL and funnel page; surfaces
|
|
271
|
+
the status code. Failures are warnings, never critical —
|
|
272
|
+
a single fetch isn't authoritative enough to fail CI.
|
|
146
273
|
|
|
147
274
|
Exit codes:
|
|
148
275
|
0 no critical issues (warnings allowed)
|
package/dist/commands/domain.js
CHANGED
|
@@ -114,23 +114,44 @@ export async function importCmd(domainArg, flags) {
|
|
|
114
114
|
}
|
|
115
115
|
success(`Imported ${res.domain} (status: ${res.status})`);
|
|
116
116
|
info('');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
// Defensive — the backend has shipped this response with a non-array
|
|
118
|
+
// `nameservers` (the domain_id string mis-serialized into the field) and
|
|
119
|
+
// missing `preserved_records` in some states. JS iterating a string
|
|
120
|
+
// prints char-per-line and reading .length on undefined crashes; see
|
|
121
|
+
// docs/cross-repo-prompts/backend-domain-import-response-shape.md. Never
|
|
122
|
+
// trust the shape blindly — degrade to pointing at `domain status`,
|
|
123
|
+
// which is queryable for the real values.
|
|
124
|
+
if (Array.isArray(res.nameservers) && res.nameservers.length > 0) {
|
|
125
|
+
info(`Nameservers (set these at your current registrar):`);
|
|
126
|
+
for (const ns of res.nameservers)
|
|
127
|
+
info(` ${ns}`);
|
|
128
|
+
info('');
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
info(`⚠ Backend returned an unexpected nameservers shape (got ${typeof res.nameservers}).`);
|
|
132
|
+
info(` The domain is imported — fetch the real nameservers with:`);
|
|
133
|
+
info(` myapi domain status ${res.domain} --json`);
|
|
134
|
+
info('');
|
|
135
|
+
}
|
|
136
|
+
const preserved = Array.isArray(res.preserved_records) ? res.preserved_records : [];
|
|
137
|
+
const preservedCount = typeof res.preserved_count === 'number' ? res.preserved_count : preserved.length;
|
|
138
|
+
if (preserved.length > 0) {
|
|
139
|
+
info(`Preserved DNS records (${preservedCount} found — verify before the NS change):`);
|
|
140
|
+
printTable(preserved.map(r => ({ type: r.type, name: r.name, content: r.content, ttl: r.ttl })), { flags });
|
|
124
141
|
info('');
|
|
125
142
|
}
|
|
126
143
|
else {
|
|
127
144
|
info(`No DNS records detected via public probe — verify directly with your registrar before the NS change.`);
|
|
128
145
|
info('');
|
|
129
146
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
if (res.probe_warning) {
|
|
148
|
+
info(res.probe_warning);
|
|
149
|
+
info('');
|
|
150
|
+
}
|
|
151
|
+
if (res.next_step) {
|
|
152
|
+
info(res.next_step);
|
|
153
|
+
info('');
|
|
154
|
+
}
|
|
134
155
|
info(`After changing nameservers, watch activation with:`);
|
|
135
156
|
info(` myapi domain status ${res.domain} --watch`);
|
|
136
157
|
}
|
|
@@ -50,7 +50,7 @@ pixel_visit | webhook_received
|
|
|
50
50
|
|
|
51
51
|
Agents cannot write events directly — the closed enum is intentional. If you need custom state, use **mydatabaseapi** (KV) keyed on the contact id; the curated timeline stays authoritative for engagement.
|
|
52
52
|
|
|
53
|
-
**Engagement-event kinds bump `last_engagement_at`**: email_sent/opened/clicked/replied, pixel_visit, webhook_received. Admin kinds (created, promoted, stage_changed) don't
|
|
53
|
+
**Engagement-event kinds bump `last_engagement_at`**: email_sent/opened/clicked/replied, pixel_visit, webhook_received. Admin kinds (created, promoted, stage_changed) don't — promoting a Goldfox lead isn't engagement.
|
|
54
54
|
|
|
55
55
|
### Auto-ingest
|
|
56
56
|
|
|
@@ -157,8 +157,7 @@ myapi crm contacts events <id> --kind webhook_received
|
|
|
157
157
|
## Notes
|
|
158
158
|
|
|
159
159
|
- **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.
|
|
160
|
-
- **Event payloads carry an `external_id`**
|
|
161
|
-
- **Goldfox enrichment field deferred** — the live join lands once the BQ get-by-id helper is wired backend-side. CLI treats it as optional today.
|
|
160
|
+
- **Event payloads carry an `external_id`** — the backend's idempotency key, equal to the underlying action's natural id (`goldfox_person_id` for `promoted`, `delivery_id` for `webhook_received`). Read the semantic field; `external_id` is a backend-internal duplicate. A legacy `message_id` on old rows holds the same value — safe to ignore.
|
|
162
161
|
- **Email + Pixel auto-ingest not yet wired**. Only `webhook_received` events fire today. Email and pixel ingest are coming — the CLI surface stays unchanged when they land.
|
|
163
162
|
- **Free in v1.** Metered later if usage shows a need.
|
|
164
163
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.5",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"lint:changelog": "node ../../scripts/lint-changelog.js"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@myapihq/sdk": "^1.3.
|
|
32
|
+
"@myapihq/sdk": "^1.3.5"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.6.0",
|