@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,257 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import {
4
+ fetchSentInboxCredentials,
5
+ filterSince,
6
+ parseLimit,
7
+ parseSince,
8
+ registerInboxCommand,
9
+ toJsonRecord,
10
+ type SentInboxRecord,
11
+ } from './inbox';
12
+
13
+ const baseRecord = (overrides: Partial<SentInboxRecord> = {}): SentInboxRecord => ({
14
+ id: 'inbox-1',
15
+ isSigned: true,
16
+ currentStatus: 'PENDING',
17
+ expiresAt: '2026-02-01T00:00:00.000Z',
18
+ createdAt: '2026-01-01T00:00:00.000Z',
19
+ issuerDid: 'did:example:issuer',
20
+ ...overrides,
21
+ });
22
+
23
+ describe('parseLimit', () => {
24
+ it('defaults to 50', () => {
25
+ expect(parseLimit(undefined)).toBe(50);
26
+ });
27
+
28
+ it('accepts positive integers', () => {
29
+ expect(parseLimit('10')).toBe(10);
30
+ });
31
+
32
+ it.each(['abc', '0', '-1', '1.5'])('rejects %s', value => {
33
+ expect(() => parseLimit(value)).toThrow('--limit');
34
+ });
35
+ });
36
+
37
+ describe('parseSince', () => {
38
+ const now = new Date('2026-01-10T00:00:00.000Z');
39
+
40
+ it('parses day durations', () => {
41
+ expect(parseSince('7d', now).toISOString()).toBe('2026-01-03T00:00:00.000Z');
42
+ });
43
+
44
+ it('parses hour durations', () => {
45
+ expect(parseSince('24h', now).toISOString()).toBe('2026-01-09T00:00:00.000Z');
46
+ });
47
+
48
+ it('parses minute durations', () => {
49
+ expect(parseSince('30m', now).toISOString()).toBe('2026-01-09T23:30:00.000Z');
50
+ });
51
+
52
+ it('parses an ISO timestamp', () => {
53
+ expect(parseSince('2026-01-05T00:00:00.000Z', now).toISOString()).toBe(
54
+ '2026-01-05T00:00:00.000Z'
55
+ );
56
+ });
57
+
58
+ it('throws on garbage input', () => {
59
+ expect(() => parseSince('not-a-duration', now)).toThrow(/Invalid --since/);
60
+ });
61
+ });
62
+
63
+ describe('filterSince', () => {
64
+ it('keeps only records at or after the cutoff', () => {
65
+ const records = [
66
+ baseRecord({ id: 'old', createdAt: '2026-01-01T00:00:00.000Z' }),
67
+ baseRecord({ id: 'new', createdAt: '2026-01-09T00:00:00.000Z' }),
68
+ ];
69
+ expect(filterSince(records, new Date('2026-01-05T00:00:00.000Z')).map(r => r.id)).toEqual([
70
+ 'new',
71
+ ]);
72
+ });
73
+
74
+ it('is a no-op without a cutoff', () => {
75
+ const records = [baseRecord()];
76
+ expect(filterSince(records, undefined)).toBe(records);
77
+ });
78
+ });
79
+
80
+ describe('inbox command registration', () => {
81
+ it('does not advertise or accept the unsupported recipient-type filter', async () => {
82
+ const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
83
+ const run = vi.fn();
84
+ registerInboxCommand(program, run);
85
+ const list = program.commands[0]!.commands[0]!;
86
+ expect(list.helpInformation()).not.toContain('--recipient-type');
87
+ await expect(
88
+ program.parseAsync(['inbox', 'list', '--recipient-type', 'email'], { from: 'user' })
89
+ ).rejects.toThrow("unknown option '--recipient-type'");
90
+ expect(run).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+
94
+ describe('fetchSentInboxCredentials', () => {
95
+ it.each([false, true])(
96
+ 'reports overflow even when server hasMore is %s',
97
+ async serverHasMore => {
98
+ const getMySentInboxCredentials = vi.fn().mockResolvedValue({
99
+ records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
100
+ hasMore: serverHasMore,
101
+ });
102
+ const result = await fetchSentInboxCredentials(
103
+ { getMySentInboxCredentials },
104
+ { limit: 1 }
105
+ );
106
+ expect(result.records.map(record => record.id)).toEqual(['1']);
107
+ expect(result.hasMore).toBe(true);
108
+ expect(getMySentInboxCredentials).toHaveBeenCalledExactlyOnceWith({ limit: 1 });
109
+ }
110
+ );
111
+
112
+ it('requests the last remaining record and stops at an exact limit', async () => {
113
+ const getMySentInboxCredentials = vi
114
+ .fn()
115
+ .mockResolvedValueOnce({ records: [baseRecord()], hasMore: true, cursor: 'next' })
116
+ .mockResolvedValueOnce({ records: [baseRecord({ id: '2' })], hasMore: false });
117
+ const result = await fetchSentInboxCredentials({ getMySentInboxCredentials }, { limit: 2 });
118
+ expect(getMySentInboxCredentials).toHaveBeenNthCalledWith(2, { limit: 1, cursor: 'next' });
119
+ expect(getMySentInboxCredentials).toHaveBeenCalledTimes(2);
120
+ expect(result.records).toHaveLength(2);
121
+ expect(result.hasMore).toBe(false);
122
+ });
123
+
124
+ it('merges cursor-paginated pages up to the requested limit', async () => {
125
+ const page1 = {
126
+ hasMore: true,
127
+ cursor: 'cursor-1',
128
+ records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
129
+ };
130
+ const page2 = { hasMore: false, records: [baseRecord({ id: '3' })] };
131
+ const getMySentInboxCredentials = vi
132
+ .fn()
133
+ .mockResolvedValueOnce(page1)
134
+ .mockResolvedValueOnce(page2);
135
+
136
+ const { records, hasMore } = await fetchSentInboxCredentials(
137
+ { getMySentInboxCredentials },
138
+ { limit: 10 }
139
+ );
140
+
141
+ expect(records.map(r => r.id)).toEqual(['1', '2', '3']);
142
+ expect(hasMore).toBe(false);
143
+ expect(getMySentInboxCredentials).toHaveBeenCalledTimes(2);
144
+ expect(getMySentInboxCredentials).toHaveBeenNthCalledWith(
145
+ 2,
146
+ expect.objectContaining({ cursor: 'cursor-1', limit: 8 })
147
+ );
148
+ });
149
+
150
+ it('stops once the requested limit is reached, reporting more remain', async () => {
151
+ const page1 = {
152
+ hasMore: true,
153
+ cursor: 'cursor-1',
154
+ records: [baseRecord({ id: '1' }), baseRecord({ id: '2' })],
155
+ };
156
+ const getMySentInboxCredentials = vi.fn().mockResolvedValueOnce(page1);
157
+
158
+ const { records, hasMore } = await fetchSentInboxCredentials(
159
+ { getMySentInboxCredentials },
160
+ { limit: 1 }
161
+ );
162
+
163
+ expect(records.map(r => r.id)).toEqual(['1']);
164
+ expect(hasMore).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe('toJsonRecord', () => {
169
+ it('shapes a record for --json output, omitting an absent recipient', () => {
170
+ const record = baseRecord({ id: 'x' });
171
+ expect(toJsonRecord(record)).toEqual({
172
+ id: 'x',
173
+ status: 'PENDING',
174
+ recipient: undefined,
175
+ createdAt: record.createdAt,
176
+ expiresAt: record.expiresAt,
177
+ credentialName: undefined,
178
+ isSigned: true,
179
+ });
180
+ });
181
+
182
+ it('includes the recipient value only when present', () => {
183
+ const withValue = baseRecord({ recipient: { type: 'email', value: 'a@example.com' } });
184
+ expect(toJsonRecord(withValue).recipient).toEqual({
185
+ type: 'email',
186
+ value: 'a@example.com',
187
+ });
188
+
189
+ const withoutValue = baseRecord({ recipient: { type: 'phone' } });
190
+ expect(toJsonRecord(withoutValue).recipient).toEqual({ type: 'phone' });
191
+ });
192
+ });
193
+
194
+ describe('inbox list pipeline', () => {
195
+ it('merges two mocked pages, filters by since, and shapes json records', async () => {
196
+ const page1 = {
197
+ hasMore: true,
198
+ cursor: 'c1',
199
+ records: [
200
+ baseRecord({
201
+ id: '1',
202
+ createdAt: '2026-01-01T00:00:00.000Z',
203
+ recipient: { type: 'email', value: 'a@example.com' },
204
+ }),
205
+ baseRecord({
206
+ id: '2',
207
+ createdAt: '2026-01-08T00:00:00.000Z',
208
+ recipient: { type: 'phone' },
209
+ }),
210
+ ],
211
+ };
212
+ const page2 = {
213
+ hasMore: false,
214
+ records: [
215
+ baseRecord({
216
+ id: '3',
217
+ createdAt: '2026-01-09T00:00:00.000Z',
218
+ recipient: { type: 'email' },
219
+ }),
220
+ ],
221
+ };
222
+ const getMySentInboxCredentials = vi
223
+ .fn()
224
+ .mockResolvedValueOnce(page1)
225
+ .mockResolvedValueOnce(page2);
226
+
227
+ const { records, hasMore } = await fetchSentInboxCredentials(
228
+ { getMySentInboxCredentials },
229
+ { limit: 10 }
230
+ );
231
+ const since = parseSince('7d', new Date('2026-01-10T00:00:00.000Z'));
232
+ const filtered = filterSince(records, since);
233
+
234
+ expect(filtered.map(r => r.id)).toEqual(['2', '3']);
235
+ expect(hasMore).toBe(false);
236
+ expect(filtered.map(toJsonRecord)).toEqual([
237
+ {
238
+ id: '2',
239
+ status: 'PENDING',
240
+ recipient: { type: 'phone' },
241
+ createdAt: '2026-01-08T00:00:00.000Z',
242
+ expiresAt: page1.records[1]!.expiresAt,
243
+ credentialName: undefined,
244
+ isSigned: true,
245
+ },
246
+ {
247
+ id: '3',
248
+ status: 'PENDING',
249
+ recipient: { type: 'email' },
250
+ createdAt: '2026-01-09T00:00:00.000Z',
251
+ expiresAt: page2.records[0]!.expiresAt,
252
+ credentialName: undefined,
253
+ isSigned: true,
254
+ },
255
+ ]);
256
+ });
257
+ });
package/src/inbox.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { Option, type Command } from 'commander';
2
+ import type {
3
+ InboxCredentialQuery,
4
+ InboxCredentialType,
5
+ PaginationOptionsType,
6
+ } from '@learncard/types';
7
+
8
+ import {
9
+ connect,
10
+ connectAsManaged,
11
+ loadProject,
12
+ type NetworkCard,
13
+ type ProjectOptions,
14
+ } from './project';
15
+ import { out } from './out';
16
+
17
+ export interface InboxRecipient {
18
+ type?: string;
19
+ value?: string;
20
+ }
21
+
22
+ /**
23
+ * The network's current inbox listing carries no recipient contact info per record
24
+ * (only the send-time response does). `recipient` is modeled as optional so this type
25
+ * stays compatible with today's API.
26
+ */
27
+ export type SentInboxRecord = InboxCredentialType & { recipient?: InboxRecipient };
28
+
29
+ export interface InboxJsonRecord {
30
+ id: string;
31
+ status: string;
32
+ recipient?: { type?: string; value?: string };
33
+ createdAt: string;
34
+ expiresAt: string;
35
+ credentialName?: string;
36
+ isSigned: boolean;
37
+ }
38
+
39
+ const STATUS_VALUES = ['PENDING', 'ISSUED', 'EXPIRED', 'DELIVERED', 'CLAIMED'] as const;
40
+ type InboxStatus = (typeof STATUS_VALUES)[number];
41
+
42
+ export const isInboxStatus = (value: string): value is InboxStatus =>
43
+ (STATUS_VALUES as readonly string[]).includes(value);
44
+
45
+ export const parseStatus = (value: string | undefined): InboxStatus | undefined => {
46
+ if (value === undefined) return undefined;
47
+ const upper = value.toUpperCase();
48
+ if (!isInboxStatus(upper))
49
+ throw new Error(
50
+ `Unknown --status "${value}". Use PENDING, ISSUED, or EXPIRED (DELIVERED and CLAIMED are deprecated aliases).`
51
+ );
52
+ return upper;
53
+ };
54
+
55
+ export const parseLimit = (value: string | undefined): number => {
56
+ if (value === undefined) return 50;
57
+ const limit = Number(value);
58
+ if (!Number.isInteger(limit) || limit < 1)
59
+ throw new Error(`Invalid --limit "${value}". Use a positive whole number.`);
60
+ return limit;
61
+ };
62
+
63
+ const DURATION_PATTERN = /^(\d+)\s*(d|h|m)$/i;
64
+ const MS_PER_UNIT = { d: 86_400_000, h: 3_600_000, m: 60_000 } as const;
65
+
66
+ /** Parses `7d` / `24h` / `30m` (relative to `now`) or an ISO timestamp into a cutoff Date. */
67
+ export const parseSince = (value: string, now: Date = new Date()): Date => {
68
+ const match = value.match(DURATION_PATTERN);
69
+ if (match) {
70
+ const amount = Number(match[1]);
71
+ const unit = match[2]!.toLowerCase() as keyof typeof MS_PER_UNIT;
72
+ return new Date(now.getTime() - amount * MS_PER_UNIT[unit]);
73
+ }
74
+ const parsed = new Date(value);
75
+ if (Number.isNaN(parsed.getTime()))
76
+ throw new Error(`Invalid --since value "${value}". Use 7d, 24h, 30m, or an ISO timestamp.`);
77
+ return parsed;
78
+ };
79
+
80
+ /** Keeps only records created at or after `since`; a no-op when `since` is undefined. */
81
+ export const filterSince = <T extends { createdAt: string }>(records: T[], since?: Date): T[] => {
82
+ if (!since) return records;
83
+ const cutoff = since.getTime();
84
+ return records.filter(record => new Date(record.createdAt).getTime() >= cutoff);
85
+ };
86
+
87
+ export interface FetchSentInboxOptions {
88
+ limit: number;
89
+ currentStatus?: InboxStatus;
90
+ }
91
+
92
+ export interface FetchSentInboxResult {
93
+ records: SentInboxRecord[];
94
+ hasMore: boolean;
95
+ }
96
+
97
+ /** Cursor-paginates the network's sent-inbox listing until `hasMore` is false or `limit` is reached. */
98
+ export const fetchSentInboxCredentials = async (
99
+ card: Pick<NetworkCard['invoke'], 'getMySentInboxCredentials'>,
100
+ options: FetchSentInboxOptions
101
+ ): Promise<FetchSentInboxResult> => {
102
+ const records: SentInboxRecord[] = [];
103
+ let cursor: string | undefined;
104
+ let serverHasMore = true;
105
+
106
+ while (serverHasMore && records.length < options.limit) {
107
+ const query: Partial<PaginationOptionsType> & { query?: InboxCredentialQuery } = {
108
+ limit: Math.max(1, options.limit - records.length),
109
+ ...(cursor ? { cursor } : {}),
110
+ ...(options.currentStatus ? { query: { currentStatus: options.currentStatus } } : {}),
111
+ };
112
+ const page = await card.getMySentInboxCredentials(query);
113
+ records.push(...page.records);
114
+ serverHasMore = page.hasMore;
115
+ cursor = page.cursor;
116
+ }
117
+
118
+ const total = records.length;
119
+ return {
120
+ records: records.slice(0, options.limit),
121
+ hasMore: serverHasMore || total > options.limit,
122
+ };
123
+ };
124
+
125
+ /** Shapes a record for `--json` output; `recipient` is omitted when the metadata lacks it. */
126
+ export const toJsonRecord = (record: SentInboxRecord): InboxJsonRecord => ({
127
+ id: record.id,
128
+ status: record.currentStatus,
129
+ recipient: record.recipient
130
+ ? {
131
+ type: record.recipient.type,
132
+ ...(record.recipient.value ? { value: record.recipient.value } : {}),
133
+ }
134
+ : undefined,
135
+ createdAt: record.createdAt,
136
+ expiresAt: record.expiresAt,
137
+ credentialName: record.credentialName,
138
+ isSigned: record.isSigned,
139
+ });
140
+
141
+ const formatRow = (record: SentInboxRecord): string => {
142
+ const created = record.createdAt.replace('T', ' ').slice(0, 19);
143
+ const recipient = record.recipient
144
+ ? `${record.recipient.type ?? '?'}${record.recipient.value ? `:${record.recipient.value}` : ''}`
145
+ : '—';
146
+ return `${record.id} ${record.currentStatus.padEnd(8)} ${recipient} ${created} ${record.credentialName ?? ''}`;
147
+ };
148
+
149
+ export type InboxListOptions = ProjectOptions & {
150
+ as?: string;
151
+ status?: string;
152
+ since?: string;
153
+
154
+ limit?: string;
155
+ };
156
+
157
+ export const runInboxList = async (options: InboxListOptions): Promise<void> => {
158
+ const project = await loadProject(process.cwd());
159
+ const learnCard = options.as
160
+ ? await connectAsManaged(project, options, options.as)
161
+ : await connect(project, options);
162
+
163
+ const currentStatus = parseStatus(options.status);
164
+ const limit = parseLimit(options.limit);
165
+ const since = options.since ? parseSince(options.since) : undefined;
166
+
167
+ const { records: fetched, hasMore } = await fetchSentInboxCredentials(learnCard.invoke, {
168
+ limit,
169
+ currentStatus,
170
+ });
171
+
172
+ const filtered = filterSince(fetched, since);
173
+
174
+ if (!filtered.length) {
175
+ out.log('No sent inbox credentials match. Try widening --since or dropping --status.');
176
+ out.set({ records: [], hasMore });
177
+ return;
178
+ }
179
+
180
+ out.log('issuanceId status recipient created name');
181
+ for (const record of filtered) out.log(formatRow(record));
182
+ if (hasMore) out.log('…more. Raise --limit to see additional sends.');
183
+
184
+ out.set({ records: filtered.map(toJsonRecord), hasMore });
185
+ };
186
+
187
+ export const registerInboxCommand = (
188
+ program: Command,
189
+ run: (
190
+ command: string,
191
+ options: { json?: boolean },
192
+ action: (didkit: Promise<Buffer>) => Promise<void>,
193
+ wrap?: boolean
194
+ ) => Promise<void>
195
+ ): void => {
196
+ const inbox = program
197
+ .command('inbox')
198
+ .description('Credentials sent through the inbox (email/phone claim flow).');
199
+ inbox
200
+ .command('list')
201
+ .description('List credentials you sent through the inbox.')
202
+ .addOption(
203
+ new Option('--as <profileId>', 'list credentials sent by a profile you manage').env(
204
+ 'LEARNCARD_AS'
205
+ )
206
+ )
207
+ .option('--status <status>', 'PENDING, ISSUED, or EXPIRED')
208
+ .option(
209
+ '--since <duration|iso>',
210
+ 'only sends at/after this: 7d, 24h, 30m, or an ISO timestamp'
211
+ )
212
+
213
+ .option('--limit <n>', 'how many to list (default: 50)')
214
+ .option('--network <url>', 'network tRPC URL or staging (default: production)')
215
+ .option('--json', 'print a single JSON result on stdout')
216
+ .action(options =>
217
+ run('inbox list', options, async didkit => {
218
+ await runInboxList({ ...options, didkit });
219
+ })
220
+ );
221
+ };
package/src/index.tsx CHANGED
@@ -12,7 +12,7 @@ import * as types from '@learncard/types';
12
12
  import { getLinkedClaimsPlugin } from '@learncard/linked-claims-plugin';
13
13
  import gradient from 'gradient-string';
14
14
  import figlet from 'figlet';
15
- import { program } from 'commander';
15
+ import { Option, program } from 'commander';
16
16
  import clipboard from 'clipboardy';
17
17
 
18
18
  import { getLerRsPlugin } from '@learncard/ler-rs-plugin';
@@ -32,6 +32,13 @@ import {
32
32
  createExportLearnCardBundleHelper,
33
33
  createRestoreLearnCardFromBundleHelper,
34
34
  } from './replHelpers';
35
+ import { registerOrgCommand } from './org';
36
+ import { registerDoctorCommand } from './doctor';
37
+ import { registerClrCommand } from './clr';
38
+ import { registerInboxCommand } from './inbox';
39
+ import { registerRefreshCommand } from './refresh';
40
+ import { registerWhoamiCommand } from './whoami';
41
+ import { registerPromoteCommand } from './promote';
35
42
 
36
43
  import packageJson from '../package.json';
37
44
 
@@ -329,9 +336,9 @@ const startCliRepl = async (colorize: (input: string) => string): Promise<void>
329
336
  };
330
337
 
331
338
  program
332
- .command('send <email>')
339
+ .command('send [recipient]')
333
340
  .description(
334
- 'Send a "Quickstart Complete" badge to an email address. Creates .env and send.mjs in the current folder.'
341
+ 'Send a "Quickstart Complete" badge to an email, phone number, profile ID, or DID (prompts if omitted). Creates .env and send.mjs in the current folder.'
335
342
  )
336
343
  .option('-y, --yes', 'accept defaults without prompting')
337
344
  .option('--name <displayName>', 'display name for your issuer profile')
@@ -342,7 +349,17 @@ program
342
349
  'public handle for your profile (default: derived from the display name)'
343
350
  )
344
351
  .option('--network <url>', 'network tRPC URL (default: production)')
345
- .option('--template', 'send using a reusable template and hosted signing authority')
352
+ .addOption(
353
+ new Option(
354
+ '--as <profileId>',
355
+ 'send as a profile you manage (from `org apply`), signed with its did:web'
356
+ ).env('LEARNCARD_AS')
357
+ )
358
+ .option(
359
+ '--template',
360
+ 'send using a reusable template and hosted signing authority (default once setup-signing or org apply has run)'
361
+ )
362
+ .option('--no-template', 'sign with the local key even if a signing authority is registered')
346
363
  .option('--template-uri <uri>', 'send from a specific template (implies --template)')
347
364
  .option('--webhook-url <url>', 'receive ISSUANCE_DELIVERED / ISSUANCE_CLAIMED at this URL')
348
365
  .option('--suppress-delivery', 'skip the claim email; you deliver inbox.claimUrl yourself')
@@ -353,7 +370,7 @@ program
353
370
  .option('--json', 'print a single JSON result on stdout')
354
371
  .action(
355
372
  async (
356
- email: string,
373
+ recipient: string | undefined,
357
374
  opts: {
358
375
  yes?: boolean;
359
376
  name?: string;
@@ -371,7 +388,7 @@ program
371
388
  require.resolve('@learncard/didkit-plugin/dist/didkit/didkit_wasm_bg.wasm')
372
389
  );
373
390
  try {
374
- await runSend(email, { ...opts, didkit });
391
+ await runSend(recipient, { ...opts, didkit });
375
392
  if (out.json) {
376
393
  process.stdout.write(
377
394
  JSON.stringify({ ok: true, command: 'send', ...out.result }) + '\n'
@@ -389,7 +406,11 @@ program
389
406
  }
390
407
  console.error(`\n${firstLine}`);
391
408
  // Input mistakes explain themselves; keep the docs link for network/auth failures.
392
- if (!/is not an email address/.test(firstLine))
409
+ if (
410
+ !/is not an email address|is a placeholder address|A recipient is required/.test(
411
+ firstLine
412
+ )
413
+ )
393
414
  console.error(
394
415
  'Troubleshooting: https://docs.learncard.com/start-here/your-first-integration#if-something-goes-wrong'
395
416
  );
@@ -441,6 +462,32 @@ const runCommand = async (
441
462
  }
442
463
  };
443
464
 
465
+ program
466
+ .command('demo')
467
+ .description('Guided demonstrations using real LearnCard credentials.')
468
+ .command('refresh')
469
+ .description('Send a demo badge, publish an update, and refresh the recipient’s copy.')
470
+ .option('--inbox', 'use Universal Inbox deferred issuance for an address with no account')
471
+ .option('--ui', 'claim and refresh in the local LearnCard app (interactive only)')
472
+ .option('--app-url <url>', 'local LearnCard app URL for --ui (default: http://localhost:3000)')
473
+ .option(
474
+ '--lca-url <url>',
475
+ 'local LCA URL for --inbox terminal mode (default: http://localhost:5100/trpc)'
476
+ )
477
+ .option(
478
+ '--email [address]',
479
+ 'real-email mode: request a provisional claim email to your own address (requires --inbox --ui; prompts when omitted)'
480
+ )
481
+ .option('-y, --yes', 'run all steps without pausing')
482
+ .option('--network <url>', 'network tRPC URL or staging', 'http://localhost:4000/trpc')
483
+ .option('--json', 'print a single JSON result on stdout (no pauses)')
484
+ .action(options =>
485
+ runCommand('demo refresh', options, async didkit => {
486
+ const { runDemoRefreshCommand } = await import('./demo-refresh-command');
487
+ await runDemoRefreshCommand({ ...options, didkit });
488
+ })
489
+ );
490
+
444
491
  commandOptions(
445
492
  program.command('consent-contract').description("Connect a user's LearnCard to your platform.")
446
493
  )
@@ -597,9 +644,18 @@ commandOptions(
597
644
  })
598
645
  );
599
646
 
647
+ registerOrgCommand(program, runCommand);
648
+ registerDoctorCommand(program, runCommand);
649
+ registerClrCommand(program, runCommand);
650
+ registerInboxCommand(program, runCommand);
651
+ registerRefreshCommand(program, runCommand);
652
+ registerWhoamiCommand(program, runCommand);
653
+ registerPromoteCommand(program, runCommand);
654
+
600
655
  const JOURNEY = [
601
656
  'send',
602
657
  'status',
658
+ 'whoami',
603
659
  'setup-signing',
604
660
  'token',
605
661
  'webhook',
@@ -608,6 +664,12 @@ const JOURNEY = [
608
664
  'verify',
609
665
  'revoke',
610
666
  'open',
667
+ 'org',
668
+ 'doctor',
669
+ 'promote',
670
+ 'clr',
671
+ 'inbox',
672
+ 'refresh',
611
673
  'init',
612
674
  'repl',
613
675
  ];
@@ -625,7 +687,7 @@ program
625
687
  })
626
688
  .addHelpText(
627
689
  'before',
628
- '\nStart here: npx @learncard/cli send you@example.com\nThen: npx @learncard/cli status\n'
690
+ '\nStart here: npx @learncard/cli send\nThen: npx @learncard/cli status\n'
629
691
  )
630
692
  .addHelpText(
631
693
  'after',
package/src/init.ts CHANGED
@@ -22,5 +22,5 @@ export const runInit = async (options: ProjectOptions): Promise<void> => {
22
22
  envPath: project.envPath,
23
23
  });
24
24
  out.log(fresh ? 'Ready. Your identity is in .env (keep it out of git).' : 'Already set up.');
25
- out.log('Next: npx @learncard/cli send you@example.com');
25
+ out.log('Next: npx @learncard/cli send');
26
26
  };
package/src/open.ts CHANGED
@@ -120,7 +120,7 @@ export const runOpen = async (
120
120
  const seed = project.env.SECURE_SEED;
121
121
  if (!seed)
122
122
  throw new Error(
123
- 'No SECURE_SEED in .env. Run a command that creates one first, e.g. npx @learncard/cli send you@example.com'
123
+ 'No SECURE_SEED in .env. Run a command that creates one first, e.g. npx @learncard/cli send'
124
124
  );
125
125
 
126
126
  const path = openPath(target, project.env);