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