@learncard/cli 3.6.8 → 4.0.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +36 -12
  3. package/dist/index.js +6673 -2038
  4. package/package.json +27 -15
  5. package/project.json +0 -42
  6. package/rollup.config.js +0 -34
  7. package/scripts/embed-snippets.ts +0 -47
  8. package/src/auth-grant.test.ts +0 -54
  9. package/src/auth-grant.ts +0 -34
  10. package/src/clr/validate.test.ts +0 -65
  11. package/src/clr/validate.ts +0 -242
  12. package/src/clr.ts +0 -119
  13. package/src/consent-contract.ts +0 -116
  14. package/src/demo-inbox-refresh.test.ts +0 -737
  15. package/src/demo-inbox-refresh.ts +0 -804
  16. package/src/demo-refresh-command.test.ts +0 -57
  17. package/src/demo-refresh-command.ts +0 -22
  18. package/src/demo-refresh-ui.test.ts +0 -66
  19. package/src/demo-refresh-ui.ts +0 -65
  20. package/src/demo-refresh.test.ts +0 -140
  21. package/src/demo-refresh.ts +0 -309
  22. package/src/doctor/checks.test.ts +0 -448
  23. package/src/doctor/checks.ts +0 -497
  24. package/src/doctor.test.ts +0 -67
  25. package/src/doctor.ts +0 -118
  26. package/src/embed.ts +0 -102
  27. package/src/inbox.test.ts +0 -257
  28. package/src/inbox.ts +0 -221
  29. package/src/index.tsx +0 -876
  30. package/src/init.ts +0 -26
  31. package/src/open.test.ts +0 -40
  32. package/src/open.ts +0 -186
  33. package/src/org/apply.test.ts +0 -1108
  34. package/src/org/apply.ts +0 -924
  35. package/src/org/branding.test.ts +0 -60
  36. package/src/org/diff.ts +0 -14
  37. package/src/org/load.ts +0 -50
  38. package/src/org/schema.test.ts +0 -256
  39. package/src/org/schema.ts +0 -216
  40. package/src/org.ts +0 -124
  41. package/src/out.test.ts +0 -40
  42. package/src/out.ts +0 -16
  43. package/src/phase-two.test.ts +0 -154
  44. package/src/project.test.ts +0 -232
  45. package/src/project.ts +0 -489
  46. package/src/promote.test.ts +0 -142
  47. package/src/promote.ts +0 -202
  48. package/src/random.ts +0 -3
  49. package/src/refresh.test.ts +0 -86
  50. package/src/refresh.ts +0 -93
  51. package/src/replHelpers.test.ts +0 -80
  52. package/src/replHelpers.ts +0 -45
  53. package/src/revoke.test.ts +0 -29
  54. package/src/revoke.ts +0 -65
  55. package/src/send-template.test.ts +0 -20
  56. package/src/send.test.ts +0 -321
  57. package/src/send.ts +0 -373
  58. package/src/setup-signing.test.ts +0 -62
  59. package/src/setup-signing.ts +0 -185
  60. package/src/snippet-files.ts +0 -16
  61. package/src/status.test.ts +0 -33
  62. package/src/status.ts +0 -94
  63. package/src/token.test.ts +0 -28
  64. package/src/token.ts +0 -139
  65. package/src/types.d.ts +0 -17
  66. package/src/verify.test.ts +0 -35
  67. package/src/verify.ts +0 -66
  68. package/src/webhook.ts +0 -247
  69. package/src/whoami.test.ts +0 -67
  70. package/src/whoami.ts +0 -129
  71. package/tsconfig.json +0 -38
  72. package/vitest.config.ts +0 -8
package/src/embed.ts DELETED
@@ -1,102 +0,0 @@
1
- import { LCNDomainOrOriginValidator } from '@learncard/types';
2
- import {
3
- connect,
4
- ensureIdentity,
5
- ensureProfile,
6
- KEYS,
7
- loadProject,
8
- resolveServices,
9
- saveProject,
10
- type ProjectOptions,
11
- } from './project';
12
- import { setupSigning } from './setup-signing';
13
- import { CLAIM_BUTTON_HTML } from './generated/snippets';
14
- import { writeSnippet } from './snippet-files';
15
- import { out } from './out';
16
-
17
- export const validateDomains = (domains: string): string[] =>
18
- domains.split(',').map(domain => {
19
- const result = LCNDomainOrOriginValidator.safeParse(domain.trim());
20
- if (!result.success)
21
- throw new Error(result.error.format()._errors.join('; ') || result.error.message);
22
- return result.data;
23
- });
24
-
25
- const scriptString = (value: string): string => JSON.stringify(value).replace(/</g, '\\u003c');
26
-
27
- export const personalizeClaimButton = (key: string, apiBaseUrl: string): string =>
28
- CLAIM_BUTTON_HTML.replace("'PUBLISHABLE_KEY_PLACEHOLDER'", () => scriptString(key)).replace(
29
- "'https://network.learncard.com/api'",
30
- () => scriptString(apiBaseUrl)
31
- );
32
-
33
- export const runEmbed = async (
34
- options: ProjectOptions & { domains?: string; rotateKey?: boolean }
35
- ): Promise<void> => {
36
- const domains = validateDomains(
37
- options.domains ?? 'http://localhost:3000,http://localhost:5173'
38
- );
39
- const project = await loadProject(process.cwd());
40
- const identity = await ensureIdentity(project, options);
41
- const learnCard = await connect(project, { ...options, lca: true });
42
- await ensureProfile(learnCard, identity, project);
43
- await setupSigning(project, learnCard);
44
- let id = project.env[KEYS.INTEGRATION_ID];
45
- const reused = Boolean(id);
46
- if (!id) {
47
- id = await learnCard.invoke.addIntegration({
48
- name: options.name ?? identity.displayName,
49
- whitelistedDomains: domains,
50
- guideType: 'embed-claim',
51
- });
52
- // Persist immediately so a later failed read/rotation never duplicates the integration.
53
- await saveProject(project, { [KEYS.INTEGRATION_ID]: id });
54
- }
55
- let integration = await learnCard.invoke.getIntegration(id);
56
- if (!integration) throw new Error('Saved integration was not found. Check INTEGRATION_ID.');
57
- if (options.rotateKey) {
58
- if (!(await learnCard.invoke.updateIntegration(id, { rotatePublishableKey: true }))) {
59
- throw new Error('Could not rotate the publishable key.');
60
- }
61
- integration = await learnCard.invoke.getIntegration(id);
62
- if (!integration) throw new Error('Could not read the rotated integration.');
63
- }
64
- await saveProject(project, {
65
- [KEYS.INTEGRATION_ID]: id,
66
- [KEYS.PUBLISHABLE_KEY]: integration.publishableKey,
67
- });
68
- const apiBase = resolveServices(project.env, options.network).network.replace(
69
- /\/trpc\/?$/,
70
- '/api'
71
- );
72
- const wroteClaimButton = await writeSnippet(
73
- 'claim-button.html',
74
- personalizeClaimButton(integration.publishableKey, apiBase)
75
- );
76
- out.log(`Publishable key: ${integration.publishableKey}`);
77
- out.log(`Whitelisted origins: ${integration.whitelistedDomains.join(', ')}`);
78
- if (options.domains && reused) {
79
- out.log(
80
- 'Existing integrations keep their saved origins; change them in the Developer Portal.'
81
- );
82
- }
83
- if (options.rotateKey)
84
- out.log('Update the publishable key in any existing claim-button.html and deployed pages.');
85
- const firstDomain = integration.whitelistedDomains[0];
86
- const origin = firstDomain?.startsWith('http')
87
- ? firstDomain
88
- : firstDomain
89
- ? `http://${firstDomain}`
90
- : undefined;
91
- out.log(
92
- origin
93
- ? `Next: Open claim-button.html from ${origin} (a whitelisted origin), not file://.\nSee the integration in the app: npx @learncard/cli open integration`
94
- : 'Next: add a whitelisted origin in the Developer Portal, then serve claim-button.html there, not file://.'
95
- );
96
- out.set({
97
- integrationId: id,
98
- publishableKey: integration.publishableKey,
99
- whitelistedDomains: integration.whitelistedDomains,
100
- files: wroteClaimButton ? ['./claim-button.html'] : [],
101
- });
102
- };
package/src/inbox.test.ts DELETED
@@ -1,257 +0,0 @@
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 DELETED
@@ -1,221 +0,0 @@
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
- };