@mnemonik/scanner 5.151.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/dist/client.d.ts +99 -0
- package/dist/client.js +140 -0
- package/dist/client.js.map +1 -0
- package/dist/daemon.d.ts +51 -0
- package/dist/daemon.js +550 -0
- package/dist/daemon.js.map +1 -0
- package/dist/discovery.d.ts +21 -0
- package/dist/discovery.js +107 -0
- package/dist/discovery.js.map +1 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +233 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fileLog.d.ts +17 -0
- package/dist/fileLog.js +70 -0
- package/dist/fileLog.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +111 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +399 -0
- package/dist/index.js.map +1 -0
- package/dist/pid.d.ts +1 -0
- package/dist/pid.js +37 -0
- package/dist/pid.js.map +1 -0
- package/dist/watcher.d.ts +30 -0
- package/dist/watcher.js +214 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +7 -3
- package/src/client.ts +0 -216
- package/src/daemon.ts +0 -679
- package/src/discovery.ts +0 -124
- package/src/doctor.ts +0 -239
- package/src/fileLog.ts +0 -67
- package/src/git.ts +0 -122
- package/src/index.ts +0 -446
- package/src/pid.ts +0 -37
- package/src/watcher.ts +0 -219
- package/tests/validateServerUrl.test.ts +0 -41
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -13
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
|
-
}
|