@myapihq/cli 2.6.0 → 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.
|
@@ -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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.6.
|
|
4
|
+
"version": "2.6.1",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@myapihq/sdk": "^2.6.
|
|
43
|
+
"@myapihq/sdk": "^2.6.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^25.6.0",
|