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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sealcode@1.3.6 — Known-projects registry + self-check.
|
|
5
|
+
*
|
|
6
|
+
* Problem this solves
|
|
7
|
+
* -------------------
|
|
8
|
+
* Today the watcher is the only thing that re-locks a recipient's
|
|
9
|
+
* working copy on revoke/pause/expiry. If the watcher dies and the
|
|
10
|
+
* recipient never runs another `sealcode` command in that project,
|
|
11
|
+
* the plaintext sits there indefinitely.
|
|
12
|
+
*
|
|
13
|
+
* We can't stop a determined user from `kill -9`-ing the watcher and
|
|
14
|
+
* never running sealcode again on that project — that's a fundamental
|
|
15
|
+
* limit of any client-side enforcement. But we can close the much
|
|
16
|
+
* more common path: the recipient uses sealcode in OTHER projects (or
|
|
17
|
+
* for other commands) on the same machine. Every one of those
|
|
18
|
+
* invocations is an opportunity to notice "hey, project X is sitting
|
|
19
|
+
* unlocked with no watcher" and re-lock it.
|
|
20
|
+
*
|
|
21
|
+
* The registry
|
|
22
|
+
* ------------
|
|
23
|
+
* One JSON file at ~/.sealcode/projects.json. Maintained by the CLI
|
|
24
|
+
* on every unlock (add) and every lock (mark locked). Format:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "/Users/alice/work/proj-a": {
|
|
28
|
+
* "addedAt": "2026-05-21T12:00:00.000Z",
|
|
29
|
+
* "lastSeenAt": "2026-05-21T12:34:56.000Z",
|
|
30
|
+
* "lastState": "unlocked" | "locked"
|
|
31
|
+
* },
|
|
32
|
+
* ...
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* Self-check (called from cli.js on EVERY invocation, including
|
|
36
|
+
* commands like `sealcode --help` and `sealcode whoami`):
|
|
37
|
+
* - Walk the registry.
|
|
38
|
+
* - For each entry whose lastState is "unlocked":
|
|
39
|
+
* - If the project no longer exists on disk, drop it.
|
|
40
|
+
* - If the watcher state file is missing or stale AND a session
|
|
41
|
+
* is cached, attempt a panic-lock in the background.
|
|
42
|
+
* - Print a single-line warning so the user sees it.
|
|
43
|
+
*
|
|
44
|
+
* The self-check is purely best-effort and never throws. It runs
|
|
45
|
+
* synchronously (cheap — just file stat-ing) and any async lock
|
|
46
|
+
* happens out-of-band so the user's command isn't delayed.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const os = require('os');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
|
|
53
|
+
const REGISTRY_DIR = path.join(os.homedir(), '.sealcode');
|
|
54
|
+
const REGISTRY_FILE = path.join(REGISTRY_DIR, 'projects.json');
|
|
55
|
+
|
|
56
|
+
function ensureDir() {
|
|
57
|
+
try { fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 }); } catch (_) { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRegistry() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(REGISTRY_FILE, 'utf8');
|
|
63
|
+
const obj = JSON.parse(raw);
|
|
64
|
+
if (obj && typeof obj === 'object') return obj;
|
|
65
|
+
} catch (_) { /* missing or corrupt — start fresh */ }
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeRegistry(obj) {
|
|
70
|
+
ensureDir();
|
|
71
|
+
// Atomic-ish write: write to .tmp, rename. Avoids torn writes if
|
|
72
|
+
// two CLI invocations race.
|
|
73
|
+
const tmp = REGISTRY_FILE + '.' + process.pid + '.tmp';
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
76
|
+
fs.renameSync(tmp, REGISTRY_FILE);
|
|
77
|
+
} catch (_) {
|
|
78
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark a project as currently unlocked. Called from runUnlock.
|
|
84
|
+
*/
|
|
85
|
+
function markUnlocked(projectRoot) {
|
|
86
|
+
if (!projectRoot) return;
|
|
87
|
+
const reg = readRegistry();
|
|
88
|
+
const abs = path.resolve(projectRoot);
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
reg[abs] = {
|
|
91
|
+
addedAt: reg[abs]?.addedAt || now,
|
|
92
|
+
lastSeenAt: now,
|
|
93
|
+
lastState: 'unlocked',
|
|
94
|
+
};
|
|
95
|
+
writeRegistry(reg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark a project as currently locked. Called from runLock + finalLock
|
|
100
|
+
* + softLock paths.
|
|
101
|
+
*/
|
|
102
|
+
function markLocked(projectRoot) {
|
|
103
|
+
if (!projectRoot) return;
|
|
104
|
+
const reg = readRegistry();
|
|
105
|
+
const abs = path.resolve(projectRoot);
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
reg[abs] = {
|
|
108
|
+
addedAt: reg[abs]?.addedAt || now,
|
|
109
|
+
lastSeenAt: now,
|
|
110
|
+
lastState: 'locked',
|
|
111
|
+
};
|
|
112
|
+
writeRegistry(reg);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove a project from the registry (e.g. when it's deleted or the
|
|
117
|
+
* user explicitly unlinks).
|
|
118
|
+
*/
|
|
119
|
+
function forget(projectRoot) {
|
|
120
|
+
if (!projectRoot) return;
|
|
121
|
+
const reg = readRegistry();
|
|
122
|
+
const abs = path.resolve(projectRoot);
|
|
123
|
+
if (reg[abs]) {
|
|
124
|
+
delete reg[abs];
|
|
125
|
+
writeRegistry(reg);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return a list of currently-known projects.
|
|
131
|
+
*/
|
|
132
|
+
function list() {
|
|
133
|
+
const reg = readRegistry();
|
|
134
|
+
return Object.entries(reg).map(([projectRoot, meta]) => ({
|
|
135
|
+
projectRoot,
|
|
136
|
+
...meta,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Return projects whose lastState is "unlocked" but:
|
|
142
|
+
* - the directory still exists, AND
|
|
143
|
+
* - either no watcher state file exists OR the watcher's pid is
|
|
144
|
+
* not alive OR the watch state hasn't been touched in > maxStaleMin.
|
|
145
|
+
*
|
|
146
|
+
* Synchronous, cheap. Used by selfCheck and by the supervisor.
|
|
147
|
+
*/
|
|
148
|
+
function findOrphanedUnlocks({ maxStaleMin = 5 } = {}) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const reg = readRegistry();
|
|
151
|
+
const cutoff = Date.now() - maxStaleMin * 60_000;
|
|
152
|
+
|
|
153
|
+
for (const [projectRoot, meta] of Object.entries(reg)) {
|
|
154
|
+
if (!meta || meta.lastState !== 'unlocked') continue;
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(projectRoot)) continue;
|
|
157
|
+
} catch (_) { continue; }
|
|
158
|
+
|
|
159
|
+
// Look up the watch state. We can't import cli-watch here (circular),
|
|
160
|
+
// so we replicate the path math.
|
|
161
|
+
const id = projectIdHash(projectRoot);
|
|
162
|
+
const watchFile = path.join(os.homedir(), '.sealcode', 'sessions', `${id}.watch.json`);
|
|
163
|
+
let watcherAlive = false;
|
|
164
|
+
try {
|
|
165
|
+
const st = JSON.parse(fs.readFileSync(watchFile, 'utf8'));
|
|
166
|
+
const mtimeMs = fs.statSync(watchFile).mtimeMs;
|
|
167
|
+
const pid = st && st.pid;
|
|
168
|
+
if (pid && mtimeMs > cutoff) {
|
|
169
|
+
try { process.kill(pid, 0); watcherAlive = true; } catch (_) { watcherAlive = false; }
|
|
170
|
+
}
|
|
171
|
+
} catch (_) { watcherAlive = false; }
|
|
172
|
+
|
|
173
|
+
if (!watcherAlive) {
|
|
174
|
+
out.push({ projectRoot, lastSeenAt: meta.lastSeenAt });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Identical hash that keystore.projectId uses. Inlined here to avoid
|
|
182
|
+
* a circular dep between cli-registry and keystore (keystore is loaded
|
|
183
|
+
* very early on every CLI invocation).
|
|
184
|
+
*/
|
|
185
|
+
function projectIdHash(projectRoot) {
|
|
186
|
+
const crypto = require('crypto');
|
|
187
|
+
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Synchronous self-check called at the top of every CLI invocation
|
|
192
|
+
* (from cli.js). NEVER throws. Returns the number of orphaned
|
|
193
|
+
* unlocked projects detected. Prints a one-line warning per project
|
|
194
|
+
* unless `quiet` is true.
|
|
195
|
+
*
|
|
196
|
+
* The actual re-lock is deferred to a background detached process so
|
|
197
|
+
* the user's command isn't blocked. We invoke `sealcode panic` in a
|
|
198
|
+
* detached child for each orphan.
|
|
199
|
+
*
|
|
200
|
+
* `quiet` is set for short-running commands (where, version, help)
|
|
201
|
+
* and for the panic command itself to avoid recursion.
|
|
202
|
+
*/
|
|
203
|
+
function selfCheck({ quiet = false, skipBackgroundLock = false } = {}) {
|
|
204
|
+
let orphans;
|
|
205
|
+
try {
|
|
206
|
+
orphans = findOrphanedUnlocks();
|
|
207
|
+
} catch (_) { return 0; }
|
|
208
|
+
|
|
209
|
+
if (orphans.length === 0) return 0;
|
|
210
|
+
|
|
211
|
+
if (!quiet) {
|
|
212
|
+
try {
|
|
213
|
+
const ui = require('./ui');
|
|
214
|
+
for (const o of orphans) {
|
|
215
|
+
ui.warn(
|
|
216
|
+
`\u26a0 ${o.projectRoot} appears unlocked but its watcher is not running. `
|
|
217
|
+
+ `Auto-locking in the background.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} catch (_) { /* ui module may not be loadable in some edge cases */ }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (skipBackgroundLock) return orphans.length;
|
|
224
|
+
|
|
225
|
+
// Detached background panic-lock per orphan. We CANNOT just call
|
|
226
|
+
// runLock here — we don't have the session key. The `panic` command
|
|
227
|
+
// (and its lock pathway) is what knows how to find the cached
|
|
228
|
+
// session and re-encrypt. If there's no session cached, the panic
|
|
229
|
+
// command is a no-op + the registry gets corrected to "locked".
|
|
230
|
+
const { spawn } = require('child_process');
|
|
231
|
+
const node = process.execPath;
|
|
232
|
+
const cli = path.resolve(__dirname, '..', 'bin', 'sealcode.js');
|
|
233
|
+
for (const o of orphans) {
|
|
234
|
+
try {
|
|
235
|
+
const child = spawn(node, [cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'], {
|
|
236
|
+
detached: true,
|
|
237
|
+
stdio: 'ignore',
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
cwd: o.projectRoot,
|
|
240
|
+
});
|
|
241
|
+
child.unref();
|
|
242
|
+
} catch (_) { /* best-effort */ }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return orphans.length;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
markUnlocked,
|
|
250
|
+
markLocked,
|
|
251
|
+
forget,
|
|
252
|
+
list,
|
|
253
|
+
findOrphanedUnlocks,
|
|
254
|
+
selfCheck,
|
|
255
|
+
REGISTRY_FILE,
|
|
256
|
+
};
|
|
@@ -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 } };
|