@sharedenv/cli 1.1.0 → 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,121 +161,100 @@ 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 };
144
188
  }
145
189
 
146
- // ── Commands ────────────────────────────────────────────────────────────────────────
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).
147
193
 
148
- async function cmdLogin({ values }) {
149
- const previous = store.readSession();
150
- const baseUrl = apiBase(
151
- values.api || process.env.SHAREDENV_API || previous?.baseUrl || DEFAULT_API
152
- );
153
- const identifier =
154
- values.identifier || process.env.SHAREDENV_IDENTIFIER || (await ask('Email or username: '));
155
- if (!identifier) {
156
- throw new UsageError(
157
- 'no identifier: run in a terminal, or pass --identifier (or set SHAREDENV_IDENTIFIER)'
158
- );
159
- }
160
- const password = await masterPassword();
161
- // Same server: sign in as the same device, so the server replaces the session it had
162
- // for this machine instead of keeping a second one alive.
163
- const cookies = previous?.baseUrl === baseUrl ? previous.cookies : {};
164
- const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
165
- const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
166
- await login(client, { identifier, password, otp });
167
- const account = await unlockAccount(client, password); // the vault opens: only then is anything kept
168
- store.writeSession({ baseUrl, cookies: client.cookies() });
169
- note(`Signed in to ${baseUrl}.`);
170
- await watchOwnKey(client, account);
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');
171
201
  }
172
202
 
173
- async function cmdPull({ values, positionals: [ref] }) {
174
- const render = FORMATS[values.format];
175
- if (!render) throw new UsageError(`unknown format "${values.format}": dotenv or json`);
176
- const { client, privateKey } = await unlock();
177
- const secrets = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
178
- const body = render(secrets);
179
- const text = body ? `${body}\n` : '';
180
- if (values.out) {
181
- writePrivate(values.out, text);
182
- note(`Wrote ${Object.keys(secrets).length} secrets to ${values.out}.`);
183
- } else {
184
- process.stdout.write(text);
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;
185
213
  }
186
214
  }
187
215
 
188
- async function cmdRun({ values, positionals: [ref], command: [cmd, ...args] }) {
189
- const { client, privateKey } = await unlock();
190
- const pulled = await pullSecrets(client, privateKey, await resolveSafe(client, ref));
191
- const { safe, blocked, kept } = splitUnsafeEnv(pulled, {
192
- base: process.env,
193
- allowUnsafe: values['allow-unsafe-env'],
194
- override: values['override-env'],
195
- });
196
- if (blocked.length) {
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') {
197
229
  note(
198
- `sharedenv: not injecting ${blocked.sort().join(', ')} — these variables can make the ` +
199
- 'command run code or redirect its traffic (--allow-unsafe-env to inject them anyway).'
230
+ ` WARNING: this machine knew ANOTHER key for ${who} ` +
231
+ `(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
200
232
  );
233
+ } else if (status === 'new') {
234
+ note(' First time this machine sees this key: compare it with them.');
201
235
  }
202
- if (kept.length) {
203
- note(
204
- `sharedenv: not overriding ${kept.sort().join(', ')} — your environment already sets ` +
205
- 'them (--override-env to let the vault replace them).'
206
- );
207
- }
208
- saveSession(); // the command may run for days
209
- process.exitCode = await runChild(cmd, args, childEnv(process.env, safe));
210
- }
211
-
212
- async function cmdList() {
213
- const client = connect();
214
- const nameOf = (row) => (row.safe?.name ?? row.name ?? '').toLowerCase();
215
- const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
216
- const safes = (await listAll(client, '/safes', 'safes')).sort(byName);
217
- const { direct, team } = await listPendingShares(client);
218
- const lines = [safes.length ? 'Vaults:' : 'No vaults yet.'];
219
- for (const s of safes) {
220
- const via = Array.isArray(s.via) && !s.via.includes('direct') ? ' (via team)' : '';
221
- lines.push(` ${s.name} ${s._id} ${LEVEL[s.level] || s.level}${via}`);
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;
222
244
  }
223
- if (direct.length || team.length) {
224
- lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
225
- for (const p of direct.sort(byName)) {
226
- lines.push(
227
- ` ${p.safe?.name} ${p.safeId} ${LEVEL[p.level] || p.level} from ${senderLabel(p)}` +
228
- ` key ${p.fingerprint || 'none on file'}`
229
- );
230
- }
231
- for (const p of team.sort(byName)) {
232
- lines.push(
233
- ` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
234
- ` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
235
- (p.provisioned ? '' : ' [you do not hold this team key yet]')
236
- );
237
- }
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;
238
255
  }
239
- say(lines.join('\n'));
256
+ pinKey(pins, subject, publicKey);
257
+ return true;
240
258
  }
241
259
 
242
260
  // A team share sealed to the team's PREVIOUS key moves to its current key on accept.
@@ -274,80 +292,61 @@ async function confirmTeamKey(pins, { share, teamPublicKey }, values) {
274
292
  }
275
293
 
276
294
  // The human step of `accept`: who sealed the share, with what key, as this machine
277
- // knows it — then yes or no. A key that CHANGED since it was pinned is never taken
278
- // without an explicit yes (or --accept-key-change).
295
+ // knows it — then yes or no.
279
296
  function confirmShare(values) {
280
297
  return async (opened) => {
281
- const { share, senderKey, fingerprint } = opened;
298
+ const { share, senderKey } = opened;
282
299
  const pins = store.readPins();
283
- const subject = `user:${share.from?._id}`;
284
- const status = pinStatus(pins, subject, senderKey);
285
300
  note(`"${share.safe?.name}" from ${senderLabel(share)}`);
286
- note(
287
- ` key fingerprint ${fingerprint}${status === 'match' ? ' (known on this machine)' : ''}`
288
- );
289
- if (status === 'changed') {
290
- note(
291
- ` WARNING: this machine knew ANOTHER key for ${senderLabel(share)} ` +
292
- `(${fingerprintOf(pins[subject])}). It changes only if they reset their keyring.`
293
- );
294
- } else if (status === 'new') {
295
- note(' First time this machine sees this key: compare it with them.');
296
- }
297
- const verdict = senderVerdict(status, fingerprint, {
298
- expected: values.fingerprint,
299
- acceptKeyChange: values['accept-key-change'],
300
- yes: values.yes,
301
- });
302
- if (verdict === 'refuse') {
303
- note(' That is not the --fingerprint you gave.');
304
- return false;
305
- }
306
- if (
307
- verdict === 'ask' &&
308
- !(await confirm(
309
- status === 'changed'
310
- ? 'Accept their NEW key? Confirm it with them first. [y/N] '
311
- : 'Accept this share? [y/N] ',
312
- status === 'changed'
313
- ? 'pass --fingerprint <fingerprint>, checked with them'
314
- : 'pass --yes, or --fingerprint <fingerprint> checked with them'
315
- ))
316
- ) {
317
- return false;
318
- }
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;
319
308
  if (opened.teamKeyVouched === false && !(await confirmTeamKey(pins, opened, values))) {
320
309
  return false;
321
310
  }
322
- store.writePins(pinKey(pins, subject, senderKey));
311
+ store.writePins(pins);
323
312
  return true;
324
313
  };
325
314
  }
326
315
 
327
- async function cmdAccept({ values, positionals: [ref] }) {
328
- const { client, privateKey } = await unlock();
329
- const safeId = await resolvePendingShare(client, ref, values.team);
330
- const confirm = confirmShare(values);
331
- const log = { store };
332
- const result = values.team
333
- ? await acceptTeamShare(client, privateKey, values.team, safeId, { confirm, log })
334
- : await acceptShare(client, privateKey, safeId, { confirm, log });
335
- note(result.accepted ? 'Accepted.' : 'Left pending.');
336
- }
337
-
338
- async function cmdDecline({ values, positionals: [ref] }) {
339
- const client = connect();
340
- const safeId = await resolvePendingShare(client, ref, values.team);
341
- if (values.team) await declineTeamShare(client, values.team, safeId);
342
- else await declineShare(client, safeId);
343
- note('Declined.');
344
- }
316
+ // ── Commands: session ───────────────────────────────────────────────────────────────
345
317
 
346
- // Derived from YOUR private key, never the server's copy: the value a teammate checks a
347
- // key against must not be one the server chose.
348
- async function cmdFingerprint() {
349
- const { privateKey } = await unlock();
350
- say(fingerprintOf(publicKeyOf(privateKey)));
318
+ async function cmdLogin({ values }) {
319
+ const previous = store.readSession();
320
+ const baseUrl = apiBase(
321
+ values.api || process.env.SHAREDENV_API || previous?.baseUrl || DEFAULT_API
322
+ );
323
+ const identifier =
324
+ values.identifier || process.env.SHAREDENV_IDENTIFIER || (await ask('Email or username: '));
325
+ if (!identifier) {
326
+ throw new UsageError(
327
+ 'no identifier: run in a terminal, or pass --identifier (or set SHAREDENV_IDENTIFIER)'
328
+ );
329
+ }
330
+ const password = await masterPassword();
331
+ // Same server: sign in as the same device, so the server replaces the session it had
332
+ // for this machine instead of keeping a second one alive.
333
+ const cookies = previous?.baseUrl === baseUrl ? previous.cookies : {};
334
+ const client = createClient({ baseUrl, cookies, userAgent: USER_AGENT });
335
+ const otp = async () => process.env.SHAREDENV_OTP || (await ask('Two-factor code: '));
336
+ await login(client, { identifier, password, otp });
337
+ // The vault opens: only then is anything kept. The server just accepted this password,
338
+ // so a vault it does not open was sealed under the one before a reset.
339
+ const account = await unlockAccount(client, password).catch((err) => {
340
+ if (err.code !== 'VAULT_SEALED') throw err;
341
+ throw new Error(
342
+ 'your password was reset, and the vault is still sealed under the old one. Sign in ' +
343
+ 'once on the web app and enter your recovery code: the vault then opens with the new ' +
344
+ 'password, here too'
345
+ );
346
+ });
347
+ store.writeSession({ baseUrl, cookies: client.cookies() });
348
+ note(`Signed in to ${baseUrl}.`);
349
+ await watchKeys(client, account);
351
350
  }
352
351
 
353
352
  async function cmdLogout() {
@@ -375,8 +374,377 @@ async function cmdLogout() {
375
374
  store.clearSession();
376
375
  }
377
376
 
377
+ // ── Commands: vaults ────────────────────────────────────────────────────────────────
378
+
379
+ // Names only: no password needed (vault and key names are stored in clear).
380
+ async function cmdVaults() {
381
+ const client = connect();
382
+ const vaults = (await listVaults(client)).sort(byName);
383
+ const me = await whoAmI(client);
384
+ const { direct, team } = await listPendingShares(client);
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)' : ''}`);
389
+ }
390
+ const pendingName = (p) => ({ name: p.safe?.name });
391
+ if (direct.length || team.length) {
392
+ lines.push('', 'Waiting for your answer (sharedenv accept|decline <vault>):');
393
+ for (const p of direct.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
394
+ lines.push(
395
+ ` ${p.safe?.name} ${p.safeId} ${levelName(p.level)} from ${senderLabel(p)}` +
396
+ ` key ${p.fingerprint || 'none on file'}`
397
+ );
398
+ }
399
+ for (const p of team.sort((a, b) => byName(pendingName(a), pendingName(b)))) {
400
+ lines.push(
401
+ ` ${p.safe?.name} ${p.safeId} for team ${p.team?.name} (--team ${p.teamId})` +
402
+ ` from ${senderLabel(p)} key ${p.fingerprint || 'none on file'}` +
403
+ (p.provisioned ? '' : ' [you do not hold this team key yet]')
404
+ );
405
+ }
406
+ }
407
+ say(lines.join('\n'));
408
+ }
409
+
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) {
488
+ note(
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.'
491
+ );
492
+ }
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;
524
+ }
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;
533
+ }
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;
538
+ }
539
+
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) {
577
+ note(
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).'
580
+ );
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
+ );
661
+ }
662
+
663
+ async function cmdAccept({ values, positionals: [ref] }) {
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 });
672
+ note(result.accepted ? 'Accepted.' : 'Left pending.');
673
+ }
674
+
675
+ async function cmdDecline({ values, positionals: [ref] }) {
676
+ const client = connect();
677
+ const { safeId, teamId } = await resolvePendingShare(client, ref, values.team);
678
+ if (teamId) await declineTeamShare(client, teamId, safeId);
679
+ else await declineShare(client, safeId);
680
+ note('Declined.');
681
+ }
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
+
709
+ // Derived from YOUR private key, never the server's copy: the value a teammate checks a
710
+ // key against must not be one the server chose.
711
+ async function cmdFingerprint() {
712
+ const { privateKey } = await unlock(connect());
713
+ say(fingerprintOf(publicKeyOf(privateKey)));
714
+ }
715
+
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.');
722
+ return;
723
+ }
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
+ );
731
+ }
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
+
378
745
  const COMMANDS = {
379
746
  login: {
747
+ group: 'Session',
380
748
  usage: 'login [--api <url>] [--identifier <email or username>]',
381
749
  summary: 'Sign in (keeps the session cookies only)',
382
750
  details:
@@ -384,93 +752,224 @@ const COMMANDS = {
384
752
  '(or SHAREDENV_IDENTIFIER), SHAREDENV_PASSWORD, and SHAREDENV_OTP if the account\n' +
385
753
  'has a second factor.',
386
754
  options: { api: { type: 'string' }, identifier: { type: 'string' } },
387
- positionals: 0,
755
+ positionals: [0, 0],
388
756
  run: cmdLogin,
389
757
  },
390
- pull: {
391
- usage: 'pull <vault> [--out <file>] [--format dotenv|json]',
392
- 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',
393
770
  details:
394
- '<vault> is a vault name or id. --out writes the file readable by you only. A dotenv\n' +
395
- 'file is safe to source in a shell: values are quoted so that none is expanded.',
396
- options: { out: { type: 'string' }, format: { type: 'string', default: 'dotenv' } },
397
- positionals: 1,
398
- 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,
399
838
  },
400
839
  run: {
401
- usage: 'run <vault> [--allow-unsafe-env] [--override-env] -- <command> [args...]',
402
- 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',
403
843
  details:
844
+ `${VAULT_DETAIL}\n` +
404
845
  'The secrets reach the command in memory, never on disk. Held back unless asked:\n' +
405
846
  'variables that make the command run code or redirect its traffic (LD_*, PATH,\n' +
406
847
  'NODE_OPTIONS, *_PROXY...: --allow-unsafe-env), and variables your environment\n' +
407
848
  "already sets (--override-env). The exit code is the command's.",
408
- options: { 'allow-unsafe-env': { type: 'boolean' }, 'override-env': { type: 'boolean' } },
409
- positionals: 1,
849
+ options: {
850
+ ...VAULT_OPTION,
851
+ 'allow-unsafe-env': { type: 'boolean' },
852
+ 'override-env': { type: 'boolean' },
853
+ },
854
+ positionals: [0, 0],
410
855
  takesCommand: true,
411
856
  run: cmdRun,
412
857
  },
413
- list: {
414
- usage: 'list',
415
- summary: 'Your vaults, then the shares waiting for your answer',
416
- options: {},
417
- positionals: 0,
418
- 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,
419
897
  },
420
898
  accept: {
899
+ group: 'Sharing',
421
900
  usage:
422
- 'accept <vault> [--team <id>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
901
+ 'accept <vault> [--team <team>] [--yes] [--fingerprint <fp>] [--accept-key-change]\n' +
423
902
  ' [--team-fingerprint <fp>]',
424
903
  summary: 'Accept a share after checking who sealed it',
425
904
  details:
426
905
  "Shows the sender and their key's fingerprint, and asks. --yes: no question for a key\n" +
427
906
  'that is new or already known here. --fingerprint: accept only if the key is this\n' +
428
907
  'one. --accept-key-change: take a key that differs from the one known here. --team:\n' +
429
- 'a share to a team you lead; if its key must be confirmed, only a human or\n' +
430
- '--team-fingerprint can.',
431
- options: {
432
- team: { type: 'string' },
433
- yes: { type: 'boolean', short: 'y' },
434
- fingerprint: { type: 'string' },
435
- 'accept-key-change': { type: 'boolean' },
436
- 'team-fingerprint': { type: 'string' },
437
- },
438
- 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],
439
912
  run: cmdAccept,
440
913
  },
441
914
  decline: {
442
- usage: 'decline <vault> [--team <id>]',
915
+ group: 'Sharing',
916
+ usage: 'decline <vault> [--team <team>]',
443
917
  summary: 'Decline a share',
444
918
  options: { team: { type: 'string' } },
445
- positionals: 1,
919
+ positionals: [1, 1],
446
920
  run: cmdDecline,
447
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
+ },
448
931
  fingerprint: {
932
+ group: 'Trust',
449
933
  usage: 'fingerprint',
450
934
  summary: 'Your public-key fingerprint, to read to a teammate',
451
935
  options: {},
452
- positionals: 0,
936
+ positionals: [0, 0],
453
937
  run: cmdFingerprint,
454
938
  },
455
- logout: {
456
- usage: 'logout',
457
- summary: 'Close the session, on the server and here',
458
- options: {},
459
- positionals: 0,
460
- 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,
461
951
  },
462
952
  };
463
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
+
464
963
  const HELP = `sharedenv ${VERSION} — zero-knowledge secrets, decrypted locally
465
964
 
466
965
  Usage: sharedenv <command> [options]
467
966
 
468
967
  Commands:
469
- ${Object.entries(COMMANDS)
470
- .map(([name, c]) => ` ${name.padEnd(12)} ${c.summary}`)
471
- .join('\n')}
472
968
 
473
- 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.
474
973
 
475
974
  Environment:
476
975
  SHAREDENV_API API URL (default ${DEFAULT_API})
@@ -492,7 +991,7 @@ async function main(argv) {
492
991
  if (!name || name === 'help' || name === '-h' || name === '--help') {
493
992
  return say(name === 'help' && rest[0] ? commandHelp(rest[0]) : HELP);
494
993
  }
495
- if (name === 'version' || name === '--version' || name === '-v') return say(VERSION);
994
+ if (name === 'version' || name === '--version') return say(VERSION);
496
995
 
497
996
  const command = COMMANDS[name];
498
997
  if (!command) throw new UsageError(`unknown command "${name}" — see \`sharedenv help\``);
@@ -507,10 +1006,9 @@ async function main(argv) {
507
1006
  throw new UsageError(`${err.message.split('. ')[0]} — see \`sharedenv help ${name}\``);
508
1007
  }
509
1008
  const shell = command.takesCommand ? rest.slice(sep + 1) : [];
510
- if (
511
- parsed.positionals.length !== command.positionals ||
512
- (command.takesCommand && (sep < 0 || shell.length === 0))
513
- ) {
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))) {
514
1012
  throw new UsageError(`usage: sharedenv ${command.usage}`);
515
1013
  }
516
1014
  try {