@sharedenv/cli 1.1.1 → 2.0.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/bin/sharedenv.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // sharedenv — pull and run with your zero-knowledge secrets, decrypted locally. The
3
- // master password and the private key never leave this process and never touch disk;
4
- // only the session cookies and the public keys you vouched for are kept (~/.sharedenv).
2
+ // sharedenv — your zero-knowledge vaults in the terminal, decrypted locally. The master
3
+ // password and the private key never leave this process and never touch disk; only the
4
+ // session cookies and the public keys you vouched for are kept (~/.sharedenv).
5
5
  //
6
6
  // This file is the terminal: arguments, questions, output, exit codes. What happens to
7
7
  // keys and secrets lives in ../lib.
@@ -12,21 +12,54 @@ import { Writable } from 'stream';
12
12
  import { parseArgs } from 'util';
13
13
  import { fingerprintOf } from '../lib/crypto.js';
14
14
  import { apiBase, createClient, expectOk, listAll } from '../lib/api.js';
15
- import { login, publicKeyOf, unlockAccount } from '../lib/auth.js';
16
- import { syncLog, watchWarnings } from '../lib/transparency.js';
15
+ import { login, publicKeyOf, unlockAccount, whoAmI } from '../lib/auth.js';
16
+ import {
17
+ acknowledgeAlerts,
18
+ heldKeys,
19
+ keptAlerts,
20
+ syncLog,
21
+ watchWarnings,
22
+ } from '../lib/transparency.js';
17
23
  import { childEnv, splitUnsafeEnv } from '../lib/env.js';
18
24
  import { FORMATS } from '../lib/format.js';
25
+ import { LINK_FILE, findLink, writeLink } from '../lib/link.js';
19
26
  import { runChild } from '../lib/run.js';
20
- import { pullSecrets, resolveSafe } from '../lib/safes.js';
27
+ import {
28
+ LEVELS,
29
+ SECRET_NAME,
30
+ accessOf,
31
+ createVault,
32
+ describeVault,
33
+ levelName,
34
+ listSecretRows,
35
+ listVaults,
36
+ openVault,
37
+ pullSecrets,
38
+ readSecret,
39
+ readVault,
40
+ resolveSafe,
41
+ setSecrets,
42
+ unsetSecrets,
43
+ } from '../lib/safes.js';
21
44
  import {
22
45
  acceptShare,
23
46
  acceptTeamShare,
24
47
  declineShare,
25
48
  declineTeamShare,
26
49
  listPendingShares,
50
+ personLabel,
27
51
  resolvePendingShare,
28
52
  senderLabel,
29
53
  } from '../lib/shares.js';
54
+ import {
55
+ assertPersonLogged,
56
+ listTeams,
57
+ lookupPerson,
58
+ resolveTeam,
59
+ shareWithPerson,
60
+ shareWithTeam,
61
+ teamLabel,
62
+ } from '../lib/sharing.js';
30
63
  import { createStore, writePrivate } from '../lib/store.js';
31
64
  import { pinKey, pinStatus, senderVerdict, teamKeyVerdict } from '../lib/trust.js';
32
65
 
@@ -35,13 +68,12 @@ const { version: VERSION } = JSON.parse(
35
68
  );
36
69
  const DEFAULT_API = 'https://api.sharedenv.com';
37
70
  const USER_AGENT = `sharedenv-cli/${VERSION}`;
38
- const LEVEL = { 10: 'read', 50: 'write', 90: 'admin' };
39
71
 
40
72
  const store = createStore();
41
73
 
42
74
  // ── Terminal ────────────────────────────────────────────────────────────────────────
43
75
  // Questions and notices go to stderr: stdout carries data only, so
44
- // `sharedenv pull prod > .env` writes the secrets and nothing else.
76
+ // `sharedenv pull > .env` writes the secrets and nothing else.
45
77
 
46
78
  const say = (text) => process.stdout.write(`${text}\n`);
47
79
  const note = (text) => process.stderr.write(`${text}\n`);
@@ -95,6 +127,13 @@ async function masterPassword() {
95
127
  return password;
96
128
  }
97
129
 
130
+ // Everything piped in, as text.
131
+ async function readStdin() {
132
+ const chunks = [];
133
+ for await (const chunk of process.stdin) chunks.push(chunk);
134
+ return Buffer.concat(chunks).toString('utf8');
135
+ }
136
+
98
137
  // ── Session ─────────────────────────────────────────────────────────────────────────
99
138
 
100
139
  let connected = null; // { client, session } of this run, to save renewed cookies
@@ -122,28 +161,159 @@ function saveSession() {
122
161
  connected.session.cookies = cookies;
123
162
  }
124
163
 
125
- // Each time the vault opens: is the key the public log holds for us still ours? A
126
- // warning, never a refusal - reading your own secrets seals nothing to anyone.
127
- async function watchOwnKey(client, { privateKey, userId }) {
164
+ // Each time the vault opens: are the keys the public log holds for us - our own, and
165
+ // those of the teams whose key we hold - still the ones we hold? A warning, never a
166
+ // refusal: reading your own secrets seals nothing to anyone. What it found is kept
167
+ // (~/.sharedenv/log.json) and said again at every unlock until `sharedenv log --ack`,
168
+ // even when the log cannot be read this time.
169
+ // → the watch, for every later read of the log in this run (a read that watches less
170
+ // would let entries go by unchecked).
171
+ async function watchKeys(client, account) {
172
+ const { watch, labels } = await heldKeys(client, account);
173
+ let report;
128
174
  try {
129
- const report = await syncLog(client, {
130
- store,
131
- watch: { [`user:${userId}`]: publicKeyOf(privateKey) },
132
- });
133
- for (const line of watchWarnings(report)) note(line);
175
+ report = await syncLog(client, { store, watch });
134
176
  } catch (err) {
135
177
  note(`WARNING: ${err.message}.`);
178
+ report = { alerts: keptAlerts(store) };
136
179
  }
180
+ for (const line of watchWarnings(report, labels)) note(line);
181
+ return watch;
137
182
  }
138
183
 
139
- async function unlock() {
140
- const client = connect();
184
+ async function unlock(client) {
141
185
  const account = await unlockAccount(client, await masterPassword());
142
- await watchOwnKey(client, account);
143
- return { client, privateKey: account.privateKey };
186
+ const watch = await watchKeys(client, account);
187
+ return { privateKey: account.privateKey, watch };
188
+ }
189
+
190
+ // ── Which vault ─────────────────────────────────────────────────────────────────────
191
+ // `-v <name or id>`, else the vault this project is linked to (`sharedenv link`). A name
192
+ // several of your vaults answer to is refused with the candidates (lib/safes.js).
193
+
194
+ const VAULT_OPTION = { vault: { type: 'string', short: 'v' } };
195
+
196
+ async function targetVault(client, values) {
197
+ if (values.vault) return resolveSafe(client, values.vault);
198
+ const link = findLink();
199
+ if (link) return link.vault;
200
+ throw new UsageError('which vault? pass -v <vault>, or run `sharedenv link` in this project');
144
201
  }
145
202
 
146
- // ── Commands ────────────────────────────────────────────────────────────────────────
203
+ // Said by every command that works on a vault: a link you did not write yourself (a
204
+ // cloned repository) may name another vault than the one you expect.
205
+ const using = (safe) => note(`sharedenv: vault "${safe.name}" (${safe._id})`);
206
+
207
+ // The link of this directory, for display only: a broken one is not an error here.
208
+ function linkedHere() {
209
+ try {
210
+ return findLink()?.vault ?? null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ const byName = (a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
217
+
218
+ // ── Trust ───────────────────────────────────────────────────────────────────────────
219
+
220
+ // Is `publicKey` the key of `who`, as this machine knows them? A key seen before passes;
221
+ // a new one is shown and asked about; a CHANGED one is never taken without its own yes
222
+ // (or --accept-key-change). --fingerprint settles it for a script. A yes pins the key in
223
+ // `pins` — the caller saves them once everything is agreed.
224
+ async function vouchFor(pins, { subject, publicKey, who }, values, { question, changed }) {
225
+ const status = pinStatus(pins, subject, publicKey);
226
+ const fingerprint = fingerprintOf(publicKey);
227
+ note(` key fingerprint ${fingerprint}${status === 'match' ? ' (known on this machine)' : ''}`);
228
+ if (status === 'changed') {
229
+ note(
230
+ ` WARNING: this machine knew ANOTHER key for ${who} ` +
231
+ `(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
232
+ );
233
+ } else if (status === 'new') {
234
+ note(' First time this machine sees this key: compare it with them.');
235
+ }
236
+ const verdict = senderVerdict(status, fingerprint, {
237
+ expected: values.fingerprint,
238
+ acceptKeyChange: values['accept-key-change'],
239
+ yes: values.yes,
240
+ });
241
+ if (verdict === 'refuse') {
242
+ note(' That is not the --fingerprint you gave.');
243
+ return false;
244
+ }
245
+ if (
246
+ verdict === 'ask' &&
247
+ !(await confirm(
248
+ status === 'changed' ? `${changed} Confirm it with them first. [y/N] ` : `${question} [y/N] `,
249
+ status === 'changed'
250
+ ? 'pass --fingerprint <fingerprint>, checked with them'
251
+ : 'pass --yes, or --fingerprint <fingerprint> checked with them'
252
+ ))
253
+ ) {
254
+ return false;
255
+ }
256
+ pinKey(pins, subject, publicKey);
257
+ return true;
258
+ }
259
+
260
+ // A team share sealed to the team's PREVIOUS key moves to its current key on accept.
261
+ // Unless we sealed that key ourselves, it must be the one this machine knows for the
262
+ // team, or its fingerprint is confirmed — by a human, or by --team-fingerprint.
263
+ async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
264
+ const subject = `team:${share.teamId}`;
265
+ const status = pinStatus(pins, subject, teamPublicKey);
266
+ const fingerprint = fingerprintOf(teamPublicKey);
267
+ const verdict = teamKeyVerdict(status, fingerprint, { expected: values['team-fingerprint'] });
268
+ if (verdict !== 'accept') {
269
+ note(
270
+ ` The share moves to the current key of team "${share.team?.name || share.teamId}", ` +
271
+ `fingerprint ${fingerprint}` +
272
+ (status === 'changed'
273
+ ? `; this machine knew ANOTHER key for it (${fingerprintOf(pins[subject])}).`
274
+ : ', never seen on this machine.')
275
+ );
276
+ }
277
+ if (verdict === 'refuse') {
278
+ note(' That is not the --team-fingerprint you gave.');
279
+ return false;
280
+ }
281
+ if (
282
+ verdict === 'ask' &&
283
+ !(await confirm(
284
+ 'Trust this team key? Confirm it with a lead of the team first. [y/N] ',
285
+ 'pass --team-fingerprint <fingerprint>, checked with a lead of the team'
286
+ ))
287
+ ) {
288
+ return false;
289
+ }
290
+ pinKey(pins, subject, teamPublicKey);
291
+ return true;
292
+ }
293
+
294
+ // The human step of `accept`: who sealed the share, with what key, as this machine
295
+ // knows it — then yes or no.
296
+ function confirmShare(values) {
297
+ return async (opened) => {
298
+ const { share, senderKey } = opened;
299
+ const pins = store.readPins();
300
+ note(`"${share.safe?.name}" from ${senderLabel(share)}`);
301
+ const sender = {
302
+ subject: `user:${share.from?._id}`,
303
+ publicKey: senderKey,
304
+ who: senderLabel(share),
305
+ };
306
+ const asked = { question: 'Accept this share?', changed: 'Accept their NEW key?' };
307
+ if (!(await vouchFor(pins, sender, values, asked))) return false;
308
+ if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
309
+ return false;
310
+ }
311
+ store.writePins(pins);
312
+ return true;
313
+ };
314
+ }
315
+
316
+ // ── Commands: session ───────────────────────────────────────────────────────────────
147
317
 
148
318
  async function cmdLogin({ values }) {
149
319
  const previous = store.readSession();
@@ -176,68 +346,57 @@ async function cmdLogin({ values }) {
176
346
  });
177
347
  store.writeSession({ baseUrl, cookies: client.cookies() });
178
348
  note(`Signed in to ${baseUrl}.`);
179
- await watchOwnKey(client, account);
180
- }
181
-
182
- async function cmdPull({ values, positionals: [ref] }) {
183
- const render = FORMATS[values.format];
184
- if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
185
- const { client, privateKey } = await unlock();
186
- const secrets = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
187
- const body = render(secrets);
188
- const text = body ? `${body}\n` : '';
189
- if (values.out) {
190
- writePrivate(values.out, text);
191
- note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
192
- } else {
193
- process.stdout.write(text);
194
- }
349
+ await watchKeys(client, account);
195
350
  }
196
351
 
197
- async function cmdRun({ values, positionals: [ref], command: [cmd, ...args] }) {
198
- const { client, privateKey } = await unlock();
199
- const pulled = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
200
- const { safe, blocked, kept } = splitUnsafeEnv(pulled, {
201
- base: process.env,
202
- allowUnsafe: values['allow-unsafe-env'],
203
- override: values['override-env'],
204
- });
205
- if (blocked.length) {
206
- note(
207
- `sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
208
- 'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
209
- );
352
+ async function cmdLogout() {
353
+ const session = store.readSession();
354
+ if (!session?.baseUrl) {
355
+ note('Not signed in.');
356
+ return;
210
357
  }
211
- if (kept.length) {
358
+ try {
359
+ const client = createClient({
360
+ baseUrl: apiBase(session.baseUrl),
361
+ cookies: session.cookies,
362
+ userAgent: USER_AGENT,
363
+ });
364
+ const res = await client.call('POST', '/auth/sign-out');
365
+ if (res.status !== 401) expectOk(res, 'sign-out refused'); // 401: already over
366
+ note('Signed out.');
367
+ } catch (err) {
212
368
  note(
213
- `sharedenv: not overriding ${kept.sort().join(', ')} — your environment already sets ` +
214
- 'them (--override-env to let the vault replace them).'
369
+ `sharedenv: the server did not close the session (${err.message}) — it stays valid ` +
370
+ 'there until it expires. Forgotten on this machine.'
215
371
  );
372
+ process.exitCode = 1;
216
373
  }
217
- saveSession(); // the command may run for days
218
- process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
374
+ store.clearSession();
219
375
  }
220
376
 
221
- async function cmdList() {
377
+ // ── Commands: vaults ────────────────────────────────────────────────────────────────
378
+
379
+ // Names only: no password needed (vault and key names are stored in clear).
380
+ async function cmdVaults() {
222
381
  const client = connect();
223
- const nameOf = (row) => (row.safe?.name ?? row.name ?? '').toLowerCase();
224
- const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
225
- const safes = (await listAll(client, '/safes', 'safes')).sort(byName);
382
+ const vaults = (await listVaults(client)).sort(byName);
383
+ const me = await whoAmI(client);
226
384
  const { direct, team } = await listPendingShares(client);
227
- const lines = [safes.length ? 'Vaults:' : 'No vaults yet.'];
228
- for (const s of safes) {
229
- const via = Array.isArray(s.via) && !s.via.includes('direct') ? ' (via team)' : '';
230
- lines.push(` ${s.name} ${s._id} ${LEVEL[s.level] || s.level}${via}`);
385
+ const linked = linkedHere();
386
+ const lines = [vaults.length ? 'Vaults:' : 'No vaults yet: sharedenv create <name>.'];
387
+ for (const row of vaults) {
388
+ lines.push(` ${describeVault(row, me)}${String(row._id) === linked ? ' (linked here)' : ''}`);
231
389
  }
390
+ const pendingName = (p) => ({ name: p.safe?.name });
232
391
  if (direct.length || team.length) {
233
392
  lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
234
- for (const p of direct.sort(byName)) {
393
+ for (const p of direct.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
235
394
  lines.push(
236
- ` ${p.safe?.name} ${p.safeId} ${LEVEL[p.level] || p.level} from ${senderLabel(p)}` +
395
+ ` ${p.safe?.name} ${p.safeId} ${levelName(p.level)} from ${senderLabel(p)}` +
237
396
  ` key ${p.fingerprint || 'none on file'}`
238
397
  );
239
398
  }
240
- for (const p of team.sort(byName)) {
399
+ for (const p of team.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
241
400
  lines.push(
242
401
  ` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
243
402
  ` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
@@ -248,144 +407,344 @@ async function cmdList() {
248
407
  say(lines.join('\n'));
249
408
  }
250
409
 
251
- // A team share sealed to the team's PREVIOUS key moves to its current key on accept.
252
- // Unless we sealed that key ourselves, it must be the one this machine knows for the
253
- // team, or its fingerprint is confirmed — by a human, or by --team-fingerprint.
254
- async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
255
- const subject = `team:${share.teamId}`;
256
- const status = pinStatus(pins, subject, teamPublicKey);
257
- const fingerprint = fingerprintOf(teamPublicKey);
258
- const verdict = teamKeyVerdict(status, fingerprint, { expected: values['team-fingerprint'] });
259
- if (verdict !== 'accept') {
410
+ async function cmdCreate({ positionals: [name] }) {
411
+ const client = connect();
412
+ const { privateKey } = await unlock(client);
413
+ const safe = await createVault(client, privateKey, name);
414
+ note(`Created vault "${safe.name}". Use it in a project: sharedenv link ${safe._id}`);
415
+ say(String(safe._id));
416
+ }
417
+
418
+ // No argument: the vaults are listed and you pick one.
419
+ async function cmdLink({ positionals: [ref] }) {
420
+ const client = connect();
421
+ const safeId = ref ? await resolveSafe(client, ref) : await pickVault(client);
422
+ const { safe } = await readVault(client, safeId); // you can open it, and its name
423
+ const file = writeLink(process.cwd(), safe._id);
424
+ note(
425
+ `Linked to vault "${safe.name}" (${safe._id}). ${file} holds its id and no secret: ` +
426
+ 'commit it, and your team works on the same vault.'
427
+ );
428
+ }
429
+
430
+ async function pickVault(client) {
431
+ const vaults = (await listVaults(client)).sort(byName);
432
+ if (vaults.length === 0) throw new Error('no vault yet: sharedenv create <name>');
433
+ if (!process.stdin.isTTY) {
434
+ throw new UsageError('no terminal to choose in: name the vault, sharedenv link <vault>');
435
+ }
436
+ const me = await whoAmI(client);
437
+ vaults.forEach((row, i) => note(` ${i + 1}. ${describeVault(row, me)}`));
438
+ const n = Number(await ask(`Vault to link (1-${vaults.length}): `));
439
+ if (!Number.isInteger(n) || n < 1 || n > vaults.length) throw new UsageError('no vault chosen');
440
+ return String(vaults[n - 1]._id);
441
+ }
442
+
443
+ // Names only, like `vaults`: no password needed.
444
+ async function cmdShow({ values }) {
445
+ const client = connect();
446
+ const safeId = await targetVault(client, values);
447
+ const vault = await readVault(client, safeId);
448
+ const { safe } = vault;
449
+ const me = await whoAmI(client);
450
+ const members = await listAll(client, `/members/${safeId}`, 'members');
451
+ const keys = (await listSecretRows(client, safeId)).map((r) => r.key).sort();
452
+
453
+ // Admins see every team the vault is shared with; others, the teams they reach it by.
454
+ const teams = vault.grants
455
+ ? vault.grants.map((g) => ({
456
+ label: g.teamName || String(g.teamId),
457
+ level: g.level,
458
+ pending: g.status === 'pending',
459
+ }))
460
+ : (safe.teams || []).map((t) => ({
461
+ label: teamLabel(t),
462
+ level: (vault.teamAccess || []).find((a) => String(a.teamId) === String(t._id))?.level,
463
+ }));
464
+
465
+ const owner = me && String(safe.ownerId) === me ? 'you' : personLabel(safe.owner);
466
+ const waiting = ' (has not accepted yet)';
467
+ const lines = [
468
+ `${safe.name} ${safe._id}`,
469
+ `Owner: ${owner} Your access: ${levelName(accessOf(vault))}`,
470
+ '',
471
+ 'Members:',
472
+ ...members.map(
473
+ (m) =>
474
+ ` ${String(m.userId) === me ? 'you' : personLabel(m.user)} ${levelName(m.level)}` +
475
+ (m.status === 'pending' ? waiting : '')
476
+ ),
477
+ ...(teams.length
478
+ ? [
479
+ 'Teams:',
480
+ ...teams.map((t) => ` ${t.label} ${levelName(t.level)}${t.pending ? waiting : ''}`),
481
+ ]
482
+ : []),
483
+ keys.length ? `Keys (${keys.length}):` : 'No keys yet: sharedenv set <KEY>=<value>.',
484
+ ...keys.map((key) => ` ${key}`),
485
+ ];
486
+ say(lines.join('\n'));
487
+ if (safe.pendingRotation) {
260
488
  note(
261
- ` The share moves to the current key of team "${share.team?.name || share.teamId}", ` +
262
- `fingerprint ${fingerprint}` +
263
- (status === 'changed'
264
- ? `; this machine knew ANOTHER key for it (${fingerprintOf(pins[subject])}).`
265
- : ', never seen on this machine.')
489
+ 'This vault must change its key (someone lost access to it): until an admin opens it ' +
490
+ 'in the web app, nothing new can be written to it.'
266
491
  );
267
492
  }
268
- if (verdict === 'refuse') {
269
- note(' That is not the --team-fingerprint you gave.');
270
- return false;
493
+ }
494
+
495
+ // ── Commands: keys ──────────────────────────────────────────────────────────────────
496
+
497
+ async function cmdGet({ values, positionals: [key] }) {
498
+ const client = connect();
499
+ const safeId = await targetVault(client, values);
500
+ const { privateKey } = await unlock(client);
501
+ const { safe, value } = await readSecret(client, privateKey, safeId, key);
502
+ using(safe);
503
+ // Nothing added to the value in a pipe or a file; a newline for the terminal's prompt.
504
+ process.stdout.write(process.stdout.isTTY ? `${value}\n` : value);
505
+ }
506
+
507
+ // KEY=value pairs, and at most one KEY alone, whose value is asked (hidden) or read
508
+ // from stdin — so that it lands neither in the shell's history nor in `ps`.
509
+ function parseAssignments(args) {
510
+ const entries = [];
511
+ let bare = null;
512
+ for (const arg of args) {
513
+ const eq = arg.indexOf('=');
514
+ const key = eq < 0 ? arg : arg.slice(0, eq);
515
+ if (!SECRET_NAME.test(key)) {
516
+ throw new UsageError(`"${key}" is not a key name: letters, digits, _ and - only`);
517
+ }
518
+ if (entries.some(([k]) => k === key) || bare === key) {
519
+ throw new UsageError(`"${key}" is given twice`);
520
+ }
521
+ if (eq >= 0) entries.push([key, arg.slice(eq + 1)]);
522
+ else if (bare) throw new UsageError('one key without a value at most: its value is asked');
523
+ else bare = key;
271
524
  }
272
- if (
273
- verdict === 'ask' &&
274
- !(await confirm(
275
- 'Trust this team key? Confirm it with a lead of the team first. [y/N] ',
276
- 'pass --team-fingerprint <fingerprint>, checked with a lead of the team'
277
- ))
278
- ) {
279
- return false;
525
+ return { entries, bare };
526
+ }
527
+
528
+ async function valueOf(key) {
529
+ if (process.stdin.isTTY) {
530
+ const value = await ask(`Value of ${key}: `, { secret: true });
531
+ if (value === null || value === '') throw new UsageError(`no value for ${key}`);
532
+ return value;
280
533
  }
281
- pinKey(pins, subject, teamPublicKey);
282
- return true;
534
+ // Piped: what came in, without the newline `echo` or a file ends with.
535
+ const value = (await readStdin()).replace(/\r?\n$/, '');
536
+ if (value === '') throw new UsageError(`no value for ${key} on stdin (an empty one: ${key}=)`);
537
+ return value;
283
538
  }
284
539
 
285
- // The human step of `accept`: who sealed the share, with what key, as this machine
286
- // knows it — then yes or no. A key that CHANGED since it was pinned is never taken
287
- // without an explicit yes (or --accept-key-change).
288
- function confirmShare(values) {
289
- return async (opened) => {
290
- const { share, senderKey, fingerprint } = opened;
291
- const pins = store.readPins();
292
- const subject = `user:${share.from?._id}`;
293
- const status = pinStatus(pins, subject, senderKey);
294
- note(`"${share.safe?.name}" from ${senderLabel(share)}`);
540
+ async function cmdSet({ values, positionals }) {
541
+ const { entries, bare } = parseAssignments(positionals);
542
+ const client = connect();
543
+ const safeId = await targetVault(client, values);
544
+ const { privateKey } = await unlock(client);
545
+ if (bare) entries.push([bare, await valueOf(bare)]);
546
+ const { safe, added, replaced } = await setSecrets(client, privateKey, safeId, entries);
547
+ using(safe);
548
+ note(
549
+ [
550
+ ...(added.length ? [`added ${added.join(', ')}`] : []),
551
+ ...(replaced.length ? [`replaced ${replaced.join(', ')}`] : []),
552
+ ].join('; ')
553
+ );
554
+ }
555
+
556
+ async function cmdUnset({ values, positionals: keys }) {
557
+ const client = connect();
558
+ const safeId = await targetVault(client, values);
559
+ const { safe } = await readVault(client, safeId);
560
+ using(safe);
561
+ await unsetSecrets(client, safeId, keys);
562
+ note(`removed ${keys.join(', ')}`);
563
+ }
564
+
565
+ async function cmdRun({ values, command: [cmd, ...args] }) {
566
+ const client = connect();
567
+ const safeId = await targetVault(client, values);
568
+ const { privateKey } = await unlock(client);
569
+ const { safe: vault, secrets } = await pullSecrets(client, privateKey, safeId);
570
+ using(vault);
571
+ const { safe, blocked, kept } = splitUnsafeEnv(secrets, {
572
+ base: process.env,
573
+ allowUnsafe: values['allow-unsafe-env'],
574
+ override: values['override-env'],
575
+ });
576
+ if (blocked.length) {
295
577
  note(
296
- ` key fingerprint ${fingerprint}${status === 'match' ? ' (known on this machine)' : ''}`
578
+ `sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
579
+ 'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
297
580
  );
298
- if (status === 'changed') {
299
- note(
300
- ` WARNING: this machine knew ANOTHER key for ${senderLabel(share)} ` +
301
- `(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
302
- );
303
- } else if (status === 'new') {
304
- note(' First time this machine sees this key: compare it with them.');
305
- }
306
- const verdict = senderVerdict(status, fingerprint, {
307
- expected: values.fingerprint,
308
- acceptKeyChange: values['accept-key-change'],
309
- yes: values.yes,
310
- });
311
- if (verdict === 'refuse') {
312
- note(' That is not the --fingerprint you gave.');
313
- return false;
314
- }
315
- if (
316
- verdict === 'ask' &&
317
- !(await confirm(
318
- status === 'changed'
319
- ? 'Accept their NEW key? Confirm it with them first. [y/N] '
320
- : 'Accept this share? [y/N] ',
321
- status === 'changed'
322
- ? 'pass --fingerprint <fingerprint>, checked with them'
323
- : 'pass --yes, or --fingerprint <fingerprint> checked with them'
324
- ))
325
- ) {
326
- return false;
327
- }
328
- if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
329
- return false;
330
- }
331
- store.writePins(pinKey(pins, subject, senderKey));
332
- return true;
333
- };
581
+ }
582
+ if (kept.length) {
583
+ note(
584
+ `sharedenv: not overriding ${kept.sort().join(', ')} — your environment already sets ` +
585
+ 'them (--override-env to let the vault replace them).'
586
+ );
587
+ }
588
+ saveSession(); // the command may run for days
589
+ process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
590
+ }
591
+
592
+ async function cmdPull({ values }) {
593
+ const render = FORMATS[values.format];
594
+ if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
595
+ const client = connect();
596
+ const safeId = await targetVault(client, values);
597
+ const { privateKey } = await unlock(client);
598
+ const { safe, secrets } = await pullSecrets(client, privateKey, safeId);
599
+ using(safe);
600
+ const body = render(secrets);
601
+ const text = body ? `${body}\n` : '';
602
+ if (values.out) {
603
+ writePrivate(values.out, text);
604
+ note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
605
+ } else {
606
+ process.stdout.write(text);
607
+ }
608
+ }
609
+
610
+ // ── Commands: sharing ───────────────────────────────────────────────────────────────
611
+
612
+ function levelOf(values) {
613
+ const chosen = Object.keys(LEVELS).filter((name) => values[name]);
614
+ if (chosen.length > 1) throw new UsageError('one of --read, --write, --admin');
615
+ return LEVELS[chosen[0] || 'read'];
616
+ }
617
+
618
+ async function cmdShare({ values, positionals: [email] }) {
619
+ if (Boolean(email) === Boolean(values.team)) {
620
+ throw new UsageError('share with a person (an email) or a team (--team <team>), one of them');
621
+ }
622
+ const level = levelOf(values);
623
+ const client = connect();
624
+ const safeId = await targetVault(client, values);
625
+ const { privateKey, watch } = await unlock(client);
626
+ const vault = await openVault(client, privateKey, safeId);
627
+ using(vault.safe);
628
+
629
+ if (values.team) {
630
+ const team = await resolveTeam(client, values.team);
631
+ const status = await shareWithTeam(client, privateKey, vault, team, level);
632
+ note(
633
+ `Shared with team ${teamLabel(team)} (${levelName(level)}).` +
634
+ (status === 'pending' ? ' It waits for a lead of the team to accept it.' : '')
635
+ );
636
+ return;
637
+ }
638
+
639
+ const person = await lookupPerson(client, email);
640
+ const who = person.username ? `${person.username} (${email})` : email;
641
+ await assertPersonLogged(client, { store, watch }, person, who);
642
+ note(`Sharing "${vault.safe.name}" with ${who}`);
643
+ const pins = store.readPins();
644
+ const ok = await vouchFor(
645
+ pins,
646
+ { subject: `user:${person.userId}`, publicKey: person.publicKey, who },
647
+ values,
648
+ { question: 'Seal this vault to this key?', changed: 'Seal it to their NEW key?' }
649
+ );
650
+ if (!ok) {
651
+ note('Not shared.');
652
+ process.exitCode = 1;
653
+ return;
654
+ }
655
+ store.writePins(pins);
656
+ const status = await shareWithPerson(client, privateKey, vault, person, level);
657
+ note(
658
+ `Shared with ${who} (${levelName(level)}).` +
659
+ (status === 'pending' ? ' It waits for their answer: sharedenv accept, on their side.' : '')
660
+ );
334
661
  }
335
662
 
336
663
  async function cmdAccept({ values, positionals: [ref] }) {
337
- const { client, privateKey } = await unlock();
338
- const safeId = await resolvePendingShare(client, ref, values.team);
339
- const confirm = confirmShare(values);
340
- const log = { store };
341
- const result = values.team
342
- ? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm, log })
343
- : await acceptShare(client, privateKey, safeId, { confirm, log });
664
+ const client = connect();
665
+ const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
666
+ const { privateKey, watch } = await unlock(client);
667
+ const confirmIt = confirmShare(values);
668
+ const log = { store, watch };
669
+ const result = teamId
670
+ ? await acceptTeamShare(client, privateKey, teamId, safeId, { confirm: confirmIt, log })
671
+ : await acceptShare(client, privateKey, safeId, { confirm: confirmIt, log });
344
672
  note(result.accepted ? 'Accepted.' : 'Left pending.');
345
673
  }
346
674
 
347
675
  async function cmdDecline({ values, positionals: [ref] }) {
348
676
  const client = connect();
349
- const safeId = await resolvePendingShare(client, ref, values.team);
350
- if (values.team) await declineTeamShare(client, values.team, safeId);
677
+ const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
678
+ if (teamId) await declineTeamShare(client, teamId, safeId);
351
679
  else await declineShare(client, safeId);
352
680
  note('Declined.');
353
681
  }
354
682
 
683
+ async function cmdTeams() {
684
+ const client = connect();
685
+ const teams = await listTeams(client);
686
+ if (teams.length === 0) {
687
+ say('No team yet: teams are made on the web app.');
688
+ return;
689
+ }
690
+ const lines = [];
691
+ const orgs = new Map();
692
+ for (const team of teams) {
693
+ const key = String(team.org._id);
694
+ if (!orgs.has(key)) orgs.set(key, { org: team.org, teams: [] });
695
+ orgs.get(key).teams.push(team);
696
+ }
697
+ for (const { org, teams: rows } of orgs.values()) {
698
+ lines.push(`${org.name} ${org._id}`);
699
+ for (const t of rows.sort(byName)) {
700
+ const count = t.memberCount === undefined ? '' : ` ${t.memberCount} member(s)`;
701
+ lines.push(` ${t.name} ${t._id}${count}`);
702
+ }
703
+ }
704
+ say(lines.join('\n'));
705
+ }
706
+
707
+ // ── Commands: trust ─────────────────────────────────────────────────────────────────
708
+
355
709
  // Derived from YOUR private key, never the server's copy: the value a teammate checks a
356
710
  // key against must not be one the server chose.
357
711
  async function cmdFingerprint() {
358
- const { privateKey } = await unlock();
712
+ const { privateKey } = await unlock(connect());
359
713
  say(fingerprintOf(publicKeyOf(privateKey)));
360
714
  }
361
715
 
362
- async function cmdLogout() {
363
- const session = store.readSession();
364
- if (!session?.baseUrl) {
365
- note('Not signed in.');
716
+ // What the public key log check found on this machine and nobody acknowledged yet. Read
717
+ // from what is kept, without the network: the warnings are the data (stdout).
718
+ function cmdLog({ values }) {
719
+ const alerts = values.ack ? acknowledgeAlerts(store) : keptAlerts(store);
720
+ if (alerts.length === 0) {
721
+ note('No warning from the public key log on this machine.');
366
722
  return;
367
723
  }
368
- try {
369
- const client = createClient({
370
- baseUrl: apiBase(session.baseUrl),
371
- cookies: session.cookies,
372
- userAgent: USER_AGENT,
373
- });
374
- const res = await client.call('POST', '/auth/sign-out');
375
- if (res.status !== 401) expectOk(res, 'sign-out refused'); // 401: already over
376
- note('Signed out.');
377
- } catch (err) {
378
- note(
379
- `sharedenv: the server did not close the session (${err.message}) — it stays valid ` +
380
- 'there until it expires. Forgotten on this machine.'
381
- );
382
- process.exitCode = 1;
383
- }
384
- store.clearSession();
724
+ const lines = watchWarnings({ alerts });
725
+ say(lines.slice(0, -1).join('\n'));
726
+ note(
727
+ values.ack
728
+ ? 'Acknowledged: a warning that is still true comes back at the next unlock.'
729
+ : lines[lines.length - 1]
730
+ );
385
731
  }
386
732
 
733
+ // ── The catalogue ───────────────────────────────────────────────────────────────────
734
+ // `positionals`: [min, max]. `takesCommand`: what follows `--` is the command to run.
735
+
736
+ const TRUST_OPTIONS = {
737
+ yes: { type: 'boolean', short: 'y' },
738
+ fingerprint: { type: 'string' },
739
+ 'accept-key-change': { type: 'boolean' },
740
+ };
741
+
742
+ const VAULT_DETAIL =
743
+ 'The vault: -v <name or id>, or the one this project is linked to (sharedenv link).';
744
+
387
745
  const COMMANDS = {
388
746
  login: {
747
+ group: 'Session',
389
748
  usage: 'login [--api <url>] [--identifier <email or username>]',
390
749
  summary: 'Sign in (keeps the session cookies only)',
391
750
  details:
@@ -393,93 +752,224 @@ const COMMANDS = {
393
752
  '(or SHAREDENV_IDENTIFIER), SHAREDENV_PASSWORD, and SHAREDENV_OTP if the account\n' +
394
753
  'has a second factor.',
395
754
  options: { api: { type: 'string' }, identifier: { type: 'string' } },
396
- positionals: 0,
755
+ positionals: [0, 0],
397
756
  run: cmdLogin,
398
757
  },
399
- pull: {
400
- usage: 'pull <vault> [--out <file>] [--format dotenv|json]',
401
- summary: 'Print or write the decrypted secrets',
758
+ logout: {
759
+ group: 'Session',
760
+ usage: 'logout',
761
+ summary: 'Close the session, on the server and here',
762
+ options: {},
763
+ positionals: [0, 0],
764
+ run: cmdLogout,
765
+ },
766
+ vaults: {
767
+ group: 'Vaults',
768
+ usage: 'vaults',
769
+ summary: 'Your vaults, and the shares waiting for your answer',
402
770
  details:
403
- '<vault> is a vault name or id. --out writes the file readable by you only. A dotenv\n' +
404
- 'file is safe to source in a shell: values are quoted so that none is expanded.',
405
- options: { out: { type: 'string' }, format: { type: 'string', default: 'dotenv' } },
406
- positionals: 1,
407
- run: cmdPull,
771
+ 'Each vault with its id, your access, its owner and the teams you reach it through:\n' +
772
+ 'what tells two vaults of the same name apart. No password asked.',
773
+ options: {},
774
+ positionals: [0, 0],
775
+ run: cmdVaults,
776
+ },
777
+ create: {
778
+ group: 'Vaults',
779
+ usage: 'create <name>',
780
+ summary: 'Create a vault (prints its id)',
781
+ options: {},
782
+ positionals: [1, 1],
783
+ run: cmdCreate,
784
+ },
785
+ link: {
786
+ group: 'Vaults',
787
+ usage: 'link [<vault>]',
788
+ summary: `Link this directory to a vault (${LINK_FILE})`,
789
+ details:
790
+ `Writes ${LINK_FILE} here: the vault's id, no secret. Every command run in this\n` +
791
+ 'directory or below then works on that vault without -v. Commit the file and your\n' +
792
+ 'team works on the same vault. Without <vault>, you pick one from the list.',
793
+ options: {},
794
+ positionals: [0, 1],
795
+ run: cmdLink,
796
+ },
797
+ show: {
798
+ group: 'Vaults',
799
+ usage: 'show [-v <vault>]',
800
+ summary: 'The vault: owner, members, teams, and the names of its keys',
801
+ details: `${VAULT_DETAIL}\nNames only, never a value: no password asked.`,
802
+ options: VAULT_OPTION,
803
+ positionals: [0, 0],
804
+ run: cmdShow,
805
+ },
806
+ get: {
807
+ group: 'Keys',
808
+ usage: 'get <KEY> [-v <vault>]',
809
+ summary: 'Print the value of one key',
810
+ details:
811
+ `${VAULT_DETAIL}\n` +
812
+ 'The value is printed as is: nothing added in a pipe or a file, a newline in a terminal.',
813
+ options: VAULT_OPTION,
814
+ positionals: [1, 1],
815
+ run: cmdGet,
816
+ },
817
+ set: {
818
+ group: 'Keys',
819
+ usage: 'set <KEY>=<value>... [<KEY>] [-v <vault>]',
820
+ summary: 'Add or replace keys',
821
+ details:
822
+ `${VAULT_DETAIL}\n` +
823
+ 'A <KEY> given without a value has its value asked without echo, or read from stdin\n' +
824
+ '(`sharedenv set TLS_CERT < cert.pem`): it lands neither in your shell history nor in\n' +
825
+ 'the process list. Values are encrypted here before they leave.',
826
+ options: VAULT_OPTION,
827
+ positionals: [1, Infinity],
828
+ run: cmdSet,
829
+ },
830
+ unset: {
831
+ group: 'Keys',
832
+ usage: 'unset <KEY>... [-v <vault>]',
833
+ summary: 'Remove keys',
834
+ details: `${VAULT_DETAIL}\nEvery key must exist, or none is removed. No password asked.`,
835
+ options: VAULT_OPTION,
836
+ positionals: [1, Infinity],
837
+ run: cmdUnset,
408
838
  },
409
839
  run: {
410
- usage: 'run <vault> [--allow-unsafe-env] [--override-env] -- <command> [args...]',
411
- summary: 'Run a command with the secrets in its environment',
840
+ group: 'Keys',
841
+ usage: 'run [-v <vault>] [--allow-unsafe-env] [--override-env] -- <command> [args...]',
842
+ summary: 'Run a command with the keys in its environment',
412
843
  details:
844
+ `${VAULT_DETAIL}\n` +
413
845
  'The secrets reach the command in memory, never on disk. Held back unless asked:\n' +
414
846
  'variables that make the command run code or redirect its traffic (LD_*, PATH,\n' +
415
847
  'NODE_OPTIONS, *_PROXY...: --allow-unsafe-env), and variables your environment\n' +
416
848
  "already sets (--override-env). The exit code is the command's.",
417
- options: { 'allow-unsafe-env': { type: 'boolean' }, 'override-env': { type: 'boolean' } },
418
- positionals: 1,
849
+ options: {
850
+ ...VAULT_OPTION,
851
+ 'allow-unsafe-env': { type: 'boolean' },
852
+ 'override-env': { type: 'boolean' },
853
+ },
854
+ positionals: [0, 0],
419
855
  takesCommand: true,
420
856
  run: cmdRun,
421
857
  },
422
- list: {
423
- usage: 'list',
424
- summary: 'Your vaults, then the shares waiting for your answer',
425
- options: {},
426
- positionals: 0,
427
- run: cmdList,
858
+ pull: {
859
+ group: 'Keys',
860
+ usage: 'pull [-v <vault>] [--out <file>] [--format dotenv|json]',
861
+ summary: 'Print or write every key, as a .env or JSON',
862
+ details:
863
+ `${VAULT_DETAIL}\n` +
864
+ '--out writes the file readable by you only. A dotenv file is safe to source in a\n' +
865
+ 'shell: values are quoted so that none is expanded.',
866
+ options: {
867
+ ...VAULT_OPTION,
868
+ out: { type: 'string' },
869
+ format: { type: 'string', default: 'dotenv' },
870
+ },
871
+ positionals: [0, 0],
872
+ run: cmdPull,
873
+ },
874
+ share: {
875
+ group: 'Sharing',
876
+ usage:
877
+ 'share <email> | --team <team> [--read|--write|--admin] [-v <vault>]\n' +
878
+ ' [--yes] [--fingerprint <fp>] [--accept-key-change]',
879
+ summary: 'Share the vault with a person or one of your teams',
880
+ details:
881
+ `${VAULT_DETAIL}\n` +
882
+ 'Read access unless --write or --admin. A person: their key fingerprint is shown and\n' +
883
+ 'asked about, as for accept (compare it with them; --yes, --fingerprint and\n' +
884
+ '--accept-key-change answer for a script); the share then waits for their answer.\n' +
885
+ '--team: a team you are a member of (name or id, see `sharedenv teams`). Removing\n' +
886
+ "someone's access changes the vault's key: that is done in the web app.",
887
+ options: {
888
+ ...VAULT_OPTION,
889
+ ...TRUST_OPTIONS,
890
+ team: { type: 'string' },
891
+ read: { type: 'boolean' },
892
+ write: { type: 'boolean' },
893
+ admin: { type: 'boolean' },
894
+ },
895
+ positionals: [0, 1],
896
+ run: cmdShare,
428
897
  },
429
898
  accept: {
899
+ group: 'Sharing',
430
900
  usage:
431
- 'accept <vault> [--team <id>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
901
+ 'accept <vault> [--team <team>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
432
902
  ' [--team-fingerprint <fp>]',
433
903
  summary: 'Accept a share after checking who sealed it',
434
904
  details:
435
905
  "Shows the sender and their key's fingerprint, and asks. --yes: no question for a key\n" +
436
906
  'that is new or already known here. --fingerprint: accept only if the key is this\n' +
437
907
  'one. --accept-key-change: take a key that differs from the one known here. --team:\n' +
438
- 'a share to a team you lead; if its key must be confirmed, only a human or\n' +
439
- '--team-fingerprint can.',
440
- options: {
441
- team: { type: 'string' },
442
- yes: { type: 'boolean', short: 'y' },
443
- fingerprint: { type: 'string' },
444
- 'accept-key-change': { type: 'boolean' },
445
- 'team-fingerprint': { type: 'string' },
446
- },
447
- positionals: 1,
908
+ 'a share to a team you lead (name or id); if its key must be confirmed, only a human\n' +
909
+ 'or --team-fingerprint can.',
910
+ options: { ...TRUST_OPTIONS, team: { type: 'string' }, 'team-fingerprint': { type: 'string' } },
911
+ positionals: [1, 1],
448
912
  run: cmdAccept,
449
913
  },
450
914
  decline: {
451
- usage: 'decline <vault> [--team <id>]',
915
+ group: 'Sharing',
916
+ usage: 'decline <vault> [--team <team>]',
452
917
  summary: 'Decline a share',
453
918
  options: { team: { type: 'string' } },
454
- positionals: 1,
919
+ positionals: [1, 1],
455
920
  run: cmdDecline,
456
921
  },
922
+ teams: {
923
+ group: 'Sharing',
924
+ usage: 'teams',
925
+ summary: 'Your organizations and their teams',
926
+ details: 'Teams and organizations are managed in the web app.',
927
+ options: {},
928
+ positionals: [0, 0],
929
+ run: cmdTeams,
930
+ },
457
931
  fingerprint: {
932
+ group: 'Trust',
458
933
  usage: 'fingerprint',
459
934
  summary: 'Your public-key fingerprint, to read to a teammate',
460
935
  options: {},
461
- positionals: 0,
936
+ positionals: [0, 0],
462
937
  run: cmdFingerprint,
463
938
  },
464
- logout: {
465
- usage: 'logout',
466
- summary: 'Close the session, on the server and here',
467
- options: {},
468
- positionals: 0,
469
- run: cmdLogout,
939
+ log: {
940
+ group: 'Trust',
941
+ usage: 'log [--ack]',
942
+ summary: 'Warnings from the public key log, kept until acknowledged',
943
+ details:
944
+ 'Each unlock checks that the keys the public key log holds for you and for your teams\n' +
945
+ 'are the ones you hold. What it finds is kept on this machine and repeated at every\n' +
946
+ 'unlock. --ack: you have read them (and told SharedEnv); a warning that is still\n' +
947
+ 'true comes back at the next unlock.',
948
+ options: { ack: { type: 'boolean' } },
949
+ positionals: [0, 0],
950
+ run: cmdLog,
470
951
  },
471
952
  };
472
953
 
954
+ function commandList() {
955
+ const groups = new Map();
956
+ for (const [name, c] of Object.entries(COMMANDS)) {
957
+ if (!groups.has(c.group)) groups.set(c.group, []);
958
+ groups.get(c.group).push(` ${name.padEnd(12)} ${c.summary}`);
959
+ }
960
+ return [...groups].map(([group, lines]) => `${group}:\n${lines.join('\n')}`).join('\n\n');
961
+ }
962
+
473
963
  const HELP = `sharedenv ${VERSION} — zero-knowledge secrets, decrypted locally
474
964
 
475
965
  Usage: sharedenv <command> [options]
476
966
 
477
967
  Commands:
478
- ${Object.entries(COMMANDS)
479
- .map(([name, c]) => ` ${name.padEnd(12)} ${c.summary}`)
480
- .join('\n')}
481
968
 
482
- Run \`sharedenv help <command>\` for its options.
969
+ ${commandList()}
970
+
971
+ A command works on the vault named by -v <name or id>, or on the one the current
972
+ directory is linked to (sharedenv link). Run \`sharedenv help <command>\` for its options.
483
973
 
484
974
  Environment:
485
975
  SHAREDENV_API API URL (default ${DEFAULT_API})
@@ -501,7 +991,7 @@ async function main(argv) {
501
991
  if (!name || name === 'help' || name === '-h' || name === '--help') {
502
992
  return say(name === 'help' && rest[0] ? commandHelp(rest[0]) : HELP);
503
993
  }
504
- if (name === 'version' || name === '--version' || name === '-v') return say(VERSION);
994
+ if (name === 'version' || name === '--version') return say(VERSION);
505
995
 
506
996
  const command = COMMANDS[name];
507
997
  if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
@@ -516,10 +1006,9 @@ async function main(argv) {
516
1006
  throw new UsageError(`${err.message.split('. ')[0]} — see \`sharedenv help ${name}\``);
517
1007
  }
518
1008
  const shell = command.takesCommand ? rest.slice(sep + 1) : [];
519
- if (
520
- parsed.positionals.length !== command.positionals ||
521
- (command.takesCommand && (sep < 0 || shell.length === 0))
522
- ) {
1009
+ const [min, max] = command.positionals;
1010
+ const count = parsed.positionals.length;
1011
+ if (count < min || count > max || (command.takesCommand && (sep < 0 || shell.length === 0))) {
523
1012
  throw new UsageError(`usage: sharedenv ${command.usage}`);
524
1013
  }
525
1014
  try {