@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.
- package/README.md +45 -15
- package/dist/args.d.ts +20 -2
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +47 -2
- package/dist/args.js.map +1 -1
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +12 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/keys.d.ts +22 -0
- package/dist/client/keys.d.ts.map +1 -0
- package/dist/client/keys.js +170 -0
- package/dist/client/keys.js.map +1 -0
- package/dist/client/maps.d.ts +107 -0
- package/dist/client/maps.d.ts.map +1 -0
- package/dist/client/maps.js +702 -0
- package/dist/client/maps.js.map +1 -0
- package/dist/client/personal-maps-key.d.ts +42 -0
- package/dist/client/personal-maps-key.d.ts.map +1 -0
- package/dist/client/personal-maps-key.js +168 -0
- package/dist/client/personal-maps-key.js.map +1 -0
- package/dist/client/projects.d.ts +23 -0
- package/dist/client/projects.d.ts.map +1 -0
- package/dist/client/projects.js +68 -0
- package/dist/client/projects.js.map +1 -0
- package/dist/client/status.d.ts +19 -0
- package/dist/client/status.d.ts.map +1 -0
- package/dist/client/status.js +62 -0
- package/dist/client/status.js.map +1 -0
- package/dist/client/team-key-bootstrap.d.ts +55 -0
- package/dist/client/team-key-bootstrap.d.ts.map +1 -0
- package/dist/client/team-key-bootstrap.js +127 -0
- package/dist/client/team-key-bootstrap.js.map +1 -0
- package/dist/client/vault-key.d.ts +5 -0
- package/dist/client/vault-key.d.ts.map +1 -0
- package/dist/client/vault-key.js +35 -0
- package/dist/client/vault-key.js.map +1 -0
- package/dist/client/wording.d.ts +59 -0
- package/dist/client/wording.d.ts.map +1 -0
- package/dist/client/wording.js +336 -0
- package/dist/client/wording.js.map +1 -0
- package/dist/cloud-commands.d.ts +30 -41
- package/dist/cloud-commands.d.ts.map +1 -1
- package/dist/cloud-commands.js +271 -461
- package/dist/cloud-commands.js.map +1 -1
- package/dist/cloud.d.ts +101 -2
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +91 -7
- package/dist/cloud.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +15 -0
- package/dist/commands.js.map +1 -1
- package/dist/credential.d.ts +7 -0
- package/dist/credential.d.ts.map +1 -1
- package/dist/credential.js +4 -0
- package/dist/credential.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -16
- package/dist/index.js.map +1 -1
- package/dist/secret-input.d.ts +23 -0
- package/dist/secret-input.d.ts.map +1 -0
- package/dist/secret-input.js +47 -0
- package/dist/secret-input.js.map +1 -0
- package/dist/team-namespace.d.ts +37 -0
- package/dist/team-namespace.d.ts.map +1 -1
- package/dist/team-namespace.js +61 -17
- package/dist/team-namespace.js.map +1 -1
- package/package.json +3 -2
package/dist/cloud-commands.js
CHANGED
|
@@ -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,
|
|
10
|
-
import {
|
|
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 {
|
|
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}
|
|
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
|
|
378
|
-
if (!
|
|
379
|
-
return
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
393
|
-
const count = map.
|
|
394
|
-
|
|
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
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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
|
|
447
|
-
if (!
|
|
448
|
-
return
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
return
|
|
717
|
-
}
|
|
718
|
-
const
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
*
|
|
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
|