sealcode 1.3.6 → 1.4.1

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.
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `sealcode remove` — permanently remove sealcode from a project.
5
+ *
6
+ * This is the destructive sibling of `sealcode link --remove`. Where
7
+ * `link --remove` only forgets the dashboard association, `remove`
8
+ * tears the whole vault out:
9
+ *
10
+ * 1. Re-verify the passphrase. We do this even if a session is
11
+ * cached, because removal is irreversible and "the laptop was
12
+ * already unlocked" is a real attack. `--session-ok` overrides
13
+ * for power users.
14
+ * 2. Require a typed confirmation (`yes-remove`). No accidental
15
+ * `sealcode rem<TAB>` deletions.
16
+ * 3. If the project is linked to a sealcode.dev account, hit the
17
+ * server's remove-notice endpoint FIRST so the legitimate owner
18
+ * gets an email and an audit-log row before any local data is
19
+ * touched. If we can't reach the server we refuse by default;
20
+ * `--offline` is the explicit override.
21
+ * 4. Decrypt every locked blob into plaintext (so the user keeps
22
+ * their source). `--burn` skips this step and removes the
23
+ * ciphertext without ever materializing plaintext.
24
+ * 5. Delete the locked dir, the sealcode config file (link block
25
+ * included), and the session cache for this project root.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const ui = require('./ui');
32
+ const { hidden, question } = require('./prompt');
33
+ const { ApiError, request, clientInfo } = require('./api');
34
+ const { SealcodeError } = require('./errors');
35
+ const { isInitialized, unwrap, clearSession } = require('./keystore');
36
+ const { runUnlock } = require('./open');
37
+ const { readManifest } = require('./manifest');
38
+ const { getLink, clearLink } = require('./link-state');
39
+ const { CONFIG_FILE, LEGACY_CONFIG_FILE } = require('./config');
40
+
41
+ function readActiveConfig(projectRoot) {
42
+ // Mirror getActiveConfig from cli.js without the dependency cycle.
43
+ for (const name of [CONFIG_FILE, LEGACY_CONFIG_FILE]) {
44
+ const p = path.join(projectRoot, name);
45
+ if (fs.existsSync(p)) {
46
+ try {
47
+ const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
48
+ if (cfg && typeof cfg.lockedDir === 'string') return { ...cfg, _file: name };
49
+ } catch (_) { /* fallthrough */ }
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function passphraseFromEnv() {
56
+ return process.env.SEALCODE_PASSPHRASE || process.env.VAULTLINE_PASSPHRASE || '';
57
+ }
58
+
59
+ async function verifyPassphrase(projectRoot, lockedDir) {
60
+ // Prefer the env var so CI / scripted teardown isn't blocked on a
61
+ // TTY. Otherwise prompt — `remove` is interactive by default.
62
+ const pp = passphraseFromEnv() || (await hidden('Passphrase (to confirm ownership):'));
63
+ if (!pp) throw new SealcodeError('SEALCODE_NO_KEY');
64
+ try {
65
+ return await unwrap(projectRoot, lockedDir, { passphrase: pp });
66
+ } catch (err) {
67
+ if (err && err.message === 'SEALCODE_WRONG_KEY') {
68
+ throw new SealcodeError('SEALCODE_WRONG_KEY');
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Send the remove-notice to the server. Returns one of:
76
+ * { status: 'sent' } — server accepted, owner email queued.
77
+ * { status: 'offline'} — could not reach the server (network error).
78
+ * { status: 'rejected', code, message } — server returned 4xx/5xx.
79
+ *
80
+ * We never throw from here so the caller can centralize policy.
81
+ */
82
+ async function tryNotifyServer({ projectId, localFingerprint, burn }) {
83
+ try {
84
+ await request('POST', `/api/v1/projects/${encodeURIComponent(projectId)}/remove-notice`, {
85
+ auth: true,
86
+ body: {
87
+ localFingerprint,
88
+ burn: !!burn,
89
+ // Best-effort device context for the owner's email. Nothing
90
+ // here is used for an access decision; we just want the
91
+ // notification to be useful ("My laptop did this — fine" vs
92
+ // "what's that hostname?").
93
+ device: clientInfo(),
94
+ occurredAt: new Date().toISOString(),
95
+ },
96
+ // Keep the timeout short — a slow / dead server should not let
97
+ // a hostile actor stall the alert until they're ready.
98
+ timeoutMs: 10000,
99
+ });
100
+ return { status: 'sent' };
101
+ } catch (err) {
102
+ if (err instanceof ApiError && err.status === 0) {
103
+ return { status: 'offline', message: err.message };
104
+ }
105
+ if (err instanceof ApiError) {
106
+ return { status: 'rejected', code: err.apiCode, message: err.message };
107
+ }
108
+ return { status: 'offline', message: err && err.message ? err.message : String(err) };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Removes the locked dir, the config file, and any cached session for
114
+ * this project root. Idempotent — safe to re-run after a partial
115
+ * failure to clean up leftover bits. Returns a list of removed paths
116
+ * for the human-readable summary.
117
+ */
118
+ function destroyVaultArtifacts(projectRoot, lockedDir) {
119
+ const removed = [];
120
+ const tryRemove = (rel) => {
121
+ const abs = path.join(projectRoot, rel);
122
+ try {
123
+ const st = fs.lstatSync(abs);
124
+ if (st.isDirectory()) {
125
+ fs.rmSync(abs, { recursive: true, force: true });
126
+ } else {
127
+ fs.unlinkSync(abs);
128
+ }
129
+ removed.push(rel);
130
+ } catch (_) { /* nothing there or already gone */ }
131
+ };
132
+ tryRemove(lockedDir);
133
+ tryRemove(CONFIG_FILE);
134
+ tryRemove(LEGACY_CONFIG_FILE);
135
+ try {
136
+ clearSession(projectRoot);
137
+ removed.push('~/.sealcode/sessions/<this-project>');
138
+ } catch (_) { /* ignore */ }
139
+ return removed;
140
+ }
141
+
142
+ async function runRemove({
143
+ projectRoot,
144
+ confirm = null,
145
+ burn = false,
146
+ offline = false,
147
+ sessionOk = false,
148
+ keepLink = false,
149
+ }) {
150
+ const config = readActiveConfig(projectRoot);
151
+ if (!config || !isInitialized(projectRoot, config.lockedDir)) {
152
+ throw new SealcodeError('SEALCODE_NO_MANIFEST', {
153
+ detail: `Nothing to remove: ${projectRoot} doesn't have a sealcode vault.`,
154
+ });
155
+ }
156
+
157
+ // ---- 1. Re-verify passphrase (or accept --session-ok) ----
158
+ let K;
159
+ if (sessionOk) {
160
+ const { loadSession } = require('./keystore');
161
+ K = loadSession(projectRoot);
162
+ if (!K) {
163
+ // --session-ok was promised but no session exists. Fall back to
164
+ // a passphrase prompt rather than silently degrading: a missing
165
+ // session is exactly the case where we WANT extra verification.
166
+ K = await verifyPassphrase(projectRoot, config.lockedDir);
167
+ }
168
+ } else {
169
+ K = await verifyPassphrase(projectRoot, config.lockedDir);
170
+ }
171
+
172
+ // ---- 2. Typed confirmation ----
173
+ const link = getLink(projectRoot);
174
+ if (!confirm) {
175
+ process.stdout.write(
176
+ [
177
+ '',
178
+ ui.c.bold('You are about to permanently remove sealcode from:'),
179
+ ` ${projectRoot}`,
180
+ '',
181
+ link
182
+ ? `This project is linked to ${ui.c.cyan(link.projectName || link.projectId)} (${link.projectId}).`
183
+ : 'This project is not linked to any sealcode.dev account.',
184
+ link
185
+ ? ui.c.yellow(' → The project owner will be notified by email.')
186
+ : ui.c.dim(' → No notification will be sent.'),
187
+ '',
188
+ burn
189
+ ? ui.c.red("--burn: plaintext will NOT be recovered. Your source will be gone.")
190
+ : 'Your source files will be decrypted back to plaintext before removal.',
191
+ '',
192
+ ].join('\n'),
193
+ );
194
+ const typed = await question('Type yes-remove to confirm');
195
+ if (typed !== 'yes-remove') {
196
+ ui.warn('aborted; nothing changed.');
197
+ return { aborted: true };
198
+ }
199
+ } else if (confirm !== 'yes-remove') {
200
+ throw new Error(
201
+ "Refusing to remove: pass --confirm yes-remove (exactly) to acknowledge this is irreversible.",
202
+ );
203
+ }
204
+
205
+ // ---- 3. Notify the server (if linked) ----
206
+ let notifyOutcome = null;
207
+ if (link) {
208
+ if (offline) {
209
+ // Owner explicitly chose to skip the alert. We still want this
210
+ // to show up somewhere visible, so log to stderr.
211
+ ui.warn(
212
+ `--offline: skipping the server notification. The owner of "${link.projectName || link.projectId}" will NOT be emailed.`,
213
+ );
214
+ notifyOutcome = { status: 'skipped' };
215
+ } else {
216
+ notifyOutcome = await tryNotifyServer({
217
+ projectId: link.projectId,
218
+ localFingerprint: link.localFingerprint || null,
219
+ burn,
220
+ });
221
+ if (notifyOutcome.status === 'offline') {
222
+ throw new SealcodeError('SEALCODE_REMOVE_OFFLINE', {
223
+ detail:
224
+ `Could not reach sealcode.dev to notify the project owner that this vault is being removed. ` +
225
+ `\`sealcode remove\` is refused so a hostile actor can't bypass the alert by cutting the network. ` +
226
+ `Re-run with \`--offline\` if you are genuinely offline and accept this.`,
227
+ });
228
+ }
229
+ if (notifyOutcome.status === 'rejected') {
230
+ throw new SealcodeError('SEALCODE_REMOVE_REJECTED', {
231
+ detail:
232
+ `The server rejected the remove notice (${notifyOutcome.code}: ${notifyOutcome.message}). ` +
233
+ `This usually means your bearer token is invalid or the project was already deleted from the dashboard. ` +
234
+ `Run \`sealcode whoami\` and \`sealcode link --info\` to triage.`,
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ // ---- 4. Decrypt to plaintext (unless --burn) ----
241
+ let decryptedFiles = 0;
242
+ if (!burn) {
243
+ // Force a full unlock so every blob lands as a plaintext file
244
+ // before we wipe lockedDir. We ignore allowedPaths / watermark
245
+ // policy — `remove` is a full restore, not a scoped session.
246
+ const manifest = readManifest(projectRoot, config.lockedDir, K);
247
+ decryptedFiles = manifest.files.length;
248
+ await runUnlock({
249
+ projectRoot,
250
+ config,
251
+ K,
252
+ removeStubs: true,
253
+ log: () => {},
254
+ });
255
+ }
256
+
257
+ // ---- 5. Destroy local artifacts ----
258
+ if (link && !keepLink) {
259
+ clearLink(projectRoot);
260
+ }
261
+ const removed = destroyVaultArtifacts(projectRoot, config.lockedDir);
262
+
263
+ // ---- Summary ----
264
+ const lines = ['', ui.c.green('✓ sealcode removed from this project.'), ''];
265
+ if (!burn) lines.push(` Restored ${decryptedFiles} files to plaintext.`);
266
+ if (burn) lines.push(` ${ui.c.red('--burn:')} ciphertext discarded without decrypting.`);
267
+ lines.push(` Removed: ${removed.join(', ')}`);
268
+ if (link && notifyOutcome && notifyOutcome.status === 'sent') {
269
+ lines.push(` Notified the owner of "${link.projectName || link.projectId}" by email.`);
270
+ }
271
+ lines.push('');
272
+ process.stdout.write(lines.join('\n'));
273
+ return {
274
+ aborted: false,
275
+ decryptedFiles,
276
+ removed,
277
+ notified: notifyOutcome && notifyOutcome.status === 'sent',
278
+ };
279
+ }
280
+
281
+ module.exports = { runRemove, _internal: { destroyVaultArtifacts, tryNotifyServer } };
package/src/cli-watch.js CHANGED
@@ -1155,4 +1155,6 @@ module.exports = {
1155
1155
  readWatcherStatus,
1156
1156
  watchStateFile,
1157
1157
  watchLogFile,
1158
+ // Exported for tests. Not part of the public surface.
1159
+ _internal: { heartbeatOnce },
1158
1160
  };
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);
@@ -447,14 +465,25 @@ function build() {
447
465
  program
448
466
  .command('status')
449
467
  .description('Show whether the project is locked / unlocked and any drift.')
450
- .option('--check', 'exit 1 if unlocked with drift (for git hooks)', false)
468
+ .option('--check', 'exit 1 if unlocked (for git hooks). sealcode@1.4.1: strict by default — passes only when locked.', false)
469
+ // sealcode@1.4.1 — opt back into the pre-1.4.1 "block only on
470
+ // drift" behavior. Useful for CI jobs that intentionally commit
471
+ // alongside an unlocked working tree (rare). The installed hook
472
+ // can also flip into this mode globally via
473
+ // `sealcode install-hook --lenient`.
474
+ .option('--allow-clean-unlock', 'pre-1.4.1 behavior: pass when unlocked as long as there is no drift', false)
451
475
  .option('--json', 'machine-readable output (for editors / CI)', false)
452
476
  .action(async (opts) => {
453
477
  try {
454
478
  const projectRoot = resolveProject(program.opts());
455
479
  const config = getActiveConfig(projectRoot);
456
480
  if (opts.check) {
457
- const r = await runPrecommitCheck(projectRoot, config, () => getKeyForCheck(projectRoot, config));
481
+ const r = await runPrecommitCheck(
482
+ projectRoot,
483
+ config,
484
+ () => getKeyForCheck(projectRoot, config),
485
+ { allowCleanUnlock: !!opts.allowCleanUnlock },
486
+ );
458
487
  if (!r.ok) {
459
488
  process.stderr.write(r.message + '\n');
460
489
  process.exitCode = 1;
@@ -503,6 +532,88 @@ function build() {
503
532
  process.stdout.write('\nUse with: sealcode init --preset <id>\n');
504
533
  });
505
534
 
535
+ // -------- scan --------
536
+ //
537
+ // sealcode@1.4.0 — dry-run inventory. Walks the project the same way the
538
+ // `auto` preset would, prints exactly what `sealcode init`/`lock` would
539
+ // touch, and flags suspicious omissions (git-tracked source files that
540
+ // exclude rules drop). No mutation.
541
+ program
542
+ .command('scan')
543
+ .description('Show what sealcode would lock in this project (read-only).')
544
+ .option('--json', 'emit machine-readable JSON instead of a human report', false)
545
+ .action(async (opts) => {
546
+ try {
547
+ const projectRoot = resolveProject(program.opts());
548
+ const report = scanProject(projectRoot);
549
+ const monorepo = detectMicroservices(projectRoot);
550
+ if (opts.json) {
551
+ process.stdout.write(
552
+ JSON.stringify(
553
+ {
554
+ projectRoot,
555
+ source: report.source,
556
+ lockedDir: report.lockedDir,
557
+ wouldLock: report.wouldLock,
558
+ wouldLockCount: report.wouldLock.length,
559
+ totalBytes: report.totalBytes,
560
+ byTopLevel: report.byTopLevel,
561
+ gitCoverage: report.gitCoverage,
562
+ oversize: report.oversize,
563
+ include: report.include,
564
+ exclude: report.exclude,
565
+ monorepo: {
566
+ isMonorepo: monorepo.isMonorepo,
567
+ reason: monorepo.reason || null,
568
+ services: monorepo.services,
569
+ workspaces: monorepo.workspaces,
570
+ },
571
+ },
572
+ null,
573
+ 2
574
+ ) + '\n'
575
+ );
576
+ return;
577
+ }
578
+ process.stdout.write(`\nsealcode · scanning ${projectRoot}`);
579
+ renderDiscoveryReport(report);
580
+ if (monorepo.isMonorepo) {
581
+ process.stdout.write('\n');
582
+ ui.warn(`Multi-project layout detected · ${monorepo.reason}`);
583
+ if (monorepo.workspaces.length) {
584
+ ui.hint(' Workspace declarations:');
585
+ for (const w of monorepo.workspaces) {
586
+ ui.hint(` • ${w.file} (${w.kind})`);
587
+ }
588
+ }
589
+ if (monorepo.services.length) {
590
+ ui.hint(` Services (${monorepo.services.length}):`);
591
+ for (const s of monorepo.services.slice(0, 10)) {
592
+ ui.hint(` • ${s.relPath}/ (${s.marker})`);
593
+ }
594
+ if (monorepo.services.length > 10) {
595
+ ui.hint(` • … and ${monorepo.services.length - 10} more`);
596
+ }
597
+ }
598
+ ui.hint(' `sealcode init` will refuse at this root — cd into each service and init separately.');
599
+ ui.hint(' Override with `sealcode init --allow-monorepo` (one vault for the whole tree; billed as one project).');
600
+ }
601
+ process.stdout.write('\n');
602
+ const next = configExists(projectRoot)
603
+ ? 'sealcode lock (apply the current config)'
604
+ : monorepo.isMonorepo
605
+ ? 'cd <service-dir> && sealcode init (per-service)'
606
+ : 'sealcode init --preset auto (write .sealcoderc.json and lock)';
607
+ ui.hint(`Next: ${next}`);
608
+ if (report.wouldLock.length === 0) {
609
+ process.stdout.write('\n');
610
+ ui.warn('Nothing matched — the project may be empty or fully excluded.');
611
+ }
612
+ } catch (err) {
613
+ process.exitCode = reportError(err);
614
+ }
615
+ });
616
+
506
617
  // -------- logout --------
507
618
  program
508
619
  .command('logout')
@@ -630,12 +741,23 @@ function build() {
630
741
  // -------- install-hook --------
631
742
  program
632
743
  .command('install-hook')
633
- .description('Install a git pre-commit hook that runs `sealcode status --check`')
634
- .action(() => {
744
+ .description('Install a git pre-commit hook that runs `sealcode status --check`.')
745
+ // sealcode@1.4.1 — the hook is strict by default (blocks any
746
+ // commit while the project is unlocked). `--lenient` writes a
747
+ // hook that uses `--allow-clean-unlock` instead, restoring the
748
+ // pre-1.4.1 "block only on drift" behavior.
749
+ .option('--lenient', 'install the pre-1.4.1 hook (only blocks commits when working tree drifts)', false)
750
+ .action((opts) => {
635
751
  try {
636
752
  const projectRoot = resolveProject(program.opts());
637
- const hookPath = installHook(projectRoot);
638
- process.stdout.write(`✓ pre-commit hook installed:\n ${hookPath}\n`);
753
+ const hookPath = installHook(projectRoot, { lenient: !!opts.lenient });
754
+ process.stdout.write(`✓ pre-commit hook installed (${opts.lenient ? 'lenient' : 'strict'} mode):\n ${hookPath}\n`);
755
+ if (!opts.lenient) {
756
+ process.stdout.write(
757
+ ' Any `git commit` while the project is unlocked will be blocked.\n' +
758
+ ' Run `sealcode lock` before committing.\n',
759
+ );
760
+ }
639
761
  } catch (err) {
640
762
  process.exitCode = reportError(err);
641
763
  }
@@ -811,6 +933,10 @@ function build() {
811
933
  .option('--info', 'just print the current link state, don\'t change it', false)
812
934
  .option('--remove', 'remove the existing link from this repository', false)
813
935
  .option('--json', 'machine-readable output (with --info)', false)
936
+ // sealcode@1.4.0 — owner-only override for the 1:1 project<->repo
937
+ // binding. The server still audit-logs the re-pin, and refuses if
938
+ // the caller isn't the project owner.
939
+ .option('--force', 're-pin this project to the current repository (owner only)', false)
814
940
  .action(async (projectId, opts) => {
815
941
  try {
816
942
  const projectRoot = resolveProject(program.opts());
@@ -822,7 +948,38 @@ function build() {
822
948
  runLinkInfo({ projectRoot, json: opts.json });
823
949
  return;
824
950
  }
825
- await runLink({ projectRoot, projectId });
951
+ await runLink({ projectRoot, projectId, force: !!opts.force });
952
+ } catch (err) {
953
+ process.exitCode = reportError(err);
954
+ }
955
+ });
956
+
957
+ // -------- remove (permanently uninstall sealcode from this project) --------
958
+ // sealcode@1.4.0 — destructive sibling of `link --remove`. See
959
+ // src/cli-remove.js for the full flow; the high-level guarantees are:
960
+ // * passphrase is re-verified even when a session is unlocked
961
+ // * the project owner is emailed BEFORE local data is touched
962
+ // * we refuse to run offline unless --offline is explicit (so a
963
+ // hostile actor can't bypass the alert by cutting the network)
964
+ program
965
+ .command('remove')
966
+ .description('Permanently remove sealcode from this project. Restores plaintext and (if linked) emails the project owner.')
967
+ .option('--confirm <phrase>', 'pass `yes-remove` to skip the interactive confirmation prompt')
968
+ .option('--burn', 'do NOT decrypt locked files before removing — discards ciphertext only', false)
969
+ .option('--offline', 'allow removal without notifying the project owner (we still try first)', false)
970
+ .option('--session-ok', 'accept a cached session instead of re-typing the passphrase', false)
971
+ .option('--keep-link', 'leave the .sealcoderc.json link block in place (rare; mostly for debugging)', false)
972
+ .action(async (opts) => {
973
+ try {
974
+ const projectRoot = resolveProject(program.opts());
975
+ await runRemove({
976
+ projectRoot,
977
+ confirm: opts.confirm || null,
978
+ burn: !!opts.burn,
979
+ offline: !!opts.offline,
980
+ sessionOk: !!opts.sessionOk,
981
+ keepLink: !!opts.keepLink,
982
+ });
826
983
  } catch (err) {
827
984
  process.exitCode = reportError(err);
828
985
  }