@myapihq/cli 1.3.2 → 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.
@@ -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 (today: DNS resolution from the user's network),
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 { hq as sdkHq } from '@myapihq/sdk';
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
- const head = ` ${MARK[i.severity] ?? '·'} ${i.message}`;
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: `dns_local_ok/${name}`,
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: `dns_local_fail/${name}`,
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
- totals[i.severity]++;
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 (DNS resolution from this machine's network).
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 — DNS resolution from your egress for each domain mentioned.
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "1.3.2",
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.2"
32
+ "@myapihq/sdk": "^1.3.4"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^25.6.0",