@veilio-inc/cli 0.5.4 → 0.6.1
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 +62 -15
- package/dist/args.d.ts +20 -2
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +48 -3
- package/dist/args.js.map +1 -1
- package/dist/client/index.d.ts +9 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +13 -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 +115 -0
- package/dist/client/maps.d.ts.map +1 -0
- package/dist/client/maps.js +798 -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/project-scope.d.ts +87 -0
- package/dist/client/project-scope.d.ts.map +1 -0
- package/dist/client/project-scope.js +132 -0
- package/dist/client/project-scope.js.map +1 -0
- package/dist/client/projects.d.ts +53 -0
- package/dist/client/projects.d.ts.map +1 -0
- package/dist/client/projects.js +129 -0
- package/dist/client/projects.js.map +1 -0
- package/dist/client/status.d.ts +21 -0
- package/dist/client/status.d.ts.map +1 -0
- package/dist/client/status.js +71 -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 +62 -0
- package/dist/client/wording.d.ts.map +1 -0
- package/dist/client/wording.js +358 -0
- package/dist/client/wording.js.map +1 -0
- package/dist/cloud-commands.d.ts +34 -43
- package/dist/cloud-commands.d.ts.map +1 -1
- package/dist/cloud-commands.js +282 -466
- 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 +14 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +24 -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 +10 -1
- package/dist/credential.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -5
- package/dist/index.js.map +1 -1
- package/dist/team-namespace.d.ts +74 -2
- package/dist/team-namespace.d.ts.map +1 -1
- package/dist/team-namespace.js +78 -18
- package/dist/team-namespace.js.map +1 -1
- package/package.json +4 -3
package/dist/cloud-commands.js
CHANGED
|
@@ -6,15 +6,22 @@
|
|
|
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 { formatProjectsList, listTeamProjects, useProject, usedSentence, } from './client/projects.js';
|
|
23
|
+
import { PROJECT_ENV, overriddenSentence, projectOverride, } from './client/project-scope.js';
|
|
24
|
+
import { personalMapsKeyPath, removePersonalMapsKey } from './client/personal-maps-key.js';
|
|
18
25
|
import { VERSION } from './version.js';
|
|
19
26
|
/** The endpoint a sign-in is checked against. See `runLogin`. */
|
|
20
27
|
const ENTITLED_PROBE = '/api/maps';
|
|
@@ -81,7 +88,7 @@ export async function runDeviceLogin(base, io) {
|
|
|
81
88
|
const account = await accountFor(base, token, io);
|
|
82
89
|
if (account === null)
|
|
83
90
|
return EXIT_ERROR;
|
|
84
|
-
writeCredential({ token, account, instance: base }, io.home);
|
|
91
|
+
writeCredential(keptProject({ token, account, instance: base }, io), io.home);
|
|
85
92
|
io.stdout(`Signed in to ${base} as ${account}\n`);
|
|
86
93
|
return EXIT_OK;
|
|
87
94
|
}
|
|
@@ -112,6 +119,25 @@ export async function runDeviceLogin(base, io) {
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* A new sign-in keeps the project chosen under the previous one only when it
|
|
124
|
+
* is the same account on the same instance (spec 052, founder 2026-10-25).
|
|
125
|
+
* A project id from another account or instance means nothing here, and
|
|
126
|
+
* keeping it would point every team map call at somebody else's choice. It is
|
|
127
|
+
* dropped, and that is said, so nobody wonders why they are back to every
|
|
128
|
+
* project's maps.
|
|
129
|
+
*/
|
|
130
|
+
function keptProject(next, io) {
|
|
131
|
+
const prior = readCredential(io.home);
|
|
132
|
+
if (!prior?.project)
|
|
133
|
+
return next;
|
|
134
|
+
if (prior.instance === next.instance && prior.account === next.account) {
|
|
135
|
+
return { ...next, project: prior.project };
|
|
136
|
+
}
|
|
137
|
+
io.stderr(`veilio: project ${prior.project} was chosen for ${prior.account} on ${prior.instance}, so it does not ` +
|
|
138
|
+
'carry over to this sign-in. Run `veilio projects use <name>` to choose one.\n');
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
115
141
|
/** Who the new session belongs to, asked of the server rather than assumed. */
|
|
116
142
|
async function accountFor(base, token, io) {
|
|
117
143
|
try {
|
|
@@ -215,7 +241,7 @@ export async function runLogin(instance, io, opts = {}) {
|
|
|
215
241
|
catch (err) {
|
|
216
242
|
return reportLoginFailure(err, io);
|
|
217
243
|
}
|
|
218
|
-
writeCredential(credential, io.home);
|
|
244
|
+
writeCredential(keptProject(credential, io), io.home);
|
|
219
245
|
io.stdout(`Signed in to ${base} as ${credential.account}\n`);
|
|
220
246
|
return EXIT_OK;
|
|
221
247
|
}
|
|
@@ -248,6 +274,7 @@ export async function runLogout(io) {
|
|
|
248
274
|
// branch where signing out quietly kept the more dangerous half.
|
|
249
275
|
try {
|
|
250
276
|
removeTeamUnlock(io.home);
|
|
277
|
+
removePersonalMapsKey(io.home);
|
|
251
278
|
removeRules(io.home);
|
|
252
279
|
removeCredential(io.home);
|
|
253
280
|
}
|
|
@@ -277,6 +304,15 @@ export async function runLogout(io) {
|
|
|
277
304
|
`(${err instanceof Error ? err.message : String(err)}). Delete ${teamUnlockPath(io.home)} by hand.\n`);
|
|
278
305
|
return EXIT_ERROR;
|
|
279
306
|
}
|
|
307
|
+
// The personal-maps key, for the same reason as the team keys.
|
|
308
|
+
try {
|
|
309
|
+
removePersonalMapsKey(io.home);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
io.stderr(`veilio: the session was revoked, but the unlocked personal-maps key could not be removed ` +
|
|
313
|
+
`(${err instanceof Error ? err.message : String(err)}). Delete ${personalMapsKeyPath(io.home)} by hand.\n`);
|
|
314
|
+
return EXIT_ERROR;
|
|
315
|
+
}
|
|
280
316
|
// The rules are the account's too, and a whitelist among them keeps names
|
|
281
317
|
// readable: they go with the session rather than outliving it.
|
|
282
318
|
try {
|
|
@@ -314,7 +350,16 @@ export function runWhoami(io) {
|
|
|
314
350
|
io.stdout('Not signed in. Run `veilio login` to connect a Veilio Cloud account.\n');
|
|
315
351
|
return EXIT_OK;
|
|
316
352
|
}
|
|
317
|
-
|
|
353
|
+
// Still no request: the variable is reported as set, not resolved - which
|
|
354
|
+
// project it names is `veilio projects`'s question, and needs the network.
|
|
355
|
+
const override = projectOverride(io.env);
|
|
356
|
+
io.stdout(`${credential.account} on ${credential.instance}` +
|
|
357
|
+
(override
|
|
358
|
+
? `, project ${override} (from ${PROJECT_ENV})`
|
|
359
|
+
: credential.project
|
|
360
|
+
? `, project ${credential.project}`
|
|
361
|
+
: '') +
|
|
362
|
+
'\n');
|
|
318
363
|
return EXIT_OK;
|
|
319
364
|
}
|
|
320
365
|
/**
|
|
@@ -365,277 +410,121 @@ async function requirePassword(io, label) {
|
|
|
365
410
|
throw new Error('this build cannot read a password interactively');
|
|
366
411
|
return io.password(label);
|
|
367
412
|
}
|
|
368
|
-
// ─── maps: list, pull, push
|
|
413
|
+
// ─── maps: list, pull, push, update, rm ──────────────────────────────────────
|
|
414
|
+
//
|
|
415
|
+
// The work is done in `client/maps.ts`, which the MCP server's map tools call
|
|
416
|
+
// too (spec 052 §3). What is left here is the terminal's half: asking for the
|
|
417
|
+
// passphrase, and saying what happened - results on stdout, refusals on
|
|
418
|
+
// stderr, in the words of the shared table.
|
|
419
|
+
/** The client's context for a terminal run: the person can be asked. */
|
|
420
|
+
function clientContext(io) {
|
|
421
|
+
return {
|
|
422
|
+
home: io.home,
|
|
423
|
+
passphrase: io.password ? () => io.password('Vault passphrase: ') : undefined,
|
|
424
|
+
env: io.env,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** A refusal on stderr, and the exit code that goes with it. */
|
|
428
|
+
function refused(refusal, io) {
|
|
429
|
+
io.stderr(renderRefusal(refusal));
|
|
430
|
+
return EXIT_ERROR;
|
|
431
|
+
}
|
|
432
|
+
function notes(lines, io) {
|
|
433
|
+
for (const line of lines)
|
|
434
|
+
io.stderr(`veilio: ${line}\n`);
|
|
435
|
+
}
|
|
369
436
|
/**
|
|
370
437
|
* List what the account holds.
|
|
371
438
|
*
|
|
372
439
|
* Prints the id, because `pull` takes one. A name would be friendlier and is
|
|
373
440
|
* 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.
|
|
441
|
+
* somebody pulls a different map over the one they were using. A teammate's
|
|
442
|
+
* team map is marked, because it is theirs to update or remove.
|
|
375
443
|
*/
|
|
376
444
|
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) {
|
|
445
|
+
const listed = await listCloudMaps(clientContext(io));
|
|
446
|
+
if (!listed.ok)
|
|
447
|
+
return refused(listed.refusal, io);
|
|
448
|
+
const { maps, project, projectFromEnv } = listed.value;
|
|
449
|
+
if (maps.length === 0) {
|
|
389
450
|
io.stdout('No maps in Cloud yet. `veilio maps push <name>` uploads the local one.\n');
|
|
390
451
|
return EXIT_OK;
|
|
391
452
|
}
|
|
392
|
-
for (const map of
|
|
393
|
-
const count = map.
|
|
394
|
-
|
|
395
|
-
|
|
453
|
+
for (const map of maps) {
|
|
454
|
+
const count = map.count === null ? '?' : String(map.count);
|
|
455
|
+
const whose = map.scope === 'team' && !map.mine ? ' (a teammate’s)' : '';
|
|
456
|
+
io.stdout(`${map.id} ${map.scope.padEnd(8)} ${count.padStart(5)} symbols ${map.name}${whose}\n`);
|
|
457
|
+
}
|
|
458
|
+
// Said on stderr so the listing itself stays parseable: which project's team
|
|
459
|
+
// maps these are, or that no project was chosen.
|
|
460
|
+
if (project)
|
|
461
|
+
io.stderr(projectFromEnv
|
|
462
|
+
? `veilio: team maps of project ${project} - ${PROJECT_ENV} chose it, so \`veilio projects use\` does not change it here\n`
|
|
463
|
+
: `veilio: team maps of project ${project} (\`veilio projects\` to change)\n`);
|
|
396
464
|
return EXIT_OK;
|
|
397
465
|
}
|
|
398
466
|
/**
|
|
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.
|
|
467
|
+
* Pull a map into the local store: decrypt FIRST, write second (see
|
|
468
|
+
* `pullCloudMap`). A personal map is opened HERE - the passphrase never leaves
|
|
469
|
+
* this machine, and there is no code path in this package that could send it.
|
|
444
470
|
*/
|
|
445
471
|
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;
|
|
472
|
+
const pulled = await pullCloudMap(clientContext(io), id, { mapPath, force: options.force });
|
|
473
|
+
if (!pulled.ok)
|
|
474
|
+
return refused(pulled.refusal, io);
|
|
475
|
+
const { name, count, path } = pulled.value;
|
|
476
|
+
io.stdout(`Pulled "${name}" (${count} symbols) into ${path}\n`);
|
|
477
|
+
return EXIT_OK;
|
|
478
|
+
}
|
|
479
|
+
/** Upload the local map, personal or the team's. Same endpoint, same quotas,
|
|
480
|
+
* same validation as the web app. */
|
|
481
|
+
export async function runMapsPush(name, mapPath, io, toTeam = false) {
|
|
482
|
+
const created = await createCloudMap(clientContext(io), {
|
|
483
|
+
name,
|
|
484
|
+
scope: toTeam ? 'team' : 'personal',
|
|
485
|
+
mapPath,
|
|
486
|
+
});
|
|
487
|
+
if (!created.ok)
|
|
488
|
+
return refused(created.refusal, io);
|
|
489
|
+
const c = created.value;
|
|
490
|
+
notes(c.notes, io);
|
|
491
|
+
if (c.scope === 'personal') {
|
|
492
|
+
io.stdout(`Pushed ${c.count} symbols to Cloud as ${c.id}\n`);
|
|
493
|
+
return EXIT_OK;
|
|
547
494
|
}
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
495
|
+
const disputed = c.disputed ?? 0;
|
|
496
|
+
io.stdout(`Published "${c.name}" (${c.count} symbols) to the team as ${c.id}\n` +
|
|
497
|
+
`The team's shared set now holds ${c.teamTotal} identifiers` +
|
|
498
|
+
(disputed > 0
|
|
499
|
+
? `, ${disputed} of which ${disputed === 1 ? 'is' : 'are'} disputed and will not restore\n`
|
|
500
|
+
: ', none disputed\n'));
|
|
501
|
+
return EXIT_OK;
|
|
502
|
+
}
|
|
503
|
+
/** Replace a Cloud map's entries with the local map's, in place (spec 052). */
|
|
504
|
+
export async function runMapsUpdate(id, mapPath, io, options = {}) {
|
|
505
|
+
const updated = await updateCloudMap(clientContext(io), id, {
|
|
506
|
+
mapPath,
|
|
507
|
+
allowDrop: options.allowDrop,
|
|
552
508
|
});
|
|
553
|
-
|
|
509
|
+
if (!updated.ok)
|
|
510
|
+
return refused(updated.refusal, io);
|
|
511
|
+
const u = updated.value;
|
|
512
|
+
notes(u.notes, io);
|
|
513
|
+
io.stdout(`Updated "${u.name}" (${u.id}) in place: ${u.count} symbols` +
|
|
514
|
+
(u.dropped > 0 ? `, ${u.dropped} dropped as asked` : '') +
|
|
515
|
+
`${u.scope === 'team' ? ', sealed with the team key' : ''}\n`);
|
|
554
516
|
return EXIT_OK;
|
|
555
517
|
}
|
|
556
518
|
/**
|
|
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.
|
|
519
|
+
* Remove a map from Cloud. Renaming and removing are allowed while a key
|
|
520
|
+
* rotation is owed, because neither stores anything under the key.
|
|
571
521
|
*/
|
|
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`);
|
|
522
|
+
export async function runMapsRm(id, io) {
|
|
523
|
+
const removed = await removeCloudMap(clientContext(io), id);
|
|
524
|
+
if (!removed.ok)
|
|
525
|
+
return refused(removed.refusal, io);
|
|
526
|
+
const { name } = removed.value;
|
|
527
|
+
io.stdout(name ? `Removed "${name}" (${removed.value.id})\n` : `Removed ${removed.value.id}\n`);
|
|
639
528
|
return EXIT_OK;
|
|
640
529
|
}
|
|
641
530
|
function notSignedIn(io) {
|
|
@@ -710,43 +599,57 @@ export async function runTeamUnlock(io, days) {
|
|
|
710
599
|
return EXIT_ERROR;
|
|
711
600
|
}
|
|
712
601
|
const mine = await getUserKeys(credential);
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
return
|
|
717
|
-
}
|
|
718
|
-
const
|
|
602
|
+
// No keypair: there is nothing to unwrap a team key with, nor to wrap a
|
|
603
|
+
// first one to. The web app makes it on the first vault unlock.
|
|
604
|
+
if (!mine.initialized)
|
|
605
|
+
return refused(refusal('keys_not_set_up'), io);
|
|
606
|
+
let { wraps } = await getTeamKeyWraps(credential, teamId);
|
|
607
|
+
const derived = await vaultKeyFor(credential, clientContext(io).passphrase);
|
|
608
|
+
if (!derived.ok)
|
|
609
|
+
return refused(derived.refusal, io);
|
|
610
|
+
const vaultKey = derived.value;
|
|
611
|
+
// The keypair, and its public half computed from the private one: a
|
|
612
|
+
// public key the server hands back is never trusted on its own word, not
|
|
613
|
+
// for a wrap's context and above all not for wrapping a key to (security
|
|
614
|
+
// review). A server answer that differs stops everything.
|
|
615
|
+
const own = await openOwnKeypair(vaultKey, mine.privateKeyEncrypted, mine.publicKey);
|
|
616
|
+
if (!own.ok)
|
|
617
|
+
return refused(refusal('own_public_key_mismatch'), io);
|
|
618
|
+
const privateKey = own.privateKey;
|
|
619
|
+
const myPublicKey = own.publicKey;
|
|
620
|
+
// None held here: the team may have no key at all - a team of one always
|
|
621
|
+
// starts that way - so try to mint its first one, as the web app does.
|
|
622
|
+
// The server says whether that is a bootstrap; a refusal means a key
|
|
623
|
+
// exists (or this member may not start one), never a reason to mint again.
|
|
624
|
+
let minted = null;
|
|
719
625
|
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;
|
|
626
|
+
const tried = await bootstrapTeamKey(credential, teamId, {
|
|
627
|
+
privateKey,
|
|
628
|
+
publicKey: myPublicKey,
|
|
629
|
+
});
|
|
630
|
+
if (tried.status === 'readback_mismatch')
|
|
631
|
+
return refused(refusal('team_key_readback_mismatch'), io);
|
|
632
|
+
if (tried.status === 'refused') {
|
|
633
|
+
if (tried.reason && /owner or an admin/i.test(tried.reason))
|
|
634
|
+
return refused(refusal('team_key_not_startable', { detail: tried.reason }), io);
|
|
635
|
+
// A team of one has nobody to ask, and must not be told to.
|
|
636
|
+
if (await isTeamOfOne(credential)) {
|
|
637
|
+
return refused(refusal('team_key_solo_unavailable', {
|
|
638
|
+
detail: tried.reason ?? 'the server did not accept a first key',
|
|
639
|
+
}), io);
|
|
640
|
+
}
|
|
641
|
+
return refused(refusal('team_key_waiting'), io);
|
|
642
|
+
}
|
|
643
|
+
minted = { version: tried.version };
|
|
644
|
+
({ wraps } = await getTeamKeyWraps(credential, teamId));
|
|
741
645
|
}
|
|
742
|
-
const privateKey = await unwrapPrivateKey(vaultKey, mine.privateKeyEncrypted);
|
|
743
646
|
const keys = [];
|
|
744
647
|
for (const wrap of wraps) {
|
|
745
648
|
try {
|
|
746
649
|
const teamKey = await unwrapTeamKey(wrap.wrapped_key, privateKey, {
|
|
747
650
|
teamId,
|
|
748
651
|
version: wrap.version,
|
|
749
|
-
myPublicKey
|
|
652
|
+
myPublicKey,
|
|
750
653
|
});
|
|
751
654
|
keys.push({ version: wrap.version, key: await exportTeamKey(teamKey) });
|
|
752
655
|
}
|
|
@@ -759,7 +662,7 @@ export async function runTeamUnlock(io, days) {
|
|
|
759
662
|
}
|
|
760
663
|
if (keys.length === 0) {
|
|
761
664
|
io.stderr('veilio: none of the team key wraps held for this account could be opened. ' +
|
|
762
|
-
'
|
|
665
|
+
'Grant it again from the team page in the web app, then run `veilio team unlock` again.\n');
|
|
763
666
|
return EXIT_ERROR;
|
|
764
667
|
}
|
|
765
668
|
const now = new Date();
|
|
@@ -773,6 +676,10 @@ export async function runTeamUnlock(io, days) {
|
|
|
773
676
|
keys,
|
|
774
677
|
}, io.home);
|
|
775
678
|
const versions = keys.map((k) => `v${k.version}`).join(', ');
|
|
679
|
+
if (minted) {
|
|
680
|
+
io.stdout(`Set up the team key (v${minted.version}): the team had none, so this account made its first one ` +
|
|
681
|
+
'and holds it. It was never sent anywhere unwrapped.\n');
|
|
682
|
+
}
|
|
776
683
|
io.stdout(`Unlocked ${keys.length === 1 ? 'the team key' : 'team keys'} (${versions}) until ` +
|
|
777
684
|
`${expiresAt}.\n` +
|
|
778
685
|
`Stored in ${teamUnlockPath(io.home)}, readable only by you. ` +
|
|
@@ -783,6 +690,17 @@ export async function runTeamUnlock(io, days) {
|
|
|
783
690
|
return reportCloudFailure(err, io);
|
|
784
691
|
}
|
|
785
692
|
}
|
|
693
|
+
/** Whether this account is its team's only member. Unknown counts as not:
|
|
694
|
+
* the general wording is still true for a team of one, just less helpful. */
|
|
695
|
+
async function isTeamOfOne(credential) {
|
|
696
|
+
try {
|
|
697
|
+
const members = (await getMyTeam(credential)).team?.members;
|
|
698
|
+
return Array.isArray(members) && members.length === 1;
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
786
704
|
/** Remove the unlocked team keys. */
|
|
787
705
|
export function runTeamLock(io) {
|
|
788
706
|
try {
|
|
@@ -800,16 +718,84 @@ export function runTeamLock(io) {
|
|
|
800
718
|
io.stdout('Team keys locked.\n');
|
|
801
719
|
return EXIT_OK;
|
|
802
720
|
}
|
|
721
|
+
// ─── projects ────────────────────────────────────────────────────────────────
|
|
722
|
+
/** The team's projects, the one in use marked with `*`. */
|
|
723
|
+
export async function runProjectsList(io) {
|
|
724
|
+
const listed = await listTeamProjects(clientContext(io));
|
|
725
|
+
if (!listed.ok)
|
|
726
|
+
return refused(listed.refusal, io);
|
|
727
|
+
const { lines, note } = formatProjectsList(listed.value, { surface: 'cli' });
|
|
728
|
+
for (const line of lines)
|
|
729
|
+
io.stdout(`${line}\n`);
|
|
730
|
+
if (note)
|
|
731
|
+
io.stderr(`veilio: ${note}\n`);
|
|
732
|
+
return EXIT_OK;
|
|
733
|
+
}
|
|
734
|
+
/** Choose the project this sign-in works in. */
|
|
735
|
+
export async function runProjectsUse(ref, io) {
|
|
736
|
+
const used = await useProject(clientContext(io), ref);
|
|
737
|
+
if (!used.ok)
|
|
738
|
+
return refused(used.refusal, io);
|
|
739
|
+
io.stdout(`${usedSentence(used.value, { surface: 'cli' })}\n`);
|
|
740
|
+
if (used.value.overriddenBy !== null)
|
|
741
|
+
io.stderr(`veilio: ${overriddenSentence(used.value, 'shell')}\n`);
|
|
742
|
+
return EXIT_OK;
|
|
743
|
+
}
|
|
744
|
+
// ─── vault: unlock, lock ─────────────────────────────────────────────────────
|
|
745
|
+
/**
|
|
746
|
+
* Unlock the personal-maps key for processes that cannot ask for a passphrase
|
|
747
|
+
* (spec 052 FR-011). See `client/keys.ts` for what is kept: that key, and
|
|
748
|
+
* nothing else the vault protects.
|
|
749
|
+
*/
|
|
750
|
+
export async function runVaultUnlock(io, days) {
|
|
751
|
+
const unlocked = await vaultUnlock(clientContext(io), { days });
|
|
752
|
+
if (!unlocked.ok)
|
|
753
|
+
return refused(unlocked.refusal, io);
|
|
754
|
+
const u = unlocked.value;
|
|
755
|
+
io.stdout(`${u.created ? 'Created and unlocked' : 'Unlocked'} the personal-maps key until ${u.expiresAt}.\n` +
|
|
756
|
+
`Stored in ${u.path}, readable only by you. It opens your personal maps and nothing else; ` +
|
|
757
|
+
'the vault passphrase and key were not kept. Run `veilio vault lock` to remove it.\n' +
|
|
758
|
+
(u.resealed > 0
|
|
759
|
+
? `Re-sealed ${u.resealed} personal map${u.resealed === 1 ? '' : 's'} under it, so the MCP server can open ${u.resealed === 1 ? 'it' : 'them'}.\n`
|
|
760
|
+
: ''));
|
|
761
|
+
if (u.failed.length > 0) {
|
|
762
|
+
io.stderr(`veilio: ${u.failed.length} personal map${u.failed.length === 1 ? '' : 's'} could not be re-sealed and ` +
|
|
763
|
+
`${u.failed.length === 1 ? 'is' : 'are'} still under the vault key, which the MCP server cannot open:\n` +
|
|
764
|
+
u.failed.map((f) => ` "${f.name}" (${f.id}): ${f.reason}\n`).join('') +
|
|
765
|
+
'Run `veilio vault unlock` again to retry them; the key itself is unlocked.\n');
|
|
766
|
+
return EXIT_ERROR;
|
|
767
|
+
}
|
|
768
|
+
return EXIT_OK;
|
|
769
|
+
}
|
|
770
|
+
/** Remove the personal-maps key from this machine. */
|
|
771
|
+
export function runVaultLock(io) {
|
|
772
|
+
try {
|
|
773
|
+
if (!vaultLock(clientContext(io))) {
|
|
774
|
+
io.stdout('No personal-maps key was unlocked.\n');
|
|
775
|
+
return EXIT_OK;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
io.stderr(`veilio: the personal-maps key could not be removed ` +
|
|
780
|
+
`(${err instanceof Error ? err.message : String(err)}). ` +
|
|
781
|
+
`Delete ${personalMapsKeyPath(io.home)} by hand.\n`);
|
|
782
|
+
return EXIT_ERROR;
|
|
783
|
+
}
|
|
784
|
+
io.stdout('Personal-maps key locked.\n');
|
|
785
|
+
return EXIT_OK;
|
|
786
|
+
}
|
|
803
787
|
// ─── restore: the team layer ────────────────────────────────────────────────
|
|
804
788
|
/**
|
|
805
789
|
* The team's maps for `veilio restore` (spec 028), handed to the local command
|
|
806
790
|
* as a callback so that command stays unlinked from the network. Signed out:
|
|
807
791
|
* no request, no layer - a local restore, as before.
|
|
808
792
|
*/
|
|
809
|
-
export async function teamLayerFor(home, missing) {
|
|
793
|
+
export async function teamLayerFor(home, missing, env) {
|
|
810
794
|
// Signed out, the resolver answers 'local' without a request. At most 5 s: a
|
|
811
795
|
// hung instance must not stall a `... | veilio restore > file` pipeline.
|
|
812
|
-
const found = await resolveTeamNamespace(home, { deadlineMs: RESTORE_CLOUD_WAIT_MS });
|
|
796
|
+
const found = await resolveTeamNamespace(home, { deadlineMs: RESTORE_CLOUD_WAIT_MS, env });
|
|
797
|
+
if (found.status === 'refused')
|
|
798
|
+
return { team: null, note: null, refused: renderRefusal(found.refusal) };
|
|
813
799
|
if (found.status === 'team' && found.conflictDetection)
|
|
814
800
|
return { team: found.analysis, note: null };
|
|
815
801
|
return { team: null, note: teamLayerNote(found, missing) };
|
|
@@ -817,8 +803,10 @@ export async function teamLayerFor(home, missing) {
|
|
|
817
803
|
const RESTORE_CLOUD_WAIT_MS = 5000;
|
|
818
804
|
/** What `veilio scrub` numbers from (spec 030): the team's namespace, or this
|
|
819
805
|
* project's map with the reason. Signed out or in no team: no request, no note. */
|
|
820
|
-
export async function teamNamespaceForScrub(home) {
|
|
821
|
-
const found = await resolveTeamNamespace(home, { deadlineMs: RESTORE_CLOUD_WAIT_MS });
|
|
806
|
+
export async function teamNamespaceForScrub(home, env) {
|
|
807
|
+
const found = await resolveTeamNamespace(home, { deadlineMs: RESTORE_CLOUD_WAIT_MS, env });
|
|
808
|
+
if (found.status === 'refused')
|
|
809
|
+
return { team: null, note: null, refused: renderRefusal(found.refusal) };
|
|
822
810
|
if (found.status === 'team') {
|
|
823
811
|
const credential = readCredential(home);
|
|
824
812
|
const teamId = found.teamId;
|
|
@@ -853,179 +841,7 @@ export async function teamNamespaceForScrub(home) {
|
|
|
853
841
|
};
|
|
854
842
|
return { team: null, note: null };
|
|
855
843
|
}
|
|
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
|
-
}
|
|
844
|
+
/** Kept importable from here: tests and callers written before spec 052 moved
|
|
845
|
+
* it into the shared client. */
|
|
846
|
+
export { conflictsWithTeam } from './client/maps.js';
|
|
1031
847
|
//# sourceMappingURL=cloud-commands.js.map
|