@learncard/cli 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
package/src/org.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
connect,
|
|
4
|
+
connectAsDidWeb,
|
|
5
|
+
connectAsDidWebSigner,
|
|
6
|
+
ensureIdentity,
|
|
7
|
+
loadProject,
|
|
8
|
+
type Project,
|
|
9
|
+
type ProjectOptions,
|
|
10
|
+
} from './project';
|
|
11
|
+
import { loadOrgSpec } from './org/load';
|
|
12
|
+
import { applyOrg } from './org/apply';
|
|
13
|
+
import { formatChanges, hasChanges } from './org/diff';
|
|
14
|
+
import { out } from './out';
|
|
15
|
+
import { generateRandomSeed } from './random';
|
|
16
|
+
|
|
17
|
+
export type OrgApplyOptions = ProjectOptions & {
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
secretsOut?: string;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
/** Operate on this in-memory project instead of loading .env from cwd (dry-run previews). */
|
|
22
|
+
project?: Project;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const runOrgApply = async (file: string, options: OrgApplyOptions): Promise<void> => {
|
|
26
|
+
const spec = await loadOrgSpec(file);
|
|
27
|
+
const project = options.project ?? (await loadProject(options.cwd ?? process.cwd()));
|
|
28
|
+
|
|
29
|
+
if (options.profileId && options.profileId !== spec.issuer.profileId)
|
|
30
|
+
throw new Error(
|
|
31
|
+
`--profile-id "${options.profileId}" does not match the spec's issuer.profileId "${spec.issuer.profileId}". Remove the flag or update the spec.`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (project.env.PROFILE_ID && project.env.PROFILE_ID !== spec.issuer.profileId)
|
|
35
|
+
throw new Error(
|
|
36
|
+
`This project's .env is already set up for profile "${project.env.PROFILE_ID}", but the spec declares "${spec.issuer.profileId}". Use a separate folder for a different issuer.`
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (options.dryRun) {
|
|
40
|
+
if (!project.env.SECURE_SEED) {
|
|
41
|
+
out.log(
|
|
42
|
+
'Dry run: no identity in this folder yet — using a throwaway seed, nothing written.'
|
|
43
|
+
);
|
|
44
|
+
project.env.SECURE_SEED = generateRandomSeed();
|
|
45
|
+
}
|
|
46
|
+
project.env.PROFILE_ID ??= spec.issuer.profileId;
|
|
47
|
+
} else {
|
|
48
|
+
await ensureIdentity(project, {
|
|
49
|
+
...options,
|
|
50
|
+
profileId: spec.issuer.profileId,
|
|
51
|
+
name: spec.issuer.displayName,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const learnCard = await connect(project, {
|
|
55
|
+
...options,
|
|
56
|
+
lca: true,
|
|
57
|
+
readOnly: !!options.dryRun,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await applyOrg(spec, learnCard, project, {
|
|
61
|
+
dryRun: options.dryRun,
|
|
62
|
+
secretsOut: options.secretsOut,
|
|
63
|
+
connectAsManager: managerDid => connectAsDidWeb(project, options, managerDid),
|
|
64
|
+
connectAsManaged: managedDid => connectAsDidWeb(project, options, managedDid),
|
|
65
|
+
connectAsManagedSigner: managedDid => connectAsDidWebSigner(project, options, managedDid),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (options.dryRun) out.log('Dry run: no changes were made.');
|
|
69
|
+
if (hasChanges(result.changes)) {
|
|
70
|
+
for (const line of formatChanges(result.changes)) out.log(line);
|
|
71
|
+
} else {
|
|
72
|
+
out.log('No changes.');
|
|
73
|
+
}
|
|
74
|
+
out.log(`Issuer DID: ${result.outputs.issuerDid}`);
|
|
75
|
+
|
|
76
|
+
out.set({ changes: result.changes, outputs: result.outputs });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const registerOrgCommand = (
|
|
80
|
+
program: Command,
|
|
81
|
+
run: (
|
|
82
|
+
command: string,
|
|
83
|
+
options: { json?: boolean },
|
|
84
|
+
action: (didkit: Promise<Buffer>) => Promise<void>
|
|
85
|
+
) => Promise<void>
|
|
86
|
+
): void => {
|
|
87
|
+
const orgCommand = program
|
|
88
|
+
.command('org')
|
|
89
|
+
.description('Manage an issuer organization declaratively.');
|
|
90
|
+
|
|
91
|
+
orgCommand
|
|
92
|
+
.command('apply <file>')
|
|
93
|
+
.description(
|
|
94
|
+
'Reconcile a YAML/JSON org spec (issuer, signing authority, districts, service accounts) against the network.'
|
|
95
|
+
)
|
|
96
|
+
.option('-y, --yes', 'accept defaults without prompting')
|
|
97
|
+
.option(
|
|
98
|
+
'--profile-id <id>',
|
|
99
|
+
'public handle for your issuer profile (default: from the spec)'
|
|
100
|
+
)
|
|
101
|
+
.option('--network <url>', 'network tRPC URL or staging (default: production)')
|
|
102
|
+
.option('--json', 'print a single JSON result on stdout')
|
|
103
|
+
.option('--dry-run', 'preview changes without applying them')
|
|
104
|
+
.option(
|
|
105
|
+
'--secrets-out <path>',
|
|
106
|
+
'write created service-account tokens to this file, e.g. ./secrets.env (mode 0600; keep it next to .env and out of git)'
|
|
107
|
+
)
|
|
108
|
+
.action(
|
|
109
|
+
(
|
|
110
|
+
file: string,
|
|
111
|
+
options: {
|
|
112
|
+
yes?: boolean;
|
|
113
|
+
profileId?: string;
|
|
114
|
+
network?: string;
|
|
115
|
+
json?: boolean;
|
|
116
|
+
dryRun?: boolean;
|
|
117
|
+
secretsOut?: string;
|
|
118
|
+
}
|
|
119
|
+
) =>
|
|
120
|
+
run('org apply', options, async didkit => {
|
|
121
|
+
await runOrgApply(file, { ...options, didkit });
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
};
|
package/src/project.test.ts
CHANGED
|
@@ -138,7 +138,10 @@ describe('project context', () => {
|
|
|
138
138
|
);
|
|
139
139
|
await fs.writeFile(path.join(cwd, '.gitignore'), '.env\n');
|
|
140
140
|
const project = await loadProject(cwd);
|
|
141
|
-
|
|
141
|
+
await expect(
|
|
142
|
+
ensureIdentity(project, { yes: true, profileId: 'someone-else' })
|
|
143
|
+
).rejects.toThrow(/--as someone-else/);
|
|
144
|
+
const identity = await ensureIdentity(project, { yes: true, profileId: 'issuer' });
|
|
142
145
|
expect(identity.seed).toBe('existing');
|
|
143
146
|
expect(identity.profileId).toBe('issuer');
|
|
144
147
|
expect(console.log).not.toHaveBeenCalled();
|
|
@@ -205,3 +208,25 @@ describe('upsertEnv value quoting', () => {
|
|
|
205
208
|
);
|
|
206
209
|
});
|
|
207
210
|
});
|
|
211
|
+
|
|
212
|
+
describe('connectAsManaged', () => {
|
|
213
|
+
it('explains how to get a profile manager when .env has none', async () => {
|
|
214
|
+
const { connectAsManaged } = await import('./project');
|
|
215
|
+
const project = { env: { SECURE_SEED: 'seed' }, envPath: '/x/.env', existing: '' };
|
|
216
|
+
await expect(connectAsManaged(project, {}, 'cs-exampleville')).rejects.toThrow(
|
|
217
|
+
/profileManager section/
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('connect readOnly', () => {
|
|
223
|
+
it('does not write NETWORK_URL to .env when readOnly is set', async () => {
|
|
224
|
+
const { connect } = await import('./project');
|
|
225
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-readonly-'));
|
|
226
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
227
|
+
const project = await loadProject(cwd);
|
|
228
|
+
project.env.SECURE_SEED = 'a'.repeat(64);
|
|
229
|
+
await connect(project, { network: 'staging', readOnly: true }).catch(() => undefined);
|
|
230
|
+
expect(await fs.readdir(cwd)).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/project.ts
CHANGED
|
@@ -2,8 +2,12 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
|
|
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';
|
|
7
11
|
import { generateRandomSeed } from './random';
|
|
8
12
|
import { out } from './out';
|
|
9
13
|
|
|
@@ -35,9 +39,11 @@ export interface ProjectOptions {
|
|
|
35
39
|
network?: string;
|
|
36
40
|
didkit?: Promise<Buffer>;
|
|
37
41
|
json?: boolean;
|
|
42
|
+
readOnly?: boolean;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export type NetworkCard = NetworkLearnCardFromSeed['returnValue'];
|
|
46
|
+
export type DidWebCard = DidWebNetworkLearnCardFromSeed['returnValue'];
|
|
41
47
|
|
|
42
48
|
export const parseEnv = (text: string): Record<string, string> => {
|
|
43
49
|
const env: Record<string, string> = {};
|
|
@@ -173,6 +179,7 @@ export const createPrompts = (yes?: boolean) => {
|
|
|
173
179
|
? createInterface({ input: process.stdin, output: process.stdout })
|
|
174
180
|
: null;
|
|
175
181
|
return {
|
|
182
|
+
interactive,
|
|
176
183
|
ask: async (question: string, fallback: string): Promise<string> => {
|
|
177
184
|
if (rl) return (await rl.question(`${question} [${fallback}] `)).trim() || fallback;
|
|
178
185
|
if (!fallback)
|
|
@@ -188,6 +195,16 @@ export const createPrompts = (yes?: boolean) => {
|
|
|
188
195
|
};
|
|
189
196
|
|
|
190
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
|
+
}
|
|
191
208
|
const existingProfileId = project.env.PROFILE_ID || options.profileId;
|
|
192
209
|
let displayName = options.name ?? project.env.DISPLAY_NAME ?? '';
|
|
193
210
|
if (!existingProfileId && !displayName) {
|
|
@@ -219,18 +236,22 @@ export const ensureIdentity = async (project: Project, options: ProjectOptions)
|
|
|
219
236
|
PROFILE_ID: profileId,
|
|
220
237
|
...(existingProfileId ? {} : { DISPLAY_NAME: displayName }),
|
|
221
238
|
});
|
|
222
|
-
|
|
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');
|
|
223
245
|
const gitignore = await readOptional(gitignorePath);
|
|
224
246
|
if (await fs.stat(gitignorePath).catch(() => null)) {
|
|
225
|
-
if (!gitignore.split('\n').some(line => line.trim() ===
|
|
226
|
-
await fs.writeFile(gitignorePath, `${gitignore.replace(/\n?$/, '\n')}
|
|
227
|
-
out.log(
|
|
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`);
|
|
228
250
|
}
|
|
229
251
|
} else {
|
|
230
|
-
await fs.writeFile(gitignorePath,
|
|
231
|
-
out.log(
|
|
252
|
+
await fs.writeFile(gitignorePath, `${entry}\n`);
|
|
253
|
+
out.log(`Created .gitignore with ${entry}`);
|
|
232
254
|
}
|
|
233
|
-
return { seed, profileId, displayName };
|
|
234
255
|
};
|
|
235
256
|
|
|
236
257
|
export const PRODUCTION_NETWORK = 'https://network.learncard.com/trpc';
|
|
@@ -349,7 +370,7 @@ export async function connect(
|
|
|
349
370
|
if (!seed) throw new Error('Create an identity before connecting.');
|
|
350
371
|
const services = resolveServices(project.env, options.network);
|
|
351
372
|
assertProjectNetwork(project, services.network);
|
|
352
|
-
if (services.network !== PRODUCTION_NETWORK || project.env.NETWORK_URL) {
|
|
373
|
+
if (!options.readOnly && (services.network !== PRODUCTION_NETWORK || project.env.NETWORK_URL)) {
|
|
353
374
|
await saveProject(project, {
|
|
354
375
|
NETWORK_URL: services.network === PRODUCTION_NETWORK ? '' : services.network,
|
|
355
376
|
});
|
|
@@ -372,6 +393,80 @@ export async function connect(
|
|
|
372
393
|
});
|
|
373
394
|
}
|
|
374
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
|
+
|
|
375
470
|
export const ensureProfile = async (
|
|
376
471
|
learnCard: { invoke: Pick<NetworkCard['invoke'], 'getProfile' | 'createProfile'> },
|
|
377
472
|
identity: { profileId: string; displayName: string },
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
});
|
package/src/promote.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Command } from 'commander';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
loadProject,
|
|
7
|
+
saveProject,
|
|
8
|
+
resolveServices,
|
|
9
|
+
PRODUCTION_NETWORK,
|
|
10
|
+
STAGING_NETWORK,
|
|
11
|
+
type Project,
|
|
12
|
+
type ProjectOptions,
|
|
13
|
+
} from './project';
|
|
14
|
+
import { loadOrgSpec } from './org/load';
|
|
15
|
+
import { runOrgApply } from './org';
|
|
16
|
+
import { runDoctor, type RunCommand } from './doctor';
|
|
17
|
+
import { out } from './out';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-network resources that must be recreated on the target network after a promotion.
|
|
21
|
+
* See docs/how-to-guides/deploy-infrastructure/test-safely.md §"What carries over". The
|
|
22
|
+
* one thing that IS portable — your seed, and therefore the resulting did:key — is
|
|
23
|
+
* printed separately by `runPromote` rather than listed here.
|
|
24
|
+
*/
|
|
25
|
+
export const PROMOTE_CHECKLIST: readonly string[] = [
|
|
26
|
+
'Profile & profile ID',
|
|
27
|
+
'API tokens',
|
|
28
|
+
'Signing authority registrations',
|
|
29
|
+
'Credential templates',
|
|
30
|
+
'ConsentFlow contracts',
|
|
31
|
+
'Issued & claimed credentials',
|
|
32
|
+
'did:web (host-bound)',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const NETWORK_ALIASES: Record<string, string> = {
|
|
36
|
+
staging: STAGING_NETWORK,
|
|
37
|
+
production: PRODUCTION_NETWORK,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `resolveServices` only special-cases the literal string "staging"; translate "production" too. */
|
|
41
|
+
const resolveNetworkUrl = (network: string): string =>
|
|
42
|
+
resolveServices({}, NETWORK_ALIASES[network] ?? network, {}).network;
|
|
43
|
+
|
|
44
|
+
const NETWORK_DIR_NAMES: Record<string, string> = {
|
|
45
|
+
[STAGING_NETWORK]: 'staging',
|
|
46
|
+
[PRODUCTION_NETWORK]: 'production',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** One project folder per network (`assertProjectNetwork` enforces this); named after the alias when known. */
|
|
50
|
+
const networkDirName = (network: string): string =>
|
|
51
|
+
NETWORK_DIR_NAMES[network] ?? new URL(network).hostname;
|
|
52
|
+
|
|
53
|
+
/** Pure: resolves --from/--to to network URLs and picks the target project folder. */
|
|
54
|
+
export const planPromotion = (
|
|
55
|
+
from: string,
|
|
56
|
+
to: string,
|
|
57
|
+
cwd: string = process.cwd()
|
|
58
|
+
): { fromNetwork: string; toNetwork: string; targetDir: string } => {
|
|
59
|
+
const fromNetwork = resolveNetworkUrl(from);
|
|
60
|
+
const toNetwork = resolveNetworkUrl(to);
|
|
61
|
+
if (fromNetwork === toNetwork)
|
|
62
|
+
throw new Error(`--from and --to resolve to the same network (${toNetwork}).`);
|
|
63
|
+
const targetDir = path.join(cwd, '.learncard', networkDirName(toNetwork));
|
|
64
|
+
return { fromNetwork, toNetwork, targetDir };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Pure: the source folder's `.env` must actually be on `--from`, or the wrong seed's org gets promoted. */
|
|
68
|
+
export const assertSourceNetwork = (
|
|
69
|
+
sourceEnv: Record<string, string>,
|
|
70
|
+
from: string,
|
|
71
|
+
fromNetwork: string
|
|
72
|
+
): void => {
|
|
73
|
+
const actual = resolveServices(sourceEnv, undefined, {}).network;
|
|
74
|
+
if (actual !== fromNetwork)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`--from ${from} does not match this folder's network (${actual}). Run promote from the folder that is on ${from}.`
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Pure: a target folder already bound to another seed would be provisioned as a different identity than the preview shows. */
|
|
81
|
+
export const assertTargetSeed = (
|
|
82
|
+
targetEnv: Record<string, string>,
|
|
83
|
+
seed: string,
|
|
84
|
+
targetDir: string
|
|
85
|
+
): void => {
|
|
86
|
+
if (targetEnv.SECURE_SEED && targetEnv.SECURE_SEED !== seed)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${path.join(targetDir, '.env')} already holds a different SECURE_SEED. Promotion carries this folder's seed; move that .env aside or pick a different target before re-running.`
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type PromoteOptions = ProjectOptions & {
|
|
93
|
+
cwd?: string;
|
|
94
|
+
from: string;
|
|
95
|
+
to: string;
|
|
96
|
+
org: string;
|
|
97
|
+
secretsOut?: string;
|
|
98
|
+
dryRun?: boolean;
|
|
99
|
+
skipDoctor?: boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const runPromote = async (options: PromoteOptions): Promise<void> => {
|
|
103
|
+
const { from, to, org, dryRun, skipDoctor } = options;
|
|
104
|
+
const { fromNetwork, toNetwork, targetDir } = planPromotion(from, to, options.cwd);
|
|
105
|
+
const secretsOut = options.secretsOut ?? path.join(targetDir, 'secrets.env');
|
|
106
|
+
out.log(`Promoting ${org} from ${from} to ${to}`);
|
|
107
|
+
out.log(`Target: ${targetDir}`);
|
|
108
|
+
|
|
109
|
+
const sourceProject = await loadProject(options.cwd ?? process.cwd());
|
|
110
|
+
if (!sourceProject.env.SECURE_SEED)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`No SECURE_SEED in .env here. Run \`org apply\` against ${from} in this folder first.`
|
|
113
|
+
);
|
|
114
|
+
assertSourceNetwork(sourceProject.env, from, fromNetwork);
|
|
115
|
+
|
|
116
|
+
// Only the seed (and, if present, the matching profile ID) carries over — every
|
|
117
|
+
// other resource (tokens, signing authority, templates, contracts) is per-network
|
|
118
|
+
// and must be recreated by `org apply` against the target network below.
|
|
119
|
+
const carried = {
|
|
120
|
+
SECURE_SEED: sourceProject.env.SECURE_SEED,
|
|
121
|
+
...(sourceProject.env.PROFILE_ID ? { PROFILE_ID: sourceProject.env.PROFILE_ID } : {}),
|
|
122
|
+
NETWORK_URL: toNetwork,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const targetProject = await loadProject(targetDir);
|
|
126
|
+
assertTargetSeed(targetProject.env, carried.SECURE_SEED, targetDir);
|
|
127
|
+
|
|
128
|
+
const spec = await loadOrgSpec(org);
|
|
129
|
+
if (spec.serviceAccounts?.length && !dryRun)
|
|
130
|
+
out.log(`Any new service-account tokens for ${to} go to ${secretsOut}`);
|
|
131
|
+
|
|
132
|
+
if (dryRun) {
|
|
133
|
+
out.log(
|
|
134
|
+
`Dry run: ${targetDir} is not created; previewing against ${to} with the carried-over seed.`
|
|
135
|
+
);
|
|
136
|
+
const preview: Project = {
|
|
137
|
+
env: { ...targetProject.env, ...carried },
|
|
138
|
+
envPath: targetProject.envPath,
|
|
139
|
+
existing: '',
|
|
140
|
+
};
|
|
141
|
+
await runOrgApply(org, {
|
|
142
|
+
...options,
|
|
143
|
+
project: preview,
|
|
144
|
+
network: toNetwork,
|
|
145
|
+
dryRun,
|
|
146
|
+
secretsOut,
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
150
|
+
await saveProject(targetProject, carried);
|
|
151
|
+
await runOrgApply(org, {
|
|
152
|
+
...options,
|
|
153
|
+
cwd: targetDir,
|
|
154
|
+
network: toNetwork,
|
|
155
|
+
dryRun,
|
|
156
|
+
secretsOut,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!skipDoctor && !dryRun) {
|
|
161
|
+
await runDoctor({ ...options, cwd: targetDir, network: toNetwork });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
out.log(`Does not carry over from ${from} — recreate on ${to}:`);
|
|
165
|
+
for (const item of PROMOTE_CHECKLIST) out.log(` - ${item}`);
|
|
166
|
+
out.log('Carries over: your seed → the same did:key.');
|
|
167
|
+
out.log(`Next: cd ${targetDir} && npx @learncard/cli doctor --network ${to}`);
|
|
168
|
+
|
|
169
|
+
out.set({ from: fromNetwork, to: toNetwork, targetDir, checklist: PROMOTE_CHECKLIST });
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const registerPromoteCommand = (program: Command, run: RunCommand): void => {
|
|
173
|
+
program
|
|
174
|
+
.command('promote')
|
|
175
|
+
.description('Move an issuer org from one network to another (e.g. staging to production).')
|
|
176
|
+
.requiredOption('--from <network>', 'source network tRPC URL or staging|production')
|
|
177
|
+
.requiredOption('--to <network>', 'target network tRPC URL or staging|production')
|
|
178
|
+
.requiredOption('--org <file>', 'org spec to re-apply on the target network')
|
|
179
|
+
.option(
|
|
180
|
+
'--secrets-out <path>',
|
|
181
|
+
'where to write the new service-account tokens (default: <target>/secrets.env, mode 0600)'
|
|
182
|
+
)
|
|
183
|
+
.option('--dry-run', 'preview changes without applying them')
|
|
184
|
+
.option('--skip-doctor', 'skip running doctor against the target network afterward')
|
|
185
|
+
.option('-y, --yes', 'accept defaults without prompting')
|
|
186
|
+
.option('--json', 'print a single JSON result on stdout')
|
|
187
|
+
.action(
|
|
188
|
+
(options: {
|
|
189
|
+
from: string;
|
|
190
|
+
to: string;
|
|
191
|
+
org: string;
|
|
192
|
+
secretsOut?: string;
|
|
193
|
+
dryRun?: boolean;
|
|
194
|
+
skipDoctor?: boolean;
|
|
195
|
+
yes?: boolean;
|
|
196
|
+
json?: boolean;
|
|
197
|
+
}) =>
|
|
198
|
+
run('promote', options, async didkit => {
|
|
199
|
+
await runPromote({ ...options, didkit });
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
};
|