@learncard/cli 3.5.1 → 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 +44 -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/send.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
5
|
connect,
|
|
6
|
+
connectAsManaged,
|
|
6
7
|
createPrompts,
|
|
7
8
|
ensureIdentity,
|
|
8
9
|
ensureProfile,
|
|
@@ -54,6 +55,7 @@ export const templateCredential = (issuerDid: string, badge: Badge = DEFAULT_BAD
|
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
type SendOptions = ProjectOptions & {
|
|
58
|
+
as?: string;
|
|
57
59
|
badge?: string;
|
|
58
60
|
description?: string;
|
|
59
61
|
template?: boolean;
|
|
@@ -120,20 +122,113 @@ export const personalizeSendFromTemplateMjs = (deliveryOptions?: SendDeliveryOpt
|
|
|
120
122
|
deliveryOptions
|
|
121
123
|
);
|
|
122
124
|
|
|
125
|
+
/** Bind a generated script to its managed issuer, never the parent seed identity. */
|
|
126
|
+
export const withManagedIssuer = (content: string, managedDid: string): string =>
|
|
127
|
+
content
|
|
128
|
+
.replace(
|
|
129
|
+
'const learnCard = await initLearnCard(',
|
|
130
|
+
`if (process.env.MANAGED_DID !== ${JSON.stringify(managedDid)}) {
|
|
131
|
+
throw new Error('MANAGED_DID is missing or changed. Re-run the CLI with --as to send as the intended profile.');
|
|
132
|
+
}
|
|
133
|
+
const learnCard = await initLearnCard(`
|
|
134
|
+
)
|
|
135
|
+
.replace(
|
|
136
|
+
'seed: process.env.SECURE_SEED,',
|
|
137
|
+
'seed: process.env.SECURE_SEED, didWeb: process.env.MANAGED_DID,'
|
|
138
|
+
)
|
|
139
|
+
.replace(
|
|
140
|
+
/if \(!\(await learnCard\.invoke\.getProfile\(\)\)\) \{[\s\S]*?\n\}/,
|
|
141
|
+
`if (!(await learnCard.invoke.getProfile())) {
|
|
142
|
+
throw new Error('The managed issuer profile could not be found.');
|
|
143
|
+
}`
|
|
144
|
+
);
|
|
145
|
+
|
|
123
146
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
124
147
|
const PHONE = /^\+?\d{10,15}$/;
|
|
148
|
+
const DID = /^did:[a-z0-9]+:.+$/;
|
|
149
|
+
const PROFILE_ID = /^[a-z0-9-]{3,40}$/;
|
|
150
|
+
|
|
151
|
+
export type RecipientKind = 'email' | 'phone' | 'did' | 'profileId';
|
|
152
|
+
|
|
153
|
+
export const classifyRecipient = (value: string): RecipientKind => {
|
|
154
|
+
if (EMAIL.test(value)) return 'email';
|
|
155
|
+
if (PHONE.test(value)) return 'phone';
|
|
156
|
+
if (DID.test(value)) return 'did';
|
|
157
|
+
if (PROFILE_ID.test(value)) return 'profileId';
|
|
158
|
+
throw new Error(
|
|
159
|
+
`"${value}" is not an email, phone number, profile ID, or DID. Example: npx @learncard/cli send you@yourdomain.com`
|
|
160
|
+
);
|
|
161
|
+
};
|
|
125
162
|
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
/** Domains reserved for documentation (RFC 2606 / RFC 6761). Mail to them goes nowhere. */
|
|
164
|
+
const PLACEHOLDER_DOMAIN = /(^|\.)example\.(com|net|org)$|(^|\.)(example|test|invalid|localhost)$/i;
|
|
165
|
+
|
|
166
|
+
export const RECIPIENT_PROMPT =
|
|
167
|
+
'Where should we send your first badge? (email, phone number, profile ID, or DID)';
|
|
168
|
+
|
|
169
|
+
export const isPlaceholderRecipient = (recipient: string): boolean => {
|
|
170
|
+
const at = recipient.lastIndexOf('@');
|
|
171
|
+
return at !== -1 && PLACEHOLDER_DOMAIN.test(recipient.slice(at + 1));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns a human-readable reason `recipient` cannot receive a badge, or `undefined` if it can.
|
|
176
|
+
* Placeholder addresses like you@example.com pass a naive email check but are undeliverable,
|
|
177
|
+
* so they are called out specifically — docs readers paste them verbatim.
|
|
178
|
+
*/
|
|
179
|
+
export const invalidRecipientReason = (recipient: string): string | undefined => {
|
|
180
|
+
if (!recipient) return 'Enter an email, phone number, profile ID, or DID.';
|
|
181
|
+
if (EMAIL.test(recipient) && isPlaceholderRecipient(recipient))
|
|
182
|
+
return `"${recipient}" is a placeholder address — nobody will receive the badge. Use a real email you can open.`;
|
|
183
|
+
if (
|
|
184
|
+
!EMAIL.test(recipient) &&
|
|
185
|
+
!PHONE.test(recipient) &&
|
|
186
|
+
!DID.test(recipient) &&
|
|
187
|
+
!PROFILE_ID.test(recipient)
|
|
188
|
+
)
|
|
189
|
+
return `"${recipient}" is not an email, phone number, profile ID, or DID.`;
|
|
190
|
+
return undefined;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
type Prompts = ReturnType<typeof createPrompts>;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Settle on a deliverable recipient. When a human is at the terminal, bad or missing input
|
|
197
|
+
* re-prompts instead of failing; non-interactive runs throw a clear error.
|
|
198
|
+
*/
|
|
199
|
+
export const resolveRecipient = async (
|
|
200
|
+
recipient: string | undefined,
|
|
201
|
+
prompts: Prompts
|
|
202
|
+
): Promise<string> => {
|
|
203
|
+
let candidate = recipient?.trim() ?? '';
|
|
204
|
+
if (!candidate && !prompts.interactive)
|
|
128
205
|
throw new Error(
|
|
129
|
-
|
|
206
|
+
'A recipient is required when running non-interactively. Example: npx @learncard/cli send you@yourdomain.com --yes'
|
|
130
207
|
);
|
|
208
|
+
if (!candidate) candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
|
|
209
|
+
let reason = invalidRecipientReason(candidate);
|
|
210
|
+
while (reason) {
|
|
211
|
+
if (!prompts.interactive)
|
|
212
|
+
throw new Error(`${reason} Example: npx @learncard/cli send you@yourdomain.com`);
|
|
213
|
+
out.log(reason);
|
|
214
|
+
candidate = (await prompts.ask(RECIPIENT_PROMPT, '')).trim();
|
|
215
|
+
reason = invalidRecipientReason(candidate);
|
|
216
|
+
}
|
|
217
|
+
return candidate;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const runSend = async (
|
|
221
|
+
recipient: string | undefined,
|
|
222
|
+
options: SendOptions
|
|
223
|
+
): Promise<void> => {
|
|
131
224
|
const cwd = process.cwd();
|
|
132
225
|
const project = await loadProject(cwd);
|
|
133
226
|
const prompts = createPrompts(options.yes);
|
|
227
|
+
let resolvedRecipient: string;
|
|
134
228
|
let displayName: string | undefined;
|
|
135
229
|
let badge: Badge;
|
|
136
230
|
try {
|
|
231
|
+
resolvedRecipient = await resolveRecipient(recipient, prompts);
|
|
137
232
|
const needsName = !project.env.PROFILE_ID && !options.profileId && !options.name;
|
|
138
233
|
displayName = needsName
|
|
139
234
|
? await prompts.ask('Display name for your issuer profile', 'My Organization')
|
|
@@ -145,12 +240,34 @@ export const runSend = async (recipientEmail: string, options: SendOptions): Pro
|
|
|
145
240
|
} finally {
|
|
146
241
|
prompts.close();
|
|
147
242
|
}
|
|
243
|
+
const recipientKind = classifyRecipient(resolvedRecipient);
|
|
148
244
|
const identity = await ensureIdentity(project, { ...options, name: displayName, yes: true });
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
245
|
+
const asManaged = options.as ? await connectAsManaged(project, options, options.as) : undefined;
|
|
246
|
+
const hostedSigning = !!project.env.SIGNING_AUTHORITY_NAME;
|
|
247
|
+
const explicitTemplate = options.templateUri ? true : options.template;
|
|
248
|
+
const useTemplate = !asManaged && (explicitTemplate ?? hostedSigning);
|
|
249
|
+
if (useTemplate && explicitTemplate === undefined) {
|
|
250
|
+
out.log(
|
|
251
|
+
`Signing through the registered signing authority "${project.env.SIGNING_AUTHORITY_NAME}" (pass --no-template to sign with the local key instead).`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (asManaged && explicitTemplate) {
|
|
255
|
+
out.log("--as signs with the managed profile's key; --template is ignored.");
|
|
256
|
+
}
|
|
257
|
+
const learnCard = asManaged
|
|
258
|
+
? asManaged
|
|
259
|
+
: useTemplate
|
|
260
|
+
? await connect(project, { ...options, lca: true })
|
|
261
|
+
: await connect(project, options);
|
|
262
|
+
if (asManaged) {
|
|
263
|
+
const managed = await learnCard.invoke.getProfile();
|
|
264
|
+
if (!managed) throw new Error(`Could not sign in as managed profile "${options.as}".`);
|
|
265
|
+
out.log(
|
|
266
|
+
`Acting as "${managed.displayName}" (${managed.profileId}) — a profile you manage.`
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
await ensureProfile(learnCard, identity, project);
|
|
270
|
+
}
|
|
154
271
|
|
|
155
272
|
const effectiveSendOptions = sendOptions(options);
|
|
156
273
|
let result;
|
|
@@ -184,7 +301,7 @@ export const runSend = async (recipientEmail: string, options: SendOptions): Pro
|
|
|
184
301
|
}
|
|
185
302
|
result = await learnCard.invoke.send({
|
|
186
303
|
type: 'boost',
|
|
187
|
-
recipient:
|
|
304
|
+
recipient: resolvedRecipient,
|
|
188
305
|
templateUri: options.templateUri ?? project.env.TEMPLATE_URI!,
|
|
189
306
|
...effectiveSendOptions,
|
|
190
307
|
});
|
|
@@ -194,7 +311,7 @@ export const runSend = async (recipientEmail: string, options: SendOptions): Pro
|
|
|
194
311
|
);
|
|
195
312
|
result = await learnCard.invoke.send({
|
|
196
313
|
type: 'boost',
|
|
197
|
-
recipient:
|
|
314
|
+
recipient: resolvedRecipient,
|
|
198
315
|
signedCredential: credential,
|
|
199
316
|
...effectiveSendOptions,
|
|
200
317
|
});
|
|
@@ -202,11 +319,15 @@ export const runSend = async (recipientEmail: string, options: SendOptions): Pro
|
|
|
202
319
|
out.log('');
|
|
203
320
|
if (result.inbox?.status === 'PENDING') {
|
|
204
321
|
out.log(
|
|
205
|
-
`Sent. ${
|
|
322
|
+
`Sent. ${resolvedRecipient} will get a claim ${recipientKind === 'phone' ? 'text' : 'email'}. You can also share this link directly:\n${result.inbox.claimUrl}`
|
|
323
|
+
);
|
|
324
|
+
} else if (recipientKind === 'email' || recipientKind === 'phone') {
|
|
325
|
+
out.log(
|
|
326
|
+
`Delivered. ${resolvedRecipient} already uses LearnCard — the credential is in their wallet.`
|
|
206
327
|
);
|
|
207
328
|
} else {
|
|
208
329
|
out.log(
|
|
209
|
-
`Delivered
|
|
330
|
+
`Delivered directly to ${resolvedRecipient} — it is waiting in their LearnCard wallet.`
|
|
210
331
|
);
|
|
211
332
|
}
|
|
212
333
|
out.log(`Reusable template for this badge: ${result.uri}`);
|
|
@@ -214,26 +335,33 @@ export const runSend = async (recipientEmail: string, options: SendOptions): Pro
|
|
|
214
335
|
const sendPath = path.join(cwd, filename);
|
|
215
336
|
let wroteSendFile = false;
|
|
216
337
|
if (!(await fs.stat(sendPath).catch(() => null))) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
338
|
+
let content = useTemplate
|
|
339
|
+
? personalizeSendFromTemplateMjs(effectiveSendOptions.options)
|
|
340
|
+
: personalizeSendMjs(identity.displayName, badge, effectiveSendOptions.options);
|
|
341
|
+
content = localizeSnippet(content, resolveServices(project.env, options.network));
|
|
342
|
+
if (asManaged) {
|
|
343
|
+
const managedDid = learnCard.id.did();
|
|
344
|
+
await saveProject(project, { MANAGED_DID: managedDid });
|
|
345
|
+
content = withManagedIssuer(content, managedDid);
|
|
346
|
+
}
|
|
347
|
+
await fs.writeFile(sendPath, content);
|
|
226
348
|
wroteSendFile = true;
|
|
227
349
|
out.log(
|
|
228
|
-
`\nThe code that just ran is in ./${filename} — run it yourself:\n npm install @learncard/init\n node --env-file=.env ${filename} ${
|
|
350
|
+
`\nThe code that just ran is in ./${filename} — run it yourself:\n npm install @learncard/init\n node --env-file=.env ${filename} ${resolvedRecipient}`
|
|
351
|
+
);
|
|
352
|
+
} else if (asManaged) {
|
|
353
|
+
out.log(
|
|
354
|
+
`Existing ./${filename} was not changed and may use a different issuer. Repeat this send with the CLI --as ${options.as} instead.`
|
|
229
355
|
);
|
|
230
356
|
}
|
|
231
357
|
out.log(`Check whether it was claimed: npx @learncard/cli status ${result.activityId}`);
|
|
232
358
|
out.log(`See it in the app: npx @learncard/cli open${options.template ? ' template' : ''}`);
|
|
233
359
|
out.set({
|
|
234
|
-
profileId: identity.profileId,
|
|
360
|
+
profileId: options.as ?? identity.profileId,
|
|
361
|
+
...(options.as && { onBehalfOf: identity.profileId }),
|
|
235
362
|
did: learnCard.id.did(),
|
|
236
|
-
recipient:
|
|
363
|
+
recipient: resolvedRecipient,
|
|
364
|
+
recipientKind,
|
|
237
365
|
status: result.inbox?.status === 'PENDING' ? 'PENDING' : 'ISSUED',
|
|
238
366
|
...(result.inbox?.claimUrl && { claimUrl: result.inbox.claimUrl }),
|
|
239
367
|
templateUri: result.uri,
|
package/src/setup-signing.ts
CHANGED
|
@@ -180,6 +180,6 @@ export const runSetupSigning = async (options: SetupSigningOptions): Promise<voi
|
|
|
180
180
|
alreadyConfigured: authority.alreadyConfigured,
|
|
181
181
|
});
|
|
182
182
|
out.log(
|
|
183
|
-
'Send from a template: npx @learncard/cli send
|
|
183
|
+
'Send from a template: npx @learncard/cli send --template\nSee it in the app: npx @learncard/cli open'
|
|
184
184
|
);
|
|
185
185
|
};
|
package/src/status.ts
CHANGED
|
@@ -46,9 +46,7 @@ type StatusOptions = ProjectOptions & { limit?: string; event?: string };
|
|
|
46
46
|
export const runStatus = async (activityId: string | undefined, options: StatusOptions) => {
|
|
47
47
|
const project = await loadProject(process.cwd());
|
|
48
48
|
if (!project.env.SECURE_SEED)
|
|
49
|
-
throw new Error(
|
|
50
|
-
'No SECURE_SEED in .env. Send something first: npx @learncard/cli send you@example.com'
|
|
51
|
-
);
|
|
49
|
+
throw new Error('No SECURE_SEED in .env. Send something first: npx @learncard/cli send');
|
|
52
50
|
await ensureIdentity(project, options);
|
|
53
51
|
const learnCard = await connect(project, options);
|
|
54
52
|
|
|
@@ -73,7 +71,7 @@ export const runStatus = async (activityId: string | undefined, options: StatusO
|
|
|
73
71
|
});
|
|
74
72
|
const records = page.records as ActivityEvent[];
|
|
75
73
|
if (!records.length) {
|
|
76
|
-
out.log('No sends yet. Try: npx @learncard/cli send
|
|
74
|
+
out.log('No sends yet. Try: npx @learncard/cli send');
|
|
77
75
|
out.set({ activities: [] });
|
|
78
76
|
return;
|
|
79
77
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
|
|
4
|
+
import { registerWhoamiCommand, summarizeServiceAccounts } from './whoami';
|
|
5
|
+
import type { AuthGrantWithActAs } from './auth-grant';
|
|
6
|
+
|
|
7
|
+
describe('whoami command', () => {
|
|
8
|
+
it('registers with --network and --json only', () => {
|
|
9
|
+
const program = new Command();
|
|
10
|
+
registerWhoamiCommand(program, async () => {});
|
|
11
|
+
const cmd = program.commands.find(c => c.name() === 'whoami');
|
|
12
|
+
expect(cmd).toBeDefined();
|
|
13
|
+
expect(
|
|
14
|
+
program
|
|
15
|
+
.createHelp()
|
|
16
|
+
.visibleOptions(cmd!)
|
|
17
|
+
.map(o => o.long)
|
|
18
|
+
.filter(l => l !== '--help')
|
|
19
|
+
.sort()
|
|
20
|
+
).toEqual(['--json', '--network']);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('summarizeServiceAccounts', () => {
|
|
25
|
+
it('shapes active grants as { name, scope, actAs } for --json, dropping revoked ones', () => {
|
|
26
|
+
const grants: AuthGrantWithActAs[] = [
|
|
27
|
+
{
|
|
28
|
+
id: 'g1',
|
|
29
|
+
name: 'ea-clr-issuer',
|
|
30
|
+
status: 'active',
|
|
31
|
+
scope: 'inbox:write',
|
|
32
|
+
actAs: 'sc-greenville,sc-north',
|
|
33
|
+
},
|
|
34
|
+
{ id: 'g2', name: 'star-issuer', status: 'active', scope: 'inbox:write', actAs: '*' },
|
|
35
|
+
{ id: 'g3', name: 'no-delegation', status: 'active', scope: 'inbox:write' },
|
|
36
|
+
{ id: 'g4', name: 'old-issuer', status: 'revoked', scope: 'inbox:write', actAs: '*' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
expect(summarizeServiceAccounts(grants)).toEqual([
|
|
40
|
+
{ name: 'ea-clr-issuer', scope: 'inbox:write', actAs: 'sc-greenville,sc-north' },
|
|
41
|
+
{ name: 'star-issuer', scope: 'inbox:write', actAs: '*' },
|
|
42
|
+
{ name: 'no-delegation', scope: 'inbox:write', actAs: undefined },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns an empty array when there are no grants', () => {
|
|
47
|
+
expect(summarizeServiceAccounts([])).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('LEARNCARD_AS', () => {
|
|
52
|
+
it('is the env fallback for --as on inbox list', async () => {
|
|
53
|
+
const { registerInboxCommand } = await import('./inbox');
|
|
54
|
+
const program = new Command().exitOverride();
|
|
55
|
+
let seen: Record<string, unknown> | undefined;
|
|
56
|
+
registerInboxCommand(program, async (_cmd, options) => {
|
|
57
|
+
seen = options as Record<string, unknown>;
|
|
58
|
+
});
|
|
59
|
+
process.env.LEARNCARD_AS = 'cs-exampleville';
|
|
60
|
+
try {
|
|
61
|
+
await program.parseAsync(['node', 'learncard', 'inbox', 'list']);
|
|
62
|
+
} finally {
|
|
63
|
+
delete process.env.LEARNCARD_AS;
|
|
64
|
+
}
|
|
65
|
+
expect(seen?.as).toBe('cs-exampleville');
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/whoami.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { AuthGrantType } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
connect,
|
|
6
|
+
connectAsDidWeb,
|
|
7
|
+
loadProject,
|
|
8
|
+
resolveServices,
|
|
9
|
+
type ProjectOptions,
|
|
10
|
+
} from './project';
|
|
11
|
+
import { out } from './out';
|
|
12
|
+
import type { RunCommand } from './doctor';
|
|
13
|
+
import { describeActAs, getGrantActAs } from './auth-grant';
|
|
14
|
+
|
|
15
|
+
export interface ManagedSummary {
|
|
16
|
+
profileId: string;
|
|
17
|
+
displayName: string;
|
|
18
|
+
did: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ServiceAccountSummary {
|
|
22
|
+
name: string;
|
|
23
|
+
scope: string;
|
|
24
|
+
actAs?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Only active grants are relevant here; revoked ones are noise for this summary. */
|
|
28
|
+
export const summarizeServiceAccounts = (
|
|
29
|
+
grants: Array<Partial<AuthGrantType>>
|
|
30
|
+
): ServiceAccountSummary[] =>
|
|
31
|
+
grants
|
|
32
|
+
.filter(grant => grant.status === 'active')
|
|
33
|
+
.map(grant => ({
|
|
34
|
+
name: grant.name ?? '',
|
|
35
|
+
scope: grant.scope ?? '',
|
|
36
|
+
actAs: getGrantActAs(grant),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
export const runWhoami = async (options: ProjectOptions): Promise<void> => {
|
|
40
|
+
const project = await loadProject(process.cwd());
|
|
41
|
+
if (!project.env.SECURE_SEED) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'No identity in this folder. Run `npx @learncard/cli send you@example.com` or `org apply` first.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const services = resolveServices(project.env, options.network);
|
|
47
|
+
const learnCard = await connect(project, options);
|
|
48
|
+
const profile = await learnCard.invoke.getProfile();
|
|
49
|
+
|
|
50
|
+
out.log(
|
|
51
|
+
profile
|
|
52
|
+
? `You are "${profile.displayName}" (${profile.profileId}) on ${services.network}`
|
|
53
|
+
: `Seed present but no profile on ${services.network} yet (PROFILE_ID=${project.env.PROFILE_ID ?? '?'}).`
|
|
54
|
+
);
|
|
55
|
+
out.log(` did: ${learnCard.id.did()}`);
|
|
56
|
+
if (project.env.SIGNING_AUTHORITY_NAME)
|
|
57
|
+
out.log(` signing authority: ${project.env.SIGNING_AUTHORITY_NAME}`);
|
|
58
|
+
|
|
59
|
+
const managerDid = project.env.ORG_PROFILE_MANAGER_DID;
|
|
60
|
+
const managed: ManagedSummary[] = [];
|
|
61
|
+
if (managerDid) {
|
|
62
|
+
const manager = await connectAsDidWeb(project, options, managerDid);
|
|
63
|
+
let cursor: string | undefined;
|
|
64
|
+
do {
|
|
65
|
+
const page = await manager.invoke.getManagedProfiles({ limit: 100, cursor });
|
|
66
|
+
for (const record of page.records) {
|
|
67
|
+
managed.push({
|
|
68
|
+
profileId: record.profileId,
|
|
69
|
+
displayName: record.displayName,
|
|
70
|
+
did: record.did,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
cursor = page.hasMore ? (page.cursor ?? undefined) : undefined;
|
|
74
|
+
} while (cursor);
|
|
75
|
+
|
|
76
|
+
out.log(` manager: ${managerDid}`);
|
|
77
|
+
if (managed.length) {
|
|
78
|
+
out.log('You can act as (--as <profileId> or LEARNCARD_AS=<profileId>):');
|
|
79
|
+
for (const entry of managed)
|
|
80
|
+
out.log(` ${entry.profileId.padEnd(24)} ${entry.displayName}`);
|
|
81
|
+
} else {
|
|
82
|
+
out.log(
|
|
83
|
+
' no managed profiles yet — add them under profileManager.managed and run `org apply`.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const grants = profile ? ((await learnCard.invoke.getAuthGrants()) ?? []) : [];
|
|
89
|
+
const serviceAccounts = summarizeServiceAccounts(grants);
|
|
90
|
+
if (serviceAccounts.length) {
|
|
91
|
+
out.log('Service accounts:');
|
|
92
|
+
const nameWidth = Math.max(...serviceAccounts.map(entry => entry.name.length));
|
|
93
|
+
const scopeWidth = Math.max(...serviceAccounts.map(entry => entry.scope.length));
|
|
94
|
+
for (const entry of serviceAccounts)
|
|
95
|
+
out.log(
|
|
96
|
+
` ${entry.name.padEnd(nameWidth)} ${entry.scope.padEnd(scopeWidth)} ${describeActAs(entry.actAs)}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (process.env.LEARNCARD_AS)
|
|
101
|
+
out.log(
|
|
102
|
+
`LEARNCARD_AS is set: commands that support --as will act as "${process.env.LEARNCARD_AS}".`
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
out.set({
|
|
106
|
+
network: services.network,
|
|
107
|
+
profileId: profile?.profileId ?? project.env.PROFILE_ID,
|
|
108
|
+
displayName: profile?.displayName,
|
|
109
|
+
did: learnCard.id.did(),
|
|
110
|
+
signingAuthority: project.env.SIGNING_AUTHORITY_NAME,
|
|
111
|
+
managerDid,
|
|
112
|
+
managed,
|
|
113
|
+
serviceAccounts,
|
|
114
|
+
actingAs: process.env.LEARNCARD_AS,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const registerWhoamiCommand = (program: Command, run: RunCommand): void => {
|
|
119
|
+
program
|
|
120
|
+
.command('whoami')
|
|
121
|
+
.description('Show the identity in this folder and the managed profiles --as can target.')
|
|
122
|
+
.option('--network <url>', 'network tRPC URL or staging (default: production)')
|
|
123
|
+
.option('--json', 'print a single JSON result on stdout')
|
|
124
|
+
.action(options =>
|
|
125
|
+
run('whoami', options, async didkit => {
|
|
126
|
+
await runWhoami({ ...options, didkit });
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
};
|