@learncard/cli 3.4.17 → 3.5.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.
package/src/project.ts ADDED
@@ -0,0 +1,394 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { initLearnCard, type NetworkLearnCardFromSeed } from '@learncard/init';
6
+ import { initLCALearnCard, type LCALearnCard } from '@learncard/lca-api-plugin';
7
+ import { generateRandomSeed } from './random';
8
+ import { out } from './out';
9
+
10
+ export const KEYS = {
11
+ SECURE_SEED: 'SECURE_SEED', // Private issuer seed; never regenerate or print.
12
+ PROFILE_ID: 'PROFILE_ID', // Public issuer handle shared by all commands.
13
+ DISPLAY_NAME: 'DISPLAY_NAME', // Issuer display name; set at creation, then synced from the network.
14
+ NETWORK_URL: 'NETWORK_URL', // Non-default network tRPC endpoint.
15
+ SIGNING_AUTHORITY_NAME: 'SIGNING_AUTHORITY_NAME', // Registered primary signer name.
16
+ SIGNING_AUTHORITY_ENDPOINT: 'SIGNING_AUTHORITY_ENDPOINT', // Hosted signing endpoint.
17
+ API_TOKEN: 'API_TOKEN', // Secret bearer token for REST requests.
18
+ API_TOKEN_SCOPE: 'API_TOKEN_SCOPE', // Space-separated permissions granted to the token.
19
+ TEMPLATE_URI: 'TEMPLATE_URI', // Reusable Boost template on this network.
20
+ CONTRACT_URI: 'CONTRACT_URI', // Consent contract for later commands.
21
+ PUBLISHABLE_KEY: 'PUBLISHABLE_KEY', // Public integration client key.
22
+ INTEGRATION_ID: 'INTEGRATION_ID', // Developer integration identifier.
23
+ } as const;
24
+
25
+ export interface Project {
26
+ env: Record<string, string>;
27
+ envPath: string;
28
+ existing: string;
29
+ }
30
+
31
+ export interface ProjectOptions {
32
+ yes?: boolean;
33
+ name?: string;
34
+ profileId?: string;
35
+ network?: string;
36
+ didkit?: Promise<Buffer>;
37
+ json?: boolean;
38
+ }
39
+
40
+ export type NetworkCard = NetworkLearnCardFromSeed['returnValue'];
41
+
42
+ export const parseEnv = (text: string): Record<string, string> => {
43
+ const env: Record<string, string> = {};
44
+ for (const line of text.split('\n')) {
45
+ const match = line.match(/^\s*(?:export\s+)?([\w]+)\s*=\s*(.*)$/);
46
+ if (!match) continue;
47
+ const key = match[1]!;
48
+ let value = match[2]!.trim();
49
+ if (value.startsWith('"') || value.startsWith("'")) {
50
+ const end = value.indexOf(value[0]!, 1);
51
+ if (end < 0) throw new Error(`Unclosed quote in .env for ${key}.`);
52
+ value = value.slice(1, end);
53
+ } else value = value.split('#')[0]!.trim();
54
+ if (key === 'SECURE_SEED' && env[key] !== undefined && env[key] !== value) {
55
+ throw new Error('Conflicting SECURE_SEED entries in .env. Keep the original identity.');
56
+ }
57
+ Object.defineProperty(env, key, {
58
+ value,
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ });
63
+ }
64
+ return env;
65
+ };
66
+
67
+ export const upsertEnv = (text: string, values: Record<string, string>): string => {
68
+ const lines = text ? text.replace(/\n$/, '').split('\n') : [];
69
+ const seen = new Set<string>();
70
+ const out = lines.map(line => {
71
+ const key = line.match(/^\s*(?:export\s+)?([\w]+)\s*=/)?.[1];
72
+ if (key && Object.prototype.hasOwnProperty.call(values, key)) {
73
+ seen.add(key);
74
+ // Quotes make multi-scope values safe in both dotenv and shell scripts.
75
+ const value = values[key]!;
76
+ if (/[\r\n]/.test(value)) throw new Error(`Invalid multiline value for ${key}`);
77
+ return `${key}=${/^[a-zA-Z0-9_./:@*=%+-]*$/.test(value) ? value : JSON.stringify(value)}`;
78
+ }
79
+ return line;
80
+ });
81
+ for (const [key, value] of Object.entries(values)) {
82
+ if (!seen.has(key)) out.push(upsertEnv(`${key}=\n`, { [key]: value }).trimEnd());
83
+ }
84
+ return `${out.join('\n')}\n`;
85
+ };
86
+
87
+ export const toProfileId = (displayName: string): string => {
88
+ const base = displayName
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, '-')
91
+ .replace(/^-+|-+$/g, '')
92
+ .slice(0, 30);
93
+ return `${base || 'issuer'}-${Math.random().toString(36).slice(2, 6)}`;
94
+ };
95
+
96
+ const readOptional = async (file: string): Promise<string> => {
97
+ try {
98
+ return await fs.readFile(file, 'utf8');
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
101
+ throw error;
102
+ }
103
+ };
104
+
105
+ export const loadProject = async (cwd: string): Promise<Project> => {
106
+ const envPath = path.join(cwd, '.env');
107
+ await assertRegularEnv(envPath);
108
+ const existing = await readOptional(envPath);
109
+ return { env: parseEnv(existing), envPath, existing };
110
+ };
111
+
112
+ const assertRegularEnv = async (envPath: string): Promise<void> => {
113
+ const info = await fs.lstat(envPath).catch((error: NodeJS.ErrnoException) => {
114
+ if (error.code === 'ENOENT') return undefined;
115
+ throw error;
116
+ });
117
+ if (info && !info.isFile()) throw new Error('.env must be a regular file, not a symlink.');
118
+ };
119
+
120
+ export const saveProject = async (
121
+ project: Project,
122
+ updates: Record<string, string>
123
+ ): Promise<void> => {
124
+ // Serialize CLI writers and atomically replace the file so a failed write cannot lose a seed.
125
+ const lockPath = `${project.envPath}.lock`;
126
+ const lock = await fs.open(lockPath, 'wx', 0o600).catch((error: NodeJS.ErrnoException) => {
127
+ if (error.code === 'EEXIST')
128
+ throw new Error(
129
+ 'Another command is updating .env. Retry when it finishes; remove a stale .env.lock only if no command is running.'
130
+ );
131
+ throw error;
132
+ });
133
+ const temporary = `${project.envPath}.${randomUUID()}.tmp`;
134
+ try {
135
+ await assertRegularEnv(project.envPath);
136
+ const existing = await readOptional(project.envPath);
137
+ const env = parseEnv(existing);
138
+ if (
139
+ env.SECURE_SEED &&
140
+ updates.SECURE_SEED !== undefined &&
141
+ env.SECURE_SEED !== updates.SECURE_SEED
142
+ ) {
143
+ throw new Error('Cannot replace an existing SECURE_SEED.');
144
+ }
145
+ const changed = Object.fromEntries(
146
+ Object.entries(updates).filter(([key, value]) => env[key] !== value)
147
+ );
148
+ project.existing = existing;
149
+ project.env = env;
150
+ if (!Object.keys(changed).length) return;
151
+ const next = upsertEnv(existing, changed);
152
+ await fs.writeFile(temporary, next, { mode: 0o600, flag: 'wx' });
153
+ await fs.rename(temporary, project.envPath);
154
+ project.existing = next;
155
+ Object.assign(project.env, changed);
156
+ const keys = Object.keys(changed);
157
+ const list =
158
+ keys.length > 2
159
+ ? `${keys.slice(0, -1).join(', ')}, and ${keys.at(-1)}`
160
+ : keys.join(' and ');
161
+ out.log(`Wrote ${list} to .env`);
162
+ } finally {
163
+ await fs.rm(temporary, { force: true });
164
+ await lock.close();
165
+ await fs.unlink(lockPath);
166
+ }
167
+ };
168
+
169
+ /** Never block on input from a script, CI job, or agent: only prompt when a human is at a real TTY. */
170
+ export const createPrompts = (yes?: boolean) => {
171
+ const interactive = !yes && !!process.stdin.isTTY && process.env.LC_YES !== '1';
172
+ const rl = interactive
173
+ ? createInterface({ input: process.stdin, output: process.stdout })
174
+ : null;
175
+ return {
176
+ ask: async (question: string, fallback: string): Promise<string> => {
177
+ if (rl) return (await rl.question(`${question} [${fallback}] `)).trim() || fallback;
178
+ if (!fallback)
179
+ throw new Error(
180
+ `${question} is required when running non-interactively. Pass it as an argument or flag.`
181
+ );
182
+ return fallback;
183
+ },
184
+ close: (): void => {
185
+ rl?.close();
186
+ },
187
+ };
188
+ };
189
+
190
+ export const ensureIdentity = async (project: Project, options: ProjectOptions) => {
191
+ const existingProfileId = project.env.PROFILE_ID || options.profileId;
192
+ let displayName = options.name ?? project.env.DISPLAY_NAME ?? '';
193
+ if (!existingProfileId && !displayName) {
194
+ const prompts = createPrompts(options.yes);
195
+ try {
196
+ displayName = await prompts.ask(
197
+ 'Display name for your issuer profile',
198
+ 'My Organization'
199
+ );
200
+ } finally {
201
+ prompts.close();
202
+ }
203
+ }
204
+ if (!displayName) displayName = 'My Organization';
205
+ if (!project.env.SECURE_SEED) {
206
+ const { network } = resolveServices(project.env, options.network);
207
+ const where =
208
+ network === PRODUCTION_NETWORK
209
+ ? 'production'
210
+ : network === STAGING_NETWORK
211
+ ? 'staging'
212
+ : network;
213
+ out.log(`No .env here — creating a new ${where} identity in ${process.cwd()}`);
214
+ }
215
+ const seed = project.env.SECURE_SEED || generateRandomSeed();
216
+ const profileId = project.env.PROFILE_ID || options.profileId || toProfileId(displayName);
217
+ await saveProject(project, {
218
+ SECURE_SEED: seed,
219
+ PROFILE_ID: profileId,
220
+ ...(existingProfileId ? {} : { DISPLAY_NAME: displayName }),
221
+ });
222
+ const gitignorePath = path.join(path.dirname(project.envPath), '.gitignore');
223
+ const gitignore = await readOptional(gitignorePath);
224
+ if (await fs.stat(gitignorePath).catch(() => null)) {
225
+ if (!gitignore.split('\n').some(line => line.trim() === '.env')) {
226
+ await fs.writeFile(gitignorePath, `${gitignore.replace(/\n?$/, '\n')}.env\n`);
227
+ out.log('Added .env to .gitignore');
228
+ }
229
+ } else {
230
+ await fs.writeFile(gitignorePath, '.env\n');
231
+ out.log('Created .gitignore with .env');
232
+ }
233
+ return { seed, profileId, displayName };
234
+ };
235
+
236
+ export const PRODUCTION_NETWORK = 'https://network.learncard.com/trpc';
237
+ export const STAGING_NETWORK = 'https://staging.network.learncard.com/trpc';
238
+
239
+ /** Network-bound resources must not be silently reused on another deployment. */
240
+ export const assertProjectNetwork = (project: Project, network: string): void => {
241
+ const previous =
242
+ project.env.NETWORK_URL === 'staging'
243
+ ? STAGING_NETWORK
244
+ : project.env.NETWORK_URL || PRODUCTION_NETWORK;
245
+ const hasResources = [
246
+ 'SIGNING_AUTHORITY_NAME',
247
+ 'API_TOKEN',
248
+ 'TEMPLATE_URI',
249
+ 'CONTRACT_URI',
250
+ 'PUBLISHABLE_KEY',
251
+ 'INTEGRATION_ID',
252
+ ].some(key => project.env[key]);
253
+ if (previous !== network && hasResources) {
254
+ throw new Error(
255
+ 'This project has resources on another network. Use a separate folder for staging or another network.'
256
+ );
257
+ }
258
+ };
259
+
260
+ /** Resolve matching staging services; explicit process variables override project values. */
261
+ export const resolveServices = (
262
+ env: Record<string, string>,
263
+ network?: string,
264
+ runtime: Record<string, string | undefined> = process.env
265
+ ) => {
266
+ const requested = network || runtime.NETWORK_URL || env.NETWORK_URL || PRODUCTION_NETWORK;
267
+ const url = requested === 'staging' ? STAGING_NETWORK : requested;
268
+ const parsed = new URL(url);
269
+ if (!['http:', 'https:'].includes(parsed.protocol))
270
+ throw new Error('Network must be an HTTP(S) tRPC URL or staging.');
271
+ const staging = url === STAGING_NETWORK;
272
+ return {
273
+ network: url,
274
+ cloud:
275
+ runtime.CLOUD_URL ||
276
+ env.CLOUD_URL ||
277
+ (staging ? 'https://staging.cloud.learncard.com/trpc' : undefined),
278
+ lcaAPI:
279
+ runtime.LCA_API_URL ||
280
+ env.LCA_API_URL ||
281
+ (staging ? 'https://staging.api.learncard.app/trpc' : undefined),
282
+ };
283
+ };
284
+
285
+ /**
286
+ * Docs snippets target production (`network: true`). When a project points at
287
+ * another network, rewrite that one line in the generated file so the code a
288
+ * developer keeps matches the network their .env is on.
289
+ */
290
+ export const PRODUCTION_APP = 'https://learncard.app';
291
+ export const STAGING_APP = 'https://staging.learncard.ai';
292
+
293
+ /** The LearnCard app that pairs with a network. Local networks have no hosted app; callers pass --app-url. */
294
+ export const appUrlFor = (network: string, override?: string): string => {
295
+ if (override) return override.replace(/\/$/, '');
296
+ if (network === STAGING_NETWORK) return STAGING_APP;
297
+ if (network === PRODUCTION_NETWORK) return PRODUCTION_APP;
298
+ return PRODUCTION_APP;
299
+ };
300
+
301
+ export const localizeSnippet = (
302
+ source: string,
303
+ services: ReturnType<typeof resolveServices>
304
+ ): string => {
305
+ if (services.network === PRODUCTION_NETWORK) return source;
306
+ const cloud = services.cloud ? `, cloud: { url: '${services.cloud}' }` : '';
307
+ return source
308
+ .replace(
309
+ /initLearnCard\(\{ seed: process\.env\.SECURE_SEED, network: true \}\)/g,
310
+ `initLearnCard({ seed: process.env.SECURE_SEED, network: '${services.network}'${cloud} })`
311
+ )
312
+ .replace(
313
+ 'https://network.learncard.com/api/send',
314
+ `${services.network.replace(/\/trpc$/, '')}/api/send`
315
+ );
316
+ };
317
+
318
+ /**
319
+ * The docs' send.sh reads $TOKEN from the environment, which is right for a
320
+ * doc. The file the CLI writes should just work, so prepend a loader that reads
321
+ * API_TOKEN from .env without sourcing (executing) the file.
322
+ */
323
+ export const ENV_TOKEN_LOADER = `#!/bin/sh
324
+ set -eu
325
+ # Read API_TOKEN from .env without executing it (strips optional quotes).
326
+ if [ -f .env ]; then
327
+ API_TOKEN=$(sed -n 's/^API_TOKEN=//p' .env | sed 's/^["'"'"']//; s/["'"'"']$//')
328
+ fi
329
+ TOKEN=\${TOKEN:-\${API_TOKEN:-}}
330
+ : "\${TOKEN:?Run npx @learncard/cli token first}"
331
+
332
+ `;
333
+
334
+ export const withEnvTokenLoader = (sendSh: string): string => ENV_TOKEN_LOADER + sendSh;
335
+
336
+ export function connect(
337
+ project: Project,
338
+ options: ProjectOptions & { lca: true }
339
+ ): Promise<LCALearnCard>;
340
+ export function connect(
341
+ project: Project,
342
+ options: ProjectOptions & { lca?: false }
343
+ ): Promise<NetworkCard>;
344
+ export async function connect(
345
+ project: Project,
346
+ options: ProjectOptions & { lca?: boolean }
347
+ ): Promise<NetworkCard | LCALearnCard> {
348
+ const seed = project.env.SECURE_SEED;
349
+ if (!seed) throw new Error('Create an identity before connecting.');
350
+ const services = resolveServices(project.env, options.network);
351
+ assertProjectNetwork(project, services.network);
352
+ if (services.network !== PRODUCTION_NETWORK || project.env.NETWORK_URL) {
353
+ await saveProject(project, {
354
+ NETWORK_URL: services.network === PRODUCTION_NETWORK ? '' : services.network,
355
+ });
356
+ }
357
+ out.log('Connecting to the LearnCard Network...');
358
+ const config = {
359
+ seed,
360
+ network: services.network,
361
+ ...(services.cloud && { cloud: { url: services.cloud } }),
362
+ ...(options.didkit && { didkit: options.didkit }),
363
+ };
364
+ return options.lca
365
+ ? initLCALearnCard({
366
+ ...config,
367
+ ...(services.lcaAPI && { lcaAPI: services.lcaAPI.replace(/\/api\/?$/, '/trpc') }),
368
+ })
369
+ : initLearnCard({
370
+ ...config,
371
+ network: services.network === PRODUCTION_NETWORK ? true : services.network,
372
+ });
373
+ }
374
+
375
+ export const ensureProfile = async (
376
+ learnCard: { invoke: Pick<NetworkCard['invoke'], 'getProfile' | 'createProfile'> },
377
+ identity: { profileId: string; displayName: string },
378
+ project?: Project
379
+ ): Promise<void> => {
380
+ const existing = await learnCard.invoke.getProfile();
381
+ if (existing) {
382
+ out.log(`Signed in as "${existing.displayName}" (${existing.profileId})`);
383
+ if (project && project.env.DISPLAY_NAME !== existing.displayName)
384
+ await saveProject(project, { DISPLAY_NAME: existing.displayName });
385
+ return;
386
+ }
387
+ await learnCard.invoke.createProfile({
388
+ profileId: identity.profileId,
389
+ displayName: identity.displayName,
390
+ bio: '',
391
+ shortBio: '',
392
+ });
393
+ out.log(`Created profile "${identity.displayName}" (${identity.profileId})`);
394
+ };
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { recipientOf, templateUriOf } from './revoke';
3
+
4
+ describe('revoke targeting', () => {
5
+ it('reads the template URI from the credential boostId', () => {
6
+ expect(templateUriOf({ boostId: 'lc:network:x/trpc:boost:1' }, {})).toBe(
7
+ 'lc:network:x/trpc:boost:1'
8
+ );
9
+ });
10
+
11
+ it('prefers an explicit --template-uri', () => {
12
+ expect(templateUriOf({ boostId: 'lc:boost:a' }, { templateUri: 'lc:boost:b' })).toBe(
13
+ 'lc:boost:b'
14
+ );
15
+ });
16
+
17
+ it('explains when a credential did not come from a template', () => {
18
+ expect(() => templateUriOf({ id: 'urn:uuid:1' }, {})).toThrow(/not issued from a template/);
19
+ });
20
+
21
+ it('finds the recipient holding the exact credential URI', () => {
22
+ const records = [
23
+ { to: { profileId: 'alice' }, uri: 'lc:network:x/trpc:credential:1' },
24
+ { to: { profileId: 'bob' }, uri: 'lc:network:x/trpc:credential:2' },
25
+ ];
26
+ expect(recipientOf(records, 'lc:network:x/trpc:credential:2')).toBe('bob');
27
+ expect(recipientOf(records, 'lc:network:x/trpc:credential:9')).toBeUndefined();
28
+ });
29
+ });
package/src/revoke.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { connect, ensureIdentity, loadProject, type ProjectOptions } from './project';
2
+ import { out } from './out';
3
+
4
+ export interface RevokeOptions extends ProjectOptions {
5
+ suspend?: boolean;
6
+ templateUri?: string;
7
+ recipient?: string;
8
+ }
9
+
10
+ /** Template URI comes from the credential's `boostId`; the recipient from the network's recipient list. */
11
+ export const templateUriOf = (credential: unknown, options: RevokeOptions): string => {
12
+ const value =
13
+ credential && typeof credential === 'object' ? (credential as Record<string, unknown>) : {};
14
+ const templateUri =
15
+ options.templateUri || (typeof value.boostId === 'string' ? value.boostId : undefined);
16
+ if (!templateUri)
17
+ throw new Error(
18
+ 'This credential was not issued from a template. Pass --template-uri <uri> and --recipient <profileId>.'
19
+ );
20
+ return templateUri;
21
+ };
22
+
23
+ type Recipient = { to: { profileId: string }; uri?: string };
24
+
25
+ /** Find which recipient of a template holds this exact credential URI. */
26
+ export const recipientOf = (records: Recipient[], credentialUri: string): string | undefined =>
27
+ records.find(record => record.uri === credentialUri)?.to.profileId;
28
+
29
+ export const runRevoke = async (uri: string, options: RevokeOptions): Promise<void> => {
30
+ const project = await loadProject(process.cwd());
31
+ await ensureIdentity(project, options);
32
+ const learnCard = await connect(project, options);
33
+ const credential =
34
+ options.templateUri && options.recipient ? undefined : await learnCard.read.get(uri);
35
+ const templateUri = templateUriOf(credential, options);
36
+ let profileId = options.recipient;
37
+ let cursor: string | undefined;
38
+ while (!profileId) {
39
+ const page = await learnCard.invoke.getPaginatedBoostRecipients(
40
+ templateUri,
41
+ 100,
42
+ cursor,
43
+ true
44
+ );
45
+ profileId = recipientOf(page.records, uri);
46
+ if (profileId || !page.hasMore) break;
47
+ cursor = page.cursor ?? undefined;
48
+ }
49
+ if (!profileId)
50
+ throw new Error(
51
+ `No recipient of ${templateUri} holds ${uri}. If it was sent to an email and not yet claimed, there is nothing to revoke; otherwise pass --recipient <profileId>.`
52
+ );
53
+ const target = { templateUri, profileId };
54
+ const result = options.suspend
55
+ ? await learnCard.invoke.suspendBoostRecipient(target.templateUri, target.profileId, uri)
56
+ : await learnCard.invoke.revokeBoostRecipient(target.templateUri, target.profileId, uri);
57
+ if (!result) throw new Error('The network did not update this credential.');
58
+ const status = options.suspend ? 'suspended' : 'revoked';
59
+ out.log(
60
+ `${options.suspend ? 'Suspended' : 'Revoked'} ${uri}. Verifiers will see status: ${status} when they next refresh its status list; no propagation interval is documented.`
61
+ );
62
+ out.log('Only credentials with a credentialStatus entry support status-list verification.');
63
+ out.log('Check the delivered JSON: npx @learncard/cli verify credential.json');
64
+ out.set({ credentialUri: uri, templateUri, recipient: profileId, action: status });
65
+ };
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { templateCredential, personalizeSendMjs } from './send';
3
+ import { SEND_FROM_TEMPLATE_MJS } from './generated/snippets';
4
+
5
+ describe('template sending', () => {
6
+ it('personalizes replacement metacharacters literally', () => {
7
+ const output = personalizeSendMjs('$&', { name: '$&', description: '$&' });
8
+ expect(output).toContain('displayName: "$&"');
9
+ expect(output).toContain('description: "$&"');
10
+ });
11
+ it('includes Boost terms needed when the network stamps boostId', () => {
12
+ const credential = templateCredential('did:key:issuer');
13
+ expect(credential['@context']).toContain('https://ctx.learncard.com/boosts/1.0.1.json');
14
+ expect(credential.type).toContain('BoostCredential');
15
+ });
16
+ it('reuses the persisted template rather than creating another one', () => {
17
+ expect(SEND_FROM_TEMPLATE_MJS).toContain('templateUri: process.env.TEMPLATE_URI');
18
+ expect(SEND_FROM_TEMPLATE_MJS).not.toContain('createBoost');
19
+ });
20
+ });
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { SEND_MJS, parseEnv, upsertEnv, toProfileId } from './send';
7
+
8
+ describe('send command', () => {
9
+ it('ships the exact send.mjs the Quickstart docs show', () => {
10
+ const docsSnippet = readFileSync(
11
+ resolve(__dirname, '../../../docs/snippets/quickstart/send.mjs'),
12
+ 'utf8'
13
+ );
14
+ expect(SEND_MJS).toBe(docsSnippet);
15
+ });
16
+
17
+ it('parses and upserts .env without touching unrelated lines', () => {
18
+ const original = '# comment\nOTHER=1\nSECURE_SEED=old\n';
19
+ expect(parseEnv(original)).toEqual({ OTHER: '1', SECURE_SEED: 'old' });
20
+ expect(upsertEnv(original, { SECURE_SEED: 'new', PROFILE_ID: 'acme' })).toBe(
21
+ '# comment\nOTHER=1\nSECURE_SEED=new\nPROFILE_ID=acme\n'
22
+ );
23
+ expect(upsertEnv('', { SECURE_SEED: 'x' })).toBe('SECURE_SEED=x\n');
24
+ });
25
+
26
+ it('derives a URL-safe profile id with a random suffix', () => {
27
+ expect(toProfileId('Acme Learning, Inc.')).toMatch(/^acme-learning-inc-[a-z0-9]{4}$/);
28
+ expect(toProfileId('!!!')).toMatch(/^issuer-[a-z0-9]{4}$/);
29
+ expect(toProfileId('x'.repeat(60)).length).toBeLessThanOrEqual(40);
30
+ });
31
+ });
32
+
33
+ describe('personalizeSendMjs', () => {
34
+ it('substitutes the display name, badge name, and description', async () => {
35
+ const { personalizeSendMjs } = await import('./send');
36
+ const out = personalizeSendMjs('Acme Learning', {
37
+ name: 'Welcome to Acme',
38
+ description: 'You joined.',
39
+ });
40
+ expect(out).toContain('displayName: "Acme Learning"');
41
+ expect(out).toContain('name: "Welcome to Acme"');
42
+ expect(out).not.toContain('Quickstart Complete');
43
+ expect(out).toContain('description: "You joined."');
44
+ });
45
+ });