@myapihq/cli 1.3.1 → 1.3.4
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.d.ts +6 -0
- package/dist/commands/doctor.js +281 -0
- package/dist/completion.js +1 -1
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FlagSchema } from '../flags.js';
|
|
2
|
+
import { type Flags } from '../helpers.js';
|
|
3
|
+
import type { Exposes } from '../exposes.js';
|
|
4
|
+
export declare const EXPOSES: Exposes;
|
|
5
|
+
export declare const SCHEMA: FlagSchema;
|
|
6
|
+
export declare function run(_subcommand: string | undefined, _args: string[], flags: Flags): Promise<void>;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// `myapi doctor` — thin client over the backend's GET /hq/orgs/{org_id}/doctor.
|
|
2
|
+
//
|
|
3
|
+
// The backend computes the heavy reference-integrity / orphan / activity
|
|
4
|
+
// checks across slots and returns a structured report. The CLI's job is:
|
|
5
|
+
// 1. fetch the report,
|
|
6
|
+
// 2. augment with *customer-perspective* probes the backend structurally
|
|
7
|
+
// can't run from its own egress: DNS resolution and HTTP reachability
|
|
8
|
+
// from the user's network,
|
|
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.
|
|
18
|
+
import { promises as dns } from 'node:dns';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { hq as sdkHq, container as sdkContainer, funnel as sdkFunnel } from '@myapihq/sdk';
|
|
21
|
+
import { requireConfig } from '../config.js';
|
|
22
|
+
import { info, error, printJson } from '../output.js';
|
|
23
|
+
import { requireOrg } from '../helpers.js';
|
|
24
|
+
export const EXPOSES = [
|
|
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',
|
|
30
|
+
];
|
|
31
|
+
export const SCHEMA = {};
|
|
32
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
33
|
+
const C = useColor ? {
|
|
34
|
+
ok: '\x1b[32m', warn: '\x1b[33m', err: '\x1b[31m',
|
|
35
|
+
dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
|
|
36
|
+
} : { ok: '', warn: '', err: '', dim: '', bold: '', reset: '' };
|
|
37
|
+
const MARK = {
|
|
38
|
+
ok: `${C.ok}✓${C.reset}`,
|
|
39
|
+
warn: `${C.warn}⚠${C.reset}`,
|
|
40
|
+
crit: `${C.err}✗${C.reset}`,
|
|
41
|
+
};
|
|
42
|
+
function rule(width = 60) {
|
|
43
|
+
return `${C.dim}${'─'.repeat(width)}${C.reset}`;
|
|
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
|
+
}
|
|
52
|
+
function fmtIssue(i) {
|
|
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}`;
|
|
59
|
+
return i.hint ? `${head}\n ${C.dim}→ ${i.hint}${C.reset}` : head;
|
|
60
|
+
}
|
|
61
|
+
// Local DNS-resolution probe for every distinct domain the report names.
|
|
62
|
+
// The backend can verify domain provisioning state from its own egress;
|
|
63
|
+
// this checks whether the operator's network reaches them today — a
|
|
64
|
+
// different epistemic signal worth surfacing on top of the backend's view.
|
|
65
|
+
async function dnsProbeSection(report) {
|
|
66
|
+
const names = new Set();
|
|
67
|
+
for (const s of report.sections) {
|
|
68
|
+
for (const i of s.issues) {
|
|
69
|
+
if (i.entity?.slot === 'domain' && i.entity.name)
|
|
70
|
+
names.add(i.entity.name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (names.size === 0)
|
|
74
|
+
return null;
|
|
75
|
+
const issues = [];
|
|
76
|
+
await Promise.all([...names].map(async (name) => {
|
|
77
|
+
try {
|
|
78
|
+
const ips = await dns.resolve4(name);
|
|
79
|
+
issues.push({
|
|
80
|
+
id: localIssueId('dns_local_ok', name),
|
|
81
|
+
severity: 'ok',
|
|
82
|
+
scope: `local/${name}`,
|
|
83
|
+
entity: { slot: 'domain', id: '', name },
|
|
84
|
+
category: 'network',
|
|
85
|
+
message: `${name} resolves from your network${ips.length ? ` (${ips[0]}${ips.length > 1 ? ` +${ips.length - 1}` : ''})` : ''}`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
issues.push({
|
|
90
|
+
id: localIssueId('dns_local_fail', name),
|
|
91
|
+
severity: 'warn',
|
|
92
|
+
scope: `local/${name}`,
|
|
93
|
+
entity: { slot: 'domain', id: '', name },
|
|
94
|
+
category: 'network',
|
|
95
|
+
message: `${name} did not resolve from your network`,
|
|
96
|
+
hint: e?.code || e?.message || String(e),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
const warns = issues.filter(i => i.severity === 'warn').length;
|
|
101
|
+
return {
|
|
102
|
+
name: 'local network',
|
|
103
|
+
summary: warns ? `${warns} resolution failure${warns === 1 ? '' : 's'}` : `${issues.length} domain${issues.length === 1 ? '' : 's'} resolved`,
|
|
104
|
+
issues,
|
|
105
|
+
};
|
|
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
|
+
}
|
|
192
|
+
export async function run(_subcommand, _args, flags) {
|
|
193
|
+
if (flags.help) {
|
|
194
|
+
info(HELP);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const config = requireConfig();
|
|
198
|
+
const orgId = requireOrg(flags, config, 'myapi doctor [--json] [--verbose]');
|
|
199
|
+
const apiKey = config.api_key;
|
|
200
|
+
const verbose = !!flags.verbose;
|
|
201
|
+
const wantJson = !!flags.json;
|
|
202
|
+
let report;
|
|
203
|
+
try {
|
|
204
|
+
report = await sdkHq.getDoctor(apiKey, orgId);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
error(`doctor endpoint failed: ${e?.message ?? String(e)}`);
|
|
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.
|
|
211
|
+
const localSection = await dnsProbeSection(report);
|
|
212
|
+
if (localSection)
|
|
213
|
+
report.sections.push(localSection);
|
|
214
|
+
const reachSection = await httpProbeSection(apiKey, orgId);
|
|
215
|
+
if (reachSection)
|
|
216
|
+
report.sections.push(reachSection);
|
|
217
|
+
// Re-tally totals after local augmentation.
|
|
218
|
+
const totals = { ok: 0, warn: 0, crit: 0 };
|
|
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;
|
|
231
|
+
if (wantJson) {
|
|
232
|
+
printJson({ ...report, totals });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
info(`${C.bold}Org doctor${C.reset} ${C.dim}· org ${report.org_id}${C.reset} ${C.dim}· ${report.generated_at}${C.reset}`);
|
|
236
|
+
for (const s of report.sections) {
|
|
237
|
+
info('');
|
|
238
|
+
info(`${C.bold}# ${s.name}${C.reset} ${C.dim}· ${s.summary}${C.reset}`);
|
|
239
|
+
for (const i of s.issues) {
|
|
240
|
+
if (!verbose && i.severity === 'ok')
|
|
241
|
+
continue;
|
|
242
|
+
info(fmtIssue(i));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
info('');
|
|
246
|
+
info(rule(60));
|
|
247
|
+
if (totals.crit) {
|
|
248
|
+
info(`${MARK.crit} ${totals.crit} critical, ${MARK.warn} ${totals.warn} warning${totals.warn === 1 ? '' : 's'}`);
|
|
249
|
+
}
|
|
250
|
+
else if (totals.warn) {
|
|
251
|
+
info(`${MARK.warn} ${totals.warn} warning${totals.warn === 1 ? '' : 's'}, no critical issues`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
info(`${MARK.ok} ${totals.ok} check${totals.ok === 1 ? '' : 's'} passed`);
|
|
255
|
+
}
|
|
256
|
+
if (!verbose)
|
|
257
|
+
info(`${C.dim}(--verbose to show passing checks · --json for machine-readable)${C.reset}`);
|
|
258
|
+
}
|
|
259
|
+
const HELP = `Usage: myapi doctor [--verbose] [--json] [--org <id>]
|
|
260
|
+
|
|
261
|
+
Org-wide consistency check. Fetches the structured report from the backend
|
|
262
|
+
(GET /hq/orgs/{org_id}/doctor) and augments it with customer-perspective
|
|
263
|
+
probes run from this machine's network.
|
|
264
|
+
|
|
265
|
+
Sections returned by the backend today:
|
|
266
|
+
funnels, webhooks, workflows, domains, containers, emails, payments
|
|
267
|
+
|
|
268
|
+
Local additions:
|
|
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.
|
|
273
|
+
|
|
274
|
+
Exit codes:
|
|
275
|
+
0 no critical issues (warnings allowed)
|
|
276
|
+
1 one or more critical issues
|
|
277
|
+
|
|
278
|
+
Options:
|
|
279
|
+
--verbose Show passing checks too.
|
|
280
|
+
--json Machine-readable output.
|
|
281
|
+
--org <id> Override the default org.`;
|
package/dist/completion.js
CHANGED
|
@@ -28,7 +28,7 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
|
28
28
|
export const COMMANDS = [
|
|
29
29
|
'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
30
|
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
|
|
31
|
-
'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel', 'queue',
|
|
31
|
+
'doctor', 'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel', 'queue',
|
|
32
32
|
'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
|
|
33
33
|
'workflow',
|
|
34
34
|
];
|
package/dist/exposes.test.js
CHANGED
|
@@ -43,6 +43,7 @@ const COMMAND_MODULES = [
|
|
|
43
43
|
'./commands/git.js',
|
|
44
44
|
'./commands/queue.js',
|
|
45
45
|
'./commands/task.js',
|
|
46
|
+
'./commands/doctor.js',
|
|
46
47
|
];
|
|
47
48
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
48
49
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import * as containerCmd from './commands/container.js';
|
|
|
35
35
|
import * as gitCmd from './commands/git.js';
|
|
36
36
|
import * as queueCmd from './commands/queue.js';
|
|
37
37
|
import * as taskCmd from './commands/task.js';
|
|
38
|
+
import * as doctorCmd from './commands/doctor.js';
|
|
38
39
|
// Each command file declares the value flags it understands. We union them
|
|
39
40
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
40
41
|
// one command means editing one file (its SCHEMA), not a global allowlist.
|
|
@@ -66,6 +67,7 @@ const COMBINED_SCHEMA = {
|
|
|
66
67
|
...gitCmd.SCHEMA,
|
|
67
68
|
...queueCmd.SCHEMA,
|
|
68
69
|
...taskCmd.SCHEMA,
|
|
70
|
+
...doctorCmd.SCHEMA,
|
|
69
71
|
// Top-level flags
|
|
70
72
|
version: 'boolean',
|
|
71
73
|
V: 'boolean',
|
|
@@ -234,6 +236,9 @@ async function main() {
|
|
|
234
236
|
case 'task':
|
|
235
237
|
await taskCmd.run(subcommand, restArgs, flags);
|
|
236
238
|
break;
|
|
239
|
+
case 'doctor':
|
|
240
|
+
await doctorCmd.run(subcommand, restArgs, flags);
|
|
241
|
+
break;
|
|
237
242
|
// Convenience aliases
|
|
238
243
|
case 'setup':
|
|
239
244
|
await setupCmd.setup(flags);
|
|
@@ -360,6 +365,7 @@ const HELP_TARGETS = {
|
|
|
360
365
|
git: f => gitCmd.run(undefined, [], f),
|
|
361
366
|
queue: f => queueCmd.run(undefined, [], f),
|
|
362
367
|
task: f => taskCmd.run(undefined, [], f),
|
|
368
|
+
doctor: f => doctorCmd.run(undefined, [], f),
|
|
363
369
|
org: f => orgCmd.run(undefined, [], f),
|
|
364
370
|
billing: f => billingCmd.run(undefined, [], f),
|
|
365
371
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -406,6 +412,7 @@ Commands:
|
|
|
406
412
|
git Hosted git repositories — repos, commits, branches, history
|
|
407
413
|
queue Durable job queue — enqueue work, retried against an HTTP consumer
|
|
408
414
|
task Agent-task queue — file, claim, and resolve units of work
|
|
415
|
+
doctor Org-wide consistency check — funnels, webhooks, domains, containers
|
|
409
416
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
410
417
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
411
418
|
email Manage mailboxes, send/read email, templates, and campaigns
|
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.4",
|
|
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.4"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.6.0",
|