@learncard/cli 3.6.7 → 4.0.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 (72) hide show
  1. package/CHANGELOG.md +79 -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 -39
  72. package/vitest.config.ts +0 -8
package/src/project.ts DELETED
@@ -1,489 +0,0 @@
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 {
6
- initLearnCard,
7
- type DidWebNetworkLearnCardFromSeed,
8
- type NetworkLearnCardFromSeed,
9
- } from '@learncard/init';
10
- import { getLCAPlugin, initLCALearnCard, type LCALearnCard } from '@learncard/lca-api-plugin';
11
- import { generateRandomSeed } from './random';
12
- import { out } from './out';
13
-
14
- export const KEYS = {
15
- SECURE_SEED: 'SECURE_SEED', // Private issuer seed; never regenerate or print.
16
- PROFILE_ID: 'PROFILE_ID', // Public issuer handle shared by all commands.
17
- DISPLAY_NAME: 'DISPLAY_NAME', // Issuer display name; set at creation, then synced from the network.
18
- NETWORK_URL: 'NETWORK_URL', // Non-default network tRPC endpoint.
19
- SIGNING_AUTHORITY_NAME: 'SIGNING_AUTHORITY_NAME', // Registered primary signer name.
20
- SIGNING_AUTHORITY_ENDPOINT: 'SIGNING_AUTHORITY_ENDPOINT', // Hosted signing endpoint.
21
- API_TOKEN: 'API_TOKEN', // Secret bearer token for REST requests.
22
- API_TOKEN_SCOPE: 'API_TOKEN_SCOPE', // Space-separated permissions granted to the token.
23
- TEMPLATE_URI: 'TEMPLATE_URI', // Reusable Boost template on this network.
24
- CONTRACT_URI: 'CONTRACT_URI', // Consent contract for later commands.
25
- PUBLISHABLE_KEY: 'PUBLISHABLE_KEY', // Public integration client key.
26
- INTEGRATION_ID: 'INTEGRATION_ID', // Developer integration identifier.
27
- } as const;
28
-
29
- export interface Project {
30
- env: Record<string, string>;
31
- envPath: string;
32
- existing: string;
33
- }
34
-
35
- export interface ProjectOptions {
36
- yes?: boolean;
37
- name?: string;
38
- profileId?: string;
39
- network?: string;
40
- didkit?: Promise<Buffer>;
41
- json?: boolean;
42
- readOnly?: boolean;
43
- }
44
-
45
- export type NetworkCard = NetworkLearnCardFromSeed['returnValue'];
46
- export type DidWebCard = DidWebNetworkLearnCardFromSeed['returnValue'];
47
-
48
- export const parseEnv = (text: string): Record<string, string> => {
49
- const env: Record<string, string> = {};
50
- for (const line of text.split('\n')) {
51
- const match = line.match(/^\s*(?:export\s+)?([\w]+)\s*=\s*(.*)$/);
52
- if (!match) continue;
53
- const key = match[1]!;
54
- let value = match[2]!.trim();
55
- if (value.startsWith('"') || value.startsWith("'")) {
56
- const end = value.indexOf(value[0]!, 1);
57
- if (end < 0) throw new Error(`Unclosed quote in .env for ${key}.`);
58
- value = value.slice(1, end);
59
- } else value = value.split('#')[0]!.trim();
60
- if (key === 'SECURE_SEED' && env[key] !== undefined && env[key] !== value) {
61
- throw new Error('Conflicting SECURE_SEED entries in .env. Keep the original identity.');
62
- }
63
- Object.defineProperty(env, key, {
64
- value,
65
- enumerable: true,
66
- configurable: true,
67
- writable: true,
68
- });
69
- }
70
- return env;
71
- };
72
-
73
- export const upsertEnv = (text: string, values: Record<string, string>): string => {
74
- const lines = text ? text.replace(/\n$/, '').split('\n') : [];
75
- const seen = new Set<string>();
76
- const out = lines.map(line => {
77
- const key = line.match(/^\s*(?:export\s+)?([\w]+)\s*=/)?.[1];
78
- if (key && Object.prototype.hasOwnProperty.call(values, key)) {
79
- seen.add(key);
80
- // Quotes make multi-scope values safe in both dotenv and shell scripts.
81
- const value = values[key]!;
82
- if (/[\r\n]/.test(value)) throw new Error(`Invalid multiline value for ${key}`);
83
- return `${key}=${/^[a-zA-Z0-9_./:@*=%+-]*$/.test(value) ? value : JSON.stringify(value)}`;
84
- }
85
- return line;
86
- });
87
- for (const [key, value] of Object.entries(values)) {
88
- if (!seen.has(key)) out.push(upsertEnv(`${key}=\n`, { [key]: value }).trimEnd());
89
- }
90
- return `${out.join('\n')}\n`;
91
- };
92
-
93
- export const toProfileId = (displayName: string): string => {
94
- const base = displayName
95
- .toLowerCase()
96
- .replace(/[^a-z0-9]+/g, '-')
97
- .replace(/^-+|-+$/g, '')
98
- .slice(0, 30);
99
- return `${base || 'issuer'}-${Math.random().toString(36).slice(2, 6)}`;
100
- };
101
-
102
- const readOptional = async (file: string): Promise<string> => {
103
- try {
104
- return await fs.readFile(file, 'utf8');
105
- } catch (error) {
106
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
107
- throw error;
108
- }
109
- };
110
-
111
- export const loadProject = async (cwd: string): Promise<Project> => {
112
- const envPath = path.join(cwd, '.env');
113
- await assertRegularEnv(envPath);
114
- const existing = await readOptional(envPath);
115
- return { env: parseEnv(existing), envPath, existing };
116
- };
117
-
118
- const assertRegularEnv = async (envPath: string): Promise<void> => {
119
- const info = await fs.lstat(envPath).catch((error: NodeJS.ErrnoException) => {
120
- if (error.code === 'ENOENT') return undefined;
121
- throw error;
122
- });
123
- if (info && !info.isFile()) throw new Error('.env must be a regular file, not a symlink.');
124
- };
125
-
126
- export const saveProject = async (
127
- project: Project,
128
- updates: Record<string, string>
129
- ): Promise<void> => {
130
- // Serialize CLI writers and atomically replace the file so a failed write cannot lose a seed.
131
- const lockPath = `${project.envPath}.lock`;
132
- const lock = await fs.open(lockPath, 'wx', 0o600).catch((error: NodeJS.ErrnoException) => {
133
- if (error.code === 'EEXIST')
134
- throw new Error(
135
- 'Another command is updating .env. Retry when it finishes; remove a stale .env.lock only if no command is running.'
136
- );
137
- throw error;
138
- });
139
- const temporary = `${project.envPath}.${randomUUID()}.tmp`;
140
- try {
141
- await assertRegularEnv(project.envPath);
142
- const existing = await readOptional(project.envPath);
143
- const env = parseEnv(existing);
144
- if (
145
- env.SECURE_SEED &&
146
- updates.SECURE_SEED !== undefined &&
147
- env.SECURE_SEED !== updates.SECURE_SEED
148
- ) {
149
- throw new Error('Cannot replace an existing SECURE_SEED.');
150
- }
151
- const changed = Object.fromEntries(
152
- Object.entries(updates).filter(([key, value]) => env[key] !== value)
153
- );
154
- project.existing = existing;
155
- project.env = env;
156
- if (!Object.keys(changed).length) return;
157
- const next = upsertEnv(existing, changed);
158
- await fs.writeFile(temporary, next, { mode: 0o600, flag: 'wx' });
159
- await fs.rename(temporary, project.envPath);
160
- project.existing = next;
161
- Object.assign(project.env, changed);
162
- const keys = Object.keys(changed);
163
- const list =
164
- keys.length > 2
165
- ? `${keys.slice(0, -1).join(', ')}, and ${keys.at(-1)}`
166
- : keys.join(' and ');
167
- out.log(`Wrote ${list} to .env`);
168
- } finally {
169
- await fs.rm(temporary, { force: true });
170
- await lock.close();
171
- await fs.unlink(lockPath);
172
- }
173
- };
174
-
175
- /** Never block on input from a script, CI job, or agent: only prompt when a human is at a real TTY. */
176
- export const createPrompts = (yes?: boolean) => {
177
- const interactive = !yes && !!process.stdin.isTTY && process.env.LC_YES !== '1';
178
- const rl = interactive
179
- ? createInterface({ input: process.stdin, output: process.stdout })
180
- : null;
181
- return {
182
- interactive,
183
- ask: async (question: string, fallback: string): Promise<string> => {
184
- if (rl) return (await rl.question(`${question} [${fallback}] `)).trim() || fallback;
185
- if (!fallback)
186
- throw new Error(
187
- `${question} is required when running non-interactively. Pass it as an argument or flag.`
188
- );
189
- return fallback;
190
- },
191
- close: (): void => {
192
- rl?.close();
193
- },
194
- };
195
- };
196
-
197
- export const ensureIdentity = async (project: Project, options: ProjectOptions) => {
198
- if (
199
- project.env.PROFILE_ID &&
200
- options.profileId &&
201
- options.profileId !== project.env.PROFILE_ID
202
- ) {
203
- throw new Error(
204
- `This folder's .env is already the profile "${project.env.PROFILE_ID}"; --profile-id ${options.profileId} would not change that. ` +
205
- `To act as a profile you manage from here, use --as ${options.profileId}. To create a separate identity, run from a new folder.`
206
- );
207
- }
208
- const existingProfileId = project.env.PROFILE_ID || options.profileId;
209
- let displayName = options.name ?? project.env.DISPLAY_NAME ?? '';
210
- if (!existingProfileId && !displayName) {
211
- const prompts = createPrompts(options.yes);
212
- try {
213
- displayName = await prompts.ask(
214
- 'Display name for your issuer profile',
215
- 'My Organization'
216
- );
217
- } finally {
218
- prompts.close();
219
- }
220
- }
221
- if (!displayName) displayName = 'My Organization';
222
- if (!project.env.SECURE_SEED) {
223
- const { network } = resolveServices(project.env, options.network);
224
- const where =
225
- network === PRODUCTION_NETWORK
226
- ? 'production'
227
- : network === STAGING_NETWORK
228
- ? 'staging'
229
- : network;
230
- out.log(`No .env here — creating a new ${where} identity in ${process.cwd()}`);
231
- }
232
- const seed = project.env.SECURE_SEED || generateRandomSeed();
233
- const profileId = project.env.PROFILE_ID || options.profileId || toProfileId(displayName);
234
- await saveProject(project, {
235
- SECURE_SEED: seed,
236
- PROFILE_ID: profileId,
237
- ...(existingProfileId ? {} : { DISPLAY_NAME: displayName }),
238
- });
239
- await ensureGitignored(path.dirname(project.envPath), '.env');
240
- return { seed, profileId, displayName };
241
- };
242
-
243
- export const ensureGitignored = async (dir: string, entry: string): Promise<void> => {
244
- const gitignorePath = path.join(dir, '.gitignore');
245
- const gitignore = await readOptional(gitignorePath);
246
- if (await fs.stat(gitignorePath).catch(() => null)) {
247
- if (!gitignore.split('\n').some(line => line.trim() === entry)) {
248
- await fs.writeFile(gitignorePath, `${gitignore.replace(/\n?$/, '\n')}${entry}\n`);
249
- out.log(`Added ${entry} to .gitignore`);
250
- }
251
- } else {
252
- await fs.writeFile(gitignorePath, `${entry}\n`);
253
- out.log(`Created .gitignore with ${entry}`);
254
- }
255
- };
256
-
257
- export const PRODUCTION_NETWORK = 'https://network.learncard.com/trpc';
258
- export const STAGING_NETWORK = 'https://staging.network.learncard.com/trpc';
259
-
260
- /** Network-bound resources must not be silently reused on another deployment. */
261
- export const assertProjectNetwork = (project: Project, network: string): void => {
262
- const previous =
263
- project.env.NETWORK_URL === 'staging'
264
- ? STAGING_NETWORK
265
- : project.env.NETWORK_URL || PRODUCTION_NETWORK;
266
- const hasResources = [
267
- 'SIGNING_AUTHORITY_NAME',
268
- 'API_TOKEN',
269
- 'TEMPLATE_URI',
270
- 'CONTRACT_URI',
271
- 'PUBLISHABLE_KEY',
272
- 'INTEGRATION_ID',
273
- ].some(key => project.env[key]);
274
- if (previous !== network && hasResources) {
275
- throw new Error(
276
- 'This project has resources on another network. Use a separate folder for staging or another network.'
277
- );
278
- }
279
- };
280
-
281
- /** Resolve matching staging services; explicit process variables override project values. */
282
- export const resolveServices = (
283
- env: Record<string, string>,
284
- network?: string,
285
- runtime: Record<string, string | undefined> = process.env
286
- ) => {
287
- const requested = network || runtime.NETWORK_URL || env.NETWORK_URL || PRODUCTION_NETWORK;
288
- const url = requested === 'staging' ? STAGING_NETWORK : requested;
289
- const parsed = new URL(url);
290
- if (!['http:', 'https:'].includes(parsed.protocol))
291
- throw new Error('Network must be an HTTP(S) tRPC URL or staging.');
292
- const staging = url === STAGING_NETWORK;
293
- return {
294
- network: url,
295
- cloud:
296
- runtime.CLOUD_URL ||
297
- env.CLOUD_URL ||
298
- (staging ? 'https://staging.cloud.learncard.com/trpc' : undefined),
299
- lcaAPI:
300
- runtime.LCA_API_URL ||
301
- env.LCA_API_URL ||
302
- (staging ? 'https://staging.api.learncard.app/trpc' : undefined),
303
- };
304
- };
305
-
306
- /**
307
- * Docs snippets target production (`network: true`). When a project points at
308
- * another network, rewrite that one line in the generated file so the code a
309
- * developer keeps matches the network their .env is on.
310
- */
311
- export const PRODUCTION_APP = 'https://learncard.app';
312
- export const STAGING_APP = 'https://staging.learncard.ai';
313
-
314
- /** The LearnCard app that pairs with a network. Local networks have no hosted app; callers pass --app-url. */
315
- export const appUrlFor = (network: string, override?: string): string => {
316
- if (override) return override.replace(/\/$/, '');
317
- if (network === STAGING_NETWORK) return STAGING_APP;
318
- if (network === PRODUCTION_NETWORK) return PRODUCTION_APP;
319
- return PRODUCTION_APP;
320
- };
321
-
322
- export const localizeSnippet = (
323
- source: string,
324
- services: ReturnType<typeof resolveServices>
325
- ): string => {
326
- if (services.network === PRODUCTION_NETWORK) return source;
327
- const cloud = services.cloud ? `, cloud: { url: '${services.cloud}' }` : '';
328
- return source
329
- .replace(
330
- /initLearnCard\(\{ seed: process\.env\.SECURE_SEED, network: true \}\)/g,
331
- `initLearnCard({ seed: process.env.SECURE_SEED, network: '${services.network}'${cloud} })`
332
- )
333
- .replace(
334
- 'https://network.learncard.com/api/send',
335
- `${services.network.replace(/\/trpc$/, '')}/api/send`
336
- );
337
- };
338
-
339
- /**
340
- * The docs' send.sh reads $TOKEN from the environment, which is right for a
341
- * doc. The file the CLI writes should just work, so prepend a loader that reads
342
- * API_TOKEN from .env without sourcing (executing) the file.
343
- */
344
- export const ENV_TOKEN_LOADER = `#!/bin/sh
345
- set -eu
346
- # Read API_TOKEN from .env without executing it (strips optional quotes).
347
- if [ -f .env ]; then
348
- API_TOKEN=$(sed -n 's/^API_TOKEN=//p' .env | sed 's/^["'"'"']//; s/["'"'"']$//')
349
- fi
350
- TOKEN=\${TOKEN:-\${API_TOKEN:-}}
351
- : "\${TOKEN:?Run npx @learncard/cli token first}"
352
-
353
- `;
354
-
355
- export const withEnvTokenLoader = (sendSh: string): string => ENV_TOKEN_LOADER + sendSh;
356
-
357
- export function connect(
358
- project: Project,
359
- options: ProjectOptions & { lca: true }
360
- ): Promise<LCALearnCard>;
361
- export function connect(
362
- project: Project,
363
- options: ProjectOptions & { lca?: false }
364
- ): Promise<NetworkCard>;
365
- export async function connect(
366
- project: Project,
367
- options: ProjectOptions & { lca?: boolean }
368
- ): Promise<NetworkCard | LCALearnCard> {
369
- const seed = project.env.SECURE_SEED;
370
- if (!seed) throw new Error('Create an identity before connecting.');
371
- const services = resolveServices(project.env, options.network);
372
- assertProjectNetwork(project, services.network);
373
- if (!options.readOnly && (services.network !== PRODUCTION_NETWORK || project.env.NETWORK_URL)) {
374
- await saveProject(project, {
375
- NETWORK_URL: services.network === PRODUCTION_NETWORK ? '' : services.network,
376
- });
377
- }
378
- out.log('Connecting to the LearnCard Network...');
379
- const config = {
380
- seed,
381
- network: services.network,
382
- ...(services.cloud && { cloud: { url: services.cloud } }),
383
- ...(options.didkit && { didkit: options.didkit }),
384
- };
385
- return options.lca
386
- ? initLCALearnCard({
387
- ...config,
388
- ...(services.lcaAPI && { lcaAPI: services.lcaAPI.replace(/\/api\/?$/, '/trpc') }),
389
- })
390
- : initLearnCard({
391
- ...config,
392
- network: services.network === PRODUCTION_NETWORK ? true : services.network,
393
- });
394
- }
395
-
396
- /**
397
- * Open a second wallet on the same seed that authenticates as a `did:web` the
398
- * network issued to this seed (e.g. a profile manager). Manager-only routes
399
- * reject the seed's base did:key, so `connect()` alone cannot call them.
400
- */
401
- export const connectAsDidWeb = async (
402
- project: Project,
403
- options: ProjectOptions,
404
- didWeb: string
405
- ): Promise<DidWebCard> => {
406
- const seed = project.env.SECURE_SEED;
407
- if (!seed) throw new Error('Create an identity before connecting.');
408
- const services = resolveServices(project.env, options.network);
409
- return initLearnCard({
410
- seed,
411
- network: services.network === PRODUCTION_NETWORK ? true : services.network,
412
- didWeb,
413
- ...(services.cloud && { cloud: { url: services.cloud } }),
414
- ...(options.didkit && { didkit: options.didkit }),
415
- });
416
- };
417
-
418
- /**
419
- * Like `connectAsDidWeb`, plus the LCA plugin so the managed profile can create and
420
- * register its own hosted signing authority (tokens acting as it have no key to sign with).
421
- */
422
- export const connectAsDidWebSigner = async (
423
- project: Project,
424
- options: ProjectOptions,
425
- didWeb: string
426
- ): Promise<LCALearnCard> => {
427
- const seed = project.env.SECURE_SEED;
428
- if (!seed) throw new Error('Create an identity before connecting.');
429
- const services = resolveServices(project.env, options.network);
430
- const learnCard = await initLearnCard({
431
- seed,
432
- network: services.network === PRODUCTION_NETWORK ? true : services.network,
433
- didWeb,
434
- ...(services.cloud && { cloud: { url: services.cloud } }),
435
- ...(options.didkit && { didkit: options.didkit }),
436
- });
437
- const lcaAPI = services.lcaAPI?.replace(/\/api\/?$/, '/trpc');
438
- return learnCard.addPlugin(
439
- await getLCAPlugin(
440
- learnCard as unknown as Parameters<typeof getLCAPlugin>[0],
441
- lcaAPI ?? 'https://api.learncard.app/trpc'
442
- )
443
- ) as unknown as LCALearnCard;
444
- };
445
-
446
- export const connectAsManaged = async (
447
- project: Project,
448
- options: ProjectOptions,
449
- managedProfileId: string
450
- ): Promise<DidWebCard> => {
451
- const managerDid = project.env.ORG_PROFILE_MANAGER_DID;
452
- if (!managerDid) {
453
- throw new Error(
454
- `--as ${managedProfileId} needs a profile manager in this folder. Run \`org apply\` with a profileManager section first.`
455
- );
456
- }
457
- const manager = await connectAsDidWeb(project, options, managerDid);
458
- let cursor: string | undefined;
459
- do {
460
- const page = await manager.invoke.getManagedProfiles({ limit: 100, cursor });
461
- const match = page.records.find(record => record.profileId === managedProfileId);
462
- if (match) return connectAsDidWeb(project, options, match.did);
463
- cursor = page.hasMore ? (page.cursor ?? undefined) : undefined;
464
- } while (cursor);
465
- throw new Error(
466
- `"${managedProfileId}" is not a profile managed from this folder. Add it under profileManager.managed in your org spec and run \`org apply\`.`
467
- );
468
- };
469
-
470
- export const ensureProfile = async (
471
- learnCard: { invoke: Pick<NetworkCard['invoke'], 'getProfile' | 'createProfile'> },
472
- identity: { profileId: string; displayName: string },
473
- project?: Project
474
- ): Promise<void> => {
475
- const existing = await learnCard.invoke.getProfile();
476
- if (existing) {
477
- out.log(`Signed in as "${existing.displayName}" (${existing.profileId})`);
478
- if (project && project.env.DISPLAY_NAME !== existing.displayName)
479
- await saveProject(project, { DISPLAY_NAME: existing.displayName });
480
- return;
481
- }
482
- await learnCard.invoke.createProfile({
483
- profileId: identity.profileId,
484
- displayName: identity.displayName,
485
- bio: '',
486
- shortBio: '',
487
- });
488
- out.log(`Created profile "${identity.displayName}" (${identity.profileId})`);
489
- };
@@ -1,142 +0,0 @@
1
- import path from 'node:path';
2
- import { describe, expect, it } from 'vitest';
3
- import { PRODUCTION_NETWORK, STAGING_NETWORK } from './project';
4
- import { assertSourceNetwork, assertTargetSeed, planPromotion, PROMOTE_CHECKLIST } from './promote';
5
-
6
- describe('planPromotion', () => {
7
- it('places the target under the supplied working directory', () => {
8
- expect(planPromotion('staging', 'production', '/tmp/source-project').targetDir).toBe(
9
- path.join('/tmp/source-project', '.learncard', 'production')
10
- );
11
- });
12
- it('defaults the working directory to process.cwd()', () => {
13
- expect(planPromotion('staging', 'production').targetDir).toBe(
14
- path.join(process.cwd(), '.learncard', 'production')
15
- );
16
- });
17
- it('resolves staging -> production and targets a project folder named after it', () => {
18
- const plan = planPromotion('staging', 'production');
19
- expect(plan.fromNetwork).toBe(STAGING_NETWORK);
20
- expect(plan.toNetwork).toBe(PRODUCTION_NETWORK);
21
- expect(plan.targetDir.endsWith(`${path.sep}production`)).toBe(true);
22
- });
23
-
24
- it('names the target folder after the hostname for a custom network URL', () => {
25
- const plan = planPromotion('staging', 'https://network.example.org:8443/trpc');
26
- expect(plan.toNetwork).toBe('https://network.example.org:8443/trpc');
27
- expect(plan.targetDir.endsWith(`${path.sep}network.example.org`)).toBe(true);
28
- });
29
-
30
- it('throws when --from and --to resolve to the same network', () => {
31
- expect(() => planPromotion('staging', 'staging')).toThrow('same network');
32
- expect(() => planPromotion('production', PRODUCTION_NETWORK)).toThrow('same network');
33
- });
34
- });
35
-
36
- describe('PROMOTE_CHECKLIST', () => {
37
- it('lists the per-network resources that must be recreated on the target', () => {
38
- expect(PROMOTE_CHECKLIST).toContain('API tokens');
39
- expect(PROMOTE_CHECKLIST).toContain('Signing authority registrations');
40
- });
41
- });
42
-
43
- describe('assertSourceNetwork', () => {
44
- it('accepts a staging .env for --from staging (alias or full URL)', () => {
45
- expect(() =>
46
- assertSourceNetwork({ NETWORK_URL: 'staging' }, 'staging', STAGING_NETWORK)
47
- ).not.toThrow();
48
- expect(() =>
49
- assertSourceNetwork({ NETWORK_URL: STAGING_NETWORK }, 'staging', STAGING_NETWORK)
50
- ).not.toThrow();
51
- });
52
-
53
- it('accepts an .env with no NETWORK_URL for --from production', () => {
54
- expect(() => assertSourceNetwork({}, 'production', PRODUCTION_NETWORK)).not.toThrow();
55
- });
56
-
57
- it('throws when the source folder is on a different network than --from', () => {
58
- expect(() => assertSourceNetwork({}, 'staging', STAGING_NETWORK)).toThrow(
59
- `--from staging does not match this folder's network (${PRODUCTION_NETWORK})`
60
- );
61
- });
62
- });
63
-
64
- describe('assertTargetSeed', () => {
65
- const seed = 'a'.repeat(64);
66
-
67
- it('accepts an empty target or one already bound to the same seed', () => {
68
- expect(() => assertTargetSeed({}, seed, '/t')).not.toThrow();
69
- expect(() => assertTargetSeed({ SECURE_SEED: seed }, seed, '/t')).not.toThrow();
70
- });
71
-
72
- it('rejects a target already bound to a different seed', () => {
73
- expect(() => assertTargetSeed({ SECURE_SEED: 'b'.repeat(64) }, seed, '/t')).toThrow(
74
- `${path.join('/t', '.env')} already holds a different SECURE_SEED`
75
- );
76
- });
77
- });
78
-
79
- describe('runPromote --dry-run', () => {
80
- it('uses options.cwd without changing process.cwd and leaves the source untouched', async () => {
81
- const fs = await import('node:fs/promises');
82
- const os = await import('node:os');
83
- const { vi } = await import('vitest');
84
- const log = vi.spyOn(console, 'log').mockImplementation(() => {});
85
- const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-promote-'));
86
- const previousCwd = process.cwd();
87
- const before = `SECURE_SEED=${'a'.repeat(64)}\nPROFILE_ID=exde\nNETWORK_URL=http://localhost:4000/trpc\n`;
88
- const runOrgApply = vi.fn().mockResolvedValue(undefined);
89
- vi.doMock('./org', () => ({ runOrgApply }));
90
- vi.doMock('./doctor', () => ({ runDoctor: vi.fn() }));
91
- vi.resetModules();
92
- try {
93
- await fs.writeFile(path.join(cwd, '.env'), before);
94
- const org = path.join(cwd, 'org.yaml');
95
- await fs.writeFile(
96
- org,
97
- 'issuer:\n profileId: exde\n displayName: Ex\n signingAuthority: { type: learncard-hosted, name: ex }\n'
98
- );
99
- const { runPromote } = await import('./promote');
100
- await runPromote({
101
- cwd,
102
- from: 'http://localhost:4000/trpc',
103
- to: 'staging',
104
- org,
105
- dryRun: true,
106
- });
107
- const targetDir = path.join(cwd, '.learncard', 'staging');
108
- expect(runOrgApply).toHaveBeenCalledWith(
109
- org,
110
- expect.objectContaining({
111
- project: expect.objectContaining({ envPath: path.join(targetDir, '.env') }),
112
- secretsOut: path.join(targetDir, 'secrets.env'),
113
- })
114
- );
115
- expect(process.cwd()).toBe(previousCwd);
116
- expect(await fs.readFile(path.join(cwd, '.env'), 'utf8')).toBe(before);
117
- expect(await fs.readdir(cwd)).toEqual(['.env', 'org.yaml']);
118
-
119
- runOrgApply.mockClear();
120
- await fs.mkdir(targetDir, { recursive: true });
121
- await fs.writeFile(path.join(targetDir, '.env'), `SECURE_SEED=${'b'.repeat(64)}\n`);
122
- for (const dryRun of [true, false]) {
123
- await expect(
124
- runPromote({
125
- cwd,
126
- from: 'http://localhost:4000/trpc',
127
- to: 'staging',
128
- org,
129
- dryRun,
130
- })
131
- ).rejects.toThrow('already holds a different SECURE_SEED');
132
- }
133
- expect(runOrgApply).not.toHaveBeenCalled();
134
- } finally {
135
- vi.doUnmock('./org');
136
- vi.doUnmock('./doctor');
137
- vi.resetModules();
138
- log.mockRestore();
139
- await fs.rm(cwd, { recursive: true, force: true });
140
- }
141
- }, 20_000);
142
- });