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/README.md +68 -0
- package/package.json +4 -3
- package/src/cli-grants.js +100 -4
- package/src/cli-link.js +147 -8
- package/src/cli-registry.js +256 -0
- package/src/cli-remove.js +281 -0
- package/src/cli-service.js +150 -37
- package/src/cli-watch.js +73 -17
- package/src/cli.js +274 -16
- package/src/discovery.js +1004 -0
- package/src/errors.js +39 -4
- package/src/init.js +152 -11
- package/src/keystore.js +31 -0
- package/src/presets.js +98 -54
- package/src/seal.js +91 -9
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-
|
|
86
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
.
|
|
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
|
|