@mnemonik/scanner 1.0.0 → 2.0.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/index.ts CHANGED
@@ -1,74 +1,103 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile, unlink, mkdir } from 'fs/promises';
3
+ import { readFile, writeFile, unlink, mkdir, stat, chmod } from 'fs/promises';
4
4
  import { join } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { ScannerDaemon } from './daemon.js';
7
7
 
8
8
  const DEFAULT_SERVER = 'https://api.mnemonik.dev';
9
9
  const MNEMONIK_DIR = join(homedir(), '.mnemonik');
10
- const DAEMONS_DIR = join(MNEMONIK_DIR, 'daemons');
11
- const LOGS_DIR = join(MNEMONIK_DIR, 'logs');
10
+ const PID_FILE = join(MNEMONIK_DIR, 'daemon.pid');
11
+ const LOG_FILE = join(MNEMONIK_DIR, 'scanner.log');
12
+ const CONFIG_FILE = join(MNEMONIK_DIR, 'scanner.json');
12
13
  const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
13
14
 
14
- function parseCliArgs(): { server?: string; key?: string } {
15
+ interface ScannerConfig {
16
+ roots: string[];
17
+ apiKey?: string;
18
+ server?: string;
19
+ refreshIntervalMs?: number;
20
+ }
21
+
22
+ interface CliArgs {
23
+ command: 'start' | 'stop' | 'status' | 'log' | 'help';
24
+ key?: string;
25
+ server?: string;
26
+ roots?: string[];
27
+ }
28
+
29
+ function parseCliArgs(): CliArgs {
15
30
  const args = process.argv.slice(2);
16
- let server: string | undefined;
31
+ const command = (args[0] ?? 'help') as CliArgs['command'];
32
+
33
+ if (!['start', 'stop', 'status', 'log', 'help'].includes(command)) {
34
+ return { command: 'help' };
35
+ }
36
+
17
37
  let key: string | undefined;
38
+ let server: string | undefined;
39
+ let roots: string[] | undefined;
18
40
 
19
- for (let i = 0; i < args.length; i++) {
20
- if (args[i] === '--server' && args[i + 1]) server = args[++i]!;
21
- else if (args[i] === '--key' && args[i + 1]) key = args[++i]!;
41
+ for (let i = 1; i < args.length; i++) {
42
+ if (args[i] === '--key' && args[i + 1]) key = args[++i]!;
43
+ else if (args[i] === '--server' && args[i + 1]) server = args[++i]!;
44
+ else if (args[i] === '--roots' && args[i + 1]) {
45
+ roots = args[++i]!.split(',').map((r) => r.trim());
46
+ }
22
47
  }
23
48
 
24
- return { server, key };
49
+ return { command, key, server, roots };
25
50
  }
26
51
 
27
- async function readProjectId(): Promise<string> {
28
- const configPath = join(process.cwd(), '.mnemonik.json');
52
+ async function readConfig(): Promise<ScannerConfig | null> {
29
53
  try {
30
- const raw = await readFile(configPath, 'utf-8');
31
- const parsed = JSON.parse(raw) as Record<string, unknown>;
32
- if (typeof parsed.projectId === 'string' && parsed.projectId.length > 0) {
33
- return parsed.projectId;
34
- }
35
- console.error('[mnemonik] .mnemonik.json missing "projectId" field.');
36
- process.exit(1);
54
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
55
+ return JSON.parse(raw) as ScannerConfig;
37
56
  } catch {
38
- console.error(
39
- '[mnemonik] No .mnemonik.json found in current directory.\n' +
40
- ' Run session_bootstrap from your IDE to create one.'
41
- );
42
- process.exit(1);
57
+ return null;
43
58
  }
44
59
  }
45
60
 
46
- function lockFile(projectId: string): string {
47
- return join(DAEMONS_DIR, `${projectId}.pid`);
61
+ async function writeConfig(config: ScannerConfig): Promise<void> {
62
+ await mkdir(MNEMONIK_DIR, { recursive: true });
63
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
48
64
  }
49
65
 
50
- function logFile(projectId: string): string {
51
- return join(LOGS_DIR, `${projectId}.log`);
66
+ async function checkConfigPermissions(): Promise<void> {
67
+ try {
68
+ const s = await stat(CONFIG_FILE);
69
+ // Check if group or other have read/write permissions
70
+ const mode = s.mode & 0o077;
71
+ if (mode !== 0) {
72
+ console.warn(
73
+ `[mnemonik] WARNING: ${CONFIG_FILE} has overly permissive permissions (${(s.mode & 0o777).toString(8)}).`
74
+ );
75
+ console.warn(`[mnemonik] Fixing to 0600 (owner read/write only).`);
76
+ await chmod(CONFIG_FILE, 0o600);
77
+ }
78
+ } catch {
79
+ // Config file doesn't exist yet
80
+ }
52
81
  }
53
82
 
54
- async function rotateLogIfNeeded(path: string): Promise<void> {
83
+ async function rotateLogIfNeeded(): Promise<void> {
55
84
  try {
56
- const { stat: fsStat, rename } = await import('fs/promises');
57
- const s = await fsStat(path);
85
+ const { rename } = await import('fs/promises');
86
+ const s = await stat(LOG_FILE);
58
87
  if (s.size > MAX_LOG_SIZE) {
59
- await rename(path, path + '.old');
88
+ await rename(LOG_FILE, LOG_FILE + '.old');
60
89
  }
61
90
  } catch {
62
91
  // Log file doesn't exist yet
63
92
  }
64
93
  }
65
94
 
66
- async function acquireLock(lockPath: string, retried = false): Promise<boolean> {
95
+ async function acquireLock(retried = false): Promise<boolean> {
67
96
  try {
68
97
  const { open: fsOpen } = await import('fs/promises');
69
98
  const { constants } = await import('fs');
70
99
  const fd = await fsOpen(
71
- lockPath,
100
+ PID_FILE,
72
101
  constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
73
102
  0o644
74
103
  );
@@ -78,7 +107,7 @@ async function acquireLock(lockPath: string, retried = false): Promise<boolean>
78
107
  } catch (err: unknown) {
79
108
  if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false;
80
109
 
81
- const existing = await readFile(lockPath, 'utf-8').catch(() => null);
110
+ const existing = await readFile(PID_FILE, 'utf-8').catch(() => null);
82
111
  if (existing) {
83
112
  const pid = parseInt(existing.trim(), 10);
84
113
  try {
@@ -89,71 +118,257 @@ async function acquireLock(lockPath: string, retried = false): Promise<boolean>
89
118
  }
90
119
  }
91
120
  if (retried) return false;
92
- await unlink(lockPath).catch(() => {});
93
- return acquireLock(lockPath, true);
121
+ await unlink(PID_FILE).catch(() => {});
122
+ return acquireLock(true);
94
123
  }
95
124
  }
96
125
 
97
- async function releaseLock(lockPath: string): Promise<void> {
98
- await unlink(lockPath).catch(() => {});
126
+ async function releaseLock(): Promise<void> {
127
+ await unlink(PID_FILE).catch(() => {});
99
128
  }
100
129
 
101
- async function main(): Promise<void> {
102
- const cli = parseCliArgs();
103
- const projectId = await readProjectId();
104
- const server = cli.server || DEFAULT_SERVER;
105
- const apiKey = cli.key;
130
+ async function readPid(): Promise<number | null> {
131
+ try {
132
+ const raw = await readFile(PID_FILE, 'utf-8');
133
+ const pid = parseInt(raw.trim(), 10);
134
+ if (isNaN(pid)) return null;
135
+ // Check if process is actually alive
136
+ try {
137
+ process.kill(pid, 0);
138
+ return pid;
139
+ } catch {
140
+ return null; // Stale PID
141
+ }
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function printHelp(): void {
148
+ console.log(`
149
+ mnemonik-scanner - Automatic codebase indexing daemon
106
150
 
151
+ Usage:
152
+ mnemonik-scanner start [options] Start the scanner daemon
153
+ mnemonik-scanner stop Stop the running daemon
154
+ mnemonik-scanner status Show daemon status
155
+ mnemonik-scanner log Tail the scanner log file
156
+ mnemonik-scanner help Show this help
157
+
158
+ Options (for start):
159
+ --key <api-key> API key (or set MNEMONIK_API_KEY env var)
160
+ --server <url> Server URL (default: https://api.mnemonik.dev)
161
+ --roots <dirs> Comma-separated directories to scan for projects
162
+
163
+ Examples:
164
+ mnemonik-scanner start --key mnk_... --roots ~/Projects,~/work
165
+ mnemonik-scanner status
166
+ mnemonik-scanner stop
167
+
168
+ Configuration is saved to ~/.mnemonik/scanner.json after first run.
169
+ Subsequent starts use saved config (CLI args override).
170
+
171
+ Environment variables:
172
+ MNEMONIK_API_KEY API key (takes precedence over config file)
173
+ `);
174
+ }
175
+
176
+ async function handleStart(cli: CliArgs): Promise<void> {
177
+ const config = await readConfig();
178
+
179
+ // Resolve API key: env var > CLI > config
180
+ const apiKey = process.env.MNEMONIK_API_KEY || cli.key || config?.apiKey;
107
181
  if (!apiKey) {
108
182
  console.error(
109
183
  '[mnemonik] Missing API key.\n' +
110
- ' Usage: mnemonik-scanner --key <api-key>\n' +
111
- ' The API key is provided by _scannerSetup in session_bootstrap.'
184
+ ' Use --key <api-key>, set MNEMONIK_API_KEY env var,\n' +
185
+ ' or run once with --key to save to config.'
186
+ );
187
+ process.exit(1);
188
+ }
189
+
190
+ const roots = cli.roots || config?.roots;
191
+ if (!roots || roots.length === 0) {
192
+ console.error(
193
+ '[mnemonik] No roots specified.\n' +
194
+ ' Use --roots ~/Projects,~/work to specify directories to scan.'
112
195
  );
113
196
  process.exit(1);
114
197
  }
115
198
 
199
+ const server = cli.server || config?.server || DEFAULT_SERVER;
200
+
116
201
  // Ensure directories exist
117
- await mkdir(DAEMONS_DIR, { recursive: true });
118
- await mkdir(LOGS_DIR, { recursive: true });
202
+ await mkdir(MNEMONIK_DIR, { recursive: true });
119
203
 
120
- const lock = lockFile(projectId);
121
- const log = logFile(projectId);
204
+ // Save config for future runs (never save env var key to file)
205
+ const configToSave: ScannerConfig = {
206
+ roots,
207
+ server,
208
+ refreshIntervalMs: config?.refreshIntervalMs,
209
+ };
210
+ // Only save API key to config if it came from CLI (not env var)
211
+ if (cli.key) {
212
+ configToSave.apiKey = cli.key;
213
+ } else if (config?.apiKey && !process.env.MNEMONIK_API_KEY) {
214
+ configToSave.apiKey = config.apiKey;
215
+ }
216
+ await writeConfig(configToSave);
217
+ await checkConfigPermissions();
122
218
 
123
- await rotateLogIfNeeded(log);
219
+ await rotateLogIfNeeded();
124
220
 
125
- const locked = await acquireLock(lock);
221
+ const locked = await acquireLock();
126
222
  if (!locked) {
127
- console.log(`[mnemonik] Scanner already running for project ${projectId}. Exiting.`);
223
+ const pid = await readPid();
224
+ console.log(
225
+ `[mnemonik] Scanner daemon already running (PID ${pid}). Use 'mnemonik-scanner stop' first.`
226
+ );
128
227
  process.exit(0);
129
228
  }
130
229
 
131
- const projectRoot = process.cwd();
132
230
  const daemon = new ScannerDaemon({
133
- projectId,
134
- projectRoot,
135
231
  serverUrl: server,
136
232
  apiKey,
233
+ roots,
234
+ refreshIntervalMs: config?.refreshIntervalMs,
137
235
  });
138
236
 
139
237
  const shutdown = async () => {
140
238
  console.log('\n[mnemonik] Shutting down...');
141
239
  await daemon.stop();
142
- await releaseLock(lock);
240
+ await releaseLock();
143
241
  process.exit(0);
144
242
  };
145
243
 
146
244
  process.on('SIGINT', shutdown);
147
245
  process.on('SIGTERM', shutdown);
246
+ process.on('SIGHUP', shutdown); // Graceful restart — external tooling sends SIGHUP then relaunches
148
247
 
149
248
  try {
150
249
  await daemon.start();
151
- console.log('[mnemonik] Watching for changes.');
152
250
  } catch (err) {
153
251
  console.error(`[mnemonik] ${(err as Error).message}`);
154
- await releaseLock(lock);
252
+ await releaseLock();
155
253
  process.exit(1);
156
254
  }
157
255
  }
158
256
 
257
+ async function handleStop(): Promise<void> {
258
+ const pid = await readPid();
259
+ if (!pid) {
260
+ console.log('[mnemonik] Scanner daemon is not running.');
261
+ // Clean up stale PID file if it exists
262
+ await unlink(PID_FILE).catch(() => {});
263
+ return;
264
+ }
265
+
266
+ try {
267
+ process.kill(pid, 'SIGTERM');
268
+ console.log(`[mnemonik] Sent SIGTERM to daemon (PID ${pid}).`);
269
+
270
+ // Wait for process to exit (up to 5 seconds)
271
+ for (let i = 0; i < 50; i++) {
272
+ await new Promise((r) => setTimeout(r, 100));
273
+ try {
274
+ process.kill(pid, 0);
275
+ } catch {
276
+ console.log('[mnemonik] Daemon stopped.');
277
+ return;
278
+ }
279
+ }
280
+ console.warn('[mnemonik] Daemon did not exit within 5 seconds.');
281
+ } catch {
282
+ console.log('[mnemonik] Daemon process not found. Cleaning up PID file.');
283
+ await unlink(PID_FILE).catch(() => {});
284
+ }
285
+ }
286
+
287
+ async function handleStatus(): Promise<void> {
288
+ const pid = await readPid();
289
+ const config = await readConfig();
290
+
291
+ if (!pid) {
292
+ console.log('Status: not running');
293
+ if (config?.roots) {
294
+ console.log(`Configured roots: ${config.roots.join(', ')}`);
295
+ }
296
+ console.log(`\nStart with: mnemonik-scanner start`);
297
+ return;
298
+ }
299
+
300
+ console.log(`Status: running (PID ${pid})`);
301
+ if (config?.roots) {
302
+ console.log(`Roots: ${config.roots.join(', ')}`);
303
+ }
304
+ if (config?.server) {
305
+ console.log(`Server: ${config.server}`);
306
+ }
307
+
308
+ // Check for old per-project daemons
309
+ try {
310
+ const { readdir: readdirAsync } = await import('fs/promises');
311
+ const oldDaemonsDir = join(MNEMONIK_DIR, 'daemons');
312
+ const oldPidFiles = await readdirAsync(oldDaemonsDir).catch(() => []);
313
+ const staleOldDaemons: string[] = [];
314
+ for (const f of oldPidFiles) {
315
+ if (!f.endsWith('.pid')) continue;
316
+ const content = await readFile(join(oldDaemonsDir, f), 'utf-8').catch(() => null);
317
+ if (content) {
318
+ const oldPid = parseInt(content.trim(), 10);
319
+ try {
320
+ process.kill(oldPid, 0);
321
+ staleOldDaemons.push(`${f} (PID ${oldPid})`);
322
+ } catch {
323
+ // Dead process, just a stale file
324
+ }
325
+ }
326
+ }
327
+ if (staleOldDaemons.length > 0) {
328
+ console.log(`\nLegacy per-project daemons still running:`);
329
+ for (const d of staleOldDaemons) {
330
+ console.log(` ${d}`);
331
+ }
332
+ console.log(' Consider stopping these — the global daemon handles all projects.');
333
+ }
334
+ } catch {
335
+ // Old daemons dir doesn't exist
336
+ }
337
+ }
338
+
339
+ async function handleLog(): Promise<void> {
340
+ try {
341
+ const content = await readFile(LOG_FILE, 'utf-8');
342
+ // Show last 50 lines
343
+ const lines = content.split('\n');
344
+ const tail = lines.slice(-50).join('\n');
345
+ console.log(tail);
346
+ } catch {
347
+ console.log('[mnemonik] No log file found at', LOG_FILE);
348
+ }
349
+ }
350
+
351
+ async function main(): Promise<void> {
352
+ const cli = parseCliArgs();
353
+
354
+ switch (cli.command) {
355
+ case 'start':
356
+ await handleStart(cli);
357
+ break;
358
+ case 'stop':
359
+ await handleStop();
360
+ break;
361
+ case 'status':
362
+ await handleStatus();
363
+ break;
364
+ case 'log':
365
+ await handleLog();
366
+ break;
367
+ case 'help':
368
+ default:
369
+ printHelp();
370
+ break;
371
+ }
372
+ }
373
+
159
374
  main();
package/src/watcher.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { watch, type FSWatcher } from 'fs';
2
2
  import { join, relative } from 'path';
3
- import { readdir, stat } from 'fs/promises';
3
+ import { readdir } from 'fs/promises';
4
4
 
5
5
  const SKIP_DIRS = new Set([
6
6
  'node_modules',
@@ -23,19 +23,23 @@ const SKIP_DIRS = new Set([
23
23
  ]);
24
24
 
25
25
  export type ChangeHandler = (changedFiles: string[]) => void;
26
+ export type ErrorHandler = (err: Error) => void;
26
27
 
27
28
  export class FileWatcher {
28
29
  private watchers: FSWatcher[] = [];
29
30
  private pendingFiles = new Set<string>();
30
31
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
31
32
  private debounceMs: number;
33
+ private onError: ErrorHandler | undefined;
32
34
 
33
35
  constructor(
34
36
  private rootPath: string,
35
37
  private onChange: ChangeHandler,
36
- debounceMs = 500
38
+ debounceMs = 500,
39
+ onError?: ErrorHandler
37
40
  ) {
38
41
  this.debounceMs = debounceMs;
42
+ this.onError = onError;
39
43
  }
40
44
 
41
45
  async start(): Promise<void> {
@@ -79,6 +83,13 @@ export class FileWatcher {
79
83
  this.scheduleFlush();
80
84
  });
81
85
 
86
+ watcher.on('error', (err) => {
87
+ console.warn(`[scanner] Watcher error for ${dir}:`, err.message);
88
+ if (dir === this.rootPath && this.onError) {
89
+ this.onError(err);
90
+ }
91
+ });
92
+
82
93
  this.watchers.push(watcher);
83
94
 
84
95
  const entries = await readdir(dir, { withFileTypes: true });