sealcode 1.3.5 → 1.4.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 CHANGED
@@ -21,7 +21,8 @@ const {
21
21
  isInitialized,
22
22
  rotatePassphrase,
23
23
  } = require('./keystore');
24
- const { runInit } = require('./init');
24
+ const { runInit, renderDiscoveryReport } = require('./init');
25
+ const { scanProject, detectMicroservices } = require('./discovery');
25
26
  const { runLock } = require('./seal');
26
27
  const { runUnlock } = require('./open');
27
28
  const { runVerify } = require('./verify');
@@ -33,6 +34,7 @@ const { installHook, uninstallHook } = require('./hooks');
33
34
  const { exportBundle, importBundle } = require('./bundle');
34
35
  const { runLogin, runSignout, runWhoami } = require('./cli-auth');
35
36
  const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
37
+ const { runRemove } = require('./cli-remove');
36
38
  const {
37
39
  runShare,
38
40
  runGrants,
@@ -82,23 +84,33 @@ function passphraseFromEnv() {
82
84
 
83
85
  /**
84
86
  * Resolve the active config: prefer the file on disk; if missing, fall back to
85
- * the auto-detected preset's defaults. This lets the tool work in stealth
86
- * mode where `.sealcoderc.json` isn't checked into the repo.
87
+ * the auto-discovered config. This lets the tool work in stealth mode where
88
+ * `.sealcoderc.json` isn't checked into the repo.
89
+ *
90
+ * sealcode@1.4.0 — implicit fallback now uses the `auto` preset (full
91
+ * discovery) instead of `detectPreset`'s first-marker-wins guess, so a
92
+ * fresh checkout without the rc file still picks up every source folder.
87
93
  */
88
94
  function getActiveConfig(projectRoot) {
89
95
  const fromFile = loadConfig(projectRoot);
90
96
  if (fromFile) return fromFile;
91
- const preset = detectPreset(projectRoot);
92
- return {
93
- version: 1,
94
- preset: preset.id,
95
- lockedDir: preset.lockedDir,
96
- include: preset.include,
97
- exclude: preset.exclude,
98
- stubs: preset.stubs || {},
99
- _file: null,
100
- _implicit: true,
101
- };
97
+ try {
98
+ const { buildAutoConfig } = require('./discovery');
99
+ const { cfg } = buildAutoConfig(projectRoot);
100
+ return { ...cfg, _file: null, _implicit: true };
101
+ } catch (_) {
102
+ const preset = detectPreset(projectRoot);
103
+ return {
104
+ version: 1,
105
+ preset: preset.id,
106
+ lockedDir: preset.lockedDir,
107
+ include: preset.include,
108
+ exclude: preset.exclude,
109
+ stubs: preset.stubs || {},
110
+ _file: null,
111
+ _implicit: true,
112
+ };
113
+ }
102
114
  }
103
115
 
104
116
  /**
@@ -205,6 +217,11 @@ function build() {
205
217
  .option('--preset <id>', 'force a specific ecosystem preset')
206
218
  .option('--force', 'overwrite an existing vault (DANGER)', false)
207
219
  .option('--noninteractive', 'use SEALCODE_PASSPHRASE env var; print recovery code to stdout', false)
220
+ .option(
221
+ '--allow-monorepo',
222
+ 'override the multi-microservice guard (one vault for the whole tree)',
223
+ false,
224
+ )
208
225
  .action(async (opts) => {
209
226
  try {
210
227
  const projectRoot = resolveProject(program.opts());
@@ -214,6 +231,7 @@ function build() {
214
231
  presetId: opts.preset,
215
232
  force: opts.force,
216
233
  noninteractive: opts.noninteractive,
234
+ allowMonorepo: opts.allowMonorepo,
217
235
  });
218
236
  // Run the first lock right away so the project ends in the locked state.
219
237
  const boot = await bootstrap(result.passphrase, result.recoverySeed);
@@ -269,6 +287,7 @@ function build() {
269
287
  sp.succeed(`locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')}`);
270
288
  const stubs = Object.keys(res.stubs);
271
289
  if (stubs.length) ui.hint(` stubs placed: ${stubs.join(', ')}`);
290
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
272
291
  } catch (err) {
273
292
  process.exitCode = reportError(err);
274
293
  }
@@ -380,6 +399,7 @@ function build() {
380
399
  });
381
400
  const _skipped = res.skipped > 0 ? ` ${ui.c.dim(`(${res.skipped} outside grant scope)`)}` : '';
382
401
  sp.succeed(`unlocked ${ui.c.bold(res.count)} files${_skipped} ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
402
+ try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
383
403
  if (res.policy && res.policy.mode === 'ro') {
384
404
  ui.hint(' Read-only grant: files set to 0444. Any edit triggers an immediate re-lock.');
385
405
  }
@@ -501,6 +521,88 @@ function build() {
501
521
  process.stdout.write('\nUse with: sealcode init --preset <id>\n');
502
522
  });
503
523
 
524
+ // -------- scan --------
525
+ //
526
+ // sealcode@1.4.0 — dry-run inventory. Walks the project the same way the
527
+ // `auto` preset would, prints exactly what `sealcode init`/`lock` would
528
+ // touch, and flags suspicious omissions (git-tracked source files that
529
+ // exclude rules drop). No mutation.
530
+ program
531
+ .command('scan')
532
+ .description('Show what sealcode would lock in this project (read-only).')
533
+ .option('--json', 'emit machine-readable JSON instead of a human report', false)
534
+ .action(async (opts) => {
535
+ try {
536
+ const projectRoot = resolveProject(program.opts());
537
+ const report = scanProject(projectRoot);
538
+ const monorepo = detectMicroservices(projectRoot);
539
+ if (opts.json) {
540
+ process.stdout.write(
541
+ JSON.stringify(
542
+ {
543
+ projectRoot,
544
+ source: report.source,
545
+ lockedDir: report.lockedDir,
546
+ wouldLock: report.wouldLock,
547
+ wouldLockCount: report.wouldLock.length,
548
+ totalBytes: report.totalBytes,
549
+ byTopLevel: report.byTopLevel,
550
+ gitCoverage: report.gitCoverage,
551
+ oversize: report.oversize,
552
+ include: report.include,
553
+ exclude: report.exclude,
554
+ monorepo: {
555
+ isMonorepo: monorepo.isMonorepo,
556
+ reason: monorepo.reason || null,
557
+ services: monorepo.services,
558
+ workspaces: monorepo.workspaces,
559
+ },
560
+ },
561
+ null,
562
+ 2
563
+ ) + '\n'
564
+ );
565
+ return;
566
+ }
567
+ process.stdout.write(`\nsealcode · scanning ${projectRoot}`);
568
+ renderDiscoveryReport(report);
569
+ if (monorepo.isMonorepo) {
570
+ process.stdout.write('\n');
571
+ ui.warn(`Multi-project layout detected · ${monorepo.reason}`);
572
+ if (monorepo.workspaces.length) {
573
+ ui.hint(' Workspace declarations:');
574
+ for (const w of monorepo.workspaces) {
575
+ ui.hint(` • ${w.file} (${w.kind})`);
576
+ }
577
+ }
578
+ if (monorepo.services.length) {
579
+ ui.hint(` Services (${monorepo.services.length}):`);
580
+ for (const s of monorepo.services.slice(0, 10)) {
581
+ ui.hint(` • ${s.relPath}/ (${s.marker})`);
582
+ }
583
+ if (monorepo.services.length > 10) {
584
+ ui.hint(` • … and ${monorepo.services.length - 10} more`);
585
+ }
586
+ }
587
+ ui.hint(' `sealcode init` will refuse at this root — cd into each service and init separately.');
588
+ ui.hint(' Override with `sealcode init --allow-monorepo` (one vault for the whole tree; billed as one project).');
589
+ }
590
+ process.stdout.write('\n');
591
+ const next = configExists(projectRoot)
592
+ ? 'sealcode lock (apply the current config)'
593
+ : monorepo.isMonorepo
594
+ ? 'cd <service-dir> && sealcode init (per-service)'
595
+ : 'sealcode init --preset auto (write .sealcoderc.json and lock)';
596
+ ui.hint(`Next: ${next}`);
597
+ if (report.wouldLock.length === 0) {
598
+ process.stdout.write('\n');
599
+ ui.warn('Nothing matched — the project may be empty or fully excluded.');
600
+ }
601
+ } catch (err) {
602
+ process.exitCode = reportError(err);
603
+ }
604
+ });
605
+
504
606
  // -------- logout --------
505
607
  program
506
608
  .command('logout')
@@ -519,19 +621,112 @@ function build() {
519
621
  program
520
622
  .command('panic')
521
623
  .description('Re-lock immediately and wipe plaintext (for "shut my laptop NOW" moments).')
522
- .action(async () => {
624
+ .option('--from-selfcheck', '[internal] called by the self-check background job — never prompts, silent on no-op')
625
+ .action(async (opts) => {
523
626
  try {
524
627
  const projectRoot = resolveProject(program.opts());
525
628
  const config = getActiveConfig(projectRoot);
629
+
630
+ // sealcode@1.3.6 — when invoked by the registry self-check we
631
+ // MUST NOT prompt for a passphrase (would hang the detached
632
+ // background process forever). If there's no cached session,
633
+ // we silently correct the registry and exit — the user can
634
+ // run `sealcode lock` manually if they want a real lock.
635
+ if (opts.fromSelfcheck) {
636
+ const cached = loadSession(projectRoot);
637
+ if (!cached) {
638
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
639
+ return;
640
+ }
641
+ try {
642
+ const sm = require('./keystore').loadSessionMeta(projectRoot);
643
+ const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
644
+ await runLock({ projectRoot, config, K: cached, preserveUnseen });
645
+ } catch (_) { /* swallow — background job */ }
646
+ try { cached.fill(0); } catch (_) { /* ignore */ }
647
+ try { clearSession(projectRoot); } catch (_) { /* ignore */ }
648
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
649
+ return;
650
+ }
651
+
526
652
  const K = await resolveKey(projectRoot, config);
527
653
  const res = await runLock({ projectRoot, config, K });
528
654
  clearSession(projectRoot);
655
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
529
656
  process.stdout.write(`✓ panic-locked ${res.count} files. Session cleared.\n`);
530
657
  } catch (err) {
531
658
  process.exitCode = reportError(err);
532
659
  }
533
660
  });
534
661
 
662
+ // -------- preuninstall-check (internal, called by npm preuninstall) --------
663
+ //
664
+ // sealcode@1.3.6 — when the user runs `npm uninstall -g sealcode`, npm
665
+ // executes this command FIRST (before deleting files). We use the small
666
+ // window to:
667
+ // 1. Walk the known-projects registry and re-lock any unlocked ones.
668
+ // 2. Print a clear warning so the user understands the implication.
669
+ //
670
+ // Bypassable with `npm uninstall --ignore-scripts`. We accept that —
671
+ // the goal is to raise the floor against accidental "I'll just remove
672
+ // sealcode" actions, not to defeat a determined attacker who reads
673
+ // our docs.
674
+ //
675
+ // ALWAYS exit 0 (via `|| true` in package.json) so npm uninstall
676
+ // succeeds even if our hook fails. We do NOT want to leave the user
677
+ // stuck with a partial uninstall.
678
+ program
679
+ .command('preuninstall-check', { hidden: true })
680
+ .description('[internal] npm preuninstall hook — re-locks any unlocked projects before uninstall')
681
+ .action(async () => {
682
+ try {
683
+ const registry = require('./cli-registry');
684
+ const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 0 });
685
+ const allUnlocked = registry.list().filter((p) => p.lastState === 'unlocked');
686
+
687
+ const total = allUnlocked.length;
688
+ if (total === 0) {
689
+ // Nothing to do. Silent exit so npm uninstall stays quiet.
690
+ return;
691
+ }
692
+
693
+ process.stderr.write(
694
+ `\n \x1b[33m\u26a0 sealcode preuninstall: ${total} project(s) are still unlocked.\x1b[0m\n`
695
+ + `\n Re-locking before uninstall so you don't leave plaintext lying around:\n`,
696
+ );
697
+
698
+ // Re-lock each one. We use the same panic-from-selfcheck flow:
699
+ // it uses ONLY the cached session (no passphrase prompt — npm
700
+ // hooks have no TTY anyway) and is a no-op if the session was
701
+ // already cleared. We run them SYNCHRONOUSLY here (not detached)
702
+ // because npm will delete our files the moment we return.
703
+ const { spawnSync } = require('child_process');
704
+ for (const p of allUnlocked) {
705
+ process.stderr.write(` - ${p.projectRoot} ... `);
706
+ const r = spawnSync(
707
+ process.execPath,
708
+ [path.resolve(__dirname, '..', 'bin', 'sealcode.js'),
709
+ 'panic', '--project', p.projectRoot, '--from-selfcheck'],
710
+ { stdio: 'ignore', timeout: 30_000 },
711
+ );
712
+ process.stderr.write((r.status === 0 ? '\x1b[32mlocked\x1b[0m' : '\x1b[31mfailed\x1b[0m') + '\n');
713
+ }
714
+
715
+ if (orphans.length > 0) {
716
+ process.stderr.write(
717
+ `\n \x1b[33mNote:\x1b[0m ${orphans.length} of these had no running watcher already. `
718
+ + `Any active access grants on those projects can no longer be revoked from the dashboard.\n`,
719
+ );
720
+ }
721
+
722
+ process.stderr.write(`\n`);
723
+ } catch (_) {
724
+ // Never fail the npm uninstall. Worst case: user uninstalls
725
+ // sealcode and we couldn't lock — the registry self-check on
726
+ // some future reinstall would still flag it.
727
+ }
728
+ });
729
+
535
730
  // -------- install-hook --------
536
731
  program
537
732
  .command('install-hook')
@@ -716,6 +911,10 @@ function build() {
716
911
  .option('--info', 'just print the current link state, don\'t change it', false)
717
912
  .option('--remove', 'remove the existing link from this repository', false)
718
913
  .option('--json', 'machine-readable output (with --info)', false)
914
+ // sealcode@1.4.0 — owner-only override for the 1:1 project<->repo
915
+ // binding. The server still audit-logs the re-pin, and refuses if
916
+ // the caller isn't the project owner.
917
+ .option('--force', 're-pin this project to the current repository (owner only)', false)
719
918
  .action(async (projectId, opts) => {
720
919
  try {
721
920
  const projectRoot = resolveProject(program.opts());
@@ -727,7 +926,38 @@ function build() {
727
926
  runLinkInfo({ projectRoot, json: opts.json });
728
927
  return;
729
928
  }
730
- await runLink({ projectRoot, projectId });
929
+ await runLink({ projectRoot, projectId, force: !!opts.force });
930
+ } catch (err) {
931
+ process.exitCode = reportError(err);
932
+ }
933
+ });
934
+
935
+ // -------- remove (permanently uninstall sealcode from this project) --------
936
+ // sealcode@1.4.0 — destructive sibling of `link --remove`. See
937
+ // src/cli-remove.js for the full flow; the high-level guarantees are:
938
+ // * passphrase is re-verified even when a session is unlocked
939
+ // * the project owner is emailed BEFORE local data is touched
940
+ // * we refuse to run offline unless --offline is explicit (so a
941
+ // hostile actor can't bypass the alert by cutting the network)
942
+ program
943
+ .command('remove')
944
+ .description('Permanently remove sealcode from this project. Restores plaintext and (if linked) emails the project owner.')
945
+ .option('--confirm <phrase>', 'pass `yes-remove` to skip the interactive confirmation prompt')
946
+ .option('--burn', 'do NOT decrypt locked files before removing — discards ciphertext only', false)
947
+ .option('--offline', 'allow removal without notifying the project owner (we still try first)', false)
948
+ .option('--session-ok', 'accept a cached session instead of re-typing the passphrase', false)
949
+ .option('--keep-link', 'leave the .sealcoderc.json link block in place (rare; mostly for debugging)', false)
950
+ .action(async (opts) => {
951
+ try {
952
+ const projectRoot = resolveProject(program.opts());
953
+ await runRemove({
954
+ projectRoot,
955
+ confirm: opts.confirm || null,
956
+ burn: !!opts.burn,
957
+ offline: !!opts.offline,
958
+ sessionOk: !!opts.sessionOk,
959
+ keepLink: !!opts.keepLink,
960
+ });
731
961
  } catch (err) {
732
962
  process.exitCode = reportError(err);
733
963
  }
@@ -1061,6 +1291,34 @@ async function run(argv) {
1061
1291
  if (bare || helpish) {
1062
1292
  ui.maybeShowWelcome(pkg.version);
1063
1293
  }
1294
+
1295
+ // sealcode@1.3.6 — registry self-check on EVERY invocation. Scans
1296
+ // ~/.sealcode/projects.json for projects marked unlocked whose
1297
+ // watcher is no longer alive, and spawns a detached panic-lock for
1298
+ // each. Closes the "recipient killed the watcher and walked away"
1299
+ // hole for anyone who uses sealcode at all on the same machine
1300
+ // afterwards.
1301
+ //
1302
+ // Quiet for short/info commands so we don't spam warnings during
1303
+ // `sealcode --version` etc. NEVER throws — wrapped in try.
1304
+ //
1305
+ // We skip the self-check when the invocation IS the self-check (the
1306
+ // detached `panic --from-selfcheck` child) to prevent infinite spawn
1307
+ // recursion.
1308
+ try {
1309
+ const first = userArgs[0];
1310
+ const isPanicFromSelfcheck =
1311
+ first === 'panic' && userArgs.includes('--from-selfcheck');
1312
+ const isSupervise = first === 'supervise';
1313
+ const quiet =
1314
+ bare || helpish || isSupervise || isPanicFromSelfcheck ||
1315
+ first === 'version' || first === '-V' || first === '--version' ||
1316
+ first === 'where' || first === 'whoami';
1317
+ if (!isPanicFromSelfcheck && !isSupervise) {
1318
+ require('./cli-registry').selfCheck({ quiet });
1319
+ }
1320
+ } catch (_) { /* never block the user's command */ }
1321
+
1064
1322
  await program.parseAsync(argv);
1065
1323
  }
1066
1324