sealcode 0.1.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/src/cli.js ADDED
@@ -0,0 +1,683 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { Command } = require('commander');
6
+
7
+ const pkg = require('../package.json');
8
+ const { detectPreset, getPreset, listPresets } = require('./presets');
9
+ const {
10
+ CONFIG_FILE,
11
+ findProjectRoot,
12
+ loadConfig,
13
+ configExists,
14
+ } = require('./config');
15
+ const {
16
+ bootstrap,
17
+ unwrap,
18
+ saveSession,
19
+ loadSession,
20
+ clearSession,
21
+ isInitialized,
22
+ rotatePassphrase,
23
+ } = require('./keystore');
24
+ const { runInit } = require('./init');
25
+ const { runLock } = require('./seal');
26
+ const { runUnlock } = require('./open');
27
+ const { runVerify } = require('./verify');
28
+ const { runStatus, renderStatus, runPrecommitCheck } = require('./status');
29
+ const { parseRecoveryCode } = require('./recovery');
30
+ const { SealcodeError, reportError } = require('./errors');
31
+ const { hidden, confirm } = require('./prompt');
32
+ const { installHook, uninstallHook } = require('./hooks');
33
+ const { exportBundle, importBundle } = require('./bundle');
34
+ const { runLogin, runSignout, runWhoami } = require('./cli-auth');
35
+ const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
36
+ const { runShare, runGrants, runRevoke, runRedeem } = require('./cli-grants');
37
+ const ui = require('./ui');
38
+
39
+ function logger(verbose) {
40
+ return verbose ? (msg) => process.stdout.write(msg + '\n') : () => {};
41
+ }
42
+
43
+ function resolveProject(opts) {
44
+ const projectRoot = opts.project ? path.resolve(opts.project) : findProjectRoot(process.cwd());
45
+ return projectRoot;
46
+ }
47
+
48
+ /**
49
+ * Read the project passphrase from env vars. Prefer SEALCODE_PASSPHRASE; fall
50
+ * back to VAULTLINE_PASSPHRASE so anyone with the env var baked into their CI
51
+ * keeps working through the rename.
52
+ */
53
+ function passphraseFromEnv() {
54
+ return process.env.SEALCODE_PASSPHRASE || process.env.VAULTLINE_PASSPHRASE || '';
55
+ }
56
+
57
+ /**
58
+ * Resolve the active config: prefer the file on disk; if missing, fall back to
59
+ * the auto-detected preset's defaults. This lets the tool work in stealth
60
+ * mode where `.sealcoderc.json` isn't checked into the repo.
61
+ */
62
+ function getActiveConfig(projectRoot) {
63
+ const fromFile = loadConfig(projectRoot);
64
+ if (fromFile) return fromFile;
65
+ const preset = detectPreset(projectRoot);
66
+ return {
67
+ version: 1,
68
+ preset: preset.id,
69
+ lockedDir: preset.lockedDir,
70
+ include: preset.include,
71
+ exclude: preset.exclude,
72
+ stubs: preset.stubs || {},
73
+ _file: null,
74
+ _implicit: true,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Resolve the master key K for an existing vault, prompting the user as
80
+ * needed. Supports:
81
+ * - process.env.SEALCODE_PASSPHRASE (non-interactive)
82
+ * - process.env.VAULTLINE_PASSPHRASE (legacy non-interactive)
83
+ * - active session cache (skip scrypt for ~8h)
84
+ * - interactive passphrase prompt
85
+ * - interactive recovery-code prompt (with --recovery)
86
+ */
87
+ async function resolveKey(projectRoot, config, { useRecovery = false } = {}) {
88
+ if (!isInitialized(projectRoot, config.lockedDir)) {
89
+ throw new SealcodeError('SEALCODE_NO_MANIFEST');
90
+ }
91
+
92
+ if (!useRecovery) {
93
+ const cached = loadSession(projectRoot);
94
+ if (cached) return cached;
95
+ }
96
+
97
+ if (useRecovery) {
98
+ const code = await (process.stdin.isTTY
99
+ ? hidden('Recovery code:')
100
+ : require('./prompt').question('Recovery code:'));
101
+ let seed;
102
+ try {
103
+ seed = parseRecoveryCode(code);
104
+ } catch (err) {
105
+ throw new SealcodeError('SEALCODE_WRONG_KEY', { detail: err.message });
106
+ }
107
+ try {
108
+ const K = await unwrap(projectRoot, config.lockedDir, { recoverySeed: seed });
109
+ saveSession(projectRoot, K);
110
+ return K;
111
+ } catch (err) {
112
+ if (err.message === 'SEALCODE_WRONG_KEY') {
113
+ throw new SealcodeError('SEALCODE_WRONG_KEY');
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ const pp = passphraseFromEnv() || (await hidden('Passphrase:'));
120
+ if (!pp) throw new SealcodeError('SEALCODE_NO_KEY');
121
+ try {
122
+ const K = await unwrap(projectRoot, config.lockedDir, { passphrase: pp });
123
+ saveSession(projectRoot, K);
124
+ return K;
125
+ } catch (err) {
126
+ if (err.message === 'SEALCODE_WRONG_KEY') {
127
+ throw new SealcodeError('SEALCODE_WRONG_KEY');
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ /** Session or SEALCODE_PASSPHRASE only — no interactive prompt (for hooks / CI). */
134
+ async function getKeyForCheck(projectRoot, config) {
135
+ let K = loadSession(projectRoot);
136
+ if (K) return K;
137
+ const pp = passphraseFromEnv();
138
+ if (pp) {
139
+ try {
140
+ return await unwrap(projectRoot, config.lockedDir, { passphrase: pp });
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function build() {
149
+ const program = new Command();
150
+
151
+ program
152
+ .name('sealcode')
153
+ .description(
154
+ 'Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what is yours.'
155
+ )
156
+ .version(pkg.version)
157
+ .option('-p, --project <dir>', 'project root (default: nearest ancestor with .sealcoderc.json)')
158
+ .addHelpText('beforeAll', ui.banner(pkg.version) + '\n');
159
+
160
+ // -------- init --------
161
+ program
162
+ .command('init')
163
+ .description('Set up a new vault in this project. Interactive wizard.')
164
+ .option('--preset <id>', 'force a specific ecosystem preset')
165
+ .option('--force', 'overwrite an existing vault (DANGER)', false)
166
+ .option('--noninteractive', 'use SEALCODE_PASSPHRASE env var; print recovery code to stdout', false)
167
+ .action(async (opts) => {
168
+ try {
169
+ const projectRoot = resolveProject(program.opts());
170
+ if (opts.noninteractive) process.env.SEALCODE_NONINTERACTIVE = '1';
171
+ const result = await runInit({
172
+ projectRoot,
173
+ presetId: opts.preset,
174
+ force: opts.force,
175
+ noninteractive: opts.noninteractive,
176
+ });
177
+ // Run the first lock right away so the project ends in the locked state.
178
+ const boot = await bootstrap(result.passphrase, result.recoverySeed);
179
+ const lockResult = await runLock({
180
+ projectRoot,
181
+ config: result.config,
182
+ K: boot.K,
183
+ bootstrapOutput: {
184
+ salt: boot.salt,
185
+ wrappedPass: boot.wrappedPass,
186
+ wrappedRecovery: boot.wrappedRecovery,
187
+ },
188
+ });
189
+ saveSession(projectRoot, boot.K);
190
+ ui.say('');
191
+ ui.ok(`Vault ready. Locked ${ui.c.bold(lockResult.count)} files into ${ui.c.cyan(result.config.lockedDir + '/')}`);
192
+ ui.hint(` Next: commit, then run \`sealcode unlock\` whenever you want to work on it.`);
193
+ } catch (err) {
194
+ process.exitCode = reportError(err);
195
+ }
196
+ });
197
+
198
+ // -------- lock --------
199
+ program
200
+ .command('lock')
201
+ .alias('seal')
202
+ .description('Encrypt your source files. Removes plaintext after success.')
203
+ .option('-v, --verbose', 'log each file as it is processed', false)
204
+ .option('--keep', 'keep plaintext on disk (testing only)', false)
205
+ .action(async (opts) => {
206
+ try {
207
+ const projectRoot = resolveProject(program.opts());
208
+ const config = getActiveConfig(projectRoot);
209
+ const K = await resolveKey(projectRoot, config);
210
+ ui.step(`locking ${ui.c.dim(projectRoot)}`);
211
+ const sp = new ui.Spinner('encrypting').start();
212
+ let n = 0;
213
+ const res = await runLock({
214
+ projectRoot,
215
+ config,
216
+ K,
217
+ keepOriginals: opts.keep,
218
+ log: (msg) => {
219
+ n += 1;
220
+ const file = String(msg).replace(/^\s*locked\s+/, '');
221
+ sp.update(`encrypting ${ui.c.dim(`(${n})`)} ${file}`);
222
+ if (opts.verbose) {
223
+ // Don't break the spinner — verbose mode just writes to stdout below it.
224
+ process.stdout.write(msg + '\n');
225
+ }
226
+ },
227
+ });
228
+ sp.succeed(`locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')}`);
229
+ const stubs = Object.keys(res.stubs);
230
+ if (stubs.length) ui.hint(` stubs placed: ${stubs.join(', ')}`);
231
+ } catch (err) {
232
+ process.exitCode = reportError(err);
233
+ }
234
+ });
235
+
236
+ // -------- unlock --------
237
+ program
238
+ .command('unlock')
239
+ .alias('open')
240
+ .description('Decrypt and restore your source files.')
241
+ .option('-v, --verbose', 'log each file as it is processed', false)
242
+ .option('--recovery', 'use recovery code instead of passphrase', false)
243
+ .option('--keep-stubs', 'do not remove stub files', false)
244
+ .action(async (opts) => {
245
+ try {
246
+ const projectRoot = resolveProject(program.opts());
247
+ const config = getActiveConfig(projectRoot);
248
+ const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
249
+ ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
250
+ const sp = new ui.Spinner('decrypting').start();
251
+ let n = 0;
252
+ const res = await runUnlock({
253
+ projectRoot,
254
+ config,
255
+ K,
256
+ removeStubs: !opts.keepStubs,
257
+ log: (msg) => {
258
+ n += 1;
259
+ const file = String(msg).replace(/^\s*unlocked\s+/, '');
260
+ sp.update(`decrypting ${ui.c.dim(`(${n})`)} ${file}`);
261
+ if (opts.verbose) {
262
+ process.stdout.write(msg + '\n');
263
+ }
264
+ },
265
+ });
266
+ sp.succeed(`unlocked ${ui.c.bold(res.count)} files ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
267
+ } catch (err) {
268
+ process.exitCode = reportError(err);
269
+ }
270
+ });
271
+
272
+ // -------- verify --------
273
+ program
274
+ .command('verify')
275
+ .description('Decrypt every blob and confirm it matches its recorded hash.')
276
+ .option('-v, --verbose', 'log each file as it is processed', false)
277
+ .action(async (opts) => {
278
+ try {
279
+ const projectRoot = resolveProject(program.opts());
280
+ const config = getActiveConfig(projectRoot);
281
+ const K = await resolveKey(projectRoot, config);
282
+ const res = await runVerify({ projectRoot, config, K, log: logger(opts.verbose) });
283
+ if (res.ok) {
284
+ ui.ok(`${ui.c.bold(res.okCount)}/${res.total} files verified`);
285
+ } else {
286
+ ui.fail(`${res.okCount}/${res.total} ok, ${res.issues.length} issues:`);
287
+ for (const i of res.issues) ui.say(` ${ui.c.red('[' + i.kind + ']')} ${i.path}: ${i.detail}`);
288
+ process.exitCode = 2;
289
+ }
290
+ } catch (err) {
291
+ process.exitCode = reportError(err);
292
+ }
293
+ });
294
+
295
+ // -------- status --------
296
+ program
297
+ .command('status')
298
+ .description('Show whether the project is locked / unlocked and any drift.')
299
+ .option('--check', 'exit 1 if unlocked with drift (for git hooks)', false)
300
+ .option('--json', 'machine-readable output (for editors / CI)', false)
301
+ .action(async (opts) => {
302
+ try {
303
+ const projectRoot = resolveProject(program.opts());
304
+ const config = getActiveConfig(projectRoot);
305
+ if (opts.check) {
306
+ const r = await runPrecommitCheck(projectRoot, config, () => getKeyForCheck(projectRoot, config));
307
+ if (!r.ok) {
308
+ process.stderr.write(r.message + '\n');
309
+ process.exitCode = 1;
310
+ }
311
+ return;
312
+ }
313
+ const s = await runStatus({ projectRoot, config });
314
+ if (opts.json) {
315
+ const drift = s.drift
316
+ ? {
317
+ added: s.drift.added.length,
318
+ modified: s.drift.modified.length,
319
+ removed: s.drift.removed.length,
320
+ total: s.drift.added.length + s.drift.modified.length + s.drift.removed.length,
321
+ }
322
+ : null;
323
+ process.stdout.write(
324
+ JSON.stringify({
325
+ projectRoot,
326
+ initialized: s.initialized,
327
+ state: s.state,
328
+ lockedDir: s.lockedDir,
329
+ lockedFileCount: s.lockedFileCount,
330
+ onDiskFileCount: s.onDiskFileCount,
331
+ sessionActive: s.sessionActive,
332
+ drift,
333
+ }) + '\n'
334
+ );
335
+ return;
336
+ }
337
+ process.stdout.write(`\n${projectRoot}\n`);
338
+ process.stdout.write(renderStatus(s) + '\n');
339
+ } catch (err) {
340
+ process.exitCode = reportError(err);
341
+ }
342
+ });
343
+
344
+ // -------- presets --------
345
+ program
346
+ .command('presets')
347
+ .description('List built-in ecosystem presets.')
348
+ .action(() => {
349
+ const all = listPresets();
350
+ process.stdout.write('\nBuilt-in presets:\n');
351
+ for (const p of all) process.stdout.write(` ${p.id.padEnd(10)} ${p.label}\n`);
352
+ process.stdout.write('\nUse with: sealcode init --preset <id>\n');
353
+ });
354
+
355
+ // -------- logout --------
356
+ program
357
+ .command('logout')
358
+ .description('Clear the cached session so the next command re-prompts for your passphrase.')
359
+ .action(() => {
360
+ try {
361
+ const projectRoot = resolveProject(program.opts());
362
+ clearSession(projectRoot);
363
+ process.stdout.write('✓ session cleared\n');
364
+ } catch (err) {
365
+ process.exitCode = reportError(err);
366
+ }
367
+ });
368
+
369
+ // -------- panic --------
370
+ program
371
+ .command('panic')
372
+ .description('Re-lock immediately and wipe plaintext (for "shut my laptop NOW" moments).')
373
+ .action(async () => {
374
+ try {
375
+ const projectRoot = resolveProject(program.opts());
376
+ const config = getActiveConfig(projectRoot);
377
+ const K = await resolveKey(projectRoot, config);
378
+ const res = await runLock({ projectRoot, config, K });
379
+ clearSession(projectRoot);
380
+ process.stdout.write(`✓ panic-locked ${res.count} files. Session cleared.\n`);
381
+ } catch (err) {
382
+ process.exitCode = reportError(err);
383
+ }
384
+ });
385
+
386
+ // -------- install-hook --------
387
+ program
388
+ .command('install-hook')
389
+ .description('Install a git pre-commit hook that runs `sealcode status --check`')
390
+ .action(() => {
391
+ try {
392
+ const projectRoot = resolveProject(program.opts());
393
+ const hookPath = installHook(projectRoot);
394
+ process.stdout.write(`✓ pre-commit hook installed:\n ${hookPath}\n`);
395
+ } catch (err) {
396
+ process.exitCode = reportError(err);
397
+ }
398
+ });
399
+
400
+ program
401
+ .command('uninstall-hook')
402
+ .description('Remove the sealcode block from .git/hooks/pre-commit')
403
+ .action(() => {
404
+ try {
405
+ const projectRoot = resolveProject(program.opts());
406
+ const r = uninstallHook(projectRoot);
407
+ if (r.removed) process.stdout.write('✓ sealcode block removed from pre-commit hook\n');
408
+ else process.stdout.write(`(nothing removed: ${r.reason})\n`);
409
+ } catch (err) {
410
+ process.exitCode = reportError(err);
411
+ }
412
+ });
413
+
414
+ // -------- rotate --------
415
+ program
416
+ .command('rotate')
417
+ .description('Change the passphrase that wraps your key (encrypted blobs are unchanged).')
418
+ .action(async () => {
419
+ try {
420
+ const projectRoot = resolveProject(program.opts());
421
+ const config = getActiveConfig(projectRoot);
422
+ if (!isInitialized(projectRoot, config.lockedDir)) {
423
+ throw new SealcodeError('SEALCODE_NO_MANIFEST');
424
+ }
425
+ let oldPp =
426
+ process.env.SEALCODE_OLD_PASSPHRASE || process.env.VAULTLINE_OLD_PASSPHRASE;
427
+ let newPp =
428
+ process.env.SEALCODE_NEW_PASSPHRASE || process.env.VAULTLINE_NEW_PASSPHRASE;
429
+ if (!oldPp) oldPp = await hidden('Current passphrase:');
430
+ if (!newPp) {
431
+ newPp = await hidden('New passphrase:');
432
+ const confirmPp = await hidden('Confirm new passphrase:');
433
+ if (newPp !== confirmPp) {
434
+ process.stderr.write('Passphrases do not match.\n');
435
+ process.exitCode = 1;
436
+ return;
437
+ }
438
+ }
439
+ await rotatePassphrase(projectRoot, config.lockedDir, oldPp, newPp);
440
+ process.stdout.write('✓ passphrase rotated (recovery code is unchanged)\n');
441
+ } catch (err) {
442
+ process.exitCode = reportError(err);
443
+ }
444
+ });
445
+
446
+ // -------- backup / restore --------
447
+ program
448
+ .command('backup')
449
+ .description('Copy the locked vault + config snapshot to a folder (offline backup)')
450
+ .argument('<dir>', 'output directory (must not exist)')
451
+ .action(async (dir) => {
452
+ try {
453
+ const projectRoot = resolveProject(program.opts());
454
+ const config = getActiveConfig(projectRoot);
455
+ const r = await exportBundle(projectRoot, config, dir);
456
+ process.stdout.write(`✓ backup written to ${r.path}\n`);
457
+ } catch (err) {
458
+ process.exitCode = reportError(err);
459
+ }
460
+ });
461
+
462
+ program
463
+ .command('restore')
464
+ .description('Restore locked data from a backup folder (from sealcode backup)')
465
+ .argument('<dir>', 'backup folder')
466
+ .option('--force', 'replace existing locked directory', false)
467
+ .action(async (dir, opts) => {
468
+ try {
469
+ const projectRoot = resolveProject(program.opts());
470
+ const r = await importBundle(projectRoot, dir, { force: opts.force });
471
+ process.stdout.write(`✓ restored locked data → ${r.lockedDir}/\n`);
472
+ } catch (err) {
473
+ process.exitCode = reportError(err);
474
+ }
475
+ });
476
+
477
+ // -------- pro --------
478
+ program
479
+ .command('pro')
480
+ .description('Learn about sealcode Pro (team key sharing, key rotation, audit log, cloud escrow).')
481
+ .action(() => {
482
+ const lines = [
483
+ '',
484
+ 'sealcode Pro — for teams who lock more than one project.',
485
+ '',
486
+ ' ✦ team key sharing invite teammates, no passphrase exchange needed',
487
+ ' ✦ key rotation rotate passphrases across N projects in one command',
488
+ ' ✦ audit log see who unlocked what, when',
489
+ ' ✦ cloud key escrow passphrase recovery via account auth',
490
+ ' ✦ ci/cd tokens short-lived deploy tokens, no secrets in actions',
491
+ '',
492
+ ' Free 14-day trial at https://sealcode.dev/pro',
493
+ '',
494
+ ].join('\n');
495
+ process.stdout.write(lines);
496
+ });
497
+
498
+ // -------- where --------
499
+ program
500
+ .command('where')
501
+ .description('Print resolved project, config, and session state (no secrets).')
502
+ .action(() => {
503
+ const projectRoot = resolveProject(program.opts());
504
+ const config = getActiveConfig(projectRoot);
505
+ const inited = isInitialized(projectRoot, config.lockedDir);
506
+ const session = loadSession(projectRoot) ? 'active' : 'idle';
507
+ process.stdout.write(
508
+ [
509
+ `project: ${projectRoot}`,
510
+ `config: ${configExists(projectRoot) ? path.join(projectRoot, CONFIG_FILE) : '(implicit defaults)'}`,
511
+ `preset: ${config.preset || '(unset)'}`,
512
+ `lockedDir: ${path.join(projectRoot, config.lockedDir)}`,
513
+ `initialized: ${inited ? 'yes' : 'no'}`,
514
+ `session: ${session}`,
515
+ ].join('\n') + '\n'
516
+ );
517
+ });
518
+
519
+ // ============================================================
520
+ // sealcode.dev account commands — talk to the web app
521
+ // ============================================================
522
+
523
+ // -------- login --------
524
+ program
525
+ .command('login')
526
+ .description('Pair this CLI with your sealcode.dev account (one-time, per machine).')
527
+ .option('--api-url <url>', 'override the server URL (default: $SEALCODE_API_URL or https://sealcode.dev)')
528
+ .action(async (opts) => {
529
+ try {
530
+ await runLogin({ apiUrl: opts.apiUrl });
531
+ } catch (err) {
532
+ process.exitCode = reportError(err);
533
+ }
534
+ });
535
+
536
+ // -------- signout --------
537
+ program
538
+ .command('signout')
539
+ .alias('logout-account')
540
+ .description('Revoke this CLI\'s account token on the server and clear local credentials.')
541
+ .action(async () => {
542
+ try {
543
+ await runSignout();
544
+ } catch (err) {
545
+ process.exitCode = reportError(err);
546
+ }
547
+ });
548
+
549
+ // -------- whoami --------
550
+ program
551
+ .command('whoami')
552
+ .description('Show which sealcode.dev account this CLI is signed in as.')
553
+ .option('-v, --verbose', 'include user id, server URL, and online/offline state', false)
554
+ .action(async (opts) => {
555
+ try {
556
+ await runWhoami({ verbose: !!opts.verbose });
557
+ } catch (err) {
558
+ process.exitCode = reportError(err);
559
+ }
560
+ });
561
+
562
+ // -------- link / unlink / link-info --------
563
+ program
564
+ .command('link')
565
+ .argument('[projectId]', 'project ID from the dashboard')
566
+ .description('Link this repository to a project on sealcode.dev (so `share` knows where to mint codes).')
567
+ .option('--info', 'just print the current link state, don\'t change it', false)
568
+ .option('--remove', 'remove the existing link from this repository', false)
569
+ .option('--json', 'machine-readable output (with --info)', false)
570
+ .action(async (projectId, opts) => {
571
+ try {
572
+ const projectRoot = resolveProject(program.opts());
573
+ if (opts.remove) {
574
+ runUnlink({ projectRoot });
575
+ return;
576
+ }
577
+ if (opts.info || !projectId) {
578
+ runLinkInfo({ projectRoot, json: opts.json });
579
+ return;
580
+ }
581
+ await runLink({ projectRoot, projectId });
582
+ } catch (err) {
583
+ process.exitCode = reportError(err);
584
+ }
585
+ });
586
+
587
+ // -------- share --------
588
+ program
589
+ .command('share')
590
+ .description('Mint a time-boxed access code for this linked project (Pro feature).')
591
+ .option('--hours <n>', 'hours until the code expires', (v) => parseInt(v, 10), 24)
592
+ .option('--email <addr>', 'developer email (for the audit log; not an invite email yet)')
593
+ .option('--label <text>', 'short human-readable label for the grant')
594
+ .option('--no-auto-lock', 'do not auto-lock the unlocked tree at expiry', false)
595
+ .option('--auto-lock-after <minutes>', 'auto-lock this many minutes after redeem (0 = at expiry)', (v) => parseInt(v, 10), 0)
596
+ .option('--strict', 'lock the project root read-only between heartbeats', false)
597
+ .option('--heartbeat <seconds>', 'how often the client must heartbeat', (v) => parseInt(v, 10), 300)
598
+ .option('--offline-grace <seconds>', 'how long a heartbeat may be late before auto-lock', (v) => parseInt(v, 10), 1800)
599
+ .option('--json', 'machine-readable output', false)
600
+ .action(async (opts) => {
601
+ try {
602
+ const projectRoot = resolveProject(program.opts());
603
+ await runShare({
604
+ projectRoot,
605
+ hours: opts.hours,
606
+ email: opts.email,
607
+ label: opts.label,
608
+ autoLock: opts.autoLock !== false,
609
+ autoLockOffsetMinutes: opts.autoLockAfter,
610
+ strict: !!opts.strict,
611
+ heartbeatSec: opts.heartbeat,
612
+ offlineGraceSec: opts.offlineGrace,
613
+ json: !!opts.json,
614
+ });
615
+ } catch (err) {
616
+ process.exitCode = reportError(err);
617
+ }
618
+ });
619
+
620
+ // -------- grants --------
621
+ program
622
+ .command('grants')
623
+ .description('List access codes for the linked project (no secrets revealed).')
624
+ .option('--json', 'machine-readable output', false)
625
+ .action(async (opts) => {
626
+ try {
627
+ const projectRoot = resolveProject(program.opts());
628
+ await runGrants({ projectRoot, json: !!opts.json });
629
+ } catch (err) {
630
+ process.exitCode = reportError(err);
631
+ }
632
+ });
633
+
634
+ // -------- revoke --------
635
+ program
636
+ .command('revoke')
637
+ .argument('<grantId>', 'grant ID to revoke (see `sealcode grants`)')
638
+ .description('Revoke an active access code so it stops working immediately.')
639
+ .action(async (grantId) => {
640
+ try {
641
+ await runRevoke({ grantId });
642
+ } catch (err) {
643
+ process.exitCode = reportError(err);
644
+ }
645
+ });
646
+
647
+ // -------- redeem --------
648
+ program
649
+ .command('redeem')
650
+ .argument('<code>', 'access code shared by the project owner (e.g. SC-XXXX-XXXX-...)')
651
+ .description('Redeem an access code shared with you. Prints project info; the owner still has to hand off the unlocked vault separately.')
652
+ .option('--json', 'machine-readable output', false)
653
+ .action(async (code, opts) => {
654
+ try {
655
+ await runRedeem({ code, json: !!opts.json });
656
+ } catch (err) {
657
+ process.exitCode = reportError(err);
658
+ }
659
+ });
660
+
661
+ return program;
662
+ }
663
+
664
+ async function run(argv) {
665
+ const program = build();
666
+ // First-run welcome: only when the user invokes `sealcode` with no subcommand.
667
+ // After the first show, a sentinel in ~/.sealcode prevents repeats.
668
+ const userArgs = argv.slice(2);
669
+ const bare = userArgs.length === 0;
670
+ const helpish = userArgs[0] === '-h' || userArgs[0] === '--help' || userArgs[0] === 'help';
671
+ if (bare || helpish) {
672
+ ui.maybeShowWelcome(pkg.version);
673
+ }
674
+ await program.parseAsync(argv);
675
+ }
676
+
677
+ module.exports = { build, run };
678
+
679
+ // Suppress unused-import warning during partial dev cycles.
680
+ void confirm;
681
+ void detectPreset;
682
+ void getPreset;
683
+ void fs;