@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/send.test.ts DELETED
@@ -1,321 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
- import fs from 'fs/promises';
4
- import * as project from './project';
5
- import { out } from './out';
6
-
7
- import { afterEach, describe, expect, it, vi } from 'vitest';
8
-
9
- import {
10
- SEND_MJS,
11
- parseEnv,
12
- upsertEnv,
13
- toProfileId,
14
- isPlaceholderRecipient,
15
- invalidRecipientReason,
16
- resolveRecipient,
17
- RECIPIENT_PROMPT,
18
- personalizeSendMjs,
19
- withManagedIssuer,
20
- runSend,
21
- } from './send';
22
-
23
- const fakePrompts = (answers: string[], interactive = true) => {
24
- const asked: string[] = [];
25
- return {
26
- asked,
27
- prompts: {
28
- interactive,
29
- ask: async (question: string, fallback: string) => {
30
- asked.push(question);
31
- return answers.shift() ?? fallback;
32
- },
33
- close: () => {},
34
- },
35
- };
36
- };
37
-
38
- describe('send command', () => {
39
- it('ships the exact send.mjs the Quickstart docs show', () => {
40
- const docsSnippet = readFileSync(
41
- resolve(__dirname, '../../../docs/snippets/quickstart/send.mjs'),
42
- 'utf8'
43
- );
44
- expect(SEND_MJS).toBe(docsSnippet);
45
- });
46
-
47
- it('parses and upserts .env without touching unrelated lines', () => {
48
- const original = '# comment\nOTHER=1\nSECURE_SEED=old\n';
49
- expect(parseEnv(original)).toEqual({ OTHER: '1', SECURE_SEED: 'old' });
50
- expect(upsertEnv(original, { SECURE_SEED: 'new', PROFILE_ID: 'acme' })).toBe(
51
- '# comment\nOTHER=1\nSECURE_SEED=new\nPROFILE_ID=acme\n'
52
- );
53
- expect(upsertEnv('', { SECURE_SEED: 'x' })).toBe('SECURE_SEED=x\n');
54
- });
55
-
56
- it('derives a URL-safe profile id with a random suffix', () => {
57
- expect(toProfileId('Acme Learning, Inc.')).toMatch(/^acme-learning-inc-[a-z0-9]{4}$/);
58
- expect(toProfileId('!!!')).toMatch(/^issuer-[a-z0-9]{4}$/);
59
- expect(toProfileId('x'.repeat(60)).length).toBeLessThanOrEqual(40);
60
- });
61
- });
62
-
63
- describe('recipient validation', () => {
64
- it('flags RFC 2606 / 6761 placeholder domains but not real ones', () => {
65
- expect(isPlaceholderRecipient('you@example.com')).toBe(true);
66
- expect(isPlaceholderRecipient('YOU@EXAMPLE.ORG')).toBe(true);
67
- expect(isPlaceholderRecipient('a@sub.example.net')).toBe(true);
68
- expect(isPlaceholderRecipient('a@foo.test')).toBe(true);
69
- expect(isPlaceholderRecipient('a@host.invalid')).toBe(true);
70
- expect(isPlaceholderRecipient('a@localhost')).toBe(true);
71
- expect(isPlaceholderRecipient('a@test')).toBe(true);
72
- expect(isPlaceholderRecipient('a@contest.com')).toBe(false);
73
- expect(isPlaceholderRecipient('a@latest.io')).toBe(false);
74
- expect(isPlaceholderRecipient('a@test.com')).toBe(false);
75
- expect(isPlaceholderRecipient('a@myexample.com')).toBe(false);
76
- expect(isPlaceholderRecipient('a@example.co.uk')).toBe(false);
77
- expect(isPlaceholderRecipient('+15555550100')).toBe(false);
78
- });
79
-
80
- it('explains why a recipient is unusable', () => {
81
- expect(invalidRecipientReason('you@example.com')).toMatch(/placeholder/);
82
- expect(invalidRecipientReason('Not A Recipient!')).toMatch(/not an email/);
83
- expect(invalidRecipientReason('')).toBe(
84
- 'Enter an email, phone number, profile ID, or DID.'
85
- );
86
- expect(invalidRecipientReason('me@acme.org')).toBeUndefined();
87
- expect(invalidRecipientReason('+15555550100')).toBeUndefined();
88
- expect(invalidRecipientReason('cs-exampleville')).toBeUndefined();
89
- expect(invalidRecipientReason('did:web:network.learncard.com:users:exde')).toBeUndefined();
90
- });
91
-
92
- it('accepts a valid recipient without prompting', async () => {
93
- const { prompts, asked } = fakePrompts([]);
94
- expect(await resolveRecipient(' me@acme.org ', prompts)).toBe('me@acme.org');
95
- expect(asked).toEqual([]);
96
- });
97
-
98
- it('prompts when the recipient is omitted', async () => {
99
- const { prompts, asked } = fakePrompts(['me@acme.org']);
100
- expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
101
- expect(asked).toEqual([RECIPIENT_PROMPT]);
102
- });
103
-
104
- it('re-prompts interactively until a placeholder is replaced', async () => {
105
- const { prompts, asked } = fakePrompts(['not a recipient', 'me@acme.org']);
106
- expect(await resolveRecipient('you@example.com', prompts)).toBe('me@acme.org');
107
- expect(asked).toEqual([RECIPIENT_PROMPT, RECIPIENT_PROMPT]);
108
- });
109
-
110
- it('re-prompts with a hint when Enter is pressed on an empty line', async () => {
111
- const { prompts, asked } = fakePrompts(['', ' ', 'me@acme.org']);
112
- expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
113
- expect(asked).toHaveLength(3);
114
- });
115
-
116
- it('fails fast on a missing recipient when non-interactive', async () => {
117
- const { prompts, asked } = fakePrompts(['me@acme.org'], false);
118
- await expect(resolveRecipient(undefined, prompts)).rejects.toThrow(/recipient is required/);
119
- expect(asked).toEqual([]);
120
- });
121
-
122
- it('fails fast on a placeholder when non-interactive', async () => {
123
- const { prompts, asked } = fakePrompts(['me@acme.org'], false);
124
- await expect(resolveRecipient('you@example.com', prompts)).rejects.toThrow(
125
- /placeholder address/
126
- );
127
- expect(asked).toEqual([]);
128
- });
129
- });
130
-
131
- describe('personalizeSendMjs', () => {
132
- const managedDid = 'did:web:network.learncard.com:users:managed';
133
- const script = withManagedIssuer(
134
- personalizeSendMjs('Parent', {
135
- name: 'Managed badge',
136
- description: 'Sent as managed.',
137
- }),
138
- managedDid
139
- );
140
- const execute = (env: Record<string, string>, initLearnCard: ReturnType<typeof vi.fn>) => {
141
- const body = script
142
- .split('\n')
143
- .filter(line => !line.startsWith('import '))
144
- .join('\n');
145
- return new Function(
146
- 'initLearnCard',
147
- 'process',
148
- 'randomUUID',
149
- 'console',
150
- `return (async () => {${body}})();`
151
- )(initLearnCard, { env, argv: ['node', 'send.mjs', 'me@acme.org'] }, () => 'uuid', {
152
- log: vi.fn(),
153
- });
154
- };
155
-
156
- it('re-runs the managed script with didWeb and issues as that profile', async () => {
157
- const card = {
158
- id: { did: () => managedDid },
159
- invoke: {
160
- getProfile: vi.fn().mockResolvedValue({ profileId: 'managed' }),
161
- issueCredential: vi.fn().mockResolvedValue({ proof: {} }),
162
- send: vi.fn().mockResolvedValue({ uri: 'boost-uri' }),
163
- },
164
- };
165
- const initLearnCard = vi.fn().mockResolvedValue(card);
166
- await execute({ SECURE_SEED: 'parent-seed', MANAGED_DID: managedDid }, initLearnCard);
167
- expect(initLearnCard).toHaveBeenCalledWith({
168
- seed: 'parent-seed',
169
- network: true,
170
- didWeb: managedDid,
171
- });
172
- expect(card.invoke.issueCredential).toHaveBeenCalledWith(
173
- expect.objectContaining({ issuer: managedDid })
174
- );
175
- expect(script).not.toContain('createProfile');
176
- });
177
-
178
- it.each([undefined, 'did:web:other:users:another'])(
179
- 'fails closed for MANAGED_DID=%s',
180
- async did => {
181
- const initLearnCard = vi.fn();
182
- await expect(execute(did ? { MANAGED_DID: did } : {}, initLearnCard)).rejects.toThrow(
183
- 'MANAGED_DID is missing or changed'
184
- );
185
- expect(initLearnCard).not.toHaveBeenCalled();
186
- }
187
- );
188
-
189
- it('does not create a parent profile when the managed profile is missing', async () => {
190
- const initLearnCard = vi
191
- .fn()
192
- .mockResolvedValue({ invoke: { getProfile: vi.fn().mockResolvedValue(null) } });
193
- await expect(execute({ MANAGED_DID: managedDid }, initLearnCard)).rejects.toThrow(
194
- 'managed issuer profile could not be found'
195
- );
196
- });
197
-
198
- it('substitutes the display name, badge name, and description', async () => {
199
- const { personalizeSendMjs } = await import('./send');
200
- const out = personalizeSendMjs('Acme Learning', {
201
- name: 'Welcome to Acme',
202
- description: 'You joined.',
203
- });
204
- expect(out).toContain('displayName: "Acme Learning"');
205
- expect(out).toContain('name: "Welcome to Acme"');
206
- expect(out).not.toContain('Quickstart Complete');
207
- expect(out).toContain('description: "You joined."');
208
- });
209
- });
210
-
211
- describe('classifyRecipient', () => {
212
- it('detects each recipient kind the network routes on', async () => {
213
- const { classifyRecipient } = await import('./send');
214
- expect(classifyRecipient('you@example.com')).toBe('email');
215
- expect(classifyRecipient('+15555550123')).toBe('phone');
216
- expect(classifyRecipient('did:web:network.learncard.com:users:exde')).toBe('did');
217
- expect(classifyRecipient('cs-exampleville')).toBe('profileId');
218
- });
219
-
220
- it('rejects anything else with an example', async () => {
221
- const { classifyRecipient } = await import('./send');
222
- expect(() => classifyRecipient('Not A Recipient!')).toThrow(/profile ID, or DID/);
223
- });
224
- });
225
-
226
- describe('send --as script generation', () => {
227
- afterEach(() => vi.restoreAllMocks());
228
-
229
- it.each([false, true])(
230
- 'preserves the managed issuer with existing script=%s',
231
- async existing => {
232
- const managedDid = 'did:web:staging.network.learncard.com:users:managed';
233
- const loaded = {
234
- env: { SECURE_SEED: 'seed', PROFILE_ID: 'parent' },
235
- envPath: '/unused/.env',
236
- existing: '',
237
- };
238
- vi.spyOn(project, 'loadProject').mockResolvedValue(loaded);
239
- vi.spyOn(project, 'createPrompts').mockReturnValue(fakePrompts([], false).prompts);
240
- vi.spyOn(project, 'ensureIdentity').mockResolvedValue({
241
- seed: 'seed',
242
- profileId: 'parent',
243
- displayName: 'Parent',
244
- });
245
- const card = {
246
- id: { did: () => managedDid },
247
- invoke: {
248
- getProfile: vi
249
- .fn()
250
- .mockResolvedValue({ profileId: 'managed', displayName: 'Managed' }),
251
- issueCredential: vi.fn().mockResolvedValue({}),
252
- send: vi.fn().mockResolvedValue({ uri: 'boost-uri', activityId: 'activity-1' }),
253
- },
254
- };
255
- vi.spyOn(project, 'connectAsManaged').mockResolvedValue(
256
- card as unknown as Awaited<ReturnType<typeof project.connectAsManaged>>
257
- );
258
- const save = vi.spyOn(project, 'saveProject').mockResolvedValue();
259
- const stat = vi.spyOn(fs, 'stat');
260
- if (existing) stat.mockResolvedValue({} as Awaited<ReturnType<typeof fs.stat>>);
261
- else stat.mockRejectedValue(new Error('ENOENT'));
262
- const write = vi.spyOn(fs, 'writeFile').mockResolvedValue();
263
- const log = vi.spyOn(out, 'log').mockImplementation(() => {});
264
- const set = vi.spyOn(out, 'set').mockImplementation(patch => patch);
265
-
266
- await runSend('me@acme.org', { as: 'managed', yes: true, network: 'staging' });
267
-
268
- if (existing) {
269
- expect(save).not.toHaveBeenCalled();
270
- expect(write).not.toHaveBeenCalled();
271
- expect(log).toHaveBeenCalledWith(
272
- expect.stringContaining('may use a different issuer')
273
- );
274
- expect(set).toHaveBeenCalledWith(expect.objectContaining({ files: [] }));
275
- } else {
276
- expect(save).toHaveBeenCalledWith(loaded, { MANAGED_DID: managedDid });
277
- expect(write).toHaveBeenCalledWith(
278
- expect.stringContaining('send.mjs'),
279
- expect.stringContaining('didWeb: process.env.MANAGED_DID')
280
- );
281
- expect(save.mock.invocationCallOrder[0]).toBeLessThan(
282
- write.mock.invocationCallOrder[0]!
283
- );
284
- expect(set).toHaveBeenCalledWith(
285
- expect.objectContaining({ files: ['./send.mjs'] })
286
- );
287
- const generated = String(write.mock.calls[0]?.[1]);
288
- const body = generated
289
- .split('\n')
290
- .filter(line => !line.startsWith('import '))
291
- .join('\n');
292
- const initLearnCard = vi.fn().mockResolvedValue(card);
293
- card.invoke.issueCredential.mockClear();
294
- await new Function(
295
- 'initLearnCard',
296
- 'process',
297
- 'randomUUID',
298
- 'console',
299
- `return (async () => {${body}})();`
300
- )(
301
- initLearnCard,
302
- {
303
- env: { SECURE_SEED: 'seed', MANAGED_DID: managedDid },
304
- argv: ['node', 'send.mjs', 'me@acme.org'],
305
- },
306
- () => 'uuid',
307
- { log: vi.fn() }
308
- );
309
- expect(initLearnCard).toHaveBeenCalledWith({
310
- seed: 'seed',
311
- network: project.STAGING_NETWORK,
312
- cloud: { url: 'https://staging.cloud.learncard.com/trpc' },
313
- didWeb: managedDid,
314
- });
315
- expect(card.invoke.issueCredential).toHaveBeenCalledWith(
316
- expect.objectContaining({ issuer: managedDid })
317
- );
318
- }
319
- }
320
- );
321
- });
package/src/send.ts DELETED
@@ -1,373 +0,0 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import { randomUUID } from 'node:crypto';
4
- import {
5
- connect,
6
- connectAsManaged,
7
- createPrompts,
8
- ensureIdentity,
9
- ensureProfile,
10
- loadProject,
11
- saveProject,
12
- type ProjectOptions,
13
- localizeSnippet,
14
- resolveServices,
15
- } from './project';
16
- import { setupSigning } from './setup-signing';
17
- import { SEND_MJS, SEND_FROM_TEMPLATE_MJS } from './generated/snippets';
18
- import { out } from './out';
19
-
20
- export { SEND_MJS } from './generated/snippets';
21
- export { parseEnv, upsertEnv, toProfileId } from './project';
22
- export type SendEnv = { SECURE_SEED?: string; PROFILE_ID?: string };
23
- export type Badge = { name: string; description: string };
24
- export const DEFAULT_BADGE: Badge = {
25
- name: 'Quickstart Complete',
26
- description: 'Sent a verifiable credential with LearnCard.',
27
- };
28
-
29
- export const quickstartCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => ({
30
- '@context': [
31
- 'https://www.w3.org/ns/credentials/v2',
32
- 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
33
- ],
34
- type: ['VerifiableCredential', 'OpenBadgeCredential'],
35
- issuer: issuerDid,
36
- validFrom: new Date().toISOString(),
37
- name: badge.name,
38
- credentialSubject: {
39
- type: ['AchievementSubject'],
40
- achievement: {
41
- id: `urn:uuid:${randomUUID()}`,
42
- type: ['Achievement'],
43
- name: badge.name,
44
- description: badge.description,
45
- criteria: { narrative: 'Ran the LearnCard quickstart.' },
46
- },
47
- },
48
- });
49
-
50
- export const templateCredential = (issuerDid: string, badge: Badge = DEFAULT_BADGE) => {
51
- const credential = quickstartCredential(issuerDid, badge);
52
- credential['@context'].push('https://ctx.learncard.com/boosts/1.0.1.json');
53
- credential.type.push('BoostCredential');
54
- return credential;
55
- };
56
-
57
- type SendOptions = ProjectOptions & {
58
- as?: string;
59
- badge?: string;
60
- description?: string;
61
- template?: boolean;
62
- templateUri?: string;
63
- webhookUrl?: string;
64
- suppressDelivery?: boolean;
65
- guardianEmail?: string;
66
- };
67
-
68
- type SendDeliveryOptions = {
69
- webhookUrl?: string;
70
- suppressDelivery?: boolean;
71
- guardianEmail?: string;
72
- };
73
-
74
- const sendOptions = (options: SendOptions): { options?: SendDeliveryOptions } => {
75
- const picked: SendDeliveryOptions = {
76
- webhookUrl: options.webhookUrl,
77
- suppressDelivery: options.suppressDelivery,
78
- guardianEmail: options.guardianEmail,
79
- };
80
- return Object.values(picked).some(v => v !== undefined) ? { options: picked } : {};
81
- };
82
-
83
- /** Inserts the effective send() delivery options right after `anchor` so a re-run of the
84
- * generated script reproduces the same call. */
85
- const withDeliveryOptions = (
86
- content: string,
87
- anchor: string,
88
- deliveryOptions?: SendDeliveryOptions
89
- ): string =>
90
- deliveryOptions
91
- ? content.replace(
92
- anchor,
93
- () => `${anchor}\n options: ${JSON.stringify(deliveryOptions)},`
94
- )
95
- : content;
96
-
97
- /** Substitute the user's choices into the template that the docs snippet uses. */
98
- export const personalizeSendMjs = (
99
- displayName: string,
100
- badge: Badge,
101
- deliveryOptions?: SendDeliveryOptions
102
- ): string =>
103
- withDeliveryOptions(
104
- SEND_MJS.replace(
105
- "displayName: 'My Organization'",
106
- () => `displayName: ${JSON.stringify(displayName)}`
107
- )
108
- .split("'Quickstart Complete'")
109
- .join(JSON.stringify(badge.name))
110
- .replace("'Sent a verifiable credential with LearnCard.'", () =>
111
- JSON.stringify(badge.description)
112
- ),
113
- ' signedCredential: credential,',
114
- deliveryOptions
115
- );
116
-
117
- /** Substitute the effective send() delivery options into the from-template script. */
118
- export const personalizeSendFromTemplateMjs = (deliveryOptions?: SendDeliveryOptions): string =>
119
- withDeliveryOptions(
120
- SEND_FROM_TEMPLATE_MJS,
121
- ' templateUri: process.env.TEMPLATE_URI,',
122
- deliveryOptions
123
- );
124
-
125
- /** Bind a generated script to its managed issuer, never the parent seed identity. */
126
- export const withManagedIssuer = (content: string, managedDid: string): string =>
127
- content
128
- .replace(
129
- 'const learnCard = await initLearnCard(',
130
- `if (process.env.MANAGED_DID !== ${JSON.stringify(managedDid)}) {
131
- throw new Error('MANAGED_DID is missing or changed. Re-run the CLI with --as to send as the intended profile.');
132
- }
133
- const learnCard = await initLearnCard(`
134
- )
135
- .replace(
136
- 'seed: process.env.SECURE_SEED,',
137
- 'seed: process.env.SECURE_SEED, didWeb: process.env.MANAGED_DID,'
138
- )
139
- .replace(
140
- /if \(!\(await learnCard\.invoke\.getProfile\(\)\)\) \{[\s\S]*?\n\}/,
141
- `if (!(await learnCard.invoke.getProfile())) {
142
- throw new Error('The managed issuer profile could not be found.');
143
- }`
144
- );
145
-
146
- const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
147
- const PHONE = /^\+?\d{10,15}$/;
148
- const DID = /^did:[a-z0-9]+:.+$/;
149
- const PROFILE_ID = /^[a-z0-9-]{3,40}$/;
150
-
151
- export type RecipientKind = 'email' | 'phone' | 'did' | 'profileId';
152
-
153
- export const classifyRecipient = (value: string): RecipientKind => {
154
- if (EMAIL.test(value)) return 'email';
155
- if (PHONE.test(value)) return 'phone';
156
- if (DID.test(value)) return 'did';
157
- if (PROFILE_ID.test(value)) return 'profileId';
158
- throw new Error(
159
- `"${value}" is not an email, phone number, profile ID, or DID. Example: npx @learncard/cli send you@yourdomain.com`
160
- );
161
- };
162
-
163
- /** Domains reserved for documentation (RFC 2606 / RFC 6761). Mail to them goes nowhere. */
164
- const PLACEHOLDER_DOMAIN = /(^|\.)example\.(com|net|org)$|(^|\.)(example|test|invalid|localhost)$/i;
165
-
166
- export const RECIPIENT_PROMPT =
167
- 'Where should we send your first badge? (email, phone number, profile ID, or DID)';
168
-
169
- export const isPlaceholderRecipient = (recipient: string): boolean => {
170
- const at = recipient.lastIndexOf('@');
171
- return at !== -1 && PLACEHOLDER_DOMAIN.test(recipient.slice(at + 1));
172
- };
173
-
174
- /**
175
- * Returns a human-readable reason `recipient` cannot receive a badge, or `undefined` if it can.
176
- * Placeholder addresses like you@example.com pass a naive email check but are undeliverable,
177
- * so they are called out specifically — docs readers paste them verbatim.
178
- */
179
- export const invalidRecipientReason = (recipient: string): string | undefined => {
180
- if (!recipient) return 'Enter an email, phone number, profile ID, or DID.';
181
- if (EMAIL.test(recipient) && isPlaceholderRecipient(recipient))
182
- return `"${recipient}" is a placeholder address — nobody will receive the badge. Use a real email you can open.`;
183
- if (
184
- !EMAIL.test(recipient) &&
185
- !PHONE.test(recipient) &&
186
- !DID.test(recipient) &&
187
- !PROFILE_ID.test(recipient)
188
- )
189
- return `"${recipient}" is not an email, phone number, profile ID, or DID.`;
190
- return undefined;
191
- };
192
-
193
- type Prompts = ReturnType<typeof createPrompts>;
194
-
195
- /**
196
- * Settle on a deliverable recipient. When a human is at the terminal, bad or missing input
197
- * re-prompts instead of failing; non-interactive runs throw a clear error.
198
- */
199
- export const resolveRecipient = async (
200
- recipient: string | undefined,
201
- prompts: Prompts
202
- ): Promise<string> => {
203
- let candidate = recipient?.trim() ?? '';
204
- if (!candidate && !prompts.interactive)
205
- throw new Error(
206
- 'A recipient is required when running non-interactively. Example: npx @learncard/cli send you@yourdomain.com --yes'
207
- );
208
- if (!candidate) candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
209
- let reason = invalidRecipientReason(candidate);
210
- while (reason) {
211
- if (!prompts.interactive)
212
- throw new Error(`${reason} Example: npx @learncard/cli send you@yourdomain.com`);
213
- out.log(reason);
214
- candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
215
- reason = invalidRecipientReason(candidate);
216
- }
217
- return candidate;
218
- };
219
-
220
- export const runSend = async (
221
- recipient: string | undefined,
222
- options: SendOptions
223
- ): Promise<void> => {
224
- const cwd = process.cwd();
225
- const project = await loadProject(cwd);
226
- const prompts = createPrompts(options.yes);
227
- let resolvedRecipient: string;
228
- let displayName: string | undefined;
229
- let badge: Badge;
230
- try {
231
- resolvedRecipient = await resolveRecipient(recipient, prompts);
232
- const needsName = !project.env.PROFILE_ID && !options.profileId && !options.name;
233
- displayName = needsName
234
- ? await prompts.ask('Display name for your issuer profile', 'My Organization')
235
- : options.name;
236
- badge = {
237
- name: options.badge ?? (await prompts.ask('Badge name', DEFAULT_BADGE.name)),
238
- description: options.description ?? DEFAULT_BADGE.description,
239
- };
240
- } finally {
241
- prompts.close();
242
- }
243
- const recipientKind = classifyRecipient(resolvedRecipient);
244
- const identity = await ensureIdentity(project, { ...options, name: displayName, yes: true });
245
- const asManaged = options.as ? await connectAsManaged(project, options, options.as) : undefined;
246
- const hostedSigning = !!project.env.SIGNING_AUTHORITY_NAME;
247
- const explicitTemplate = options.templateUri ? true : options.template;
248
- const useTemplate = !asManaged && (explicitTemplate ?? hostedSigning);
249
- if (useTemplate && explicitTemplate === undefined) {
250
- out.log(
251
- `Signing through the registered signing authority "${project.env.SIGNING_AUTHORITY_NAME}" (pass --no-template to sign with the local key instead).`
252
- );
253
- }
254
- if (asManaged && explicitTemplate) {
255
- out.log("--as signs with the managed profile's key; --template is ignored.");
256
- }
257
- const learnCard = asManaged
258
- ? asManaged
259
- : useTemplate
260
- ? await connect(project, { ...options, lca: true })
261
- : await connect(project, options);
262
- if (asManaged) {
263
- const managed = await learnCard.invoke.getProfile();
264
- if (!managed) throw new Error(`Could not sign in as managed profile "${options.as}".`);
265
- out.log(
266
- `Acting as "${managed.displayName}" (${managed.profileId}) — a profile you manage.`
267
- );
268
- } else {
269
- await ensureProfile(learnCard, identity, project);
270
- }
271
-
272
- const effectiveSendOptions = sendOptions(options);
273
- let result;
274
- if (useTemplate) {
275
- // This branch connected with the LCA plugin; setupSigning accepts its required methods.
276
- await setupSigning(
277
- project,
278
- learnCard as Awaited<
279
- ReturnType<typeof import('@learncard/lca-api-plugin').initLCALearnCard>
280
- >
281
- );
282
- if (options.templateUri) {
283
- out.log(`Sending from template ${options.templateUri}.`);
284
- if (!project.env.TEMPLATE_URI) {
285
- await saveProject(project, { TEMPLATE_URI: options.templateUri });
286
- }
287
- } else if (!project.env.TEMPLATE_URI) {
288
- const uri = await learnCard.invoke.createBoost(
289
- templateCredential(learnCard.id.did(), badge),
290
- {
291
- name: badge.name,
292
- category: 'Achievement',
293
- status: 'LIVE',
294
- }
295
- );
296
- await saveProject(project, { TEMPLATE_URI: uri });
297
- } else {
298
- out.log(
299
- `Reusing template ${project.env.TEMPLATE_URI}; its saved badge name and description are unchanged.`
300
- );
301
- }
302
- result = await learnCard.invoke.send({
303
- type: 'boost',
304
- recipient: resolvedRecipient,
305
- templateUri: options.templateUri ?? project.env.TEMPLATE_URI!,
306
- ...effectiveSendOptions,
307
- });
308
- } else {
309
- const credential = await learnCard.invoke.issueCredential(
310
- quickstartCredential(learnCard.id.did(), badge)
311
- );
312
- result = await learnCard.invoke.send({
313
- type: 'boost',
314
- recipient: resolvedRecipient,
315
- signedCredential: credential,
316
- ...effectiveSendOptions,
317
- });
318
- }
319
- out.log('');
320
- if (result.inbox?.status === 'PENDING') {
321
- out.log(
322
- `Sent. ${resolvedRecipient} will get a claim ${recipientKind === 'phone' ? 'text' : 'email'}. You can also share this link directly:\n${result.inbox.claimUrl}`
323
- );
324
- } else if (recipientKind === 'email' || recipientKind === 'phone') {
325
- out.log(
326
- `Delivered. ${resolvedRecipient} already uses LearnCard — the credential is in their wallet.`
327
- );
328
- } else {
329
- out.log(
330
- `Delivered directly to ${resolvedRecipient} — it is waiting in their LearnCard wallet.`
331
- );
332
- }
333
- out.log(`Reusable template for this badge: ${result.uri}`);
334
- const filename = useTemplate ? 'send-from-template.mjs' : 'send.mjs';
335
- const sendPath = path.join(cwd, filename);
336
- let wroteSendFile = false;
337
- if (!(await fs.stat(sendPath).catch(() => null))) {
338
- let content = useTemplate
339
- ? personalizeSendFromTemplateMjs(effectiveSendOptions.options)
340
- : personalizeSendMjs(identity.displayName, badge, effectiveSendOptions.options);
341
- content = localizeSnippet(content, resolveServices(project.env, options.network));
342
- if (asManaged) {
343
- const managedDid = learnCard.id.did();
344
- await saveProject(project, { MANAGED_DID: managedDid });
345
- content = withManagedIssuer(content, managedDid);
346
- }
347
- await fs.writeFile(sendPath, content);
348
- wroteSendFile = true;
349
- out.log(
350
- `\nThe code that just ran is in ./${filename} — run it yourself:\n npm install @learncard/init\n node --env-file=.env ${filename} ${resolvedRecipient}`
351
- );
352
- } else if (asManaged) {
353
- out.log(
354
- `Existing ./${filename} was not changed and may use a different issuer. Repeat this send with the CLI --as ${options.as} instead.`
355
- );
356
- }
357
- out.log(`Check whether it was claimed: npx @learncard/cli status ${result.activityId}`);
358
- out.log(`See it in the app: npx @learncard/cli open${options.template ? ' template' : ''}`);
359
- out.set({
360
- profileId: options.as ?? identity.profileId,
361
- ...(options.as && { onBehalfOf: identity.profileId }),
362
- did: learnCard.id.did(),
363
- recipient: resolvedRecipient,
364
- recipientKind,
365
- status: result.inbox?.status === 'PENDING' ? 'PENDING' : 'ISSUED',
366
- ...(result.inbox?.claimUrl && { claimUrl: result.inbox.claimUrl }),
367
- templateUri: result.uri,
368
- activityId: result.activityId,
369
- ...(result.credentialUri && { credentialUri: result.credentialUri }),
370
- ...(result.inbox?.issuanceId && { issuanceId: result.inbox.issuanceId }),
371
- files: wroteSendFile ? [`./${filename}`] : [],
372
- });
373
- };