@mnemonik/scanner 5.137.0 → 5.151.2

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/index.ts DELETED
@@ -1,446 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFile, writeFile, unlink, mkdir, stat, chmod } from 'fs/promises';
4
- import { existsSync } from 'fs';
5
- import { join } from 'path';
6
- import { homedir } from 'os';
7
- import { ScannerDaemon } from './daemon.js';
8
- import { installFileLogging } from './fileLog.js';
9
- import { runDoctor } from './doctor.js';
10
- import { pidIsScanner } from './pid.js';
11
-
12
- const DEFAULT_SERVER = 'https://api.mnemonik.dev';
13
- const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
14
-
15
- /**
16
- * Reject plaintext http:// server URLs except when talking to a local
17
- * daemon on the same machine (localhost/127.0.0.1/[::1]) — e.g. during
18
- * development against a locally-run server. Everything else must be https://
19
- * since the scanner pushes full file content (see O2/O8b,
20
- * codebase-indexing-audit-2026-07-02.md §5) and a plaintext channel would
21
- * expose that content (and the API key) to network eavesdropping.
22
- * Extracted as a pure function so the guard is unit-testable without
23
- * spinning up the CLI.
24
- */
25
- export function validateServerUrl(url: string): { valid: boolean; error?: string } {
26
- let parsed: URL;
27
- try {
28
- parsed = new URL(url);
29
- } catch {
30
- return { valid: false, error: `[mnemonik] Invalid --server URL: "${url}"` };
31
- }
32
- if (parsed.protocol !== 'https:' && !LOCAL_HOSTS.has(parsed.hostname)) {
33
- return {
34
- valid: false,
35
- error:
36
- `[mnemonik] Refusing insecure server URL "${url}".\n` +
37
- ' Only https:// is allowed for --server, except for localhost/127.0.0.1/[::1]\n' +
38
- ' (used for local development). Scanned file content and your API key would\n' +
39
- ' otherwise be sent in plaintext.',
40
- };
41
- }
42
- return { valid: true };
43
- }
44
- const MNEMONIK_DIR = join(homedir(), '.mnemonik');
45
- const PID_FILE = join(MNEMONIK_DIR, 'daemon.pid');
46
- const LOG_FILE = join(MNEMONIK_DIR, 'scanner.log');
47
- const CONFIG_FILE = join(MNEMONIK_DIR, 'scanner.json');
48
- const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
49
-
50
- interface ScannerConfig {
51
- roots: string[];
52
- apiKey?: string;
53
- server?: string;
54
- refreshIntervalMs?: number;
55
- }
56
-
57
- interface CliArgs {
58
- command: 'start' | 'stop' | 'status' | 'log' | 'doctor' | 'help';
59
- key?: string;
60
- server?: string;
61
- roots?: string[];
62
- }
63
-
64
- function parseCliArgs(): CliArgs {
65
- const args = process.argv.slice(2);
66
- const command = (args[0] ?? 'help') as CliArgs['command'];
67
-
68
- if (!['start', 'stop', 'status', 'log', 'doctor', 'help'].includes(command)) {
69
- return { command: 'help' };
70
- }
71
-
72
- let key: string | undefined;
73
- let server: string | undefined;
74
- let roots: string[] | undefined;
75
-
76
- for (let i = 1; i < args.length; i++) {
77
- if (args[i] === '--key' && args[i + 1]) key = args[++i]!;
78
- else if (args[i] === '--server' && args[i + 1]) server = args[++i]!;
79
- else if (args[i] === '--roots' && args[i + 1]) {
80
- roots = args[++i]!.split(',').map((r) => r.trim());
81
- }
82
- }
83
-
84
- return { command, key, server, roots };
85
- }
86
-
87
- async function readConfig(): Promise<ScannerConfig | null> {
88
- try {
89
- const raw = await readFile(CONFIG_FILE, 'utf-8');
90
- return JSON.parse(raw) as ScannerConfig;
91
- } catch {
92
- return null;
93
- }
94
- }
95
-
96
- async function writeConfig(config: ScannerConfig): Promise<void> {
97
- await mkdir(MNEMONIK_DIR, { recursive: true });
98
- await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
99
- }
100
-
101
- async function checkConfigPermissions(): Promise<void> {
102
- try {
103
- const s = await stat(CONFIG_FILE);
104
- // Check if group or other have read/write permissions
105
- const mode = s.mode & 0o077;
106
- if (mode !== 0) {
107
- console.warn(
108
- `[mnemonik] WARNING: ${CONFIG_FILE} has overly permissive permissions (${(s.mode & 0o777).toString(8)}).`
109
- );
110
- console.warn(`[mnemonik] Fixing to 0600 (owner read/write only).`);
111
- await chmod(CONFIG_FILE, 0o600);
112
- }
113
- } catch {
114
- // Config file doesn't exist yet
115
- }
116
- }
117
-
118
- async function acquireLock(retried = false): Promise<boolean> {
119
- try {
120
- const { open: fsOpen } = await import('fs/promises');
121
- const { constants } = await import('fs');
122
- const fd = await fsOpen(
123
- PID_FILE,
124
- constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
125
- 0o644
126
- );
127
- await fd.writeFile(String(process.pid));
128
- await fd.close();
129
- return true;
130
- } catch (err: unknown) {
131
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false;
132
-
133
- const existing = await readFile(PID_FILE, 'utf-8').catch(() => null);
134
- if (existing) {
135
- const pid = parseInt(existing.trim(), 10);
136
- try {
137
- process.kill(pid, 0);
138
- // Alive is not enough — after PID reuse the number can belong to an
139
- // unrelated process. Only an actual scanner holds the lock.
140
- if (pidIsScanner(pid)) {
141
- return false; // Process alive and is the scanner — lock is valid
142
- }
143
- // Live but foreign PID — treat as stale and reclaim below.
144
- } catch {
145
- // Holder is dead — remove stale lock and retry once
146
- }
147
- }
148
- if (retried) return false;
149
- await unlink(PID_FILE).catch(() => {});
150
- return acquireLock(true);
151
- }
152
- }
153
-
154
- async function releaseLock(): Promise<void> {
155
- await unlink(PID_FILE).catch(() => {});
156
- }
157
-
158
- async function readPid(): Promise<number | null> {
159
- try {
160
- const raw = await readFile(PID_FILE, 'utf-8');
161
- const pid = parseInt(raw.trim(), 10);
162
- if (isNaN(pid)) return null;
163
- // Check if process is actually alive
164
- try {
165
- process.kill(pid, 0);
166
- } catch {
167
- return null; // Stale PID
168
- }
169
- // Alive but foreign (PID reuse) is just as stale — never report it as
170
- // the daemon, and never let handleStop() SIGTERM it.
171
- if (!pidIsScanner(pid)) return null;
172
- return pid;
173
- } catch {
174
- return null;
175
- }
176
- }
177
-
178
- function printHelp(): void {
179
- console.log(`
180
- mnemonik-scanner - Automatic codebase indexing daemon
181
-
182
- Usage:
183
- mnemonik-scanner start [options] Start the scanner daemon
184
- mnemonik-scanner stop Stop the running daemon
185
- mnemonik-scanner status Show daemon status
186
- mnemonik-scanner log Tail the scanner log file
187
- mnemonik-scanner doctor Check install health (drift detection)
188
- mnemonik-scanner help Show this help
189
-
190
- Options (for start):
191
- --key <api-key> API key (or set MNEMONIK_API_KEY env var)
192
- --server <url> Server URL (default: https://api.mnemonik.dev)
193
- --roots <dirs> Comma-separated directories to scan for projects
194
-
195
- Examples:
196
- mnemonik-scanner start --key mnk_... --roots ~/Projects,~/work
197
- mnemonik-scanner status
198
- mnemonik-scanner stop
199
-
200
- Configuration is saved to ~/.mnemonik/scanner.json after first run.
201
- Subsequent starts use saved config (CLI args override).
202
-
203
- Environment variables:
204
- MNEMONIK_API_KEY API key (takes precedence over config file)
205
- `);
206
- }
207
-
208
- async function handleStart(cli: CliArgs): Promise<void> {
209
- // Guardrail — this daemon is meant to run under the systemd user service
210
- // (`systemctl --user start mnemonik-scanner`; see BUILD_AND_DEPLOY.md
211
- // "Scanner daemon"). systemd sets INVOCATION_ID on processes it spawns. A
212
- // manual foreground `start` attaches the daemon to the invoking shell: it
213
- // dies when the shell exits and can spawn duplicate/stale daemons — the exact
214
- // failure mode behind version skew and apparent "restart loops". If a unit
215
- // exists but we were NOT launched by systemd, refuse (overridable).
216
- if (!process.env.INVOCATION_ID && !process.env.MNEMONIK_SCANNER_FOREGROUND) {
217
- const unitPath = join(homedir(), '.config/systemd/user/mnemonik-scanner.service');
218
- if (existsSync(unitPath)) {
219
- console.error(
220
- '[mnemonik] A systemd unit already manages this scanner.\n' +
221
- ' Start/restart it with:\n' +
222
- ' systemctl --user restart mnemonik-scanner\n' +
223
- ' Running `mnemonik-scanner start` by hand attaches the daemon to your shell — it\n' +
224
- ' dies when the shell exits and can leave duplicate/stale daemons running (version\n' +
225
- ' skew, apparent "restart loops"). To force a foreground run, set\n' +
226
- ' MNEMONIK_SCANNER_FOREGROUND=1.'
227
- );
228
- process.exit(1);
229
- }
230
- }
231
-
232
- const config = await readConfig();
233
-
234
- // Resolve API key: env var > CLI > config
235
- const apiKey = process.env.MNEMONIK_API_KEY || cli.key || config?.apiKey;
236
- if (!apiKey) {
237
- console.error(
238
- '[mnemonik] Missing API key.\n' +
239
- ' Use --key <api-key>, set MNEMONIK_API_KEY env var,\n' +
240
- ' or run once with --key to save to config.'
241
- );
242
- process.exit(1);
243
- }
244
-
245
- const roots = cli.roots || config?.roots;
246
- if (!roots || roots.length === 0) {
247
- console.error(
248
- '[mnemonik] No roots specified.\n' +
249
- ' Use --roots ~/Projects,~/work to specify directories to scan.'
250
- );
251
- process.exit(1);
252
- }
253
-
254
- const server = cli.server || config?.server || DEFAULT_SERVER;
255
- const serverUrlCheck = validateServerUrl(server);
256
- if (!serverUrlCheck.valid) {
257
- console.error(serverUrlCheck.error);
258
- process.exit(1);
259
- }
260
-
261
- // Ensure directories exist
262
- await mkdir(MNEMONIK_DIR, { recursive: true });
263
-
264
- // Mirror all console output into scanner.log so `mnemonik-scanner log`
265
- // reflects live activity regardless of supervisor. Under systemd, stdout is
266
- // wired to the journal socket, not this file — without the tee the on-disk
267
- // log goes stale and `log` shows nothing current. Install before any daemon
268
- // output so the whole session is captured.
269
- installFileLogging(LOG_FILE, MAX_LOG_SIZE);
270
-
271
- // Save config for future runs (never save env var key to file)
272
- const configToSave: ScannerConfig = {
273
- roots,
274
- server,
275
- refreshIntervalMs: config?.refreshIntervalMs,
276
- };
277
- // Only save API key to config if it came from CLI (not env var)
278
- if (cli.key) {
279
- configToSave.apiKey = cli.key;
280
- } else if (config?.apiKey && !process.env.MNEMONIK_API_KEY) {
281
- configToSave.apiKey = config.apiKey;
282
- }
283
- await writeConfig(configToSave);
284
- await checkConfigPermissions();
285
-
286
- const locked = await acquireLock();
287
- if (!locked) {
288
- const pid = await readPid();
289
- console.log(
290
- `[mnemonik] Scanner daemon already running (PID ${pid}). Use 'mnemonik-scanner stop' first.`
291
- );
292
- process.exit(0);
293
- }
294
-
295
- const daemon = new ScannerDaemon({
296
- serverUrl: server,
297
- apiKey,
298
- roots,
299
- refreshIntervalMs: config?.refreshIntervalMs,
300
- });
301
-
302
- const shutdown = async () => {
303
- console.log('\n[mnemonik] Shutting down...');
304
- await daemon.stop();
305
- await releaseLock();
306
- process.exit(0);
307
- };
308
-
309
- process.on('SIGINT', shutdown);
310
- process.on('SIGTERM', shutdown);
311
- process.on('SIGHUP', shutdown); // Graceful restart — external tooling sends SIGHUP then relaunches
312
-
313
- try {
314
- await daemon.start();
315
- } catch (err) {
316
- console.error(`[mnemonik] ${(err as Error).message}`);
317
- await releaseLock();
318
- process.exit(1);
319
- }
320
- }
321
-
322
- async function handleStop(): Promise<void> {
323
- const pid = await readPid();
324
- if (!pid) {
325
- console.log('[mnemonik] Scanner daemon is not running.');
326
- // Clean up stale PID file if it exists
327
- await unlink(PID_FILE).catch(() => {});
328
- return;
329
- }
330
-
331
- try {
332
- process.kill(pid, 'SIGTERM');
333
- console.log(`[mnemonik] Sent SIGTERM to daemon (PID ${pid}).`);
334
-
335
- // Wait for process to exit (up to 5 seconds)
336
- for (let i = 0; i < 50; i++) {
337
- await new Promise((r) => setTimeout(r, 100));
338
- try {
339
- process.kill(pid, 0);
340
- } catch {
341
- console.log('[mnemonik] Daemon stopped.');
342
- return;
343
- }
344
- }
345
- console.warn('[mnemonik] Daemon did not exit within 5 seconds.');
346
- } catch {
347
- console.log('[mnemonik] Daemon process not found. Cleaning up PID file.');
348
- await unlink(PID_FILE).catch(() => {});
349
- }
350
- }
351
-
352
- async function handleStatus(): Promise<void> {
353
- const pid = await readPid();
354
- const config = await readConfig();
355
-
356
- if (!pid) {
357
- console.log('Status: not running');
358
- if (config?.roots) {
359
- console.log(`Configured roots: ${config.roots.join(', ')}`);
360
- }
361
- console.log(`\nStart with: mnemonik-scanner start`);
362
- return;
363
- }
364
-
365
- console.log(`Status: running (PID ${pid})`);
366
- if (config?.roots) {
367
- console.log(`Roots: ${config.roots.join(', ')}`);
368
- }
369
- if (config?.server) {
370
- console.log(`Server: ${config.server}`);
371
- }
372
-
373
- // Check for old per-project daemons
374
- try {
375
- const { readdir: readdirAsync } = await import('fs/promises');
376
- const oldDaemonsDir = join(MNEMONIK_DIR, 'daemons');
377
- const oldPidFiles = await readdirAsync(oldDaemonsDir).catch(() => []);
378
- const staleOldDaemons: string[] = [];
379
- for (const f of oldPidFiles) {
380
- if (!f.endsWith('.pid')) continue;
381
- const content = await readFile(join(oldDaemonsDir, f), 'utf-8').catch(() => null);
382
- if (content) {
383
- const oldPid = parseInt(content.trim(), 10);
384
- try {
385
- process.kill(oldPid, 0);
386
- // Same PID-reuse guard as readPid(): only report genuinely live
387
- // scanner processes, not whatever now occupies a recycled PID.
388
- if (pidIsScanner(oldPid)) {
389
- staleOldDaemons.push(`${f} (PID ${oldPid})`);
390
- }
391
- } catch {
392
- // Dead process, just a stale file
393
- }
394
- }
395
- }
396
- if (staleOldDaemons.length > 0) {
397
- console.log(`\nLegacy per-project daemons still running:`);
398
- for (const d of staleOldDaemons) {
399
- console.log(` ${d}`);
400
- }
401
- console.log(' Consider stopping these — the global daemon handles all projects.');
402
- }
403
- } catch {
404
- // Old daemons dir doesn't exist
405
- }
406
- }
407
-
408
- async function handleLog(): Promise<void> {
409
- try {
410
- const content = await readFile(LOG_FILE, 'utf-8');
411
- // Show last 50 lines
412
- const lines = content.split('\n');
413
- const tail = lines.slice(-50).join('\n');
414
- console.log(tail);
415
- } catch {
416
- console.log('[mnemonik] No log file found at', LOG_FILE);
417
- }
418
- }
419
-
420
- async function main(): Promise<void> {
421
- const cli = parseCliArgs();
422
-
423
- switch (cli.command) {
424
- case 'start':
425
- await handleStart(cli);
426
- break;
427
- case 'stop':
428
- await handleStop();
429
- break;
430
- case 'status':
431
- await handleStatus();
432
- break;
433
- case 'log':
434
- await handleLog();
435
- break;
436
- case 'doctor':
437
- await runDoctor();
438
- break;
439
- case 'help':
440
- default:
441
- printHelp();
442
- break;
443
- }
444
- }
445
-
446
- main();
package/src/pid.ts DELETED
@@ -1,37 +0,0 @@
1
- import { execFileSync } from 'child_process';
2
- import { readFileSync } from 'fs';
3
-
4
- /**
5
- * PID-reuse guard shared by the CLI lock paths (index.ts) and doctor.
6
- *
7
- * `process.kill(pid, 0)` only proves that *some* process is alive at that
8
- * PID, not that it is ours. After a reboot or plain PID recycling the number
9
- * in daemon.pid can belong to an unrelated process — SIGTERMing it from
10
- * `stop`, or refusing to `start` because of it, would be wrong. So a live PID
11
- * only counts as the scanner when its command line matches the daemon's
12
- * signature (same pattern doctor uses to find daemon processes via `ps`).
13
- */
14
- const SCANNER_CMD_PATTERN = /(mnemonik-scanner|scanner\/dist\/index\.js)/;
15
-
16
- function processCmdline(pid: number): string | null {
17
- // Linux: /proc/<pid>/cmdline is NUL-separated argv — cheap and exact.
18
- try {
19
- return readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ');
20
- } catch {
21
- // /proc unavailable (macOS) or unreadable — fall back to ps below.
22
- }
23
- try {
24
- return execFileSync('ps', ['-o', 'args=', '-p', String(pid)], {
25
- encoding: 'utf-8',
26
- stdio: ['ignore', 'pipe', 'ignore'],
27
- timeout: 5000,
28
- }).trim();
29
- } catch {
30
- return null; // No such process, or ps unavailable — cannot confirm identity.
31
- }
32
- }
33
-
34
- export function pidIsScanner(pid: number): boolean {
35
- const cmdline = processCmdline(pid);
36
- return cmdline !== null && SCANNER_CMD_PATTERN.test(cmdline);
37
- }
package/src/watcher.ts DELETED
@@ -1,219 +0,0 @@
1
- import { watch, type FSWatcher } from 'fs';
2
- import { join, relative } from 'path';
3
- import { readdir, stat } from 'fs/promises';
4
- import { isGitBoundary } from '@mnemonik/shared';
5
-
6
- const SKIP_DIRS = new Set([
7
- 'node_modules',
8
- '.git',
9
- 'dist',
10
- 'build',
11
- '.next',
12
- '.nuxt',
13
- '.output',
14
- '__pycache__',
15
- '.venv',
16
- 'venv',
17
- '.tox',
18
- 'target',
19
- '.cache',
20
- 'coverage',
21
- '.turbo',
22
- '.vercel',
23
- '.svelte-kit',
24
- ]);
25
-
26
- export type ChangeHandler = (changedFiles: string[]) => void;
27
- export type ErrorHandler = (err: Error) => void;
28
-
29
- export class FileWatcher {
30
- // Keyed by directory so a watcher can be closed and re-attached when the
31
- // directory is deleted and recreated at the same path.
32
- private watchers = new Map<string, FSWatcher>();
33
- private watchedDirs = new Set<string>();
34
- // Inode per watched directory: a delete+recreate faster than the delete
35
- // event's stat() presents as "directory exists and is already watched",
36
- // but the live watcher is bound to the OLD inode and is inert. Comparing
37
- // inodes at event time detects the swap so the subtree can re-attach.
38
- private watchedDirInodes = new Map<string, number>();
39
- private pendingFiles = new Set<string>();
40
- private flushTimer: ReturnType<typeof setTimeout> | null = null;
41
- private debounceMs: number;
42
- private onError: ErrorHandler | undefined;
43
-
44
- constructor(
45
- private rootPath: string,
46
- private onChange: ChangeHandler,
47
- debounceMs = 500,
48
- onError?: ErrorHandler
49
- ) {
50
- this.debounceMs = debounceMs;
51
- this.onError = onError;
52
- }
53
-
54
- async start(): Promise<void> {
55
- await this.watchDir(this.rootPath);
56
- console.log(`[scanner] Watching ${this.rootPath} for changes`);
57
- }
58
-
59
- stop(): void {
60
- for (const w of this.watchers.values()) {
61
- w.close();
62
- }
63
- this.watchers.clear();
64
- this.watchedDirs.clear();
65
- this.watchedDirInodes.clear();
66
- if (this.flushTimer) {
67
- clearTimeout(this.flushTimer);
68
- this.flushTimer = null;
69
- }
70
- this.pendingFiles.clear();
71
- }
72
-
73
- private scheduleFlush(): void {
74
- if (this.flushTimer) clearTimeout(this.flushTimer);
75
- this.flushTimer = setTimeout(() => {
76
- this.flushTimer = null;
77
- if (this.pendingFiles.size === 0) return;
78
- const batch = [...this.pendingFiles];
79
- this.pendingFiles.clear();
80
- this.onChange(batch);
81
- }, this.debounceMs);
82
- }
83
-
84
- private async watchDir(dir: string): Promise<void> {
85
- const dirName = dir.split('/').pop() ?? '';
86
- if (SKIP_DIRS.has(dirName)) return;
87
- // Dedup guard — the change callback re-enters watchDir for new
88
- // subdirectories, and a rapid create/rename burst can resolve the same
89
- // path twice before the first watch is registered.
90
- if (this.watchedDirs.has(dir)) return;
91
- this.watchedDirs.add(dir);
92
-
93
- try {
94
- // Record the inode BEFORE attaching so watchNewDir can distinguish a
95
- // benign event on this directory from a recreate-at-same-path.
96
- this.watchedDirInodes.set(dir, (await stat(dir)).ino);
97
- const watcher = watch(dir, { persistent: true }, (event, filename) => {
98
- if (!filename) return;
99
- const fullPath = join(dir, filename);
100
- const relPath = relative(this.rootPath, fullPath);
101
- this.pendingFiles.add(relPath);
102
- this.scheduleFlush();
103
- // fs.watch is non-recursive: the initial recursion below only covers
104
- // directories that existed at start(). When this event is a newly
105
- // created directory, attach a watcher to it too — otherwise the
106
- // subtree is a permanent blind spot until restart. Fire-and-forget;
107
- // watchNewDir no-ops for plain files, skip dirs, git boundaries, and
108
- // already-watched paths.
109
- void this.watchNewDir(fullPath, event);
110
- });
111
-
112
- watcher.on('error', (err) => {
113
- console.warn(`[scanner] Watcher error for ${dir}:`, err.message);
114
- if (dir === this.rootPath && this.onError) {
115
- this.onError(err);
116
- }
117
- });
118
-
119
- this.watchers.set(dir, watcher);
120
-
121
- const entries = await readdir(dir, { withFileTypes: true });
122
- for (const entry of entries) {
123
- if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
124
- const child = join(dir, entry.name);
125
- // Nested-git-boundary rule (see @mnemonik/shared isGitBoundary):
126
- // linked worktrees / nested clones are other repositories — don't
127
- // watch their subtrees as part of this project.
128
- if (await isGitBoundary(child)) continue;
129
- await this.watchDir(child);
130
- }
131
- }
132
- } catch (err) {
133
- // Watch registration failed. Never fatal for the subtree's parent, but
134
- // the *reason* matters: inotify/fd limit exhaustion means silently
135
- // growing blind spots, while permission-denied is a benign property of
136
- // the directory itself. Drop the bookkeeping (and any watcher that did
137
- // attach before the failure) so a later retry can re-enter cleanly.
138
- this.watchedDirs.delete(dir);
139
- this.watchedDirInodes.delete(dir);
140
- const stale = this.watchers.get(dir);
141
- if (stale) {
142
- stale.close();
143
- this.watchers.delete(dir);
144
- }
145
- const code = (err as NodeJS.ErrnoException).code;
146
- if (code === 'ENOSPC' || code === 'EMFILE' || code === 'ENFILE') {
147
- console.warn(
148
- `[scanner] Watch limit reached (${code}) — subtree unwatched: ${dir}. ` +
149
- 'Raise fs.inotify.max_user_watches or trim scanner roots.'
150
- );
151
- // Surface to the daemon only for the root — losing the root means the
152
- // whole project is blind; a subtree gap must not tear the project down
153
- // (the daemon's onError removes the project entirely).
154
- if (dir === this.rootPath && this.onError) {
155
- this.onError(err as Error);
156
- }
157
- } else {
158
- // Permission denied or inaccessible directory
159
- console.warn(`[scanner] Cannot watch ${dir}: ${(err as Error).message}`);
160
- }
161
- }
162
- }
163
-
164
- /**
165
- * Attach a watcher to a directory created after start(). Called from the
166
- * per-directory change callback with every event path; stats the path and
167
- * only recurses when it is a genuinely new, watchable directory.
168
- */
169
- private async watchNewDir(fullPath: string, eventType: string): Promise<void> {
170
- try {
171
- // Stat before the dedup check: a delete event arrives with the same
172
- // path as the original create, and the stale watchedDirs entry must
173
- // not short-circuit the vanish detection below.
174
- const s = await stat(fullPath);
175
- if (!s.isDirectory()) return;
176
- if (this.watchedDirs.has(fullPath)) {
177
- // Already watched — but a delete+recreate faster than this stat
178
- // presents exactly like this, with the live watcher bound to the
179
- // OLD (dead) inode and inert. A 'rename' event means the entry's
180
- // identity changed (created / deleted / moved), so re-attach
181
- // unconditionally — comparing inodes is NOT sufficient there
182
- // because ext4 routinely hands the freed inode straight back to
183
- // the recreated directory. 'change' events are attrib noise (a
184
- // write inside the child bumps its mtime, which fires on the
185
- // parent), so the cheap inode check keeps those churn-free.
186
- if (eventType !== 'rename' && this.watchedDirInodes.get(fullPath) === s.ino) return;
187
- this.unwatchSubtree(fullPath);
188
- }
189
- if (await isGitBoundary(fullPath)) return;
190
- await this.watchDir(fullPath);
191
- } catch {
192
- // Path vanished between event and stat. If it (or anything under it)
193
- // was a watched directory, drop the bookkeeping and close the dead
194
- // watchers — otherwise the dedup guards in watchDir/watchNewDir block
195
- // re-attachment forever when the path is recreated (build-output
196
- // wipes, codegen, rm -rf && mkdir).
197
- this.unwatchSubtree(fullPath);
198
- }
199
- }
200
-
201
- /**
202
- * Forget a deleted directory and everything watched beneath it. fs.watch
203
- * emits no per-descendant events on a recursive delete, so the whole
204
- * prefix must be purged here for a recreate to re-watch the full subtree.
205
- */
206
- private unwatchSubtree(root: string): void {
207
- const prefix = root + '/';
208
- for (const dir of this.watchedDirs) {
209
- if (dir !== root && !dir.startsWith(prefix)) continue;
210
- this.watchedDirs.delete(dir);
211
- this.watchedDirInodes.delete(dir);
212
- const w = this.watchers.get(dir);
213
- if (w) {
214
- w.close();
215
- this.watchers.delete(dir);
216
- }
217
- }
218
- }
219
- }