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
package/src/errors.js
CHANGED
|
@@ -34,7 +34,7 @@ const CODES = {
|
|
|
34
34
|
headline: 'This project already has a sealcode vault.',
|
|
35
35
|
detail:
|
|
36
36
|
"There's an existing vault here. Re-running `init` would overwrite it and orphan your locked files — refusing.",
|
|
37
|
-
try: 'Try: sealcode status (see what state you are in)\n sealcode
|
|
37
|
+
try: 'Try: sealcode status (see what state you are in)\n sealcode remove (start over; requires passphrase, notifies owner if linked)',
|
|
38
38
|
},
|
|
39
39
|
SEALCODE_INIT_NEEDS_TTY: {
|
|
40
40
|
headline: 'sealcode init needs an interactive terminal.',
|
|
@@ -72,6 +72,36 @@ const CODES = {
|
|
|
72
72
|
'The first heartbeat failed. We refuse to start the watcher without confirming the code is reachable — otherwise a revoke would never be observed.',
|
|
73
73
|
try: 'Try: check connectivity, then re-run `sealcode watch <code>`.',
|
|
74
74
|
},
|
|
75
|
+
SEALCODE_PROJECT_REPO_MISMATCH: {
|
|
76
|
+
headline: 'That project is already linked to a different repository.',
|
|
77
|
+
detail:
|
|
78
|
+
'sealcode binds each paid project to exactly one local repository, identified by an opaque hash of its vault salt. The server has already pinned this project to a different repo — accepting this link would let one billing slot cover multiple unrelated codebases, which is not allowed.',
|
|
79
|
+
try: 'Try: sealcode link <id> from the original repo\n create a new project on https://sealcode.dev/dashboard for this repo\n sealcode link --force <id> (owner-only: re-pin to this repo)',
|
|
80
|
+
},
|
|
81
|
+
SEALCODE_REMOVE_OFFLINE: {
|
|
82
|
+
headline: "I can't reach sealcode.dev to notify the project owner.",
|
|
83
|
+
detail:
|
|
84
|
+
'`sealcode remove` refuses to run when the project is linked and the server is unreachable — otherwise an attacker could bypass the owner-email alert just by cutting the network. Confirm connectivity, or re-run with `--offline` if you accept that the owner will NOT be alerted.',
|
|
85
|
+
try: 'Try: sealcode whoami (confirm the API URL and connectivity)\n sealcode remove --offline (skip the alert; the owner can still see the local state never updated on the dashboard)',
|
|
86
|
+
},
|
|
87
|
+
SEALCODE_REMOVE_REJECTED: {
|
|
88
|
+
headline: 'The server rejected the remove notice.',
|
|
89
|
+
detail:
|
|
90
|
+
"sealcode.dev refused to record this removal. Usually that's a stale bearer token, a project that was already deleted on the dashboard, or a server-side validation error.",
|
|
91
|
+
try: 'Try: sealcode whoami (confirm you are signed in)\n sealcode link --info (confirm the project still exists)',
|
|
92
|
+
},
|
|
93
|
+
SEALCODE_FORBIDDEN: {
|
|
94
|
+
headline: "You don't have permission to do that.",
|
|
95
|
+
detail:
|
|
96
|
+
'The server rejected this action for your account. If you think you should have access, double-check your role on the project dashboard.',
|
|
97
|
+
try: 'Try: sealcode whoami (confirm which account is signed in)',
|
|
98
|
+
},
|
|
99
|
+
SEALCODE_MONOREPO_DETECTED: {
|
|
100
|
+
headline: 'This folder looks like a monorepo / multi-service project.',
|
|
101
|
+
detail:
|
|
102
|
+
'sealcode is licensed per project. Each microservice should be initialized and shared on its own so it gets its own keys, its own grants, and its own audit trail. Run `sealcode init` from inside each service directory listed below.',
|
|
103
|
+
try: 'Try: cd <service-dir> && sealcode init\n sealcode init --allow-monorepo (override: one vault for the whole tree)',
|
|
104
|
+
},
|
|
75
105
|
};
|
|
76
106
|
|
|
77
107
|
class SealcodeError extends Error {
|
|
@@ -101,10 +131,15 @@ function reportError(err) {
|
|
|
101
131
|
// Sentinel codes thrown deep inside modules surface here as Error.message.
|
|
102
132
|
const code = isSC ? err.code : isSealcodeCode(err.message) ? err.message : null;
|
|
103
133
|
let meta;
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
if (isSC) {
|
|
135
|
+
// The SealcodeError constructor already merged any caller-supplied
|
|
136
|
+
// overrides on top of the static CODES entry, so the instance's own
|
|
137
|
+
// fields are authoritative. The pre-1.4 behavior of always reading
|
|
138
|
+
// from CODES[code] silently dropped per-throw overrides (e.g. the
|
|
139
|
+
// monorepo guard's dynamic list of detected services).
|
|
107
140
|
meta = { headline: err.headline, detail: err.detail, try: err.tryHint };
|
|
141
|
+
} else if (code && CODES[code]) {
|
|
142
|
+
meta = CODES[code];
|
|
108
143
|
} else {
|
|
109
144
|
meta = {
|
|
110
145
|
headline: err.message || 'Something went wrong.',
|
package/src/hooks.js
CHANGED
|
@@ -41,8 +41,18 @@ function findGitDir(startDir) {
|
|
|
41
41
|
/**
|
|
42
42
|
* Install the pre-commit hook. Idempotent: replaces only the sealcode block
|
|
43
43
|
* (and any legacy vaultline block left over from an older install).
|
|
44
|
+
*
|
|
45
|
+
* sealcode@1.4.1 — `opts.lenient` toggles the behavior of the hook
|
|
46
|
+
* itself, not just the install message. Strict (default) refuses any
|
|
47
|
+
* commit while the project is unlocked. Lenient appends
|
|
48
|
+
* `--allow-clean-unlock`, restoring the pre-1.4.1 "block only on drift"
|
|
49
|
+
* behavior for niche workflows that intentionally commit alongside an
|
|
50
|
+
* unlocked tree.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} projectRoot
|
|
53
|
+
* @param {{ lenient?: boolean }} [opts]
|
|
44
54
|
*/
|
|
45
|
-
function installHook(projectRoot) {
|
|
55
|
+
function installHook(projectRoot, opts = {}) {
|
|
46
56
|
const gitDir = findGitDir(projectRoot);
|
|
47
57
|
if (!gitDir) {
|
|
48
58
|
throw new Error('No .git directory found above this folder. Run `git init` first.');
|
|
@@ -50,15 +60,17 @@ function installHook(projectRoot) {
|
|
|
50
60
|
const hookPath = path.join(gitDir, 'hooks', 'pre-commit');
|
|
51
61
|
ensureHooksDir(gitDir);
|
|
52
62
|
|
|
63
|
+
const flag = opts.lenient ? ' --allow-clean-unlock' : '';
|
|
53
64
|
const block = [
|
|
54
65
|
MARK_BEGIN,
|
|
66
|
+
`# mode: ${opts.lenient ? 'lenient' : 'strict'}`,
|
|
55
67
|
'sealcode_check() {',
|
|
56
68
|
' ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0',
|
|
57
69
|
' cd "$ROOT" || return 0',
|
|
58
70
|
' if command -v sealcode >/dev/null 2>&1; then',
|
|
59
|
-
|
|
71
|
+
` sealcode status --check${flag} || exit 1`,
|
|
60
72
|
' elif command -v npx >/dev/null 2>&1; then',
|
|
61
|
-
|
|
73
|
+
` npx --yes sealcode@latest status --check${flag} || exit 1`,
|
|
62
74
|
' else',
|
|
63
75
|
' echo "sealcode: install the CLI (npm i -g sealcode) or npx for pre-commit checks." >&2',
|
|
64
76
|
' exit 1',
|
package/src/init.js
CHANGED
|
@@ -21,6 +21,7 @@ const { makeRecoveryCode } = require('./recovery');
|
|
|
21
21
|
const { isInitialized } = require('./keystore');
|
|
22
22
|
const { SealcodeError } = require('./errors');
|
|
23
23
|
const { question, confirm, hidden, select } = require('./prompt');
|
|
24
|
+
const { buildAutoConfig, isGitRepo, detectMicroservices } = require('./discovery');
|
|
24
25
|
|
|
25
26
|
const GITIGNORE_BLOCK = `
|
|
26
27
|
# sealcode — local-only files (never commit)
|
|
@@ -47,6 +48,13 @@ function appendGitignoreOnce(projectRoot) {
|
|
|
47
48
|
async function pickPreset(projectRoot, { suggestedId, noninteractive = false } = {}) {
|
|
48
49
|
const guess = suggestedId ? getPreset(suggestedId) : detectPreset(projectRoot);
|
|
49
50
|
process.stdout.write(`\nDetected ecosystem: ${guess.label} (${guess.id})\n`);
|
|
51
|
+
if (guess.id === 'auto') {
|
|
52
|
+
const gitNote = isGitRepo(projectRoot) ? 'git-aware' : 'filesystem scan';
|
|
53
|
+
process.stdout.write(
|
|
54
|
+
` Auto mode locks every source file in the repo (${gitNote}, smart\n`
|
|
55
|
+
+ ' excludes for build artifacts, binaries, secrets, and locked dir).\n'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
50
58
|
if (noninteractive) {
|
|
51
59
|
process.stdout.write(' (non-interactive: accepting detected preset)\n');
|
|
52
60
|
return guess;
|
|
@@ -62,6 +70,69 @@ async function pickPreset(projectRoot, { suggestedId, noninteractive = false } =
|
|
|
62
70
|
return getPreset(choice);
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Render the auto-discovery report to stdout. The same renderer is reused
|
|
75
|
+
* by `sealcode scan`.
|
|
76
|
+
*/
|
|
77
|
+
function renderDiscoveryReport(report) {
|
|
78
|
+
const { source, lockedDir, wouldLock, totalBytes, byTopLevel, gitCoverage, oversize } = report;
|
|
79
|
+
const sizeStr = formatBytes(totalBytes);
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
`\n source: ${source === 'git' ? `git (${gitCoverage.tracked} tracked files)` : 'filesystem walk'}\n`
|
|
82
|
+
);
|
|
83
|
+
process.stdout.write(` would lock: ${wouldLock.length} files (~${sizeStr})\n`);
|
|
84
|
+
process.stdout.write(` locked dir: ${lockedDir}/\n`);
|
|
85
|
+
|
|
86
|
+
const tops = Object.entries(byTopLevel).sort((a, b) => b[1] - a[1]).slice(0, 12);
|
|
87
|
+
if (tops.length) {
|
|
88
|
+
process.stdout.write('\n Coverage by folder:\n');
|
|
89
|
+
const width = Math.max(...tops.map(([k]) => k.length));
|
|
90
|
+
for (const [k, n] of tops) {
|
|
91
|
+
process.stdout.write(` ${k.padEnd(width + 2)} ${n} files\n`);
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(byTopLevel).length > tops.length) {
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
` ... and ${Object.keys(byTopLevel).length - tops.length} more directories\n`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (gitCoverage) {
|
|
101
|
+
const reasons = Object.entries(gitCoverage.excludedByReason).sort((a, b) => b[1] - a[1]);
|
|
102
|
+
if (reasons.length) {
|
|
103
|
+
process.stdout.write(`\n Excluded (${gitCoverage.excluded} files):\n`);
|
|
104
|
+
for (const [reason, n] of reasons) {
|
|
105
|
+
process.stdout.write(` ${reason.padEnd(10)} ${n}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (gitCoverage.suspiciousExcluded.length) {
|
|
109
|
+
process.stdout.write(
|
|
110
|
+
`\n ⚠ ${gitCoverage.suspiciousExcluded.length} source-looking file(s) excluded — review:\n`
|
|
111
|
+
);
|
|
112
|
+
for (const p of gitCoverage.suspiciousExcluded.slice(0, 10)) {
|
|
113
|
+
process.stdout.write(` ${p}\n`);
|
|
114
|
+
}
|
|
115
|
+
if (gitCoverage.suspiciousExcluded.length > 10) {
|
|
116
|
+
process.stdout.write(` ... and ${gitCoverage.suspiciousExcluded.length - 10} more\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (oversize.length) {
|
|
122
|
+
process.stdout.write(`\n Skipped (>5MB): ${oversize.length} file(s)\n`);
|
|
123
|
+
for (const { path: p, size } of oversize.slice(0, 5)) {
|
|
124
|
+
process.stdout.write(` ${p} (${formatBytes(size)})\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatBytes(n) {
|
|
130
|
+
if (n < 1024) return `${n} B`;
|
|
131
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
132
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
133
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
134
|
+
}
|
|
135
|
+
|
|
65
136
|
async function askPassphrase() {
|
|
66
137
|
while (true) {
|
|
67
138
|
const pp = await hidden('Choose a passphrase:');
|
|
@@ -115,7 +186,17 @@ async function showRecoveryCode(code) {
|
|
|
115
186
|
* @param {string} [opts.presetId] optional override
|
|
116
187
|
* @param {boolean} [opts.force] overwrite an existing vault
|
|
117
188
|
*/
|
|
118
|
-
async function runInit({
|
|
189
|
+
async function runInit({
|
|
190
|
+
projectRoot,
|
|
191
|
+
presetId,
|
|
192
|
+
force = false,
|
|
193
|
+
noninteractive = false,
|
|
194
|
+
// sealcode@1.4.0 — refuse to init at the root of a monorepo so each
|
|
195
|
+
// microservice gets its own vault and its own billing line item.
|
|
196
|
+
// Set true to override (after `sealcode scan` has surfaced the
|
|
197
|
+
// detected services to the operator).
|
|
198
|
+
allowMonorepo = false,
|
|
199
|
+
}) {
|
|
119
200
|
const ni =
|
|
120
201
|
noninteractive ||
|
|
121
202
|
!!process.env.SEALCODE_NONINTERACTIVE ||
|
|
@@ -138,6 +219,42 @@ async function runInit({ projectRoot, presetId, force = false, noninteractive =
|
|
|
138
219
|
}
|
|
139
220
|
}
|
|
140
221
|
|
|
222
|
+
// sealcode@1.4.0 — multi-microservice guard. Run BEFORE we touch the
|
|
223
|
+
// wizard so an accidental `sealcode init` at the root of a monorepo
|
|
224
|
+
// never proceeds. The `--allow-monorepo` flag (or
|
|
225
|
+
// SEALCODE_ALLOW_MONOREPO=1 in CI) skips this check for the rare
|
|
226
|
+
// "one vault for the whole tree" case.
|
|
227
|
+
if (!allowMonorepo && !process.env.SEALCODE_ALLOW_MONOREPO) {
|
|
228
|
+
const ms = detectMicroservices(projectRoot);
|
|
229
|
+
if (ms.isMonorepo) {
|
|
230
|
+
const lines = [];
|
|
231
|
+
lines.push(ms.reason);
|
|
232
|
+
if (ms.workspaces.length) {
|
|
233
|
+
lines.push('Workspace declarations:');
|
|
234
|
+
for (const w of ms.workspaces) {
|
|
235
|
+
lines.push(` • ${w.file} (${w.kind})`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (ms.services.length) {
|
|
239
|
+
lines.push(`Service directories (${ms.services.length}):`);
|
|
240
|
+
for (const s of ms.services.slice(0, 20)) {
|
|
241
|
+
lines.push(` • ${s.relPath}/ (${s.marker})`);
|
|
242
|
+
}
|
|
243
|
+
if (ms.services.length > 20) {
|
|
244
|
+
lines.push(` • … and ${ms.services.length - 20} more`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
lines.push('');
|
|
248
|
+
lines.push('Initialize each service on its own:');
|
|
249
|
+
for (const s of ms.services.slice(0, 4)) {
|
|
250
|
+
lines.push(` cd ${s.relPath} && sealcode init`);
|
|
251
|
+
}
|
|
252
|
+
throw new SealcodeError('SEALCODE_MONOREPO_DETECTED', {
|
|
253
|
+
detail: lines.join('\n'),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
141
258
|
process.stdout.write(`\nsealcode · setting up ${projectRoot}\n`);
|
|
142
259
|
|
|
143
260
|
const preset = await pickPreset(projectRoot, { suggestedId: presetId, noninteractive: ni });
|
|
@@ -154,15 +271,39 @@ async function runInit({ projectRoot, presetId, force = false, noninteractive =
|
|
|
154
271
|
|
|
155
272
|
const { seed: recoverySeed, code: recoveryCode } = makeRecoveryCode();
|
|
156
273
|
|
|
157
|
-
// Persist the config (gitignored).
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
274
|
+
// Persist the config (gitignored). For the auto preset we delegate to
|
|
275
|
+
// discovery, which builds a deterministic include/exclude pair from the
|
|
276
|
+
// actual project contents (git ls-files when available, filesystem walk
|
|
277
|
+
// otherwise). Stack-specific presets keep their hand-tuned templates.
|
|
278
|
+
let cfg;
|
|
279
|
+
let report = null;
|
|
280
|
+
if (preset.id === 'auto') {
|
|
281
|
+
const auto = buildAutoConfig(projectRoot);
|
|
282
|
+
cfg = auto.cfg;
|
|
283
|
+
report = auto.report;
|
|
284
|
+
if (!ni) {
|
|
285
|
+
process.stdout.write('\nsealcode · scanning project');
|
|
286
|
+
renderDiscoveryReport(report);
|
|
287
|
+
const ok = await confirm('\nLock this set?', { default: true });
|
|
288
|
+
if (!ok) {
|
|
289
|
+
throw new SealcodeError('SEALCODE_INIT_NEEDS_TTY', {
|
|
290
|
+
detail: 'Cancelled at the discovery confirmation step.',
|
|
291
|
+
hint: 'Try: sealcode init --preset <other-id> (pick a stack-specific preset)',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
} else if (report.wouldLock.length === 0) {
|
|
295
|
+
throw new Error('SEALCODE_NOTHING_TO_LOCK');
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
cfg = {
|
|
299
|
+
version: 1,
|
|
300
|
+
preset: preset.id,
|
|
301
|
+
lockedDir: preset.lockedDir,
|
|
302
|
+
include: preset.include,
|
|
303
|
+
exclude: preset.exclude,
|
|
304
|
+
stubs: preset.stubs || {},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
166
307
|
writeConfig(projectRoot, cfg);
|
|
167
308
|
appendGitignoreOnce(projectRoot);
|
|
168
309
|
|
|
@@ -177,4 +318,4 @@ async function runInit({ projectRoot, presetId, force = false, noninteractive =
|
|
|
177
318
|
return { config: cfg, preset, passphrase, recoverySeed, recoveryCode };
|
|
178
319
|
}
|
|
179
320
|
|
|
180
|
-
module.exports = { runInit };
|
|
321
|
+
module.exports = { runInit, renderDiscoveryReport, formatBytes };
|
package/src/keystore.js
CHANGED
|
@@ -298,6 +298,36 @@ function isInitialized(projectRoot, lockedDir) {
|
|
|
298
298
|
);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* sealcode@1.4.0 — stable, opaque fingerprint of this LOCAL repo's vault.
|
|
303
|
+
*
|
|
304
|
+
* Used by `sealcode link` so the server can enforce 1:1 between a paid
|
|
305
|
+
* `sealcode` project record and a single local repository. Two different
|
|
306
|
+
* `sealcode init` runs produce two different fingerprints (because each
|
|
307
|
+
* generates its own random salt) so a user can't take one paid project
|
|
308
|
+
* slot and reuse it across five unrelated microservices.
|
|
309
|
+
*
|
|
310
|
+
* Properties we care about:
|
|
311
|
+
* - DETERMINISTIC per local repo: derived from the salt file, which
|
|
312
|
+
* never changes after init. A teammate cloning the repo gets the
|
|
313
|
+
* same fingerprint, so they can `sealcode link` to the same server
|
|
314
|
+
* project without a re-mint.
|
|
315
|
+
* - OPAQUE: it's a one-way hash of the salt — leaking it doesn't
|
|
316
|
+
* reveal anything that helps an attacker.
|
|
317
|
+
* - 24 hex chars (96 bits): collision-resistant for our scale and
|
|
318
|
+
* short enough to read on screen during conflict errors.
|
|
319
|
+
*
|
|
320
|
+
* Returns null if the project hasn't been initialized yet (no salt on
|
|
321
|
+
* disk) — the link command refuses in that case with a clear error.
|
|
322
|
+
*/
|
|
323
|
+
function localProjectFingerprint(projectRoot, lockedDir) {
|
|
324
|
+
const salt = readSalt(projectRoot, lockedDir);
|
|
325
|
+
if (!salt) return null;
|
|
326
|
+
// Domain-separated hash so this never collides with any other use of
|
|
327
|
+
// the salt elsewhere in the codebase.
|
|
328
|
+
return sha256Hex(Buffer.concat([Buffer.from('sealcode-project-link-v1'), salt])).slice(0, 24);
|
|
329
|
+
}
|
|
330
|
+
|
|
301
331
|
module.exports = {
|
|
302
332
|
SALT_NAME,
|
|
303
333
|
WRAP_PASS_NAME,
|
|
@@ -314,6 +344,7 @@ module.exports = {
|
|
|
314
344
|
updateSessionMeta,
|
|
315
345
|
clearSession,
|
|
316
346
|
isInitialized,
|
|
347
|
+
localProjectFingerprint,
|
|
317
348
|
manifestBlobPath,
|
|
318
349
|
projectId,
|
|
319
350
|
};
|
package/src/presets.js
CHANGED
|
@@ -42,59 +42,108 @@ const STUB_GENERIC_PKG_JSON =
|
|
|
42
42
|
|
|
43
43
|
/** @type {Preset[]} */
|
|
44
44
|
const PRESETS = [
|
|
45
|
+
// sealcode@1.4.0 — `auto` is the new default. It has no fixed include /
|
|
46
|
+
// exclude list; the init wizard delegates to src/discovery.js, which
|
|
47
|
+
// walks `git ls-files` (or a filesystem fallback) and writes a fully
|
|
48
|
+
// deterministic .sealcoderc.json. The marker list is empty so it never
|
|
49
|
+
// wins detectPreset on its own — it is selected explicitly by init or
|
|
50
|
+
// when the user passes --preset auto.
|
|
45
51
|
{
|
|
46
|
-
id: '
|
|
47
|
-
label: '
|
|
48
|
-
markers: [
|
|
52
|
+
id: 'auto',
|
|
53
|
+
label: 'Auto (any project, any layout)',
|
|
54
|
+
markers: [],
|
|
55
|
+
lockedDir: 'vendor',
|
|
56
|
+
include: ['**/*'],
|
|
57
|
+
exclude: [...SHARED_EXCLUDES, 'node_modules/**', 'vendor/**', 'dist/**', 'build/**'],
|
|
58
|
+
},
|
|
59
|
+
// sealcode@1.4.0 — framework-specific presets now appear BEFORE the
|
|
60
|
+
// generic per-language ones so `detectPreset` resolves Next.js / Django
|
|
61
|
+
// before falling back to `node` / `python`. Without this reorder, a
|
|
62
|
+
// Next.js project (which always has package.json) was mis-detected as
|
|
63
|
+
// plain Node and only `src/` + `lib/` got locked.
|
|
64
|
+
{
|
|
65
|
+
id: 'nextjs',
|
|
66
|
+
label: 'Next.js',
|
|
67
|
+
markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
|
|
49
68
|
lockedDir: 'vendor',
|
|
50
69
|
include: [
|
|
51
70
|
'src/**/*',
|
|
71
|
+
'app/**/*',
|
|
72
|
+
'pages/**/*',
|
|
73
|
+
'components/**/*',
|
|
52
74
|
'lib/**/*',
|
|
75
|
+
'public/**/*',
|
|
53
76
|
'package.json',
|
|
54
|
-
'
|
|
77
|
+
'next.config.*',
|
|
55
78
|
'tsconfig.json',
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'docker-compose*.yaml',
|
|
79
|
+
'tailwind.config.*',
|
|
80
|
+
'postcss.config.*',
|
|
59
81
|
'.env.example',
|
|
60
|
-
'README.md',
|
|
61
82
|
],
|
|
62
83
|
exclude: [
|
|
63
84
|
...SHARED_EXCLUDES,
|
|
64
85
|
'node_modules/**',
|
|
65
|
-
'dist/**',
|
|
66
|
-
'build/**',
|
|
67
86
|
'.next/**',
|
|
68
|
-
'.turbo/**',
|
|
69
|
-
'.nuxt/**',
|
|
70
87
|
'out/**',
|
|
71
88
|
'vendor/**',
|
|
72
89
|
],
|
|
73
|
-
stubs: { 'package.json':
|
|
90
|
+
stubs: { 'package.json': '{\n "name": "app",\n "version": "1.0.0",\n "private": true\n}\n' },
|
|
74
91
|
},
|
|
75
92
|
{
|
|
76
|
-
id: '
|
|
77
|
-
label: '
|
|
78
|
-
markers: ['
|
|
93
|
+
id: 'django',
|
|
94
|
+
label: 'Python — Django',
|
|
95
|
+
markers: ['manage.py'],
|
|
96
|
+
lockedDir: '_site_packages',
|
|
97
|
+
include: [
|
|
98
|
+
'**/*.py',
|
|
99
|
+
'manage.py',
|
|
100
|
+
'requirements*.txt',
|
|
101
|
+
'pyproject.toml',
|
|
102
|
+
'Dockerfile',
|
|
103
|
+
'docker-compose*.yml',
|
|
104
|
+
'.env.example',
|
|
105
|
+
'templates/**/*',
|
|
106
|
+
'static/**/*',
|
|
107
|
+
],
|
|
108
|
+
exclude: [
|
|
109
|
+
...SHARED_EXCLUDES,
|
|
110
|
+
'venv/**',
|
|
111
|
+
'.venv/**',
|
|
112
|
+
'__pycache__/**',
|
|
113
|
+
'**/__pycache__/**',
|
|
114
|
+
'*.pyc',
|
|
115
|
+
'media/**',
|
|
116
|
+
'staticfiles/**',
|
|
117
|
+
'db.sqlite3',
|
|
118
|
+
'_site_packages/**',
|
|
119
|
+
],
|
|
120
|
+
stubs: { 'README.md': '# app\n' },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'node',
|
|
124
|
+
label: 'Node.js / TypeScript',
|
|
125
|
+
markers: ['package.json'],
|
|
79
126
|
lockedDir: 'vendor',
|
|
80
127
|
include: [
|
|
81
128
|
'src/**/*',
|
|
82
|
-
'app/**/*',
|
|
83
|
-
'pages/**/*',
|
|
84
|
-
'components/**/*',
|
|
85
129
|
'lib/**/*',
|
|
86
|
-
'public/**/*',
|
|
87
130
|
'package.json',
|
|
88
|
-
'
|
|
131
|
+
'package-lock.json',
|
|
89
132
|
'tsconfig.json',
|
|
90
|
-
'
|
|
91
|
-
'
|
|
133
|
+
'Dockerfile',
|
|
134
|
+
'docker-compose*.yml',
|
|
135
|
+
'docker-compose*.yaml',
|
|
92
136
|
'.env.example',
|
|
137
|
+
'README.md',
|
|
93
138
|
],
|
|
94
139
|
exclude: [
|
|
95
140
|
...SHARED_EXCLUDES,
|
|
96
141
|
'node_modules/**',
|
|
142
|
+
'dist/**',
|
|
143
|
+
'build/**',
|
|
97
144
|
'.next/**',
|
|
145
|
+
'.turbo/**',
|
|
146
|
+
'.nuxt/**',
|
|
98
147
|
'out/**',
|
|
99
148
|
'vendor/**',
|
|
100
149
|
],
|
|
@@ -137,36 +186,6 @@ const PRESETS = [
|
|
|
137
186
|
],
|
|
138
187
|
stubs: { 'README.md': '# app\n' },
|
|
139
188
|
},
|
|
140
|
-
{
|
|
141
|
-
id: 'django',
|
|
142
|
-
label: 'Python — Django',
|
|
143
|
-
markers: ['manage.py'],
|
|
144
|
-
lockedDir: '_site_packages',
|
|
145
|
-
include: [
|
|
146
|
-
'**/*.py',
|
|
147
|
-
'manage.py',
|
|
148
|
-
'requirements*.txt',
|
|
149
|
-
'pyproject.toml',
|
|
150
|
-
'Dockerfile',
|
|
151
|
-
'docker-compose*.yml',
|
|
152
|
-
'.env.example',
|
|
153
|
-
'templates/**/*',
|
|
154
|
-
'static/**/*',
|
|
155
|
-
],
|
|
156
|
-
exclude: [
|
|
157
|
-
...SHARED_EXCLUDES,
|
|
158
|
-
'venv/**',
|
|
159
|
-
'.venv/**',
|
|
160
|
-
'__pycache__/**',
|
|
161
|
-
'**/__pycache__/**',
|
|
162
|
-
'*.pyc',
|
|
163
|
-
'media/**',
|
|
164
|
-
'staticfiles/**',
|
|
165
|
-
'db.sqlite3',
|
|
166
|
-
'_site_packages/**',
|
|
167
|
-
],
|
|
168
|
-
stubs: { 'README.md': '# app\n' },
|
|
169
|
-
},
|
|
170
189
|
{
|
|
171
190
|
id: 'go',
|
|
172
191
|
label: 'Go',
|
|
@@ -312,11 +331,36 @@ const PRESETS = [
|
|
|
312
331
|
|
|
313
332
|
/**
|
|
314
333
|
* Sniff the project root and guess the ecosystem.
|
|
334
|
+
*
|
|
335
|
+
* sealcode@1.4.0 — the default detection now returns `auto`, our universal
|
|
336
|
+
* preset. The legacy marker-based detection is preserved under
|
|
337
|
+
* `detectLegacyPreset` for callers that still want a specific stack guess
|
|
338
|
+
* (e.g. for choosing a sensible `lockedDir`).
|
|
339
|
+
*
|
|
315
340
|
* @param {string} projectRoot
|
|
316
341
|
* @returns {Preset}
|
|
317
342
|
*/
|
|
318
343
|
function detectPreset(projectRoot) {
|
|
344
|
+
// We deliberately ignore markers here. `auto` covers any layout and is
|
|
345
|
+
// the safe default; users can override with `sealcode init --preset <id>`.
|
|
346
|
+
// The walk still happens implicitly via the legacy helper below for
|
|
347
|
+
// any caller that opts in.
|
|
348
|
+
void projectRoot;
|
|
349
|
+
return PRESETS.find((p) => p.id === 'auto') || PRESETS[PRESETS.length - 1];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Legacy marker-based detection. Iterates presets in order (most specific
|
|
354
|
+
* first after the 1.4.0 reorder), skipping `auto` and `generic`. Used by
|
|
355
|
+
* code paths that need an ecosystem hint (lockedDir choice, stub
|
|
356
|
+
* templates) without committing to a stack-specific include list.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} projectRoot
|
|
359
|
+
* @returns {Preset}
|
|
360
|
+
*/
|
|
361
|
+
function detectLegacyPreset(projectRoot) {
|
|
319
362
|
for (const preset of PRESETS) {
|
|
363
|
+
if (preset.id === 'auto') continue;
|
|
320
364
|
if (preset.id === 'generic') continue;
|
|
321
365
|
for (const marker of preset.markers) {
|
|
322
366
|
if (fs.existsSync(path.join(projectRoot, marker))) {
|
|
@@ -335,4 +379,4 @@ function listPresets() {
|
|
|
335
379
|
return PRESETS.map((p) => ({ id: p.id, label: p.label }));
|
|
336
380
|
}
|
|
337
381
|
|
|
338
|
-
module.exports = { PRESETS, detectPreset, getPreset, listPresets };
|
|
382
|
+
module.exports = { PRESETS, detectPreset, detectLegacyPreset, getPreset, listPresets };
|
package/src/seal.js
CHANGED
|
@@ -131,6 +131,51 @@ async function runLock({
|
|
|
131
131
|
throw new Error('SEALCODE_NOTHING_TO_LOCK');
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// sealcode@1.4.0 — coverage warning. The pre-1.4 preset system would
|
|
135
|
+
// silently lock only a fraction of a project when the chosen preset
|
|
136
|
+
// didn't match the layout (Next.js with `node` preset, Django with
|
|
137
|
+
// `python`, monorepos with anything). The new `auto` preset / git
|
|
138
|
+
// discovery is the structural fix, but we also emit a one-line warning
|
|
139
|
+
// here so existing users on stack-specific presets notice the
|
|
140
|
+
// mismatch instead of shipping a half-locked repo.
|
|
141
|
+
//
|
|
142
|
+
// Only runs on first lock (no prevManifest), only when git is
|
|
143
|
+
// available, and only when coverage looks suspicious. Silent
|
|
144
|
+
// otherwise — we never want to nag on routine re-locks.
|
|
145
|
+
//
|
|
146
|
+
// Goes through stderr (via ui.warn) so it's visible in non-verbose
|
|
147
|
+
// mode but doesn't pollute stdout for any caller piping `sealcode
|
|
148
|
+
// lock` through grep/jq.
|
|
149
|
+
if (!prevManifest && config.preset !== 'auto') {
|
|
150
|
+
try {
|
|
151
|
+
const { isGitRepo, gitListFiles, looksLikeSource } = require('./discovery');
|
|
152
|
+
if (isGitRepo(projectRoot)) {
|
|
153
|
+
const tracked = gitListFiles(projectRoot) || [];
|
|
154
|
+
const lockedSet = new Set(files);
|
|
155
|
+
const trackedSourceMissing = tracked.filter(
|
|
156
|
+
(p) => looksLikeSource(p) && !lockedSet.has(p) && !p.startsWith(`${lockedDir}/`)
|
|
157
|
+
);
|
|
158
|
+
if (tracked.length >= 20 && trackedSourceMissing.length > tracked.length * 0.2) {
|
|
159
|
+
let ui;
|
|
160
|
+
try { ui = require('./ui'); } catch (_) { ui = null; }
|
|
161
|
+
const headline =
|
|
162
|
+
`coverage: only ${files.length}/${tracked.length} git-tracked files match this preset — `
|
|
163
|
+
+ `${trackedSourceMissing.length} source-looking files will remain UNLOCKED.`;
|
|
164
|
+
const hint = 'consider: sealcode init --preset auto --force (universal coverage)';
|
|
165
|
+
if (ui && typeof ui.warn === 'function') {
|
|
166
|
+
ui.warn(headline);
|
|
167
|
+
if (typeof ui.hint === 'function') ui.hint(' ' + hint);
|
|
168
|
+
} else {
|
|
169
|
+
log(' [coverage] ' + headline);
|
|
170
|
+
log(' [coverage] ' + hint);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (_) {
|
|
175
|
+
// Discovery is best-effort; never fail a lock because of it.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
// Auto-promote preserveUnseen on suspicious shrink. Threshold: only
|
|
135
180
|
// engage when the previous manifest had at least 5 entries (avoids
|
|
136
181
|
// false positives on tiny projects) AND the new plaintext set is
|
package/src/status.js
CHANGED
|
@@ -155,11 +155,27 @@ function renderStatus(status) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Pre-commit / CI gate
|
|
159
|
-
*
|
|
158
|
+
* Pre-commit / CI gate.
|
|
159
|
+
*
|
|
160
|
+
* sealcode@1.4.1 — strict by default. Any commit while the project is
|
|
161
|
+
* in the `unlocked` state is rejected, regardless of whether the working
|
|
162
|
+
* tree has drifted vs. the last lock. Before 1.4.1 we only blocked when
|
|
163
|
+
* drift was non-zero, which let a recipient `sealcode unlock && git add
|
|
164
|
+
* . && git commit` push the entire decrypted source into git without a
|
|
165
|
+
* single warning. The new default closes that footgun.
|
|
166
|
+
*
|
|
167
|
+
* Callers (CI scripts, IDE integrations, niche workflows) can opt back
|
|
168
|
+
* into the old "block only on drift" behavior with
|
|
169
|
+
* `{ allowCleanUnlock: true }`. The flag is also surfaced as a CLI
|
|
170
|
+
* option on `sealcode status --check`.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} projectRoot
|
|
173
|
+
* @param {object} config
|
|
160
174
|
* @param {( ) => Promise<Buffer|null>} getK
|
|
175
|
+
* @param {{ allowCleanUnlock?: boolean }} [opts]
|
|
161
176
|
*/
|
|
162
|
-
async function runPrecommitCheck(projectRoot, config, getK) {
|
|
177
|
+
async function runPrecommitCheck(projectRoot, config, getK, opts = {}) {
|
|
178
|
+
const allowCleanUnlock = !!opts.allowCleanUnlock;
|
|
163
179
|
const s = await runStatus({ projectRoot, config });
|
|
164
180
|
if (!s.initialized) return { ok: true, message: '' };
|
|
165
181
|
if (s.state === 'locked') return { ok: true, message: '' };
|
|
@@ -171,6 +187,21 @@ async function runPrecommitCheck(projectRoot, config, getK) {
|
|
|
171
187
|
};
|
|
172
188
|
}
|
|
173
189
|
|
|
190
|
+
// Strict default: refuse to let an unlocked tree reach `git commit`.
|
|
191
|
+
// We do this BEFORE computing drift because computing drift requires
|
|
192
|
+
// K, and we don't want a failed `getK()` to mask a much more
|
|
193
|
+
// important "you are about to commit plaintext source" warning.
|
|
194
|
+
if (!allowCleanUnlock) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
message:
|
|
198
|
+
'sealcode: project is UNLOCKED. Committing now would put plaintext source into git.\n' +
|
|
199
|
+
' Fix: run `sealcode lock` first, then `git add` the updated locked blobs.\n' +
|
|
200
|
+
' (Override only if you really mean it: `sealcode status --check --allow-clean-unlock`,\n' +
|
|
201
|
+
' or re-install the hook with `sealcode install-hook --lenient`.)',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
174
205
|
const K = await getK();
|
|
175
206
|
if (!K) {
|
|
176
207
|
return {
|