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.
- package/README.md +72 -2
- package/package.json +4 -4
- package/src/cli-grants.js +99 -4
- package/src/cli-link.js +147 -8
- package/src/cli-remove.js +281 -0
- package/src/cli-watch.js +2 -0
- package/src/cli.js +178 -21
- package/src/discovery.js +1004 -0
- package/src/errors.js +39 -4
- package/src/hooks.js +15 -3
- package/src/init.js +152 -11
- package/src/keystore.js +31 -0
- package/src/presets.js +98 -54
- package/src/seal.js +45 -0
- package/src/status.js +34 -3
|
@@ -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
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);
|
|
@@ -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
|
|
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(
|
|
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
|
-
.
|
|
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
|
}
|