md-semantic-search 1.0.1 → 1.0.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/bin/cli.mjs CHANGED
@@ -1136,9 +1136,29 @@ async function main() {
1136
1136
  }
1137
1137
  }
1138
1138
 
1139
- // Run only when executed directly (`node bin/cli.mjs`, `mdss`), never when
1140
- // imported by tests or library consumers (issue #29).
1141
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1139
+ /**
1140
+ * True only when this module is the process entry script (`node bin/cli.mjs`,
1141
+ * global `mdss`), never when imported by tests or library consumers (issue #29).
1142
+ *
1143
+ * The comparison goes through fs.realpathSync on BOTH sides of the identity:
1144
+ * on Windows a global install is an NTFS junction, so Node resolves this module
1145
+ * to its real path (D:\Repo\...\bin\cli.mjs) while process.argv[1] keeps the
1146
+ * junction path (C:\...\npm\node_modules\md-semantic-search\bin\cli.mjs). A
1147
+ * plain string comparison then never matches and the CLI silently does nothing.
1148
+ */
1149
+ function isDirectRun() {
1150
+ const entry = process.argv[1];
1151
+ if (!entry) return false;
1152
+ try {
1153
+ return fs.realpathSync(entry) === fileURLToPath(import.meta.url);
1154
+ } catch {
1155
+ // argv[1] vanished between spawn and here (or is not a real file) —
1156
+ // fall back to lexical resolution so behavior matches the old guard.
1157
+ return pathToFileURL(path.resolve(entry)).href === import.meta.url;
1158
+ }
1159
+ }
1160
+
1161
+ if (isDirectRun()) {
1142
1162
  main().catch(e => die(e.message));
1143
1163
  }
1144
1164
 
package/dist/core.js CHANGED
@@ -375,6 +375,9 @@ export function assertSafePath(targetPath, allowedRoots) {
375
375
  if (targetPath.startsWith('\\\\') || targetPath.startsWith('//')) {
376
376
  throw new Error('Forbidden path: UNC network paths not allowed');
377
377
  }
378
+ if (/^[a-zA-Z]:[\\/]/.test(targetPath) && process.platform !== 'win32') {
379
+ throw new Error('Forbidden path: Windows drive paths not allowed on non-Windows systems');
380
+ }
378
381
  const resolved = path.resolve(targetPath);
379
382
  let canonical = resolved;
380
383
  try {
package/dist/serve.js CHANGED
@@ -40,13 +40,19 @@ export async function createServe(opts) {
40
40
  apiKey,
41
41
  healthPublic,
42
42
  };
43
+ const sockets = new Set();
43
44
  const server = http.createServer((req, res) => {
44
45
  handleRequest(req, res, state).catch((e) => {
45
46
  json(res, 500, { error: e.message });
46
47
  });
47
48
  });
49
+ server.on('connection', (socket) => {
50
+ sockets.add(socket);
51
+ socket.on('close', () => sockets.delete(socket));
52
+ });
48
53
  let stopped = false;
49
54
  let timer = null;
55
+ let resolveTimer = null;
50
56
  const reload = async () => {
51
57
  if (!db)
52
58
  return false;
@@ -149,7 +155,13 @@ export async function createServe(opts) {
149
155
  let pending = false;
150
156
  while (!stopped) {
151
157
  await new Promise((r) => {
152
- timer = setTimeout(r, watchInterval);
158
+ resolveTimer = r;
159
+ timer = setTimeout(() => {
160
+ timer = null;
161
+ resolveTimer = null;
162
+ r();
163
+ }, watchInterval);
164
+ timer.unref?.();
153
165
  });
154
166
  if (stopped)
155
167
  return;
@@ -200,15 +212,24 @@ export async function createServe(opts) {
200
212
  }
201
213
  const close = async () => {
202
214
  stopped = true;
203
- if (timer)
215
+ if (timer) {
204
216
  clearTimeout(timer);
217
+ timer = null;
218
+ }
219
+ if (resolveTimer) {
220
+ resolveTimer();
221
+ resolveTimer = null;
222
+ }
205
223
  if (nativeWatcher)
206
224
  nativeWatcher.close();
207
- // closeAllConnections() forcibly closes keep-alive sockets so server.close()
208
- // doesn't hang indefinitely waiting for them — critical on Node 18 where the
209
- // HTTP server does NOT auto-drain idle connections before calling back.
210
- // The method was back-ported to Node 18.2.0 and is always present in CI.
211
225
  server.closeAllConnections?.();
226
+ for (const socket of sockets) {
227
+ try {
228
+ socket.destroy();
229
+ }
230
+ catch { }
231
+ }
232
+ sockets.clear();
212
233
  await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
213
234
  };
214
235
  return { server, state, close };
package/dist/watcher.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { globToRegExp } from './core.js';
10
10
  export function createFileWatcher(dbPath, onChange, options = {}) {
11
- const { debounceMs = 250, ignore = [], log = () => { }, fallbackIntervalMs = 1000, } = options;
11
+ const { debounceMs = 250, ignore = [], log = () => { }, fallbackIntervalMs = 50, } = options;
12
12
  let closed = false;
13
13
  let debounceTimer = null;
14
14
  const ignoreRegexes = [
@@ -47,6 +47,7 @@ export function createFileWatcher(dbPath, onChange, options = {}) {
47
47
  log(`watcher onChange error: ${err.message}`);
48
48
  }
49
49
  }, debounceMs);
50
+ debounceTimer.unref?.();
50
51
  };
51
52
  // Attempt recursive native fs.watch first
52
53
  try {
@@ -61,6 +62,11 @@ export function createFileWatcher(dbPath, onChange, options = {}) {
61
62
  fsWatcher.on('error', (err) => {
62
63
  log(`Native watcher error (${err.message}), continuing.`);
63
64
  });
65
+ // unref() lets the event loop exit even if the watcher is still active.
66
+ // Without this, on Node 18/Linux the inotify handle keeps the process
67
+ // alive indefinitely after all tests finish (or after close() is called
68
+ // but the handle hasn't been fully released by the kernel yet).
69
+ fsWatcher.unref?.();
64
70
  return {
65
71
  isNative: true,
66
72
  close: () => {
@@ -80,13 +86,14 @@ export function createFileWatcher(dbPath, onChange, options = {}) {
80
86
  let lastMtime = 0;
81
87
  const getDirMtime = (dir) => {
82
88
  try {
83
- const stat = fs.statSync(dir);
84
- let max = stat.mtimeMs;
89
+ let max = 0;
85
90
  const entries = fs.readdirSync(dir, { withFileTypes: true });
86
91
  for (const entry of entries) {
87
92
  if (entry.name.startsWith('.') || entry.name === '.mdss')
88
93
  continue;
89
94
  const full = path.join(dir, entry.name);
95
+ if (shouldIgnore(entry.name))
96
+ continue;
90
97
  if (entry.isDirectory()) {
91
98
  const sub = getDirMtime(full);
92
99
  if (sub > max)
@@ -117,6 +124,7 @@ export function createFileWatcher(dbPath, onChange, options = {}) {
117
124
  scheduleChange();
118
125
  }
119
126
  }, fallbackIntervalMs);
127
+ pollInterval.unref?.();
120
128
  return {
121
129
  isNative: false,
122
130
  close: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-semantic-search",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/core.ts CHANGED
@@ -436,6 +436,9 @@ export function assertSafePath(targetPath: string, allowedRoots?: string[]): str
436
436
  if (targetPath.startsWith('\\\\') || targetPath.startsWith('//')) {
437
437
  throw new Error('Forbidden path: UNC network paths not allowed');
438
438
  }
439
+ if (/^[a-zA-Z]:[\\/]/.test(targetPath) && process.platform !== 'win32') {
440
+ throw new Error('Forbidden path: Windows drive paths not allowed on non-Windows systems');
441
+ }
439
442
  const resolved = path.resolve(targetPath);
440
443
  let canonical = resolved;
441
444
  try {
package/src/serve.ts CHANGED
@@ -92,14 +92,20 @@ export async function createServe(opts: CreateServeOptions): Promise<{
92
92
  healthPublic,
93
93
  };
94
94
 
95
+ const sockets = new Set<import('node:net').Socket>();
95
96
  const server = http.createServer((req, res) => {
96
97
  handleRequest(req, res, state).catch((e) => {
97
98
  json(res, 500, { error: e.message });
98
99
  });
99
100
  });
101
+ server.on('connection', (socket) => {
102
+ sockets.add(socket);
103
+ socket.on('close', () => sockets.delete(socket));
104
+ });
100
105
 
101
106
  let stopped = false;
102
107
  let timer: NodeJS.Timeout | null = null;
108
+ let resolveTimer: (() => void) | null = null;
103
109
 
104
110
  const reload = async () => {
105
111
  if (!db) return false;
@@ -199,8 +205,14 @@ export async function createServe(opts: CreateServeOptions): Promise<{
199
205
  let lastChangeAt = 0;
200
206
  let pending = false;
201
207
  while (!stopped) {
202
- await new Promise((r) => {
203
- timer = setTimeout(r, watchInterval);
208
+ await new Promise<void>((r) => {
209
+ resolveTimer = r;
210
+ timer = setTimeout(() => {
211
+ timer = null;
212
+ resolveTimer = null;
213
+ r();
214
+ }, watchInterval);
215
+ timer.unref?.();
204
216
  });
205
217
  if (stopped) return;
206
218
  const tree = scanTree();
@@ -248,13 +260,22 @@ export async function createServe(opts: CreateServeOptions): Promise<{
248
260
 
249
261
  const close = async (): Promise<void> => {
250
262
  stopped = true;
251
- if (timer) clearTimeout(timer);
263
+ if (timer) {
264
+ clearTimeout(timer);
265
+ timer = null;
266
+ }
267
+ if (resolveTimer) {
268
+ resolveTimer();
269
+ resolveTimer = null;
270
+ }
252
271
  if (nativeWatcher) nativeWatcher.close();
253
- // closeAllConnections() forcibly closes keep-alive sockets so server.close()
254
- // doesn't hang indefinitely waiting for them — critical on Node 18 where the
255
- // HTTP server does NOT auto-drain idle connections before calling back.
256
- // The method was back-ported to Node 18.2.0 and is always present in CI.
257
272
  server.closeAllConnections?.();
273
+ for (const socket of sockets) {
274
+ try {
275
+ socket.destroy();
276
+ } catch {}
277
+ }
278
+ sockets.clear();
258
279
  await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
259
280
  };
260
281
 
package/src/watcher.ts CHANGED
@@ -29,7 +29,7 @@ export function createFileWatcher(
29
29
  debounceMs = 250,
30
30
  ignore = [],
31
31
  log = () => {},
32
- fallbackIntervalMs = 1000,
32
+ fallbackIntervalMs = 50,
33
33
  } = options;
34
34
 
35
35
  let closed = false;
@@ -68,6 +68,7 @@ export function createFileWatcher(
68
68
  log(`watcher onChange error: ${err.message}`);
69
69
  }
70
70
  }, debounceMs);
71
+ debounceTimer.unref?.();
71
72
  };
72
73
 
73
74
  // Attempt recursive native fs.watch first
@@ -88,6 +89,12 @@ export function createFileWatcher(
88
89
  log(`Native watcher error (${err.message}), continuing.`);
89
90
  });
90
91
 
92
+ // unref() lets the event loop exit even if the watcher is still active.
93
+ // Without this, on Node 18/Linux the inotify handle keeps the process
94
+ // alive indefinitely after all tests finish (or after close() is called
95
+ // but the handle hasn't been fully released by the kernel yet).
96
+ fsWatcher.unref?.();
97
+
91
98
  return {
92
99
  isNative: true,
93
100
  close: () => {
@@ -105,12 +112,12 @@ export function createFileWatcher(
105
112
  let lastMtime = 0;
106
113
  const getDirMtime = (dir: string): number => {
107
114
  try {
108
- const stat = fs.statSync(dir);
109
- let max = stat.mtimeMs;
115
+ let max = 0;
110
116
  const entries = fs.readdirSync(dir, { withFileTypes: true });
111
117
  for (const entry of entries) {
112
118
  if (entry.name.startsWith('.') || entry.name === '.mdss') continue;
113
119
  const full = path.join(dir, entry.name);
120
+ if (shouldIgnore(entry.name)) continue;
114
121
  if (entry.isDirectory()) {
115
122
  const sub = getDirMtime(full);
116
123
  if (sub > max) max = sub;
@@ -136,6 +143,7 @@ export function createFileWatcher(
136
143
  scheduleChange();
137
144
  }
138
145
  }, fallbackIntervalMs);
146
+ pollInterval.unref?.();
139
147
 
140
148
  return {
141
149
  isNative: false,