@veilio-inc/cli 0.1.0 → 0.3.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.
@@ -0,0 +1,656 @@
1
+ // The commands that reach the network: login, logout, whoami.
2
+ //
3
+ // Deliberately NOT in commands.ts. That module is the local command path, and
4
+ // `offline.test.ts` walks its import graph and fails if it can reach `cloud.ts`
5
+ // — which is the structural half of the promise that `veilio scrub` on a laptop
6
+ // with no account touches nothing (FR-002). Putting these here is what keeps
7
+ // that walk meaningful instead of something to be deleted the moment it becomes
8
+ // inconvenient.
9
+ import { CloudError, createMap, getMap, getVault, getUserKeys, getTeamKeyWraps, listMaps, listRules, request, } from './cloud.js';
10
+ import { checkVaultVerifier, decryptMapFromVault, deriveVaultKey, unwrapPrivateKey, unwrapTeamKey, exportTeamKey, encryptMapForVault, fromBase64, parseVaultEnvelope, importTeamKey, decryptTeamMapWithAny, } from '@veilio-inc/engine';
11
+ import { loadMap, loadRemote, saveMap } from './store.js';
12
+ import { readTeamUnlock, removeTeamUnlock, writeTeamUnlock, teamUnlockPath, expiryFrom, } from './team-unlock.js';
13
+ import { readCredential, removeCredential, writeCredential } from './credential.js';
14
+ import { mergeRules, removeRules, rulesPath, writeRules } from './rules.js';
15
+ import { EXIT_ERROR, EXIT_OK } from './commands.js';
16
+ import { DEFAULT_INSTANCE } from './cloud.js';
17
+ /** The endpoint a sign-in is checked against. See `runLogin`. */
18
+ const ENTITLED_PROBE = '/api/maps';
19
+ /**
20
+ * Sign in, and store the credential only if the account can actually use it.
21
+ *
22
+ * Two requests, and the second one is the interesting one.
23
+ *
24
+ * `POST /api/auth/login` answers "is this the right password". It does NOT
25
+ * answer "does this plan include the terminal" — a free account signs in
26
+ * perfectly well, because signing in is how you reach the billing page. So a
27
+ * CLI that stopped after the first request would store a credential for an
28
+ * account that gets refused by every command that follows, and would report
29
+ * that refusal as though the sign-in had worked.
30
+ *
31
+ * The obvious shortcut is to read `user.plan` out of the login response and
32
+ * decide locally. That is exactly what FR-009 forbids: entitlement is the
33
+ * server's answer on each request, never the client's reading of a plan name.
34
+ * A copy of the rule shipped to other people's laptops is a second definition of
35
+ * it, and the one that goes stale first.
36
+ *
37
+ * So the second request asks the server a real question and lets it refuse. Only
38
+ * then is anything written — validate, then persist (Constitution IV). A
39
+ * credential written first and validated after leaves a file behind for an
40
+ * attempt that failed, and everything looks signed in until the first command
41
+ * that isn't.
42
+ */
43
+ export async function runLogin(instance, io) {
44
+ const base = instance ?? DEFAULT_INSTANCE;
45
+ const account = (await requirePrompt(io, `Email for ${base}: `)).trim();
46
+ if (account === '') {
47
+ io.stderr('veilio: no email given\n');
48
+ return EXIT_ERROR;
49
+ }
50
+ const password = await requirePassword(io, 'Password: ');
51
+ if (password === '') {
52
+ io.stderr('veilio: no password given\n');
53
+ return EXIT_ERROR;
54
+ }
55
+ let session;
56
+ try {
57
+ session = await request('/api/auth/login', {
58
+ instance: base,
59
+ method: 'POST',
60
+ body: { email: account, password },
61
+ });
62
+ }
63
+ catch (err) {
64
+ return reportLoginFailure(err, io);
65
+ }
66
+ // A second factor. The token that came back is a challenge, not a session:
67
+ // storing it made every later command fail, and the failure read as a wrong
68
+ // password (found on staging, 2026-09-26). Ask for the code and complete the
69
+ // sign-in the way the web app does.
70
+ if (session.secondFactorRequired === true) {
71
+ const code = (await requirePrompt(io, 'Authentication code (or a recovery code): ')).trim();
72
+ if (code === '') {
73
+ io.stderr('veilio: no authentication code given\n');
74
+ return EXIT_ERROR;
75
+ }
76
+ try {
77
+ session = await request('/api/auth/2fa/verify', {
78
+ credential: { token: session.token, account, instance: base },
79
+ method: 'POST',
80
+ body: { code },
81
+ });
82
+ }
83
+ catch (err) {
84
+ if (err instanceof CloudError && err.kind === 'unauthenticated') {
85
+ io.stderr('veilio: that authentication code was not accepted, or it expired. ' +
86
+ 'Run `veilio login` again with a fresh code.\n');
87
+ return EXIT_ERROR;
88
+ }
89
+ return reportLoginFailure(err, io);
90
+ }
91
+ }
92
+ if (typeof session.token !== 'string' || session.token === '') {
93
+ // A 200 with no token is not a sign-in. Writing an empty credential here
94
+ // would produce a "signed in" state that fails on every use.
95
+ io.stderr(`veilio: ${base} accepted the sign-in but returned no session\n`);
96
+ return EXIT_ERROR;
97
+ }
98
+ const credential = {
99
+ token: session.token,
100
+ account: session.user?.email ?? account,
101
+ instance: base,
102
+ };
103
+ try {
104
+ await request(ENTITLED_PROBE, { credential });
105
+ }
106
+ catch (err) {
107
+ return reportLoginFailure(err, io);
108
+ }
109
+ writeCredential(credential, io.home);
110
+ io.stdout(`Signed in to ${base} as ${credential.account}\n`);
111
+ return EXIT_OK;
112
+ }
113
+ /**
114
+ * Sign out: revoke server-side, THEN remove the file.
115
+ *
116
+ * The order is the control. Removing the file first makes `logout` always look
117
+ * like it worked, while leaving a live session on the server that the user has
118
+ * been told is gone — and nothing anywhere reports that. So a failed revocation
119
+ * keeps the credential and says so, which is the loud failure rather than the
120
+ * quiet one (Constitution V).
121
+ */
122
+ export async function runLogout(io) {
123
+ const credential = readCredential(io.home);
124
+ if (!credential) {
125
+ io.stdout('Not signed in.\n');
126
+ return EXIT_OK;
127
+ }
128
+ try {
129
+ await request('/api/auth/logout', { credential, method: 'POST', body: {} });
130
+ }
131
+ catch (err) {
132
+ if (err instanceof CloudError && err.kind === 'unauthenticated') {
133
+ // Already revoked, or expired. The server-side state is what logout
134
+ // wanted, so finishing the job locally is correct rather than an error.
135
+ //
136
+ // "Locally" has to include the team keys. They outlive the session that
137
+ // fetched them — a revoked token stops working, an unlocked team key goes
138
+ // on opening the team's maps — so leaving them here would be the one
139
+ // branch where signing out quietly kept the more dangerous half.
140
+ try {
141
+ removeTeamUnlock(io.home);
142
+ removeRules(io.home);
143
+ removeCredential(io.home);
144
+ }
145
+ catch (removeErr) {
146
+ io.stderr(`veilio: that session was already gone, but the credential file could not be removed ` +
147
+ `(${removeErr instanceof Error ? removeErr.message : String(removeErr)}).\n`);
148
+ return EXIT_ERROR;
149
+ }
150
+ io.stdout('That session was already revoked or expired. Signed out locally.\n');
151
+ return EXIT_OK;
152
+ }
153
+ const detail = err instanceof Error ? err.message : String(err);
154
+ io.stderr(`veilio: could not revoke the session (${detail}).\n` +
155
+ 'You are still signed in — the credential has been kept so you can try again. ' +
156
+ 'To end the session now, sign out from the web app.\n');
157
+ return EXIT_ERROR;
158
+ }
159
+ // Before the credential, because this is key material rather than a token
160
+ // the server has already revoked. Signing out while the team's keys stay
161
+ // readable on disk is exactly the gap between what a person believes they
162
+ // gave up and what is still there.
163
+ try {
164
+ removeTeamUnlock(io.home);
165
+ }
166
+ catch (err) {
167
+ io.stderr(`veilio: the session was revoked, but the unlocked team keys could not be removed ` +
168
+ `(${err instanceof Error ? err.message : String(err)}). Delete ${teamUnlockPath(io.home)} by hand.\n`);
169
+ return EXIT_ERROR;
170
+ }
171
+ // The rules are the account's too, and a whitelist among them keeps names
172
+ // readable: they go with the session rather than outliving it.
173
+ try {
174
+ removeRules(io.home);
175
+ }
176
+ catch (err) {
177
+ io.stderr(`veilio: the session was revoked, but the cached custom rules could not be removed ` +
178
+ `(${err instanceof Error ? err.message : String(err)}). Delete ${rulesPath(io.home)} by hand.\n`);
179
+ return EXIT_ERROR;
180
+ }
181
+ try {
182
+ removeCredential(io.home);
183
+ }
184
+ catch (err) {
185
+ // The session IS revoked — that part worked. What failed is removing the
186
+ // local file, and saying "signed out" while a dead token sits on disk is a
187
+ // smaller lie than the reverse but still a lie.
188
+ io.stderr(`veilio: the session was revoked, but the credential file could not be removed ` +
189
+ `(${err instanceof Error ? err.message : String(err)}). Delete it by hand.\n`);
190
+ return EXIT_ERROR;
191
+ }
192
+ io.stdout(`Signed out of ${credential.instance}.\n`);
193
+ return EXIT_OK;
194
+ }
195
+ /**
196
+ * Who is signed in, answered from disk.
197
+ *
198
+ * Makes no request, on purpose. Its job is "am I signed in", not "is Cloud up" —
199
+ * an answer that needs the network is useless at exactly the moment somebody is
200
+ * trying to work out why the network isn't working.
201
+ */
202
+ export function runWhoami(io) {
203
+ const credential = readCredential(io.home);
204
+ if (!credential) {
205
+ io.stdout('Not signed in. Run `veilio login` to connect a Veilio Cloud account.\n');
206
+ return EXIT_OK;
207
+ }
208
+ io.stdout(`${credential.account} on ${credential.instance}\n`);
209
+ return EXIT_OK;
210
+ }
211
+ /**
212
+ * Say which kind of refusal this was.
213
+ *
214
+ * FR-007: "wrong password" and "not on your plan" have different remedies and
215
+ * must never print the same thing. Sending someone to re-type a password that
216
+ * was correct is worse than saying nothing.
217
+ */
218
+ function reportLoginFailure(err, io) {
219
+ if (err instanceof CloudError) {
220
+ switch (err.kind) {
221
+ case 'unauthenticated':
222
+ io.stderr('veilio: that email and password were not accepted.\n');
223
+ return EXIT_ERROR;
224
+ case 'unentitled':
225
+ io.stderr(`veilio: signed in, but this account’s plan does not include terminal access — ${err.message}\n` +
226
+ 'Your password is fine. Change the plan in the web app to use the CLI with Cloud.\n');
227
+ return EXIT_ERROR;
228
+ case 'suspended':
229
+ // A different refusal with a different remedy again, and the one that
230
+ // must not read as a billing problem: this account is under review, and
231
+ // buying a bigger plan changes nothing.
232
+ io.stderr(`veilio: ${err.message}\n` +
233
+ 'Export and deletion still work in the web app. Nothing else will until the review ends.\n');
234
+ return EXIT_ERROR;
235
+ case 'forbidden':
236
+ io.stderr(`veilio: ${err.message}\n`);
237
+ return EXIT_ERROR;
238
+ case 'unreachable':
239
+ io.stderr(`veilio: ${err.message}\n`);
240
+ return EXIT_ERROR;
241
+ case 'server':
242
+ io.stderr(`veilio: the instance reported an error — ${err.message}\n`);
243
+ return EXIT_ERROR;
244
+ }
245
+ }
246
+ io.stderr(`veilio: ${err instanceof Error ? err.message : String(err)}\n`);
247
+ return EXIT_ERROR;
248
+ }
249
+ async function requirePrompt(io, label) {
250
+ if (!io.prompt)
251
+ throw new Error('this build cannot read input interactively');
252
+ return io.prompt(label);
253
+ }
254
+ async function requirePassword(io, label) {
255
+ if (!io.password)
256
+ throw new Error('this build cannot read a password interactively');
257
+ return io.password(label);
258
+ }
259
+ // ─── maps: list, pull, push ──────────────────────────────────────────────────
260
+ /**
261
+ * List what the account holds.
262
+ *
263
+ * Prints the id, because `pull` takes one. A name would be friendlier and is
264
+ * wrong: names are not unique, and picking "the first one that matches" is how
265
+ * somebody pulls a different map over the one they were using.
266
+ */
267
+ export async function runMapsList(io) {
268
+ const credential = readCredential(io.home);
269
+ if (!credential)
270
+ return notSignedIn(io);
271
+ let list;
272
+ try {
273
+ list = await listMaps(credential);
274
+ }
275
+ catch (err) {
276
+ return reportCloudFailure(err, io);
277
+ }
278
+ const all = [...list.personalMaps, ...list.teamMaps];
279
+ if (all.length === 0) {
280
+ io.stdout('No maps in Cloud yet. `veilio maps push <name>` uploads the local one.\n');
281
+ return EXIT_OK;
282
+ }
283
+ for (const map of all) {
284
+ const count = map.identifier_count === null ? '?' : String(map.identifier_count);
285
+ io.stdout(`${map.id} ${map.scope.padEnd(8)} ${count.padStart(5)} symbols ${map.name}\n`);
286
+ }
287
+ return EXIT_OK;
288
+ }
289
+ /**
290
+ * Open a team map with the team keys `veilio team unlock` stored.
291
+ *
292
+ * The same keys the MCP server reads, for the same reason: this machine may
293
+ * have nobody present to type a vault passphrase, and the team key - not the
294
+ * vault key - is what a team map is sealed under. Reports and returns null
295
+ * when it cannot, so the caller writes nothing.
296
+ */
297
+ async function openTeamEnvelope(credential, mapData, io) {
298
+ const unlock = readTeamUnlock({ instance: credential.instance, account: credential.account }, io.home);
299
+ if (!unlock || unlock.keys.length === 0) {
300
+ io.stderr('veilio: that is a team map, and no team key is unlocked on this machine. ' +
301
+ 'Run `veilio team unlock` first. Nothing was written.\n');
302
+ return null;
303
+ }
304
+ const keys = [];
305
+ for (const stored of unlock.keys) {
306
+ try {
307
+ keys.push({ version: stored.version, key: await importTeamKey(stored.key) });
308
+ }
309
+ catch {
310
+ // A damaged entry; the others may still open the map.
311
+ continue;
312
+ }
313
+ }
314
+ try {
315
+ return await decryptTeamMapWithAny(keys, JSON.parse(mapData));
316
+ }
317
+ catch (err) {
318
+ io.stderr(`veilio: that team map could not be opened with the unlocked team keys — ` +
319
+ `${err instanceof Error ? err.message : String(err)}. ` +
320
+ 'If the team key was rotated since, run `veilio team unlock` again. Nothing was written.\n');
321
+ return null;
322
+ }
323
+ }
324
+ /**
325
+ * Pull a map into the local store: decrypt FIRST, write second.
326
+ *
327
+ * The ordering is the control (Constitution IV). Writing before decrypting means
328
+ * a wrong passphrase leaves a half-built store behind — and the store is the one
329
+ * thing that can restore text already anonymized, so a partial write there is
330
+ * not an inconvenience, it is unrecoverable text.
331
+ *
332
+ * A personal map is opened HERE. The server holds the salt and a verifier and
333
+ * nothing else; the passphrase never leaves this machine, and there is no code
334
+ * path in this package that could send it.
335
+ */
336
+ export async function runMapsPull(id, mapPath, io, options = {}) {
337
+ const credential = readCredential(io.home);
338
+ if (!credential)
339
+ return notSignedIn(io);
340
+ if (!id) {
341
+ io.stderr('veilio: which map? Run `veilio maps list` for the ids.\n');
342
+ return EXIT_ERROR;
343
+ }
344
+ let remote;
345
+ try {
346
+ remote = await getMap(credential, id);
347
+ }
348
+ catch (err) {
349
+ return reportCloudFailure(err, io);
350
+ }
351
+ // Every map is an envelope the server cannot open: a personal map under the
352
+ // vault key, a team map under the team key. Which one is written on the
353
+ // envelope itself.
354
+ if (typeof remote.map_data !== 'string') {
355
+ io.stderr('veilio: that map is not an encrypted envelope, so it was not written.\n');
356
+ return EXIT_ERROR;
357
+ }
358
+ let envelope;
359
+ try {
360
+ envelope = JSON.parse(remote.map_data);
361
+ }
362
+ catch {
363
+ io.stderr('veilio: that map is not an encrypted envelope, so it was not written.\n');
364
+ return EXIT_ERROR;
365
+ }
366
+ let map;
367
+ if (envelope.alg === 'AES-256-GCM-TEAM') {
368
+ const opened = await openTeamEnvelope(credential, remote.map_data, io);
369
+ if (!opened)
370
+ return EXIT_ERROR;
371
+ map = opened;
372
+ }
373
+ else {
374
+ let vault;
375
+ try {
376
+ vault = await getVault(credential);
377
+ }
378
+ catch (err) {
379
+ return reportCloudFailure(err, io);
380
+ }
381
+ if (!vault.initialized) {
382
+ // Stated plainly rather than surfaced as a decryption failure. There is
383
+ // nothing to try a passphrase against, and inviting one would be asking
384
+ // somebody to guess at a lock that does not exist.
385
+ io.stderr('veilio: this account has no vault, so there is no key for that map. ' +
386
+ 'Create one in the web app first — it is the passphrase your maps are encrypted under.\n');
387
+ return EXIT_ERROR;
388
+ }
389
+ const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
390
+ if (passphrase === '') {
391
+ io.stderr('veilio: no passphrase given\n');
392
+ return EXIT_ERROR;
393
+ }
394
+ const key = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
395
+ // The verifier answers "is this the right passphrase" before anything is
396
+ // decrypted, so a wrong one is named as a wrong passphrase rather than
397
+ // surfacing as an authentication-tag failure that reads like corruption.
398
+ if (!(await checkVaultVerifier(key, vault.verifier))) {
399
+ io.stderr('veilio: that vault passphrase is not right. Nothing was written.\n');
400
+ return EXIT_ERROR;
401
+ }
402
+ try {
403
+ map = await decryptMapFromVault(key, parseVaultEnvelope(remote.map_data));
404
+ }
405
+ catch (err) {
406
+ io.stderr(`veilio: that map could not be opened — ${err instanceof Error ? err.message : String(err)}. ` +
407
+ 'Nothing was written.\n');
408
+ return EXIT_ERROR;
409
+ }
410
+ }
411
+ // Only now is anything touched on disk.
412
+ const existing = loadRemote(mapPath);
413
+ const local = loadMap(mapPath);
414
+ const hasLocal = Object.keys(local).length > 0;
415
+ const diverged = hasLocal &&
416
+ existing !== undefined &&
417
+ existing.id === remote.id &&
418
+ existing.updatedAt !== remote.updated_at &&
419
+ JSON.stringify(local) !== JSON.stringify(map);
420
+ if (diverged && options.force !== true) {
421
+ // Reported, not resolved. Overwriting either side silently loses entries
422
+ // that some text out there still depends on.
423
+ io.stderr(`veilio: this map changed in Cloud (${existing.updatedAt} → ${remote.updated_at}) and ` +
424
+ 'locally since it was pulled. Neither copy has been touched.\n' +
425
+ `Pass --force to overwrite the local copy, or export it first with \`veilio map --json\`.\n`);
426
+ return EXIT_ERROR;
427
+ }
428
+ const now = new Date().toISOString();
429
+ saveMap(mapPath, map, {
430
+ force: true,
431
+ remote: { id: remote.id, updatedAt: remote.updated_at, pulledAt: now },
432
+ });
433
+ io.stdout(`Pulled "${remote.name}" (${Object.keys(map).length} symbols) into ${mapPath}\n`);
434
+ return EXIT_OK;
435
+ }
436
+ /** Upload the local map. Same endpoint, same quotas, same validation. */
437
+ export async function runMapsPush(name, mapPath, io) {
438
+ const credential = readCredential(io.home);
439
+ if (!credential)
440
+ return notSignedIn(io);
441
+ const map = loadMap(mapPath);
442
+ if (Object.keys(map).length === 0) {
443
+ io.stderr(`veilio: there is no local map at ${mapPath} to push.\n`);
444
+ return EXIT_ERROR;
445
+ }
446
+ let vault;
447
+ try {
448
+ vault = await getVault(credential);
449
+ }
450
+ catch (err) {
451
+ return reportCloudFailure(err, io);
452
+ }
453
+ if (!vault.initialized) {
454
+ io.stderr('veilio: this account has no vault, so there is no key to encrypt with. ' +
455
+ 'Create one in the web app first.\n');
456
+ return EXIT_ERROR;
457
+ }
458
+ const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
459
+ if (passphrase === '') {
460
+ io.stderr('veilio: no passphrase given\n');
461
+ return EXIT_ERROR;
462
+ }
463
+ const key = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
464
+ if (!(await checkVaultVerifier(key, vault.verifier))) {
465
+ // Caught before anything is uploaded. Pushing under the wrong key would
466
+ // store a map the account can never open again.
467
+ io.stderr('veilio: that vault passphrase is not right. Nothing was uploaded.\n');
468
+ return EXIT_ERROR;
469
+ }
470
+ const envelope = await encryptMapForVault(key, map);
471
+ let created;
472
+ try {
473
+ created = await createMap(credential, {
474
+ name: name ?? 'from the terminal',
475
+ map_data: JSON.stringify(envelope),
476
+ identifier_count: Object.keys(map).length,
477
+ scope: 'personal',
478
+ });
479
+ }
480
+ catch (err) {
481
+ return reportCloudFailure(err, io);
482
+ }
483
+ saveMap(mapPath, map, {
484
+ force: true,
485
+ remote: {
486
+ id: created.id,
487
+ updatedAt: new Date().toISOString(),
488
+ pushedAt: new Date().toISOString(),
489
+ },
490
+ });
491
+ io.stdout(`Pushed ${Object.keys(map).length} symbols to Cloud as ${created.id}\n`);
492
+ return EXIT_OK;
493
+ }
494
+ function notSignedIn(io) {
495
+ io.stderr('veilio: not signed in. Run `veilio login` first.\n');
496
+ return EXIT_ERROR;
497
+ }
498
+ /** Same distinctions as a failed sign-in, minus the ones only login can hit. */
499
+ function reportCloudFailure(err, io) {
500
+ return reportLoginFailure(err, io);
501
+ }
502
+ // ─── rules: pull ──────────────────────────────────────────────────────────────
503
+ /**
504
+ * Fetch the account's custom rules so `veilio scrub` can apply them offline.
505
+ *
506
+ * Merged here, in the web app's order, so every later scrub applies exactly
507
+ * what the browser would. A plan without custom rules REMOVES any cache left
508
+ * from before: rules the account can no longer use must not go on shaping
509
+ * its output. Any other failure keeps the cache and says so.
510
+ */
511
+ export async function runRulesPull(io) {
512
+ const credential = readCredential(io.home);
513
+ if (!credential)
514
+ return notSignedIn(io);
515
+ let fetched;
516
+ try {
517
+ fetched = await listRules(credential);
518
+ }
519
+ catch (err) {
520
+ if (err instanceof CloudError && err.kind === 'unentitled') {
521
+ const removed = removeRules(io.home);
522
+ io.stderr(`veilio: this account's plan does not include custom rules${removed ? ' - the cached rules were removed' : ''}.\n`);
523
+ return EXIT_ERROR;
524
+ }
525
+ return reportCloudFailure(err, io);
526
+ }
527
+ const personal = fetched.rules ?? [];
528
+ const team = fetched.teamRules ?? [];
529
+ const rules = mergeRules(personal, team);
530
+ writeRules({
531
+ instance: credential.instance,
532
+ account: credential.account,
533
+ pulledAt: new Date().toISOString(),
534
+ rules,
535
+ }, io.home);
536
+ const skipped = personal.length + team.length - rules.length;
537
+ io.stdout(`Pulled ${rules.length} custom rule${rules.length === 1 ? '' : 's'} ` +
538
+ `(${personal.length} personal, ${team.length} team${skipped > 0 ? `, ${skipped} disabled and skipped` : ''}). ` +
539
+ '`veilio scrub` applies them.\n');
540
+ return EXIT_OK;
541
+ }
542
+ // ─── team: unlock, lock ──────────────────────────────────────────────────────
543
+ /**
544
+ * Unlock this team's keys for processes that cannot ask for a passphrase.
545
+ *
546
+ * The MCP server starts inside a coding agent with no terminal attached, so the
547
+ * keys it needs have to be put on disk by a run like this one, where a person
548
+ * is present to type the passphrase. See `team-unlock.ts` for what is stored
549
+ * and why it is the team key rather than the vault key.
550
+ */
551
+ export async function runTeamUnlock(io, days) {
552
+ const credential = readCredential(io.home);
553
+ if (!credential) {
554
+ io.stderr('veilio: not signed in. Run `veilio login` first.\n');
555
+ return EXIT_ERROR;
556
+ }
557
+ try {
558
+ const list = await listMaps(credential);
559
+ const teamId = list.team?.id;
560
+ if (!teamId) {
561
+ io.stderr('veilio: this account is not in a team, so there are no team keys to unlock. ' +
562
+ 'Shared placeholders are a Team-plan feature.\n');
563
+ return EXIT_ERROR;
564
+ }
565
+ const mine = await getUserKeys(credential);
566
+ if (!mine.initialized) {
567
+ io.stderr('veilio: this account has no keypair yet. Open the web app once — that is where a ' +
568
+ 'keypair is created and where a teammate grants you the team key.\n');
569
+ return EXIT_ERROR;
570
+ }
571
+ const { wraps } = await getTeamKeyWraps(credential, teamId);
572
+ if (wraps.length === 0) {
573
+ io.stderr('veilio: no teammate has granted you the team key yet. Ask someone already in the ' +
574
+ 'team to open the web app, which is what performs the grant.\n');
575
+ return EXIT_ERROR;
576
+ }
577
+ const vault = await getVault(credential);
578
+ if (!vault.initialized) {
579
+ io.stderr('veilio: this account has no vault, so there is no key to unwrap anything with. ' +
580
+ 'Create one in the web app first.\n');
581
+ return EXIT_ERROR;
582
+ }
583
+ const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
584
+ if (passphrase === '') {
585
+ io.stderr('veilio: no passphrase given\n');
586
+ return EXIT_ERROR;
587
+ }
588
+ const vaultKey = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
589
+ // Named as a wrong passphrase here rather than surfacing later as an
590
+ // authentication-tag failure that reads like corruption.
591
+ if (!(await checkVaultVerifier(vaultKey, vault.verifier))) {
592
+ io.stderr('veilio: that vault passphrase is not right. Nothing was written.\n');
593
+ return EXIT_ERROR;
594
+ }
595
+ const privateKey = await unwrapPrivateKey(vaultKey, mine.privateKeyEncrypted);
596
+ const keys = [];
597
+ for (const wrap of wraps) {
598
+ try {
599
+ const teamKey = await unwrapTeamKey(wrap.wrapped_key, privateKey, {
600
+ teamId,
601
+ version: wrap.version,
602
+ myPublicKey: mine.publicKey,
603
+ });
604
+ keys.push({ version: wrap.version, key: await exportTeamKey(teamKey) });
605
+ }
606
+ catch {
607
+ // A wrap this account cannot open — from a granter whose key has since
608
+ // changed, most likely. Skipped rather than fatal: another version may
609
+ // still open most of the team's maps, and none opening is reported below.
610
+ continue;
611
+ }
612
+ }
613
+ if (keys.length === 0) {
614
+ io.stderr('veilio: none of the team key wraps held for this account could be opened. ' +
615
+ 'Ask a teammate to grant access again from the web app.\n');
616
+ return EXIT_ERROR;
617
+ }
618
+ const now = new Date();
619
+ const expiresAt = expiryFrom(now, days);
620
+ writeTeamUnlock({
621
+ v: 1,
622
+ instance: credential.instance,
623
+ account: credential.account,
624
+ teamId,
625
+ expiresAt,
626
+ keys,
627
+ }, io.home);
628
+ const versions = keys.map((k) => `v${k.version}`).join(', ');
629
+ io.stdout(`Unlocked ${keys.length === 1 ? 'the team key' : 'team keys'} (${versions}) until ` +
630
+ `${expiresAt}.\n` +
631
+ `Stored in ${teamUnlockPath(io.home)}, readable only by you. ` +
632
+ `Run \`veilio team lock\` to remove them.\n`);
633
+ return EXIT_OK;
634
+ }
635
+ catch (err) {
636
+ return reportCloudFailure(err, io);
637
+ }
638
+ }
639
+ /** Remove the unlocked team keys. */
640
+ export function runTeamLock(io) {
641
+ try {
642
+ if (!removeTeamUnlock(io.home)) {
643
+ io.stdout('No team keys were unlocked.\n');
644
+ return EXIT_OK;
645
+ }
646
+ }
647
+ catch (err) {
648
+ io.stderr(`veilio: the unlocked team keys could not be removed ` +
649
+ `(${err instanceof Error ? err.message : String(err)}). ` +
650
+ `Delete ${teamUnlockPath(io.home)} by hand.\n`);
651
+ return EXIT_ERROR;
652
+ }
653
+ io.stdout('Team keys locked.\n');
654
+ return EXIT_OK;
655
+ }
656
+ //# sourceMappingURL=cloud-commands.js.map