@veilio-inc/cli 0.5.3 → 0.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.
Files changed (69) hide show
  1. package/README.md +45 -15
  2. package/dist/args.d.ts +20 -2
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +47 -2
  5. package/dist/args.js.map +1 -1
  6. package/dist/client/index.d.ts +8 -0
  7. package/dist/client/index.d.ts.map +1 -0
  8. package/dist/client/index.js +12 -0
  9. package/dist/client/index.js.map +1 -0
  10. package/dist/client/keys.d.ts +22 -0
  11. package/dist/client/keys.d.ts.map +1 -0
  12. package/dist/client/keys.js +170 -0
  13. package/dist/client/keys.js.map +1 -0
  14. package/dist/client/maps.d.ts +107 -0
  15. package/dist/client/maps.d.ts.map +1 -0
  16. package/dist/client/maps.js +702 -0
  17. package/dist/client/maps.js.map +1 -0
  18. package/dist/client/personal-maps-key.d.ts +42 -0
  19. package/dist/client/personal-maps-key.d.ts.map +1 -0
  20. package/dist/client/personal-maps-key.js +168 -0
  21. package/dist/client/personal-maps-key.js.map +1 -0
  22. package/dist/client/projects.d.ts +23 -0
  23. package/dist/client/projects.d.ts.map +1 -0
  24. package/dist/client/projects.js +68 -0
  25. package/dist/client/projects.js.map +1 -0
  26. package/dist/client/status.d.ts +19 -0
  27. package/dist/client/status.d.ts.map +1 -0
  28. package/dist/client/status.js +62 -0
  29. package/dist/client/status.js.map +1 -0
  30. package/dist/client/team-key-bootstrap.d.ts +55 -0
  31. package/dist/client/team-key-bootstrap.d.ts.map +1 -0
  32. package/dist/client/team-key-bootstrap.js +127 -0
  33. package/dist/client/team-key-bootstrap.js.map +1 -0
  34. package/dist/client/vault-key.d.ts +5 -0
  35. package/dist/client/vault-key.d.ts.map +1 -0
  36. package/dist/client/vault-key.js +35 -0
  37. package/dist/client/vault-key.js.map +1 -0
  38. package/dist/client/wording.d.ts +59 -0
  39. package/dist/client/wording.d.ts.map +1 -0
  40. package/dist/client/wording.js +336 -0
  41. package/dist/client/wording.js.map +1 -0
  42. package/dist/cloud-commands.d.ts +30 -41
  43. package/dist/cloud-commands.d.ts.map +1 -1
  44. package/dist/cloud-commands.js +271 -461
  45. package/dist/cloud-commands.js.map +1 -1
  46. package/dist/cloud.d.ts +101 -2
  47. package/dist/cloud.d.ts.map +1 -1
  48. package/dist/cloud.js +91 -7
  49. package/dist/cloud.js.map +1 -1
  50. package/dist/commands.d.ts +1 -1
  51. package/dist/commands.d.ts.map +1 -1
  52. package/dist/commands.js +15 -0
  53. package/dist/commands.js.map +1 -1
  54. package/dist/credential.d.ts +7 -0
  55. package/dist/credential.d.ts.map +1 -1
  56. package/dist/credential.js +4 -0
  57. package/dist/credential.js.map +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +33 -16
  60. package/dist/index.js.map +1 -1
  61. package/dist/secret-input.d.ts +23 -0
  62. package/dist/secret-input.d.ts.map +1 -0
  63. package/dist/secret-input.js +47 -0
  64. package/dist/secret-input.js.map +1 -0
  65. package/dist/team-namespace.d.ts +37 -0
  66. package/dist/team-namespace.d.ts.map +1 -1
  67. package/dist/team-namespace.js +61 -17
  68. package/dist/team-namespace.js.map +1 -1
  69. package/package.json +3 -2
@@ -6,15 +6,21 @@
6
6
  // with no account touches nothing (FR-002). Putting these here is what keeps
7
7
  // that walk meaningful instead of something to be deleted the moment it becomes
8
8
  // inconvenient.
9
- import { CloudError, startDeviceLogin, exchangeDeviceCode, createMap, deleteMap, getMap, reservePlaceholders, getVault, getUserKeys, getTeamKeyWraps, listMaps, listRules, request, } from './cloud.js';
10
- import { checkVaultVerifier, decryptMapFromVault, deriveVaultKey, unwrapPrivateKey, unwrapTeamKey, exportTeamKey, encryptMapForVault, fromBase64, parseVaultEnvelope, importTeamKey, encryptTeamMap, decryptTeamMapWithAny, } from '@veilio-inc/engine';
11
- import { loadMap, loadRemote, saveMap } from './store.js';
9
+ import { CloudError, startDeviceLogin, exchangeDeviceCode, reservePlaceholders, getUserKeys, getTeamKeyWraps, getMyTeam, listMaps, listRules, request, } from './cloud.js';
10
+ import { unwrapTeamKey, exportTeamKey } from '@veilio-inc/engine';
12
11
  import { resolveTeamNamespace, teamLayerNote } from './team-namespace.js';
13
- import { readTeamUnlock, removeTeamUnlock, writeTeamUnlock, teamUnlockPath, expiryFrom, } from './team-unlock.js';
12
+ import { removeTeamUnlock, writeTeamUnlock, teamUnlockPath, expiryFrom, } from './team-unlock.js';
14
13
  import { readCredential, removeCredential, writeCredential } from './credential.js';
15
14
  import { mergeRules, removeRules, rulesPath, writeRules } from './rules.js';
16
15
  import { EXIT_ERROR, EXIT_OK } from './commands.js';
17
16
  import { DEFAULT_INSTANCE } from './cloud.js';
17
+ import { createCloudMap, listCloudMaps, pullCloudMap, removeCloudMap, updateCloudMap, } from './client/maps.js';
18
+ import { refusal, renderRefusal } from './client/wording.js';
19
+ import { bootstrapTeamKey, openOwnKeypair } from './client/team-key-bootstrap.js';
20
+ import { vaultLock, vaultUnlock } from './client/keys.js';
21
+ import { vaultKeyFor } from './client/vault-key.js';
22
+ import { listTeamProjects, useProject } from './client/projects.js';
23
+ import { personalMapsKeyPath, removePersonalMapsKey } from './client/personal-maps-key.js';
18
24
  import { VERSION } from './version.js';
19
25
  /** The endpoint a sign-in is checked against. See `runLogin`. */
20
26
  const ENTITLED_PROBE = '/api/maps';
@@ -81,7 +87,7 @@ export async function runDeviceLogin(base, io) {
81
87
  const account = await accountFor(base, token, io);
82
88
  if (account === null)
83
89
  return EXIT_ERROR;
84
- writeCredential({ token, account, instance: base }, io.home);
90
+ writeCredential(keptProject({ token, account, instance: base }, io), io.home);
85
91
  io.stdout(`Signed in to ${base} as ${account}\n`);
86
92
  return EXIT_OK;
87
93
  }
@@ -112,6 +118,25 @@ export async function runDeviceLogin(base, io) {
112
118
  }
113
119
  }
114
120
  }
121
+ /**
122
+ * A new sign-in keeps the project chosen under the previous one only when it
123
+ * is the same account on the same instance (spec 052, founder 2026-10-25).
124
+ * A project id from another account or instance means nothing here, and
125
+ * keeping it would point every team map call at somebody else's choice. It is
126
+ * dropped, and that is said, so nobody wonders why they are back to every
127
+ * project's maps.
128
+ */
129
+ function keptProject(next, io) {
130
+ const prior = readCredential(io.home);
131
+ if (!prior?.project)
132
+ return next;
133
+ if (prior.instance === next.instance && prior.account === next.account) {
134
+ return { ...next, project: prior.project };
135
+ }
136
+ io.stderr(`veilio: project ${prior.project} was chosen for ${prior.account} on ${prior.instance}, so it does not ` +
137
+ 'carry over to this sign-in. Run `veilio projects use <name>` to choose one.\n');
138
+ return next;
139
+ }
115
140
  /** Who the new session belongs to, asked of the server rather than assumed. */
116
141
  async function accountFor(base, token, io) {
117
142
  try {
@@ -215,7 +240,7 @@ export async function runLogin(instance, io, opts = {}) {
215
240
  catch (err) {
216
241
  return reportLoginFailure(err, io);
217
242
  }
218
- writeCredential(credential, io.home);
243
+ writeCredential(keptProject(credential, io), io.home);
219
244
  io.stdout(`Signed in to ${base} as ${credential.account}\n`);
220
245
  return EXIT_OK;
221
246
  }
@@ -248,6 +273,7 @@ export async function runLogout(io) {
248
273
  // branch where signing out quietly kept the more dangerous half.
249
274
  try {
250
275
  removeTeamUnlock(io.home);
276
+ removePersonalMapsKey(io.home);
251
277
  removeRules(io.home);
252
278
  removeCredential(io.home);
253
279
  }
@@ -277,6 +303,15 @@ export async function runLogout(io) {
277
303
  `(${err instanceof Error ? err.message : String(err)}). Delete ${teamUnlockPath(io.home)} by hand.\n`);
278
304
  return EXIT_ERROR;
279
305
  }
306
+ // The personal-maps key, for the same reason as the team keys.
307
+ try {
308
+ removePersonalMapsKey(io.home);
309
+ }
310
+ catch (err) {
311
+ io.stderr(`veilio: the session was revoked, but the unlocked personal-maps key could not be removed ` +
312
+ `(${err instanceof Error ? err.message : String(err)}). Delete ${personalMapsKeyPath(io.home)} by hand.\n`);
313
+ return EXIT_ERROR;
314
+ }
280
315
  // The rules are the account's too, and a whitelist among them keeps names
281
316
  // readable: they go with the session rather than outliving it.
282
317
  try {
@@ -314,7 +349,9 @@ export function runWhoami(io) {
314
349
  io.stdout('Not signed in. Run `veilio login` to connect a Veilio Cloud account.\n');
315
350
  return EXIT_OK;
316
351
  }
317
- io.stdout(`${credential.account} on ${credential.instance}\n`);
352
+ io.stdout(`${credential.account} on ${credential.instance}` +
353
+ (credential.project ? `, project ${credential.project}` : '') +
354
+ '\n');
318
355
  return EXIT_OK;
319
356
  }
320
357
  /**
@@ -365,277 +402,118 @@ async function requirePassword(io, label) {
365
402
  throw new Error('this build cannot read a password interactively');
366
403
  return io.password(label);
367
404
  }
368
- // ─── maps: list, pull, push ──────────────────────────────────────────────────
405
+ // ─── maps: list, pull, push, update, rm ──────────────────────────────────────
406
+ //
407
+ // The work is done in `client/maps.ts`, which the MCP server's map tools call
408
+ // too (spec 052 §3). What is left here is the terminal's half: asking for the
409
+ // passphrase, and saying what happened - results on stdout, refusals on
410
+ // stderr, in the words of the shared table.
411
+ /** The client's context for a terminal run: the person can be asked. */
412
+ function clientContext(io) {
413
+ return {
414
+ home: io.home,
415
+ passphrase: io.password ? () => io.password('Vault passphrase: ') : undefined,
416
+ };
417
+ }
418
+ /** A refusal on stderr, and the exit code that goes with it. */
419
+ function refused(refusal, io) {
420
+ io.stderr(renderRefusal(refusal));
421
+ return EXIT_ERROR;
422
+ }
423
+ function notes(lines, io) {
424
+ for (const line of lines)
425
+ io.stderr(`veilio: ${line}\n`);
426
+ }
369
427
  /**
370
428
  * List what the account holds.
371
429
  *
372
430
  * Prints the id, because `pull` takes one. A name would be friendlier and is
373
431
  * wrong: names are not unique, and picking "the first one that matches" is how
374
- * somebody pulls a different map over the one they were using.
432
+ * somebody pulls a different map over the one they were using. A teammate's
433
+ * team map is marked, because it is theirs to update or remove.
375
434
  */
376
435
  export async function runMapsList(io) {
377
- const credential = readCredential(io.home);
378
- if (!credential)
379
- return notSignedIn(io);
380
- let list;
381
- try {
382
- list = await listMaps(credential);
383
- }
384
- catch (err) {
385
- return reportCloudFailure(err, io);
386
- }
387
- const all = [...list.personalMaps, ...list.teamMaps];
388
- if (all.length === 0) {
436
+ const listed = await listCloudMaps(clientContext(io));
437
+ if (!listed.ok)
438
+ return refused(listed.refusal, io);
439
+ const { maps, project } = listed.value;
440
+ if (maps.length === 0) {
389
441
  io.stdout('No maps in Cloud yet. `veilio maps push <name>` uploads the local one.\n');
390
442
  return EXIT_OK;
391
443
  }
392
- for (const map of all) {
393
- const count = map.identifier_count === null ? '?' : String(map.identifier_count);
394
- io.stdout(`${map.id} ${map.scope.padEnd(8)} ${count.padStart(5)} symbols ${map.name}\n`);
444
+ for (const map of maps) {
445
+ const count = map.count === null ? '?' : String(map.count);
446
+ const whose = map.scope === 'team' && !map.mine ? ' (a teammate’s)' : '';
447
+ io.stdout(`${map.id} ${map.scope.padEnd(8)} ${count.padStart(5)} symbols ${map.name}${whose}\n`);
395
448
  }
449
+ // Said on stderr so the listing itself stays parseable: which project's team
450
+ // maps these are, or that no project was chosen.
451
+ if (project)
452
+ io.stderr(`veilio: team maps of project ${project} (\`veilio projects\` to change)\n`);
396
453
  return EXIT_OK;
397
454
  }
398
455
  /**
399
- * Open a team map with the team keys `veilio team unlock` stored.
400
- *
401
- * The same keys the MCP server reads, for the same reason: this machine may
402
- * have nobody present to type a vault passphrase, and the team key - not the
403
- * vault key - is what a team map is sealed under. Reports and returns null
404
- * when it cannot, so the caller writes nothing.
405
- */
406
- async function openTeamEnvelope(credential, mapData, io) {
407
- const unlock = readTeamUnlock({ instance: credential.instance, account: credential.account }, io.home);
408
- if (!unlock || unlock.keys.length === 0) {
409
- io.stderr('veilio: that is a team map, and no team key is unlocked on this machine. ' +
410
- 'Run `veilio team unlock` first. Nothing was written.\n');
411
- return null;
412
- }
413
- const keys = [];
414
- for (const stored of unlock.keys) {
415
- try {
416
- keys.push({ version: stored.version, key: await importTeamKey(stored.key) });
417
- }
418
- catch {
419
- // A damaged entry; the others may still open the map.
420
- continue;
421
- }
422
- }
423
- try {
424
- return await decryptTeamMapWithAny(keys, JSON.parse(mapData));
425
- }
426
- catch (err) {
427
- io.stderr(`veilio: that team map could not be opened with the unlocked team keys — ` +
428
- `${err instanceof Error ? err.message : String(err)}. ` +
429
- 'If the team key was rotated since, run `veilio team unlock` again. Nothing was written.\n');
430
- return null;
431
- }
432
- }
433
- /**
434
- * Pull a map into the local store: decrypt FIRST, write second.
435
- *
436
- * The ordering is the control (Constitution IV). Writing before decrypting means
437
- * a wrong passphrase leaves a half-built store behind — and the store is the one
438
- * thing that can restore text already anonymized, so a partial write there is
439
- * not an inconvenience, it is unrecoverable text.
440
- *
441
- * A personal map is opened HERE. The server holds the salt and a verifier and
442
- * nothing else; the passphrase never leaves this machine, and there is no code
443
- * path in this package that could send it.
456
+ * Pull a map into the local store: decrypt FIRST, write second (see
457
+ * `pullCloudMap`). A personal map is opened HERE - the passphrase never leaves
458
+ * this machine, and there is no code path in this package that could send it.
444
459
  */
445
460
  export async function runMapsPull(id, mapPath, io, options = {}) {
446
- const credential = readCredential(io.home);
447
- if (!credential)
448
- return notSignedIn(io);
449
- if (!id) {
450
- io.stderr('veilio: which map? Run `veilio maps list` for the ids.\n');
451
- return EXIT_ERROR;
452
- }
453
- let remote;
454
- try {
455
- remote = await getMap(credential, id);
456
- }
457
- catch (err) {
458
- return reportCloudFailure(err, io);
459
- }
460
- // Every map is an envelope the server cannot open: a personal map under the
461
- // vault key, a team map under the team key. Which one is written on the
462
- // envelope itself.
463
- if (typeof remote.map_data !== 'string') {
464
- io.stderr('veilio: that map is not an encrypted envelope, so it was not written.\n');
465
- return EXIT_ERROR;
466
- }
467
- let envelope;
468
- try {
469
- envelope = JSON.parse(remote.map_data);
470
- }
471
- catch {
472
- io.stderr('veilio: that map is not an encrypted envelope, so it was not written.\n');
473
- return EXIT_ERROR;
474
- }
475
- let map;
476
- if (envelope.alg === 'AES-256-GCM-TEAM') {
477
- const opened = await openTeamEnvelope(credential, remote.map_data, io);
478
- if (!opened)
479
- return EXIT_ERROR;
480
- map = opened;
481
- }
482
- else {
483
- let vault;
484
- try {
485
- vault = await getVault(credential);
486
- }
487
- catch (err) {
488
- return reportCloudFailure(err, io);
489
- }
490
- if (!vault.initialized) {
491
- // Stated plainly rather than surfaced as a decryption failure. There is
492
- // nothing to try a passphrase against, and inviting one would be asking
493
- // somebody to guess at a lock that does not exist.
494
- io.stderr('veilio: this account has no vault, so there is no key for that map. ' +
495
- 'Create one in the web app first — it is the passphrase your maps are encrypted under.\n');
496
- return EXIT_ERROR;
497
- }
498
- const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
499
- if (passphrase === '') {
500
- io.stderr('veilio: no passphrase given\n');
501
- return EXIT_ERROR;
502
- }
503
- const key = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
504
- // The verifier answers "is this the right passphrase" before anything is
505
- // decrypted, so a wrong one is named as a wrong passphrase rather than
506
- // surfacing as an authentication-tag failure that reads like corruption.
507
- if (!(await checkVaultVerifier(key, vault.verifier))) {
508
- io.stderr('veilio: that vault passphrase is not right. Nothing was written.\n');
509
- return EXIT_ERROR;
510
- }
511
- try {
512
- map = await decryptMapFromVault(key, parseVaultEnvelope(remote.map_data));
513
- }
514
- catch (err) {
515
- io.stderr(`veilio: that map could not be opened — ${err instanceof Error ? err.message : String(err)}. ` +
516
- 'Nothing was written.\n');
517
- return EXIT_ERROR;
518
- }
519
- }
520
- // Only now is anything touched on disk.
521
- const existing = loadRemote(mapPath);
522
- const local = loadMap(mapPath);
523
- const hasLocal = Object.keys(local).length > 0;
524
- const diverged = hasLocal &&
525
- existing !== undefined &&
526
- existing.id === remote.id &&
527
- existing.updatedAt !== remote.updated_at &&
528
- JSON.stringify(local) !== JSON.stringify(map);
529
- if (diverged && options.force !== true) {
530
- // Reported, not resolved. Overwriting either side silently loses entries
531
- // that some text out there still depends on.
532
- io.stderr(`veilio: this map changed in Cloud (${existing.updatedAt} → ${remote.updated_at}) and ` +
533
- 'locally since it was pulled. Neither copy has been touched.\n' +
534
- `Pass --force to overwrite the local copy, or export it first with \`veilio map --json\`.\n`);
535
- return EXIT_ERROR;
536
- }
537
- // Whether or not the Cloud copy changed: a local entry the pulled map lacks,
538
- // or gives another name, is a placeholder some masked text still depends on
539
- // (staging walk, 2026-10-01 - a scrub after a pull was lost on the next pull).
540
- const dropped = Object.keys(local).filter((p) => map[p] !== local[p]);
541
- if (dropped.length > 0 && options.force !== true) {
542
- const shown = dropped.slice(0, 5).join(', ') + (dropped.length > 5 ? ` and ${dropped.length - 5} more` : '');
543
- io.stderr(`veilio: pulling "${remote.name}" would drop ${dropped.length} placeholder(s) this project's map has: ${shown}. ` +
544
- 'Nothing was written.\n' +
545
- `Push this map first (\`veilio maps push\`), pass --force to replace it, or export it with \`veilio map --json\`.\n`);
546
- return EXIT_ERROR;
461
+ const pulled = await pullCloudMap(clientContext(io), id, { mapPath, force: options.force });
462
+ if (!pulled.ok)
463
+ return refused(pulled.refusal, io);
464
+ const { name, count, path } = pulled.value;
465
+ io.stdout(`Pulled "${name}" (${count} symbols) into ${path}\n`);
466
+ return EXIT_OK;
467
+ }
468
+ /** Upload the local map, personal or the team's. Same endpoint, same quotas,
469
+ * same validation as the web app. */
470
+ export async function runMapsPush(name, mapPath, io, toTeam = false) {
471
+ const created = await createCloudMap(clientContext(io), {
472
+ name,
473
+ scope: toTeam ? 'team' : 'personal',
474
+ mapPath,
475
+ });
476
+ if (!created.ok)
477
+ return refused(created.refusal, io);
478
+ const c = created.value;
479
+ notes(c.notes, io);
480
+ if (c.scope === 'personal') {
481
+ io.stdout(`Pushed ${c.count} symbols to Cloud as ${c.id}\n`);
482
+ return EXIT_OK;
547
483
  }
548
- const now = new Date().toISOString();
549
- saveMap(mapPath, map, {
550
- force: true,
551
- remote: { id: remote.id, updatedAt: remote.updated_at, pulledAt: now },
484
+ const disputed = c.disputed ?? 0;
485
+ io.stdout(`Published "${c.name}" (${c.count} symbols) to the team as ${c.id}\n` +
486
+ `The team's shared set now holds ${c.teamTotal} identifiers` +
487
+ (disputed > 0
488
+ ? `, ${disputed} of which ${disputed === 1 ? 'is' : 'are'} disputed and will not restore\n`
489
+ : ', none disputed\n'));
490
+ return EXIT_OK;
491
+ }
492
+ /** Replace a Cloud map's entries with the local map's, in place (spec 052). */
493
+ export async function runMapsUpdate(id, mapPath, io, options = {}) {
494
+ const updated = await updateCloudMap(clientContext(io), id, {
495
+ mapPath,
496
+ allowDrop: options.allowDrop,
552
497
  });
553
- io.stdout(`Pulled "${remote.name}" (${Object.keys(map).length} symbols) into ${mapPath}\n`);
498
+ if (!updated.ok)
499
+ return refused(updated.refusal, io);
500
+ const u = updated.value;
501
+ notes(u.notes, io);
502
+ io.stdout(`Updated "${u.name}" (${u.id}) in place: ${u.count} symbols` +
503
+ (u.dropped > 0 ? `, ${u.dropped} dropped as asked` : '') +
504
+ `${u.scope === 'team' ? ', sealed with the team key' : ''}\n`);
554
505
  return EXIT_OK;
555
506
  }
556
507
  /**
557
- * Placeholders the local map and the team's namespace use for DIFFERENT names.
558
- *
559
- * Publishing one of these creates a DISPUTED placeholder, and
560
- * `restoreLayers.disputedIn` then refuses to restore it for every member of
561
- * the team until somebody deletes a map. One person's push, a cost paid by
562
- * everyone, and nothing errors at the time - so it is checked before anything
563
- * is sealed rather than reported afterwards.
564
- *
565
- * An identifier the team holds under a DIFFERENT placeholder is an alias, not
566
- * a conflict: both still restore (spec 017), so those pass.
567
- *
568
- * A map anonymized with `reservedAnonymizeOverTeam` cannot reach this, because
569
- * its numbers came out of the team's own allocation. This is for maps that
570
- * predate it - numbered locally, possibly before this account joined a team.
508
+ * Remove a map from Cloud. Renaming and removing are allowed while a key
509
+ * rotation is owed, because neither stores anything under the key.
571
510
  */
572
- export function conflictsWithTeam(local, teamNamespace) {
573
- const out = [];
574
- for (const [placeholder, mine] of Object.entries(local)) {
575
- const theirs = teamNamespace[placeholder];
576
- if (theirs !== undefined && theirs !== mine)
577
- out.push({ placeholder, mine, theirs });
578
- }
579
- return out;
580
- }
581
- /** Upload the local map. Same endpoint, same quotas, same validation. */
582
- export async function runMapsPush(name, mapPath, io, toTeam = false) {
583
- const credential = readCredential(io.home);
584
- if (!credential)
585
- return notSignedIn(io);
586
- const map = loadMap(mapPath);
587
- if (Object.keys(map).length === 0) {
588
- io.stderr(`veilio: there is no local map at ${mapPath} to push.\n`);
589
- return EXIT_ERROR;
590
- }
591
- if (toTeam)
592
- return pushToTeam(name, mapPath, map, credential, io);
593
- let vault;
594
- try {
595
- vault = await getVault(credential);
596
- }
597
- catch (err) {
598
- return reportCloudFailure(err, io);
599
- }
600
- if (!vault.initialized) {
601
- io.stderr('veilio: this account has no vault, so there is no key to encrypt with. ' +
602
- 'Create one in the web app first.\n');
603
- return EXIT_ERROR;
604
- }
605
- const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
606
- if (passphrase === '') {
607
- io.stderr('veilio: no passphrase given\n');
608
- return EXIT_ERROR;
609
- }
610
- const key = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
611
- if (!(await checkVaultVerifier(key, vault.verifier))) {
612
- // Caught before anything is uploaded. Pushing under the wrong key would
613
- // store a map the account can never open again.
614
- io.stderr('veilio: that vault passphrase is not right. Nothing was uploaded.\n');
615
- return EXIT_ERROR;
616
- }
617
- const envelope = await encryptMapForVault(key, map);
618
- let created;
619
- try {
620
- created = await createMap(credential, {
621
- name: name ?? 'from the terminal',
622
- map_data: JSON.stringify(envelope),
623
- identifier_count: Object.keys(map).length,
624
- scope: 'personal',
625
- });
626
- }
627
- catch (err) {
628
- return reportCloudFailure(err, io);
629
- }
630
- saveMap(mapPath, map, {
631
- force: true,
632
- remote: {
633
- id: created.id,
634
- updatedAt: new Date().toISOString(),
635
- pushedAt: new Date().toISOString(),
636
- },
637
- });
638
- io.stdout(`Pushed ${Object.keys(map).length} symbols to Cloud as ${created.id}\n`);
511
+ export async function runMapsRm(id, io) {
512
+ const removed = await removeCloudMap(clientContext(io), id);
513
+ if (!removed.ok)
514
+ return refused(removed.refusal, io);
515
+ const { name } = removed.value;
516
+ io.stdout(name ? `Removed "${name}" (${removed.value.id})\n` : `Removed ${removed.value.id}\n`);
639
517
  return EXIT_OK;
640
518
  }
641
519
  function notSignedIn(io) {
@@ -710,43 +588,57 @@ export async function runTeamUnlock(io, days) {
710
588
  return EXIT_ERROR;
711
589
  }
712
590
  const mine = await getUserKeys(credential);
713
- if (!mine.initialized) {
714
- io.stderr('veilio: this account has no keypair yet. Open the web app once — that is where a ' +
715
- 'keypair is created and where a teammate grants you the team key.\n');
716
- return EXIT_ERROR;
717
- }
718
- const { wraps } = await getTeamKeyWraps(credential, teamId);
591
+ // No keypair: there is nothing to unwrap a team key with, nor to wrap a
592
+ // first one to. The web app makes it on the first vault unlock.
593
+ if (!mine.initialized)
594
+ return refused(refusal('keys_not_set_up'), io);
595
+ let { wraps } = await getTeamKeyWraps(credential, teamId);
596
+ const derived = await vaultKeyFor(credential, clientContext(io).passphrase);
597
+ if (!derived.ok)
598
+ return refused(derived.refusal, io);
599
+ const vaultKey = derived.value;
600
+ // The keypair, and its public half computed from the private one: a
601
+ // public key the server hands back is never trusted on its own word, not
602
+ // for a wrap's context and above all not for wrapping a key to (security
603
+ // review). A server answer that differs stops everything.
604
+ const own = await openOwnKeypair(vaultKey, mine.privateKeyEncrypted, mine.publicKey);
605
+ if (!own.ok)
606
+ return refused(refusal('own_public_key_mismatch'), io);
607
+ const privateKey = own.privateKey;
608
+ const myPublicKey = own.publicKey;
609
+ // None held here: the team may have no key at all - a team of one always
610
+ // starts that way - so try to mint its first one, as the web app does.
611
+ // The server says whether that is a bootstrap; a refusal means a key
612
+ // exists (or this member may not start one), never a reason to mint again.
613
+ let minted = null;
719
614
  if (wraps.length === 0) {
720
- io.stderr('veilio: no teammate has granted you the team key yet. Ask someone already in the ' +
721
- 'team to open the web app, which is what performs the grant.\n');
722
- return EXIT_ERROR;
723
- }
724
- const vault = await getVault(credential);
725
- if (!vault.initialized) {
726
- io.stderr('veilio: this account has no vault, so there is no key to unwrap anything with. ' +
727
- 'Create one in the web app first.\n');
728
- return EXIT_ERROR;
729
- }
730
- const passphrase = io.password ? await io.password('Vault passphrase: ') : '';
731
- if (passphrase === '') {
732
- io.stderr('veilio: no passphrase given\n');
733
- return EXIT_ERROR;
734
- }
735
- const vaultKey = await deriveVaultKey(passphrase, fromBase64(vault.salt), vault.kdf ? { name: 'PBKDF2-SHA256', iterations: vault.kdf.iterations } : undefined);
736
- // Named as a wrong passphrase here rather than surfacing later as an
737
- // authentication-tag failure that reads like corruption.
738
- if (!(await checkVaultVerifier(vaultKey, vault.verifier))) {
739
- io.stderr('veilio: that vault passphrase is not right. Nothing was written.\n');
740
- return EXIT_ERROR;
615
+ const tried = await bootstrapTeamKey(credential, teamId, {
616
+ privateKey,
617
+ publicKey: myPublicKey,
618
+ });
619
+ if (tried.status === 'readback_mismatch')
620
+ return refused(refusal('team_key_readback_mismatch'), io);
621
+ if (tried.status === 'refused') {
622
+ if (tried.reason && /owner or an admin/i.test(tried.reason))
623
+ return refused(refusal('team_key_not_startable', { detail: tried.reason }), io);
624
+ // A team of one has nobody to ask, and must not be told to.
625
+ if (await isTeamOfOne(credential)) {
626
+ return refused(refusal('team_key_solo_unavailable', {
627
+ detail: tried.reason ?? 'the server did not accept a first key',
628
+ }), io);
629
+ }
630
+ return refused(refusal('team_key_waiting'), io);
631
+ }
632
+ minted = { version: tried.version };
633
+ ({ wraps } = await getTeamKeyWraps(credential, teamId));
741
634
  }
742
- const privateKey = await unwrapPrivateKey(vaultKey, mine.privateKeyEncrypted);
743
635
  const keys = [];
744
636
  for (const wrap of wraps) {
745
637
  try {
746
638
  const teamKey = await unwrapTeamKey(wrap.wrapped_key, privateKey, {
747
639
  teamId,
748
640
  version: wrap.version,
749
- myPublicKey: mine.publicKey,
641
+ myPublicKey,
750
642
  });
751
643
  keys.push({ version: wrap.version, key: await exportTeamKey(teamKey) });
752
644
  }
@@ -759,7 +651,7 @@ export async function runTeamUnlock(io, days) {
759
651
  }
760
652
  if (keys.length === 0) {
761
653
  io.stderr('veilio: none of the team key wraps held for this account could be opened. ' +
762
- 'Ask a teammate to grant access again from the web app.\n');
654
+ 'Grant it again from the team page in the web app, then run `veilio team unlock` again.\n');
763
655
  return EXIT_ERROR;
764
656
  }
765
657
  const now = new Date();
@@ -773,6 +665,10 @@ export async function runTeamUnlock(io, days) {
773
665
  keys,
774
666
  }, io.home);
775
667
  const versions = keys.map((k) => `v${k.version}`).join(', ');
668
+ if (minted) {
669
+ io.stdout(`Set up the team key (v${minted.version}): the team had none, so this account made its first one ` +
670
+ 'and holds it. It was never sent anywhere unwrapped.\n');
671
+ }
776
672
  io.stdout(`Unlocked ${keys.length === 1 ? 'the team key' : 'team keys'} (${versions}) until ` +
777
673
  `${expiresAt}.\n` +
778
674
  `Stored in ${teamUnlockPath(io.home)}, readable only by you. ` +
@@ -783,6 +679,17 @@ export async function runTeamUnlock(io, days) {
783
679
  return reportCloudFailure(err, io);
784
680
  }
785
681
  }
682
+ /** Whether this account is its team's only member. Unknown counts as not:
683
+ * the general wording is still true for a team of one, just less helpful. */
684
+ async function isTeamOfOne(credential) {
685
+ try {
686
+ const members = (await getMyTeam(credential)).team?.members;
687
+ return Array.isArray(members) && members.length === 1;
688
+ }
689
+ catch {
690
+ return false;
691
+ }
692
+ }
786
693
  /** Remove the unlocked team keys. */
787
694
  export function runTeamLock(io) {
788
695
  try {
@@ -800,6 +707,81 @@ export function runTeamLock(io) {
800
707
  io.stdout('Team keys locked.\n');
801
708
  return EXIT_OK;
802
709
  }
710
+ // ─── projects ────────────────────────────────────────────────────────────────
711
+ /** The team's projects, the one in use marked with `*`. */
712
+ export async function runProjectsList(io) {
713
+ const listed = await listTeamProjects(clientContext(io));
714
+ if (!listed.ok)
715
+ return refused(listed.refusal, io);
716
+ const { projects, chosen } = listed.value;
717
+ for (const p of projects) {
718
+ const marks = [
719
+ ...(p.inUse ? ['in use'] : []),
720
+ ...(p.isDefault ? ['default'] : []),
721
+ ...(p.maps !== null ? [`${p.maps} team map${p.maps === 1 ? '' : 's'}`] : []),
722
+ ];
723
+ io.stdout(`${p.inUse ? '*' : ' '} ${p.id} ${p.name} (${marks.join(', ')})\n`);
724
+ }
725
+ if (chosen === null) {
726
+ // Said, because "none" is a real state with a real consequence: team maps
727
+ // from every project the team works in are listed and masked with.
728
+ io.stderr('veilio: No project is chosen, so team maps from every project are listed and used, and a new ' +
729
+ 'team map lands in the default. Run `veilio projects use <name>` to work in one.\n');
730
+ }
731
+ return EXIT_OK;
732
+ }
733
+ /** Choose the project this sign-in works in. */
734
+ export async function runProjectsUse(ref, io) {
735
+ const used = await useProject(clientContext(io), ref);
736
+ if (!used.ok)
737
+ return refused(used.refusal, io);
738
+ io.stdout(`Now working in project ${used.value.name} (${used.value.id}). Team maps, pushes, updates and ` +
739
+ '`scrub --team` use this project only; the MCP server picks it up on its next call.\n');
740
+ return EXIT_OK;
741
+ }
742
+ // ─── vault: unlock, lock ─────────────────────────────────────────────────────
743
+ /**
744
+ * Unlock the personal-maps key for processes that cannot ask for a passphrase
745
+ * (spec 052 FR-011). See `client/keys.ts` for what is kept: that key, and
746
+ * nothing else the vault protects.
747
+ */
748
+ export async function runVaultUnlock(io, days) {
749
+ const unlocked = await vaultUnlock(clientContext(io), { days });
750
+ if (!unlocked.ok)
751
+ return refused(unlocked.refusal, io);
752
+ const u = unlocked.value;
753
+ io.stdout(`${u.created ? 'Created and unlocked' : 'Unlocked'} the personal-maps key until ${u.expiresAt}.\n` +
754
+ `Stored in ${u.path}, readable only by you. It opens your personal maps and nothing else; ` +
755
+ 'the vault passphrase and key were not kept. Run `veilio vault lock` to remove it.\n' +
756
+ (u.resealed > 0
757
+ ? `Re-sealed ${u.resealed} personal map${u.resealed === 1 ? '' : 's'} under it, so the MCP server can open ${u.resealed === 1 ? 'it' : 'them'}.\n`
758
+ : ''));
759
+ if (u.failed.length > 0) {
760
+ io.stderr(`veilio: ${u.failed.length} personal map${u.failed.length === 1 ? '' : 's'} could not be re-sealed and ` +
761
+ `${u.failed.length === 1 ? 'is' : 'are'} still under the vault key, which the MCP server cannot open:\n` +
762
+ u.failed.map((f) => ` "${f.name}" (${f.id}): ${f.reason}\n`).join('') +
763
+ 'Run `veilio vault unlock` again to retry them; the key itself is unlocked.\n');
764
+ return EXIT_ERROR;
765
+ }
766
+ return EXIT_OK;
767
+ }
768
+ /** Remove the personal-maps key from this machine. */
769
+ export function runVaultLock(io) {
770
+ try {
771
+ if (!vaultLock(clientContext(io))) {
772
+ io.stdout('No personal-maps key was unlocked.\n');
773
+ return EXIT_OK;
774
+ }
775
+ }
776
+ catch (err) {
777
+ io.stderr(`veilio: the personal-maps key could not be removed ` +
778
+ `(${err instanceof Error ? err.message : String(err)}). ` +
779
+ `Delete ${personalMapsKeyPath(io.home)} by hand.\n`);
780
+ return EXIT_ERROR;
781
+ }
782
+ io.stdout('Personal-maps key locked.\n');
783
+ return EXIT_OK;
784
+ }
803
785
  // ─── restore: the team layer ────────────────────────────────────────────────
804
786
  /**
805
787
  * The team's maps for `veilio restore` (spec 028), handed to the local command
@@ -853,179 +835,7 @@ export async function teamNamespaceForScrub(home) {
853
835
  };
854
836
  return { team: null, note: null };
855
837
  }
856
- /**
857
- * Publish the local map as the TEAM's, from the terminal (spec 011 US1).
858
- *
859
- * Seals what is on disk AS IT IS NUMBERED. It does not renumber: by the time
860
- * anybody runs this, the local map's placeholders are already present in the
861
- * files they masked, and renumbering here would publish a map that disagrees
862
- * with every one of them - while `saveMap(force)` rewrote the local map too,
863
- * breaking local restore as well. Numbers come out of the team's allocation at
864
- * ANONYMIZE time (`reservedAnonymizeOverTeam`), which is where the text and
865
- * the numbers can still change together.
866
- *
867
- * So the ordering here is: establish that publishing is safe, THEN seal, THEN
868
- * upload. Every refusal happens before the first byte leaves (FR-007).
869
- */
870
- async function pushToTeam(name, mapPath, map, credential, io) {
871
- // 1. The key. Read from the unlock file, never derived here: publishing
872
- // needs the TEAM key, and nobody's passphrase can produce it.
873
- const unlock = readTeamUnlock({ instance: credential.instance, account: credential.account }, io.home);
874
- if (!unlock || unlock.keys.length === 0) {
875
- io.stderr('veilio: no team key is unlocked on this machine, so this map cannot be sealed for ' +
876
- 'the team. Run `veilio team unlock` first. Nothing was uploaded.\n');
877
- return EXIT_ERROR;
878
- }
879
- // Newest first: a team mid-rotation holds two, and new content belongs under
880
- // the newer one. The version is declared on upload so the server can refuse
881
- // a write under a key a departed member knew (spec 014).
882
- const newest = [...unlock.keys].sort((a, b) => b.version - a.version)[0];
883
- let teamKey;
884
- try {
885
- teamKey = await importTeamKey(newest.key);
886
- }
887
- catch (err) {
888
- io.stderr(`veilio: the unlocked team key could not be read (${err instanceof Error ? err.message : String(err)}). ` +
889
- 'Run `veilio team unlock` again. Nothing was uploaded.\n');
890
- return EXIT_ERROR;
891
- }
892
- // 2. The team's namespace, for the conflict check and for the report. A
893
- // failure here is NOT fallback-to-local: publishing blind is what the
894
- // check exists to prevent.
895
- const found = await resolveTeamNamespace(io.home);
896
- if (found.status === 'local') {
897
- io.stderr('veilio: this account is not in a team, so there is no shared map to publish to. ' +
898
- 'Nothing was uploaded.\n');
899
- return EXIT_ERROR;
900
- }
901
- if (found.status === 'locked') {
902
- io.stderr('veilio: the team key on this machine does not match this team. ' +
903
- 'Run `veilio team unlock` again. Nothing was uploaded.\n');
904
- return EXIT_ERROR;
905
- }
906
- if (found.status === 'unavailable') {
907
- io.stderr(`veilio: could not read the team's maps (${found.reason}), so whether this map ` +
908
- 'conflicts with the team cannot be known. Nothing was uploaded.\n');
909
- return EXIT_ERROR;
910
- }
911
- // 3. The conflict check. Before sealing, so a refusal costs nothing.
912
- const clashes = conflictsWithTeam(map, found.analysis.namespace);
913
- if (clashes.length > 0) {
914
- io.stderr(`veilio: ${clashes.length} placeholder${clashes.length === 1 ? '' : 's'} in this map already ` +
915
- `mean something else to the team. Publishing would leave ${clashes.length === 1 ? 'it' : 'them'} ` +
916
- `unrestorable for every member, so nothing was uploaded:\n`);
917
- for (const { placeholder, mine, theirs } of clashes.slice(0, 10)) {
918
- io.stderr(` ${placeholder}: this map says ${mine}, the team says ${theirs}\n`);
919
- }
920
- if (clashes.length > 10)
921
- io.stderr(` … and ${clashes.length - 10} more\n`);
922
- io.stderr('Re-run `veilio scrub --team` to number this project out of the team\u2019s own ' +
923
- 'allocation, then push the result.\n');
924
- return EXIT_ERROR;
925
- }
926
- // 4. Seal and upload. Nothing above this line has sent anything.
927
- const envelope = await encryptTeamMap(teamKey, map);
928
- let created;
929
- try {
930
- created = await createMap(credential, {
931
- name: name ?? 'from the terminal',
932
- map_data: JSON.stringify(envelope),
933
- identifier_count: Object.keys(map).length,
934
- scope: 'team',
935
- key_version: newest.version,
936
- });
937
- }
938
- catch (err) {
939
- return reportTeamWriteFailure(err, io);
940
- }
941
- // The local map is NOT rewritten with a remote id: a team map is not this
942
- // project's copy of anything, and recording it as `remote` would make the
943
- // next `maps pull` treat the team's map as this project's own.
944
- // A union, not a sum: a name already in the team that this map also holds is
945
- // ONE identifier in the shared set, and the pre-flight above has just checked
946
- // that every such overlap agrees. Adding the two counts said "about" three
947
- // for two (spec 011 T030).
948
- const total = new Set([...Object.keys(found.analysis.namespace), ...Object.keys(map)]).size;
949
- const disputed = Object.keys(found.analysis.conflicts).length;
950
- // The name the person chose, beside the id they would type to remove it (T014).
951
- const label = name ?? 'from the terminal';
952
- io.stdout(`Published "${label}" (${Object.keys(map).length} symbols) to the team as ${created.id}\n` +
953
- `The team's shared set now holds ${total} identifiers` +
954
- (disputed > 0
955
- ? `, ${disputed} of which ${disputed === 1 ? 'is' : 'are'} disputed and will not restore\n`
956
- : ', none disputed\n'));
957
- return EXIT_OK;
958
- }
959
- /**
960
- * Remove a map from Cloud.
961
- *
962
- * Who may remove a team map is the server's decision and is not second-guessed
963
- * here - this says what each refusal means and changes nothing locally either
964
- * way. Renaming and removing are allowed while a key rotation is owed, because
965
- * neither stores anything under the key.
966
- */
967
- export async function runMapsRm(id, io) {
968
- const credential = readCredential(io.home);
969
- if (!credential)
970
- return notSignedIn(io);
971
- if (!id) {
972
- io.stderr('veilio: which map? Run `veilio maps list` for the ids.\n');
973
- return EXIT_ERROR;
974
- }
975
- // The name, read first, so the confirmation says WHICH map went: an id is
976
- // what a person types, the name is what they recognise (spec 011 T032). A
977
- // failed read never stops the removal - it only means the id is all there is
978
- // to report.
979
- let label;
980
- try {
981
- label = (await getMap(credential, id)).name || null;
982
- }
983
- catch {
984
- label = null;
985
- }
986
- try {
987
- await deleteMap(credential, id);
988
- }
989
- catch (err) {
990
- if (err instanceof CloudError && err.status === 404) {
991
- io.stderr(`veilio: there is no map ${id} on this account.\n`);
992
- return EXIT_ERROR;
993
- }
994
- if (err instanceof CloudError && err.status === 403) {
995
- io.stderr(`veilio: ${id} belongs to someone else on your team, so it is theirs to remove.\n`);
996
- return EXIT_ERROR;
997
- }
998
- return reportCloudFailure(err, io);
999
- }
1000
- io.stdout(label ? `Removed "${label}" (${id})\n` : `Removed ${id}\n`);
1001
- return EXIT_OK;
1002
- }
1003
- /**
1004
- * A failed team-map write, said in terms of what happened and who can fix it.
1005
- *
1006
- * The person reading this is often not the person who can clear it: a rotation
1007
- * is owed by whoever holds the current key, not by whoever happened to push.
1008
- * An unexplained refusal in a terminal reads as a broken tool (spec 011 US3).
1009
- */
1010
- function reportTeamWriteFailure(err, io) {
1011
- if (err instanceof CloudError && err.status === 409) {
1012
- io.stderr('veilio: a teammate left while holding the team key, so no new team content may be ' +
1013
- 'written until the key is changed. A member who holds the current key has to do ' +
1014
- 'that — it cannot be done from here. Nothing was uploaded.\n');
1015
- return EXIT_ERROR;
1016
- }
1017
- if (err instanceof CloudError && err.status === 413) {
1018
- io.stderr('veilio: this map is too large for Cloud to accept, so nothing was stored. ' +
1019
- 'Split it, or remove entries you no longer need.\n');
1020
- return EXIT_ERROR;
1021
- }
1022
- if (err instanceof CloudError && err.kind === 'forbidden' && /limit/i.test(err.message)) {
1023
- // The ceiling is an abuse limit, not a plan feature. Saying "upgrade" here
1024
- // would take money for a change that does nothing.
1025
- io.stderr(`veilio: ${err.message}\nThis ceiling is the same on every paid plan, so upgrading ` +
1026
- 'will not raise it. Nothing was uploaded.\n');
1027
- return EXIT_ERROR;
1028
- }
1029
- return reportCloudFailure(err, io);
1030
- }
838
+ /** Kept importable from here: tests and callers written before spec 052 moved
839
+ * it into the shared client. */
840
+ export { conflictsWithTeam } from './client/maps.js';
1031
841
  //# sourceMappingURL=cloud-commands.js.map