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/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 reset --force (start over; DELETES locked files)',
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 (code && CODES[code]) {
105
- meta = CODES[code];
106
- } else if (isSC) {
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
- ' sealcode status --check || exit 1',
71
+ ` sealcode status --check${flag} || exit 1`,
60
72
  ' elif command -v npx >/dev/null 2>&1; then',
61
- ' npx --yes sealcode@latest status --check || exit 1',
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({ projectRoot, presetId, force = false, noninteractive = false }) {
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
- const cfg = {
159
- version: 1,
160
- preset: preset.id,
161
- lockedDir: preset.lockedDir,
162
- include: preset.include,
163
- exclude: preset.exclude,
164
- stubs: preset.stubs || {},
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: 'node',
47
- label: 'Node.js / TypeScript',
48
- markers: ['package.json'],
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
- 'package-lock.json',
77
+ 'next.config.*',
55
78
  'tsconfig.json',
56
- 'Dockerfile',
57
- 'docker-compose*.yml',
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': STUB_GENERIC_PKG_JSON },
90
+ stubs: { 'package.json': '{\n "name": "app",\n "version": "1.0.0",\n "private": true\n}\n' },
74
91
  },
75
92
  {
76
- id: 'nextjs',
77
- label: 'Next.js',
78
- markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
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
- 'next.config.*',
131
+ 'package-lock.json',
89
132
  'tsconfig.json',
90
- 'tailwind.config.*',
91
- 'postcss.config.*',
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: pass only if locked, or unlocked with no drift.
159
- * Caller supplies `getK` — session load, env passphrase unwrap, or null.
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 {