@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.
- package/CHANGELOG.md +79 -0
- package/README.md +36 -12
- package/dist/index.js +6673 -2038
- package/package.json +27 -15
- package/project.json +0 -42
- package/rollup.config.js +0 -34
- package/scripts/embed-snippets.ts +0 -47
- package/src/auth-grant.test.ts +0 -54
- package/src/auth-grant.ts +0 -34
- package/src/clr/validate.test.ts +0 -65
- package/src/clr/validate.ts +0 -242
- package/src/clr.ts +0 -119
- package/src/consent-contract.ts +0 -116
- package/src/demo-inbox-refresh.test.ts +0 -737
- package/src/demo-inbox-refresh.ts +0 -804
- package/src/demo-refresh-command.test.ts +0 -57
- package/src/demo-refresh-command.ts +0 -22
- package/src/demo-refresh-ui.test.ts +0 -66
- package/src/demo-refresh-ui.ts +0 -65
- package/src/demo-refresh.test.ts +0 -140
- package/src/demo-refresh.ts +0 -309
- package/src/doctor/checks.test.ts +0 -448
- package/src/doctor/checks.ts +0 -497
- package/src/doctor.test.ts +0 -67
- package/src/doctor.ts +0 -118
- package/src/embed.ts +0 -102
- package/src/inbox.test.ts +0 -257
- package/src/inbox.ts +0 -221
- package/src/index.tsx +0 -876
- package/src/init.ts +0 -26
- package/src/open.test.ts +0 -40
- package/src/open.ts +0 -186
- package/src/org/apply.test.ts +0 -1108
- package/src/org/apply.ts +0 -924
- package/src/org/branding.test.ts +0 -60
- package/src/org/diff.ts +0 -14
- package/src/org/load.ts +0 -50
- package/src/org/schema.test.ts +0 -256
- package/src/org/schema.ts +0 -216
- package/src/org.ts +0 -124
- package/src/out.test.ts +0 -40
- package/src/out.ts +0 -16
- package/src/phase-two.test.ts +0 -154
- package/src/project.test.ts +0 -232
- package/src/project.ts +0 -489
- package/src/promote.test.ts +0 -142
- package/src/promote.ts +0 -202
- package/src/random.ts +0 -3
- package/src/refresh.test.ts +0 -86
- package/src/refresh.ts +0 -93
- package/src/replHelpers.test.ts +0 -80
- package/src/replHelpers.ts +0 -45
- package/src/revoke.test.ts +0 -29
- package/src/revoke.ts +0 -65
- package/src/send-template.test.ts +0 -20
- package/src/send.test.ts +0 -321
- package/src/send.ts +0 -373
- package/src/setup-signing.test.ts +0 -62
- package/src/setup-signing.ts +0 -185
- package/src/snippet-files.ts +0 -16
- package/src/status.test.ts +0 -33
- package/src/status.ts +0 -94
- package/src/token.test.ts +0 -28
- package/src/token.ts +0 -139
- package/src/types.d.ts +0 -17
- package/src/verify.test.ts +0 -35
- package/src/verify.ts +0 -66
- package/src/webhook.ts +0 -247
- package/src/whoami.test.ts +0 -67
- package/src/whoami.ts +0 -129
- package/tsconfig.json +0 -39
- package/vitest.config.ts +0 -8
package/src/org.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
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/out.test.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { out } from './out';
|
|
3
|
-
|
|
4
|
-
afterEach(() => {
|
|
5
|
-
out.json = false;
|
|
6
|
-
out.result = {};
|
|
7
|
-
vi.restoreAllMocks();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
describe('out', () => {
|
|
11
|
-
it('routes log to console.log in human mode', () => {
|
|
12
|
-
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
13
|
-
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
-
|
|
15
|
-
out.json = false;
|
|
16
|
-
out.log('hello', 1);
|
|
17
|
-
|
|
18
|
-
expect(log).toHaveBeenCalledWith('hello', 1);
|
|
19
|
-
expect(error).not.toHaveBeenCalled();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('routes log to console.error in json mode, keeping stdout free for the result', () => {
|
|
23
|
-
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
24
|
-
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
25
|
-
|
|
26
|
-
out.json = true;
|
|
27
|
-
out.log('hidden from stdout');
|
|
28
|
-
|
|
29
|
-
expect(error).toHaveBeenCalledWith('hidden from stdout');
|
|
30
|
-
expect(log).not.toHaveBeenCalled();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('accumulates result patches, later keys winning on conflict', () => {
|
|
34
|
-
out.result = {};
|
|
35
|
-
out.set({ a: 1, b: 2 });
|
|
36
|
-
out.set({ b: 3 });
|
|
37
|
-
|
|
38
|
-
expect(out.result).toEqual({ a: 1, b: 3 });
|
|
39
|
-
});
|
|
40
|
-
});
|
package/src/out.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared output router for every CLI command.
|
|
3
|
-
*
|
|
4
|
-
* Human mode: `out.log(...)` behaves exactly like `console.log(...)`.
|
|
5
|
-
* `--json` mode: `out.log(...)` is redirected to stderr (so stdout stays reserved
|
|
6
|
-
* for the single JSON result line) and each command accumulates its result via
|
|
7
|
-
* `out.set(...)`. `index.tsx`'s `runCommand` prints the final `out.result` object
|
|
8
|
-
* as one JSON line on stdout after the command succeeds (or an error envelope on
|
|
9
|
-
* failure) and sets `out.json` before running the command's action.
|
|
10
|
-
*/
|
|
11
|
-
export const out = {
|
|
12
|
-
json: false,
|
|
13
|
-
log: (...a: unknown[]) => (out.json ? console.error(...a) : console.log(...a)),
|
|
14
|
-
result: {} as Record<string, unknown>,
|
|
15
|
-
set: (patch: Record<string, unknown>) => Object.assign(out.result, patch),
|
|
16
|
-
};
|
package/src/phase-two.test.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { consentUrl } from './consent-contract';
|
|
3
|
-
import { personalizeClaimButton, validateDomains } from './embed';
|
|
4
|
-
import { PRODUCTION_NETWORK, STAGING_NETWORK } from './project';
|
|
5
|
-
import { closeWebhookReceiver, loadWebhookModule, webhookConfig } from './webhook';
|
|
6
|
-
|
|
7
|
-
describe('consent URL', () => {
|
|
8
|
-
it('encodes both parameters and selects the app for the network', () => {
|
|
9
|
-
for (const [network, origin] of [
|
|
10
|
-
[PRODUCTION_NETWORK, 'https://learncard.app'],
|
|
11
|
-
[STAGING_NETWORK, 'https://staging.learncard.ai'],
|
|
12
|
-
['http://localhost:4000/trpc', 'https://learncard.app'],
|
|
13
|
-
]) {
|
|
14
|
-
const url = new URL(
|
|
15
|
-
consentUrl('lc:network:contract?a&b', 'https://example.com/cb?a=1&b=2', network!)
|
|
16
|
-
);
|
|
17
|
-
expect(url.origin).toBe(origin);
|
|
18
|
-
expect(url.searchParams.get('uri')).toBe('lc:network:contract?a&b');
|
|
19
|
-
expect(url.searchParams.get('returnTo')).toBe('https://example.com/cb?a=1&b=2');
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('embed helpers', () => {
|
|
25
|
-
it('uses the network domain/origin validator, including its error message', () => {
|
|
26
|
-
expect(validateDomains('http://localhost:3000, example.com')).toEqual([
|
|
27
|
-
'http://localhost:3000',
|
|
28
|
-
'example.com',
|
|
29
|
-
]);
|
|
30
|
-
for (const invalid of [
|
|
31
|
-
'',
|
|
32
|
-
'https://example.com/path',
|
|
33
|
-
'file://example.com',
|
|
34
|
-
'https://*.example.com',
|
|
35
|
-
]) {
|
|
36
|
-
expect(() => validateDomains(invalid)).toThrow(/Must be a valid/);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
it('substitutes keys safely, including replacement and script-closing characters', () => {
|
|
40
|
-
const html = personalizeClaimButton("pk_$&'</script>", 'http://localhost:4000/api');
|
|
41
|
-
expect(html).not.toContain('PUBLISHABLE_KEY_PLACEHOLDER');
|
|
42
|
-
expect(html).toContain("pk_$&'\\u003c/script>");
|
|
43
|
-
expect(html).toContain('http://localhost:4000/api');
|
|
44
|
-
expect(html).toContain('LearnCard.init(');
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('canonical webhook helpers', () => {
|
|
49
|
-
it('resolves trusted DID and port from the project with explicit overrides', () => {
|
|
50
|
-
const env = { PORT: '9000', EXPECTED_NETWORK_DID: 'did:key:project' };
|
|
51
|
-
expect(webhookConfig(env, {}, {})).toEqual({ port: 9000, expectedDid: 'did:key:project' });
|
|
52
|
-
expect(
|
|
53
|
-
webhookConfig(
|
|
54
|
-
env,
|
|
55
|
-
{ port: '9001' },
|
|
56
|
-
{ PORT: '9002', EXPECTED_NETWORK_DID: 'did:key:runtime' }
|
|
57
|
-
)
|
|
58
|
-
).toEqual({ port: 9001, expectedDid: 'did:key:runtime' });
|
|
59
|
-
expect(() => webhookConfig({}, { port: '0' }, {})).toThrow('Port must be');
|
|
60
|
-
});
|
|
61
|
-
it('closes connections even when verification never completes', async () => {
|
|
62
|
-
const { createWebhookReceiver } = await loadWebhookModule();
|
|
63
|
-
let started!: () => void;
|
|
64
|
-
const verifying = new Promise<void>(resolve => {
|
|
65
|
-
started = resolve;
|
|
66
|
-
});
|
|
67
|
-
const server = createWebhookReceiver({
|
|
68
|
-
invoke: {
|
|
69
|
-
verifyPresentation: () => {
|
|
70
|
-
started();
|
|
71
|
-
return new Promise(() => {});
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
76
|
-
const address = server.address();
|
|
77
|
-
if (!address || typeof address === 'string') throw new Error('Expected TCP address');
|
|
78
|
-
const request = fetch(`http://127.0.0.1:${address.port}`, {
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers: { Authorization: 'Bearer pending' },
|
|
81
|
-
body: '{}',
|
|
82
|
-
}).catch(() => undefined);
|
|
83
|
-
await verifying;
|
|
84
|
-
await closeWebhookReceiver(server);
|
|
85
|
-
await request;
|
|
86
|
-
expect(server.listening).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
it('rejects invalid authentication and payloads, and acknowledges duplicate events once', async () => {
|
|
89
|
-
const { createWebhookReceiver } = await loadWebhookModule();
|
|
90
|
-
const verifyPresentation = vi.fn().mockResolvedValue({ errors: [] });
|
|
91
|
-
const server = createWebhookReceiver({ invoke: { verifyPresentation } }, 'did:key:trusted');
|
|
92
|
-
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
93
|
-
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
94
|
-
const address = server.address();
|
|
95
|
-
if (!address || typeof address === 'string') throw new Error('Expected TCP address');
|
|
96
|
-
const url = `http://127.0.0.1:${address.port}`;
|
|
97
|
-
const token = (iss: string): string =>
|
|
98
|
-
`header.${Buffer.from(JSON.stringify({ iss })).toString('base64url')}.signature`;
|
|
99
|
-
const post = (body: string, bearer = token('did:key:trusted')) =>
|
|
100
|
-
fetch(url, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: { Authorization: `Bearer ${bearer}` },
|
|
103
|
-
body,
|
|
104
|
-
});
|
|
105
|
-
try {
|
|
106
|
-
expect((await fetch(url, { method: 'POST' })).status).toBe(401);
|
|
107
|
-
expect((await post('{}', token('did:key:other'))).status).toBe(403);
|
|
108
|
-
verifyPresentation.mockResolvedValueOnce({ errors: ['invalid signature'] });
|
|
109
|
-
expect((await post('{}')).status).toBe(401);
|
|
110
|
-
expect((await post('{')).status).toBe(400);
|
|
111
|
-
expect((await post('{}')).status).toBe(400);
|
|
112
|
-
const data = { inbox: { issuanceId: 'same-id', status: 'PENDING' } };
|
|
113
|
-
const delivered = JSON.stringify({ type: 'ISSUANCE_DELIVERED', data });
|
|
114
|
-
expect((await post(delivered)).status).toBe(200);
|
|
115
|
-
expect((await post(delivered)).status).toBe(200);
|
|
116
|
-
expect((await post(JSON.stringify({ type: 'ISSUANCE_CLAIMED', data }))).status).toBe(
|
|
117
|
-
200
|
|
118
|
-
);
|
|
119
|
-
expect(log).toHaveBeenCalledTimes(2);
|
|
120
|
-
expect(verifyPresentation).toHaveBeenCalledWith(expect.any(String), {
|
|
121
|
-
proofFormat: 'jwt',
|
|
122
|
-
});
|
|
123
|
-
} finally {
|
|
124
|
-
log.mockRestore();
|
|
125
|
-
await new Promise<void>((resolve, reject) =>
|
|
126
|
-
server.close(error => (error ? reject(error) : resolve()))
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
it('extracts only bearer credentials', async () => {
|
|
131
|
-
const { extractBearer } = await loadWebhookModule();
|
|
132
|
-
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
|
133
|
-
expect(extractBearer('bearer token')).toBe('token');
|
|
134
|
-
for (const invalid of [undefined, null, [], 'Basic abc', 'Bearer', 'Bearer a b']) {
|
|
135
|
-
expect(extractBearer(invalid)).toBeUndefined();
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
it('deduplicates retries without conflating delivered and claimed', async () => {
|
|
139
|
-
const { webhookDedupeKey } = await loadWebhookModule();
|
|
140
|
-
const data = { inbox: { issuanceId: 'abc' } };
|
|
141
|
-
expect(webhookDedupeKey({ type: 'ISSUANCE_DELIVERED', data })).toBe(
|
|
142
|
-
'ISSUANCE_DELIVERED:abc'
|
|
143
|
-
);
|
|
144
|
-
expect(webhookDedupeKey({ type: 'ISSUANCE_CLAIMED', data })).toBe('ISSUANCE_CLAIMED:abc');
|
|
145
|
-
for (const invalid of [
|
|
146
|
-
null,
|
|
147
|
-
{},
|
|
148
|
-
{ type: 'OTHER', data },
|
|
149
|
-
{ type: 'ISSUANCE_DELIVERED', data: { inbox: {} } },
|
|
150
|
-
]) {
|
|
151
|
-
expect(webhookDedupeKey(invalid)).toBeUndefined();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
});
|
package/src/project.test.ts
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
assertProjectNetwork,
|
|
7
|
-
KEYS,
|
|
8
|
-
createPrompts,
|
|
9
|
-
ensureIdentity,
|
|
10
|
-
loadProject,
|
|
11
|
-
parseEnv,
|
|
12
|
-
resolveServices,
|
|
13
|
-
saveProject,
|
|
14
|
-
upsertEnv,
|
|
15
|
-
} from './project';
|
|
16
|
-
|
|
17
|
-
afterEach(() => vi.restoreAllMocks());
|
|
18
|
-
|
|
19
|
-
describe('createPrompts non-interactive behavior', () => {
|
|
20
|
-
const originalIsTTY = process.stdin.isTTY;
|
|
21
|
-
const originalLcYes = process.env.LC_YES;
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
25
|
-
if (originalLcYes === undefined) delete process.env.LC_YES;
|
|
26
|
-
else process.env.LC_YES = originalLcYes;
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns the fallback without opening a prompt when yes is true', async () => {
|
|
30
|
-
const prompts = createPrompts(true);
|
|
31
|
-
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
32
|
-
'My Organization'
|
|
33
|
-
);
|
|
34
|
-
prompts.close();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('returns the fallback when stdin is not a TTY, even without --yes', async () => {
|
|
38
|
-
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
39
|
-
const prompts = createPrompts(undefined);
|
|
40
|
-
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
41
|
-
'My Organization'
|
|
42
|
-
);
|
|
43
|
-
prompts.close();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('treats LC_YES=1 as non-interactive even when stdin looks like a real TTY', async () => {
|
|
47
|
-
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
48
|
-
process.env.LC_YES = '1';
|
|
49
|
-
const prompts = createPrompts(undefined);
|
|
50
|
-
await expect(prompts.ask('Display name', 'My Organization')).resolves.toBe(
|
|
51
|
-
'My Organization'
|
|
52
|
-
);
|
|
53
|
-
prompts.close();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('throws a descriptive error when non-interactive and no fallback exists', async () => {
|
|
57
|
-
const prompts = createPrompts(true);
|
|
58
|
-
await expect(prompts.ask('Recipient email (--to <email>)', '')).rejects.toThrow(
|
|
59
|
-
'Recipient email (--to <email>) is required when running non-interactively. Pass it as an argument or flag.'
|
|
60
|
-
);
|
|
61
|
-
prompts.close();
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
describe('project context', () => {
|
|
65
|
-
it('parses exported and commented seeds without changing identity', () => {
|
|
66
|
-
expect(
|
|
67
|
-
parseEnv('export SECURE_SEED="original" # keep\nPROFILE_ID=issuer # comment\n')
|
|
68
|
-
).toEqual({ SECURE_SEED: 'original', PROFILE_ID: 'issuer' });
|
|
69
|
-
expect(upsertEnv('export PROFILE_ID=old\n', { PROFILE_ID: 'new' })).toBe(
|
|
70
|
-
'PROFILE_ID=new\n'
|
|
71
|
-
);
|
|
72
|
-
expect(() => parseEnv('SECURE_SEED=one\nSECURE_SEED=two\n')).toThrow('Conflicting');
|
|
73
|
-
});
|
|
74
|
-
it('rejects network changes when resources are already saved', () => {
|
|
75
|
-
const project = {
|
|
76
|
-
existing: '',
|
|
77
|
-
envPath: '/unused/.env',
|
|
78
|
-
env: { TEMPLATE_URI: 'lc:template' },
|
|
79
|
-
};
|
|
80
|
-
expect(() =>
|
|
81
|
-
assertProjectNetwork(project, 'https://staging.network.learncard.com/trpc')
|
|
82
|
-
).toThrow('separate folder');
|
|
83
|
-
expect(() =>
|
|
84
|
-
assertProjectNetwork(project, 'https://network.learncard.com/trpc')
|
|
85
|
-
).not.toThrow();
|
|
86
|
-
});
|
|
87
|
-
it('documents the stable shared keys', () => {
|
|
88
|
-
expect(Object.keys(KEYS)).toEqual([
|
|
89
|
-
'SECURE_SEED',
|
|
90
|
-
'PROFILE_ID',
|
|
91
|
-
'DISPLAY_NAME',
|
|
92
|
-
'NETWORK_URL',
|
|
93
|
-
'SIGNING_AUTHORITY_NAME',
|
|
94
|
-
'SIGNING_AUTHORITY_ENDPOINT',
|
|
95
|
-
'API_TOKEN',
|
|
96
|
-
'API_TOKEN_SCOPE',
|
|
97
|
-
'TEMPLATE_URI',
|
|
98
|
-
'CONTRACT_URI',
|
|
99
|
-
'PUBLISHABLE_KEY',
|
|
100
|
-
'INTEGRATION_ID',
|
|
101
|
-
]);
|
|
102
|
-
expect(Object.values(KEYS)).toEqual(Object.keys(KEYS));
|
|
103
|
-
});
|
|
104
|
-
it('preserves unrelated lines and quotes scope lists', () => {
|
|
105
|
-
const result = upsertEnv('# keep\nOTHER=one\n', {
|
|
106
|
-
API_TOKEN_SCOPE: 'boosts:write inbox:read',
|
|
107
|
-
});
|
|
108
|
-
expect(result).toBe('# keep\nOTHER=one\nAPI_TOKEN_SCOPE="boosts:write inbox:read"\n');
|
|
109
|
-
expect(parseEnv(result).API_TOKEN_SCOPE).toBe('boosts:write inbox:read');
|
|
110
|
-
});
|
|
111
|
-
it('does not ask for a display name when a profile already exists', async () => {
|
|
112
|
-
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-'));
|
|
113
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
114
|
-
try {
|
|
115
|
-
await fs.writeFile(path.join(cwd, '.env'), 'SECURE_SEED=existing\nPROFILE_ID=issuer\n');
|
|
116
|
-
const project = await loadProject(cwd);
|
|
117
|
-
vi.spyOn(process.stdin, 'isTTY', 'get').mockReturnValue(true);
|
|
118
|
-
const identity = await Promise.race([
|
|
119
|
-
ensureIdentity(project, {}),
|
|
120
|
-
new Promise<never>((_, reject) =>
|
|
121
|
-
setTimeout(() => reject(new Error('prompted for a display name')), 500)
|
|
122
|
-
),
|
|
123
|
-
]);
|
|
124
|
-
expect(identity.profileId).toBe('issuer');
|
|
125
|
-
expect((await loadProject(cwd)).env.DISPLAY_NAME).toBeUndefined();
|
|
126
|
-
} finally {
|
|
127
|
-
vi.restoreAllMocks();
|
|
128
|
-
await fs.rm(cwd, { recursive: true, force: true });
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
it('accumulates keys, does not rewrite unchanged values, and never replaces a seed', async () => {
|
|
132
|
-
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-'));
|
|
133
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
134
|
-
try {
|
|
135
|
-
await fs.writeFile(
|
|
136
|
-
path.join(cwd, '.env'),
|
|
137
|
-
'# keep\nSECURE_SEED=existing\nPROFILE_ID=issuer\n'
|
|
138
|
-
);
|
|
139
|
-
await fs.writeFile(path.join(cwd, '.gitignore'), '.env\n');
|
|
140
|
-
const project = await loadProject(cwd);
|
|
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' });
|
|
145
|
-
expect(identity.seed).toBe('existing');
|
|
146
|
-
expect(identity.profileId).toBe('issuer');
|
|
147
|
-
expect(console.log).not.toHaveBeenCalled();
|
|
148
|
-
await saveProject(project, { TEMPLATE_URI: 'lc:template' });
|
|
149
|
-
await saveProject(project, { API_TOKEN: 'secret' });
|
|
150
|
-
expect((await fs.stat(project.envPath)).mode & 0o777).toBe(0o600);
|
|
151
|
-
expect((await loadProject(cwd)).env).toMatchObject({
|
|
152
|
-
SECURE_SEED: 'existing',
|
|
153
|
-
TEMPLATE_URI: 'lc:template',
|
|
154
|
-
API_TOKEN: 'secret',
|
|
155
|
-
});
|
|
156
|
-
expect(console.log).toHaveBeenCalledWith('Wrote TEMPLATE_URI to .env');
|
|
157
|
-
await expect(saveProject(project, { SECURE_SEED: 'replacement' })).rejects.toThrow(
|
|
158
|
-
'Cannot replace'
|
|
159
|
-
);
|
|
160
|
-
} finally {
|
|
161
|
-
await fs.rm(cwd, { recursive: true, force: true });
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
it('refuses stale identity writes and symlinked env files', async () => {
|
|
165
|
-
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-project-safety-'));
|
|
166
|
-
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
167
|
-
try {
|
|
168
|
-
const stale = await loadProject(cwd);
|
|
169
|
-
const current = await loadProject(cwd);
|
|
170
|
-
await saveProject(current, { SECURE_SEED: 'original' });
|
|
171
|
-
await expect(saveProject(stale, { SECURE_SEED: 'different' })).rejects.toThrow(
|
|
172
|
-
'Cannot replace'
|
|
173
|
-
);
|
|
174
|
-
expect((await loadProject(cwd)).env.SECURE_SEED).toBe('original');
|
|
175
|
-
await fs.rename(current.envPath, path.join(cwd, 'target'));
|
|
176
|
-
await fs.symlink(path.join(cwd, 'target'), current.envPath);
|
|
177
|
-
await expect(loadProject(cwd)).rejects.toThrow('symlink');
|
|
178
|
-
} finally {
|
|
179
|
-
log.mockRestore();
|
|
180
|
-
await fs.rm(cwd, { recursive: true, force: true });
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
it('resolves staging services together and preserves explicit local LCA URLs', () => {
|
|
184
|
-
expect(resolveServices({}, 'staging', {})).toEqual({
|
|
185
|
-
network: 'https://staging.network.learncard.com/trpc',
|
|
186
|
-
cloud: 'https://staging.cloud.learncard.com/trpc',
|
|
187
|
-
lcaAPI: 'https://staging.api.learncard.app/trpc',
|
|
188
|
-
});
|
|
189
|
-
expect(
|
|
190
|
-
resolveServices({ NETWORK_URL: 'http://localhost:4000/trpc' }, undefined, {
|
|
191
|
-
LCA_API_URL: 'http://localhost:5200/api',
|
|
192
|
-
}).lcaAPI
|
|
193
|
-
).toBe('http://localhost:5200/api');
|
|
194
|
-
expect(() => resolveServices({}, 'file:///tmp/network', {})).toThrow('HTTP');
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe('upsertEnv value quoting', () => {
|
|
199
|
-
it('writes percent-encoded network URIs bare so shell scripts can read them', () => {
|
|
200
|
-
expect(upsertEnv('', { TEMPLATE_URI: 'lc:network:localhost%3A4000/trpc:boost:abc' })).toBe(
|
|
201
|
-
'TEMPLATE_URI=lc:network:localhost%3A4000/trpc:boost:abc\n'
|
|
202
|
-
);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('quotes values with spaces or shell-significant characters', () => {
|
|
206
|
-
expect(upsertEnv('', { API_TOKEN_SCOPE: 'boosts:write inbox:read' })).toBe(
|
|
207
|
-
'API_TOKEN_SCOPE="boosts:write inbox:read"\n'
|
|
208
|
-
);
|
|
209
|
-
});
|
|
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
|
-
});
|