@learncard/cli 3.5.1 → 3.6.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +230 -2
  3. package/dist/index.js +4337 -986
  4. package/examples/branded.network.yaml +20 -0
  5. package/examples/delegated-service-account.network.yaml +23 -0
  6. package/examples/minimal.network.yaml +7 -0
  7. package/examples/self-hosted-signing.network.yaml +10 -0
  8. package/examples/service-account.network.yaml +13 -0
  9. package/examples/state-districts.network.yaml +36 -0
  10. package/package.json +21 -17
  11. package/src/auth-grant.test.ts +54 -0
  12. package/src/auth-grant.ts +34 -0
  13. package/src/clr/validate.test.ts +65 -0
  14. package/src/clr/validate.ts +242 -0
  15. package/src/clr.ts +119 -0
  16. package/src/demo-inbox-refresh.test.ts +737 -0
  17. package/src/demo-inbox-refresh.ts +804 -0
  18. package/src/demo-refresh-command.test.ts +57 -0
  19. package/src/demo-refresh-command.ts +22 -0
  20. package/src/demo-refresh-ui.test.ts +66 -0
  21. package/src/demo-refresh-ui.ts +65 -0
  22. package/src/demo-refresh.test.ts +140 -0
  23. package/src/demo-refresh.ts +309 -0
  24. package/src/doctor/checks.test.ts +448 -0
  25. package/src/doctor/checks.ts +497 -0
  26. package/src/doctor.test.ts +67 -0
  27. package/src/doctor.ts +118 -0
  28. package/src/inbox.test.ts +257 -0
  29. package/src/inbox.ts +221 -0
  30. package/src/index.tsx +70 -8
  31. package/src/init.ts +1 -1
  32. package/src/open.ts +1 -1
  33. package/src/org/apply.test.ts +1108 -0
  34. package/src/org/apply.ts +924 -0
  35. package/src/org/branding.test.ts +60 -0
  36. package/src/org/diff.ts +14 -0
  37. package/src/org/load.ts +50 -0
  38. package/src/org/schema.test.ts +256 -0
  39. package/src/org/schema.ts +216 -0
  40. package/src/org.ts +124 -0
  41. package/src/project.test.ts +26 -1
  42. package/src/project.ts +105 -10
  43. package/src/promote.test.ts +142 -0
  44. package/src/promote.ts +202 -0
  45. package/src/refresh.test.ts +86 -0
  46. package/src/refresh.ts +93 -0
  47. package/src/send.test.ts +278 -2
  48. package/src/send.ts +152 -24
  49. package/src/setup-signing.ts +1 -1
  50. package/src/status.ts +2 -4
  51. package/src/whoami.test.ts +67 -0
  52. package/src/whoami.ts +129 -0
  53. package/tsconfig.json +1 -1
@@ -0,0 +1,497 @@
1
+ import type { UnsignedVC } from '@learncard/types';
2
+ import {
3
+ PRODUCTION_NETWORK,
4
+ STAGING_NETWORK,
5
+ resolveServices,
6
+ type NetworkCard,
7
+ type Project,
8
+ } from '../project';
9
+ import { describeActAs, getGrantActAs } from '../auth-grant';
10
+
11
+ export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
12
+
13
+ export interface CheckResult {
14
+ status: CheckStatus;
15
+ detail?: string;
16
+ fix?: string;
17
+ }
18
+
19
+ /** Only the invoke methods each check actually calls, so tests can pass minimal mocks. */
20
+ export type DoctorCard = {
21
+ invoke: Pick<
22
+ NetworkCard['invoke'],
23
+ | 'getProfile'
24
+ | 'getRegisteredSigningAuthorities'
25
+ | 'getAuthGrants'
26
+ | 'getCredentialRefreshHistory'
27
+ | 'issueCredential'
28
+ | 'verifyCredential'
29
+ >;
30
+ id: Pick<NetworkCard['id'], 'did'>;
31
+ };
32
+
33
+ export interface DoctorContext {
34
+ project: Project;
35
+ services: ReturnType<typeof resolveServices>;
36
+ learnCard: DoctorCard;
37
+ fetch: typeof fetch;
38
+ requiredScopes: string[];
39
+ webhookUrl?: string;
40
+ tenantConfig?: unknown;
41
+ }
42
+
43
+ export interface Check {
44
+ id: string;
45
+ title: string;
46
+ run: (ctx: DoctorContext) => Promise<CheckResult>;
47
+ }
48
+
49
+ /** A Universal Inbox issuer needs to write/read the inbox and mint/read credentials. */
50
+ export const DEFAULT_REQUIRED_SCOPES = [
51
+ 'inbox:write',
52
+ 'inbox:read',
53
+ 'credentials:write',
54
+ 'credentials:read',
55
+ ];
56
+
57
+ const DEFAULT_TIMEOUT_MS = 5000;
58
+
59
+ const errorMessage = (error: unknown): string =>
60
+ error instanceof Error ? error.message : String(error);
61
+
62
+ /** `https://network.learncard.com/trpc` -> `https://network.learncard.com` */
63
+ const networkBase = (network: string): string => network.replace(/\/trpc$/, '');
64
+
65
+ const describeNetwork = (network: string): string => {
66
+ if (network === PRODUCTION_NETWORK) return 'production';
67
+ if (network === STAGING_NETWORK) return 'staging';
68
+ return network;
69
+ };
70
+
71
+ /** Every doctor network call is time-bound so one hung endpoint cannot hang the whole preflight. */
72
+ const fetchWithTimeout = async (
73
+ doFetch: typeof fetch,
74
+ url: string,
75
+ init: Parameters<typeof fetch>[1] = {},
76
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
77
+ ): Promise<Awaited<ReturnType<typeof fetch>>> => {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ return await doFetch(url, { ...init, signal: controller.signal });
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Does one granted scope string cover one required "resource:action" entry?
89
+ * Mirrors brain-service's `userHasRequiredScopes` wildcard rules (a `*` resource
90
+ * or a `*` action satisfies any concrete counterpart).
91
+ */
92
+ const scopeCovers = (grantScope: string, required: string): boolean => {
93
+ const granted = grantScope.split(' ').filter(Boolean);
94
+ if (granted.includes(required)) return true;
95
+ const [requiredResource, requiredAction] = required.split(':');
96
+ return granted.some(entry => {
97
+ const [resource, action] = entry.split(':');
98
+ if (resource === '*' && action === '*') return true;
99
+ if (resource === requiredResource && action === '*') return true;
100
+ if (resource === '*' && action === requiredAction) return true;
101
+ return false;
102
+ });
103
+ };
104
+
105
+ const isLoopback = (url: string): boolean => {
106
+ try {
107
+ return ['localhost', '127.0.0.1', '[::1]', 'host.docker.internal'].includes(
108
+ new URL(url).hostname
109
+ );
110
+ } catch {
111
+ return false;
112
+ }
113
+ };
114
+
115
+ const buildDoctorTestVc = (issuerDid: string): UnsignedVC => ({
116
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
117
+ type: ['VerifiableCredential'],
118
+ issuer: issuerDid,
119
+ credentialSubject: { id: issuerDid },
120
+ validFrom: new Date().toISOString(),
121
+ });
122
+
123
+ /** 1. `.env` has an identity, and the network agrees it's who .env says it is. */
124
+ export const identityCheck: Check = {
125
+ id: 'identity',
126
+ title: 'Identity',
127
+ run: async ({ project, learnCard }): Promise<CheckResult> => {
128
+ const fix = 'Run `npx @learncard/cli init` or `org apply`.';
129
+ if (!project.env.SECURE_SEED || !project.env.PROFILE_ID) {
130
+ return { status: 'fail', detail: 'Missing SECURE_SEED or PROFILE_ID in .env.', fix };
131
+ }
132
+ const profile = await learnCard.invoke.getProfile();
133
+ if (!profile) {
134
+ return {
135
+ status: 'fail',
136
+ detail: 'No profile found on the network for this seed.',
137
+ fix,
138
+ };
139
+ }
140
+ if (profile.profileId !== project.env.PROFILE_ID) {
141
+ return {
142
+ status: 'fail',
143
+ detail: `Network profile "${profile.profileId}" does not match PROFILE_ID "${project.env.PROFILE_ID}" in .env.`,
144
+ fix,
145
+ };
146
+ }
147
+ return { status: 'pass', detail: `Signed in as "${profile.profileId}".` };
148
+ },
149
+ };
150
+
151
+ const healthCheck = async (
152
+ doFetch: typeof fetch,
153
+ trpcUrl: string,
154
+ label: string,
155
+ fix: string
156
+ ): Promise<CheckResult> => {
157
+ const url = `${networkBase(trpcUrl)}/api/health-check`;
158
+ try {
159
+ const res = await fetchWithTimeout(doFetch, url);
160
+ if (!res.ok) {
161
+ return { status: 'fail', detail: `${label} health-check returned ${res.status}.`, fix };
162
+ }
163
+ return { status: 'pass', detail: `Connected to ${label}.` };
164
+ } catch (error) {
165
+ return {
166
+ status: 'fail',
167
+ detail: `Could not reach ${label}: ${errorMessage(error)}`,
168
+ fix,
169
+ };
170
+ }
171
+ };
172
+
173
+ /** 2. The configured network's health-check answers within 5s. */
174
+ export const networkCheck: Check = {
175
+ id: 'network',
176
+ title: 'Network',
177
+ run: ({ services, fetch: doFetch }): Promise<CheckResult> =>
178
+ healthCheck(
179
+ doFetch,
180
+ services.network,
181
+ describeNetwork(services.network),
182
+ 'Check --network (or NETWORK_URL in .env) points at a real LearnCard Network deployment.'
183
+ ),
184
+ };
185
+
186
+ /** 2b. The hosted signing service (lca-api) answers, when one is configured. */
187
+ export const signingServiceCheck: Check = {
188
+ id: 'signing-service',
189
+ title: 'Signing service',
190
+ run: async ({ services, fetch: doFetch }): Promise<CheckResult> => {
191
+ if (!services.lcaAPI) {
192
+ return {
193
+ status: 'skip',
194
+ detail: 'No LCA_API_URL configured; hosted signing (setup-signing, org apply) is unavailable on this network.',
195
+ };
196
+ }
197
+ return healthCheck(
198
+ doFetch,
199
+ services.lcaAPI,
200
+ `signing service ${services.lcaAPI}`,
201
+ 'Check LCA_API_URL in .env. If it is a local container, `docker logs` it — a port that accepts and immediately closes connections usually means the service failed env validation on boot.'
202
+ );
203
+ },
204
+ };
205
+
206
+ /** 3. If an API_TOKEN is configured, it maps to an active, unexpired grant with enough scope. */
207
+ export const tokenScopesCheck: Check = {
208
+ id: 'token-scopes',
209
+ title: 'Token scopes',
210
+ run: async ({ project, learnCard, requiredScopes }): Promise<CheckResult> => {
211
+ const fix = `Run \`npx @learncard/cli token --scope '${requiredScopes.join(' ')}'\``;
212
+ const grants = (await learnCard.invoke.getAuthGrants()) ?? [];
213
+ const active = grants.filter(grant => grant.status === 'active');
214
+
215
+ const token = project.env.API_TOKEN;
216
+ if (!token) {
217
+ const covering = active.find(
218
+ candidate =>
219
+ (!candidate.expiresAt ||
220
+ new Date(candidate.expiresAt).getTime() > Date.now()) &&
221
+ requiredScopes.every(required => scopeCovers(candidate.scope ?? '', required))
222
+ );
223
+ if (covering) {
224
+ return {
225
+ status: 'pass',
226
+ detail: `Active grant "${covering.name ?? covering.id}" covers ${requiredScopes.join(' ')} (its token is stored outside .env, e.g. your --secrets-out file); may act as: ${describeActAs(getGrantActAs(covering))}.`,
227
+ };
228
+ }
229
+ return {
230
+ status: 'warn',
231
+ detail: 'No API_TOKEN in .env and no active grant covers the required scopes.',
232
+ fix,
233
+ };
234
+ }
235
+
236
+ const grantId = project.env.API_TOKEN_GRANT_ID;
237
+ if (!grantId && !project.env.API_TOKEN_SCOPE) {
238
+ return {
239
+ status: 'warn',
240
+ detail: 'API_TOKEN has no grant ID or scope metadata; its grant cannot be identified.',
241
+ fix: 'Set API_TOKEN_GRANT_ID in .env to the grant ID for this token.',
242
+ };
243
+ }
244
+ const grant = grantId
245
+ ? active.find(candidate => candidate.id === grantId)
246
+ : active.find(candidate => candidate.scope === project.env.API_TOKEN_SCOPE);
247
+ if (!grant) {
248
+ return {
249
+ status: 'fail',
250
+ detail: 'API_TOKEN does not match any active auth grant on this network.',
251
+ fix,
252
+ };
253
+ }
254
+ if (grant.expiresAt && new Date(grant.expiresAt).getTime() < Date.now()) {
255
+ return {
256
+ status: 'fail',
257
+ detail: `Auth grant ${grant.id} expired ${grant.expiresAt}.`,
258
+ fix,
259
+ };
260
+ }
261
+ const scope = grant.scope ?? '';
262
+ const missing = requiredScopes.filter(required => !scopeCovers(scope, required));
263
+ if (missing.length) {
264
+ return {
265
+ status: 'fail',
266
+ detail: `Token scope "${scope}" is missing: ${missing.join(', ')}.`,
267
+ fix,
268
+ };
269
+ }
270
+ return {
271
+ status: 'pass',
272
+ detail: `Token scope "${scope}" covers ${requiredScopes.join(' ')}; may act as: ${describeActAs(getGrantActAs(grant))}.`,
273
+ };
274
+ },
275
+ };
276
+
277
+ /**
278
+ * 4. A primary hosted signer is registered over https, and a real credential can be
279
+ * signed and verified. The network plugin has no public "issue via this signing
280
+ * authority and hand me back the VC" method (only `sendBoost`/inbox flows exercise a
281
+ * signing authority end to end), so this test-signs locally with the wallet's own
282
+ * keys and says so in `detail` rather than silently pretending it hit the SA endpoint.
283
+ */
284
+ export const signingAuthorityCheck: Check = {
285
+ id: 'signing-authority',
286
+ title: 'Signing authority',
287
+ run: async ({ learnCard }): Promise<CheckResult> => {
288
+ const fix = 'Run `npx @learncard/cli setup-signing`.';
289
+ const authorities = await learnCard.invoke.getRegisteredSigningAuthorities();
290
+ const primary = authorities.find(authority => authority.relationship.isPrimary);
291
+ if (!primary) {
292
+ return { status: 'fail', detail: 'No primary signing authority registered.', fix };
293
+ }
294
+ const endpoint = primary.signingAuthority.endpoint;
295
+ if (!/^https:\/\//.test(endpoint) && !isLoopback(endpoint)) {
296
+ return {
297
+ status: 'fail',
298
+ detail: `Signing authority endpoint "${endpoint}" is not https.`,
299
+ fix: 'Re-register the signing authority with an https endpoint (plain http is only accepted for localhost).',
300
+ };
301
+ }
302
+ try {
303
+ const issuerDid = learnCard.id.did();
304
+ const signed = await learnCard.invoke.issueCredential(buildDoctorTestVc(issuerDid));
305
+ const result = await learnCard.invoke.verifyCredential(signed);
306
+ if (result.errors.length) {
307
+ return {
308
+ status: 'fail',
309
+ detail: `Test credential failed verification: ${result.errors.join('; ')}`,
310
+ fix,
311
+ };
312
+ }
313
+ return {
314
+ status: 'pass',
315
+ detail: `Primary signing authority "${primary.relationship.name}" registered; verified a locally-signed test credential (this checks your keys, not the ${primary.signingAuthority.endpoint} endpoint).`,
316
+ };
317
+ } catch (error) {
318
+ return { status: 'fail', detail: `Test-sign failed: ${errorMessage(error)}`, fix };
319
+ }
320
+ },
321
+ };
322
+
323
+ /** 5. If this profile has a did:web, its DID document actually resolves and lists a key. */
324
+ export const didWebCheck: Check = {
325
+ id: 'did-web',
326
+ title: 'did:web',
327
+ run: async ({ project, services, learnCard, fetch: doFetch }): Promise<CheckResult> => {
328
+ let did: string | undefined;
329
+ try {
330
+ did = learnCard.id.did('web');
331
+ } catch {
332
+ did = undefined;
333
+ }
334
+ if (!did || !did.startsWith('did:web:')) {
335
+ return {
336
+ status: 'warn',
337
+ detail: 'No did:web on this profile; issuer will be did:key.',
338
+ };
339
+ }
340
+
341
+ const fix = 'Re-register the signing authority to refresh the DID document.';
342
+ const url = `${networkBase(services.network)}/users/${project.env.PROFILE_ID}/did.json`;
343
+ let doc: unknown;
344
+ try {
345
+ const res = await fetchWithTimeout(doFetch, url);
346
+ if (!res.ok) return { status: 'fail', detail: `${url} returned ${res.status}.`, fix };
347
+ doc = await res.json();
348
+ } catch (error) {
349
+ return {
350
+ status: 'fail',
351
+ detail: `Could not fetch ${url}: ${errorMessage(error)}`,
352
+ fix,
353
+ };
354
+ }
355
+ const verificationMethod =
356
+ doc && typeof doc === 'object'
357
+ ? (doc as Record<string, unknown>).verificationMethod
358
+ : undefined;
359
+ if (!Array.isArray(verificationMethod)) {
360
+ return {
361
+ status: 'fail',
362
+ detail: `${url} did not return a verificationMethod array.`,
363
+ fix,
364
+ };
365
+ }
366
+
367
+ const authorities = await learnCard.invoke.getRegisteredSigningAuthorities();
368
+ const primary = authorities.find(authority => authority.relationship.isPrimary);
369
+ if (primary && primary.relationship.did !== did) {
370
+ const suffix = `#${primary.relationship.name}`;
371
+ const hasMethod = verificationMethod.some(
372
+ entry =>
373
+ entry &&
374
+ typeof entry === 'object' &&
375
+ typeof (entry as Record<string, unknown>).id === 'string' &&
376
+ ((entry as Record<string, unknown>).id as string).endsWith(suffix)
377
+ );
378
+ if (!hasMethod) {
379
+ return {
380
+ status: 'warn',
381
+ detail: `${url} has no verificationMethod ending in "${suffix}".`,
382
+ fix,
383
+ };
384
+ }
385
+ }
386
+ return {
387
+ status: 'pass',
388
+ detail: `${did} resolves with ${verificationMethod.length} verification method(s).`,
389
+ };
390
+ },
391
+ };
392
+
393
+ /** 6. Only runs when a webhook URL is configured: does it accept a signed doctor ping? */
394
+ export const webhookCheck: Check = {
395
+ id: 'webhook',
396
+ title: 'Webhook',
397
+ run: async ({ webhookUrl, fetch: doFetch }): Promise<CheckResult> => {
398
+ if (!webhookUrl) {
399
+ return { status: 'skip', detail: 'No --webhook-url or WEBHOOK_URL configured.' };
400
+ }
401
+ const fix = 'Check the webhook endpoint is deployed and reachable.';
402
+ try {
403
+ const res = await fetchWithTimeout(doFetch, webhookUrl, {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json', 'X-LearnCard-Doctor': '1' },
406
+ body: JSON.stringify({ type: 'DOCTOR_PING', timestamp: new Date().toISOString() }),
407
+ });
408
+ if (res.ok) {
409
+ return {
410
+ status: 'pass',
411
+ detail: `${webhookUrl} accepted the ping (${res.status}).`,
412
+ };
413
+ }
414
+ if (res.status >= 400 && res.status < 500) {
415
+ return {
416
+ status: 'warn',
417
+ detail: `${webhookUrl} rejected the ping (${res.status}).`,
418
+ fix: 'Confirm the webhook endpoint accepts POST requests with an X-LearnCard-Doctor header.',
419
+ };
420
+ }
421
+ return { status: 'fail', detail: `${webhookUrl} returned ${res.status}.`, fix };
422
+ } catch (error) {
423
+ return {
424
+ status: 'fail',
425
+ detail: `Could not reach ${webhookUrl}: ${errorMessage(error)}`,
426
+ fix,
427
+ };
428
+ }
429
+ },
430
+ };
431
+
432
+ /**
433
+ * 7. Managed credential refresh is a network-wide feature toggle. Probing a
434
+ * syntactically valid but unknown refreshId distinguishes "feature is off" (NOT_FOUND
435
+ * whose message says the feature is not available) from "feature is on, this id just
436
+ * doesn't exist" (NOT_FOUND "Credential refresh not found") without allocating anything.
437
+ * Any other error (transport, auth) is inconclusive and reported as a warning.
438
+ */
439
+ export const refreshEnabledCheck: Check = {
440
+ id: 'refresh-enabled',
441
+ title: 'Credential refresh',
442
+ run: async ({ learnCard }): Promise<CheckResult> => {
443
+ try {
444
+ await learnCard.invoke.getCredentialRefreshHistory({
445
+ refreshId: '00000000-0000-4000-8000-000000000000',
446
+ limit: 1,
447
+ });
448
+ return { status: 'pass', detail: 'Credential refresh is enabled on this network.' };
449
+ } catch (error) {
450
+ const message = errorMessage(error);
451
+ if (/not available/i.test(message)) {
452
+ return {
453
+ status: 'fail',
454
+ detail: message,
455
+ fix: "Credential refresh isn't enabled on this network; use `--network staging` or contact LearnCard.",
456
+ };
457
+ }
458
+ if (/credential refresh not found/i.test(message)) {
459
+ return {
460
+ status: 'pass',
461
+ detail: 'Credential refresh is enabled on this network.',
462
+ };
463
+ }
464
+ return {
465
+ status: 'warn',
466
+ detail: `Could not determine whether credential refresh is enabled: ${message}`,
467
+ fix: 'Check network reachability and your API token, then rerun `doctor`.',
468
+ };
469
+ }
470
+ },
471
+ };
472
+
473
+ /**
474
+ * 8. There is no query method for Trusted Registry membership on the network plugin
475
+ * (only unrelated federation/boost-authenticity "trusted" concepts exist in
476
+ * `learn-card-network`'s plugin.ts) — this stays a manual step.
477
+ */
478
+ export const trustedRegistryCheck: Check = {
479
+ id: 'trusted-registry',
480
+ title: 'Trusted Registry',
481
+ run: async (): Promise<CheckResult> => ({
482
+ status: 'skip',
483
+ detail: 'manual: ask LearnCard to add this profile to the Trusted Registry to address recipients by phone or state_student_id',
484
+ }),
485
+ };
486
+
487
+ export const CHECKS: Check[] = [
488
+ identityCheck,
489
+ networkCheck,
490
+ signingServiceCheck,
491
+ tokenScopesCheck,
492
+ signingAuthorityCheck,
493
+ didWebCheck,
494
+ webhookCheck,
495
+ refreshEnabledCheck,
496
+ trustedRegistryCheck,
497
+ ];
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { summarize } from './doctor';
3
+
4
+ const results = (...statuses: Array<'pass' | 'warn' | 'fail' | 'skip'>) =>
5
+ statuses.map(status => ({ status }));
6
+
7
+ describe('doctor summary', () => {
8
+ it('counts each status independently', () => {
9
+ const summary = summarize(results('pass', 'pass', 'warn', 'fail', 'skip'));
10
+ expect(summary).toMatchObject({ passed: 2, warnings: 1, failed: 1, skipped: 1 });
11
+ });
12
+
13
+ it('is ok when nothing failed, even with warnings', () => {
14
+ expect(summarize(results('pass', 'warn')).ok).toBe(true);
15
+ });
16
+
17
+ it('is not ok when anything failed', () => {
18
+ expect(summarize(results('pass', 'fail')).ok).toBe(false);
19
+ });
20
+
21
+ it('is not ok in strict mode when there are warnings, even with no failures', () => {
22
+ expect(summarize(results('pass', 'warn'), true).ok).toBe(false);
23
+ });
24
+
25
+ it('is ok in strict mode with only passes', () => {
26
+ expect(summarize(results('pass', 'pass'), true).ok).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe('runDoctor', () => {
31
+ it('connects read-only so a diagnostic --network never rewrites .env', async () => {
32
+ const fs = await import('node:fs/promises');
33
+ const os = await import('node:os');
34
+ const path = await import('node:path');
35
+ const { vi } = await import('vitest');
36
+ const connect = vi.fn().mockResolvedValue({});
37
+ vi.doMock('./project', async importOriginal => ({
38
+ ...(await importOriginal<typeof import('./project')>()),
39
+ connect,
40
+ }));
41
+ vi.doMock('./doctor/checks', async importOriginal => ({
42
+ ...(await importOriginal<typeof import('./doctor/checks')>()),
43
+ CHECKS: [],
44
+ }));
45
+ vi.resetModules();
46
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
47
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-doctor-'));
48
+ const before = `SECURE_SEED=${'a'.repeat(64)}\n`;
49
+ try {
50
+ await fs.writeFile(path.join(cwd, '.env'), before);
51
+ const { runDoctor } = await import('./doctor');
52
+ await runDoctor({ cwd, network: 'staging' });
53
+ expect(connect).toHaveBeenCalledWith(
54
+ expect.anything(),
55
+ expect.objectContaining({ network: 'staging', readOnly: true })
56
+ );
57
+ expect(await fs.readFile(path.join(cwd, '.env'), 'utf8')).toBe(before);
58
+ } finally {
59
+ vi.doUnmock('./project');
60
+ vi.doUnmock('./doctor/checks');
61
+ vi.resetModules();
62
+ log.mockRestore();
63
+ process.exitCode = undefined;
64
+ await fs.rm(cwd, { recursive: true, force: true });
65
+ }
66
+ });
67
+ });
package/src/doctor.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { Command } from 'commander';
2
+ import { connect, loadProject, resolveServices, type ProjectOptions } from './project';
3
+ import { validateScope } from './token';
4
+ import { out } from './out';
5
+ import {
6
+ CHECKS,
7
+ DEFAULT_REQUIRED_SCOPES,
8
+ type CheckResult,
9
+ type CheckStatus,
10
+ type DoctorContext,
11
+ } from './doctor/checks';
12
+
13
+ const SYMBOLS: Record<CheckStatus, string> = { pass: '✔', warn: '⚠', fail: '✖', skip: '–' };
14
+
15
+ const printResult = (title: string, result: CheckResult): void => {
16
+ out.log(`${SYMBOLS[result.status]} ${title}${result.detail ? ` ${result.detail}` : ''}`);
17
+ if ((result.status === 'warn' || result.status === 'fail') && result.fix) {
18
+ out.log(` → ${result.fix}`);
19
+ }
20
+ };
21
+
22
+ export interface DoctorSummary {
23
+ passed: number;
24
+ warnings: number;
25
+ failed: number;
26
+ skipped: number;
27
+ ok: boolean;
28
+ }
29
+
30
+ /** Pure so tests can drive it without a project, network, or wallet. */
31
+ export const summarize = (
32
+ results: { status: CheckStatus }[],
33
+ strict: boolean = false
34
+ ): DoctorSummary => {
35
+ const passed = results.filter(result => result.status === 'pass').length;
36
+ const warnings = results.filter(result => result.status === 'warn').length;
37
+ const failed = results.filter(result => result.status === 'fail').length;
38
+ const skipped = results.filter(result => result.status === 'skip').length;
39
+ return { passed, warnings, failed, skipped, ok: failed === 0 && (!strict || warnings === 0) };
40
+ };
41
+
42
+ type DoctorOptions = ProjectOptions & {
43
+ scopes?: string;
44
+ webhookUrl?: string;
45
+ strict?: boolean;
46
+ cwd?: string;
47
+ };
48
+
49
+ export const runDoctor = async (options: DoctorOptions): Promise<void> => {
50
+ const project = await loadProject(options.cwd ?? process.cwd());
51
+ if (!project.env.SECURE_SEED) {
52
+ throw new Error(
53
+ 'No SECURE_SEED in .env. Run `npx @learncard/cli init` or `org apply` first.'
54
+ );
55
+ }
56
+ const learnCard = await connect(project, { ...options, readOnly: true });
57
+ const services = resolveServices(project.env, options.network);
58
+ const requiredScopes = options.scopes
59
+ ? validateScope(options.scopes).split(' ').filter(Boolean)
60
+ : DEFAULT_REQUIRED_SCOPES;
61
+ const webhookUrl = options.webhookUrl || project.env.WEBHOOK_URL || undefined;
62
+ const context: DoctorContext = {
63
+ project,
64
+ services,
65
+ learnCard,
66
+ fetch,
67
+ requiredScopes,
68
+ webhookUrl,
69
+ };
70
+
71
+ const results: (CheckResult & { id: string; title: string })[] = [];
72
+ for (const check of CHECKS) {
73
+ const result = await check.run(context).catch((error: unknown): CheckResult => ({
74
+ status: 'fail',
75
+ detail: error instanceof Error ? error.message : String(error),
76
+ fix: 'Check network reachability and your .env, then rerun `doctor`.',
77
+ }));
78
+ results.push({ id: check.id, title: check.title, ...result });
79
+ printResult(check.title, result);
80
+ }
81
+
82
+ const summary = summarize(results, !!options.strict);
83
+ out.log(`${summary.passed} passed, ${summary.warnings} warnings, ${summary.failed} failed`);
84
+ out.set({
85
+ network: services.network,
86
+ checks: results.map(({ id, status, detail, fix }) => ({ id, status, detail, fix })),
87
+ ok: summary.ok,
88
+ });
89
+ if (!summary.ok) process.exitCode = 1;
90
+ };
91
+
92
+ export type RunCommand = (
93
+ command: string,
94
+ options: { json?: boolean },
95
+ action: (didkit: Promise<Buffer>) => Promise<void>,
96
+ wrap?: boolean
97
+ ) => Promise<void>;
98
+
99
+ export const registerDoctorCommand = (program: Command, run: RunCommand): void => {
100
+ program
101
+ .command('doctor')
102
+ .description("Preflight: check this project's issuer setup against the network.")
103
+ .option('-y, --yes', 'accept defaults without prompting')
104
+ .option('--profile-id <id>', 'public handle for your issuer profile')
105
+ .option('--network <url>', 'network tRPC URL or staging (default: production)')
106
+ .option('--json', 'print a single JSON result on stdout')
107
+ .option(
108
+ '--scopes <list>',
109
+ `space-separated required scopes (default: "${DEFAULT_REQUIRED_SCOPES.join(' ')}")`
110
+ )
111
+ .option('--webhook-url <url>', 'send a test ping to this webhook URL')
112
+ .option('--strict', 'also exit non-zero when there are warnings')
113
+ .action(options =>
114
+ run('doctor', options, async didkit => {
115
+ await runDoctor({ ...options, didkit });
116
+ })
117
+ );
118
+ };