claude-code-memory-explorer 0.9.1 → 0.11.0

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.
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // Network boundary for a local-only dev server.
4
+ //
5
+ // Binding loopback is necessary but not sufficient. Three distinct attackers:
6
+ //
7
+ // * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
8
+ // outright; the handshake never completes off-box.
9
+ // * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
10
+ // name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
11
+ // The page's origin *is* evil.com:3541, so the request is same-origin and
12
+ // CORS never fires; the connection is to loopback, so the bind is satisfied.
13
+ // The only thing that differs from a legitimate request is the Host header,
14
+ // which script cannot forge. A Host allowlist is the entire defense.
15
+ // * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
16
+ // allowlisted value, so hostGuard passes it; this needs a separate Origin /
17
+ // Sec-Fetch-Site check on state-changing methods.
18
+ //
19
+ // There is no authentication. Exposing this to a network means trusting everyone
20
+ // on it with the user's Claude Code history.
21
+ //
22
+ // CANONICAL COPY: scripts/security-lib/net-guard.js in the claude-code-hub repo.
23
+ // Run scripts/sync-security-lib.sh after editing.
24
+
25
+ const LOOPBACK_HOSTS = new Set([
26
+ 'localhost',
27
+ '127.0.0.1',
28
+ '::1',
29
+ '0:0:0:0:0:0:0:1',
30
+ '::ffff:127.0.0.1',
31
+ ]);
32
+
33
+ // Hoisted: parseHostHeader runs on every request, and neither the regex nor the
34
+ // predicate depends on the argument.
35
+ const PORT_RE = /^[0-9]{1,5}$/;
36
+ // The port is validated but not compared — the listen(0) fallbacks make the real
37
+ // port dynamic, so only the host is allowlisted.
38
+ const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
+
40
+ function isLoopbackAddress(host) {
41
+ if (!host) return false;
42
+ const h = String(host).toLowerCase();
43
+ return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
44
+ }
45
+
46
+ // Hand-parsed rather than via new URL(), which is far more permissive than we
47
+ // want here and happily accepts things no real client sends. Returns the bare
48
+ // hostname, lowercased, or null if the header is malformed.
49
+ //
50
+ // The port is deliberately ignored: ports are dynamic in these apps because of
51
+ // the listen(0) fallback when the default is busy, so pinning one would break
52
+ // the fallback path.
53
+ function parseHostHeader(value) {
54
+ if (typeof value !== 'string') return null;
55
+ const raw = value.trim();
56
+ if (!raw || raw.length > 259) return null;
57
+
58
+ // Reject whitespace and control characters. Written as a code-point scan rather
59
+ // than a character class so it cannot be misread — a stray range in a regex
60
+ // here would silently reject legitimate hostnames containing a dash.
61
+ for (let i = 0; i < raw.length; i++) {
62
+ const code = raw.charCodeAt(i);
63
+ if (code <= 0x20 || code === 0x7f) return null;
64
+ }
65
+ if (raw.includes('@')) return null; // userinfo has no business in a Host header
66
+
67
+ let host;
68
+ if (raw.startsWith('[')) {
69
+ const end = raw.indexOf(']');
70
+ if (end === -1) return null;
71
+ host = raw.slice(1, end);
72
+ const rest = raw.slice(end + 1);
73
+ if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
74
+ } else {
75
+ const parts = raw.split(':');
76
+ if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
77
+ host = parts[0];
78
+ if (parts.length === 2 && !validPort(parts[1])) return null;
79
+ }
80
+
81
+ if (!host) return null;
82
+ if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
83
+ if (!host) return null;
84
+ return host.toLowerCase();
85
+ }
86
+
87
+ function parseList(value) {
88
+ return String(value || '')
89
+ .split(',')
90
+ .map((s) => s.trim().toLowerCase())
91
+ .filter(Boolean);
92
+ }
93
+
94
+ function getFlag(name) {
95
+ const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
96
+ if (idx === -1) return null;
97
+ const arg = process.argv[idx];
98
+ if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
99
+ const next = process.argv[idx + 1];
100
+ return next && !next.startsWith('--') ? next : null;
101
+ }
102
+
103
+ /**
104
+ * @param {{appName?: string}} [config]
105
+ */
106
+ function createNetGuard(config = {}) {
107
+ const appName = config.appName || 'This app';
108
+
109
+ const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
110
+ const BIND_HOST_LC = BIND_HOST.toLowerCase();
111
+ const EXPOSED = !isLoopbackAddress(BIND_HOST);
112
+ // Resolved here and re-exported so the hub can forward the same value to its
113
+ // children rather than re-parsing the flag with its own argv scanner — a parent
114
+ // and child that disagree means the shell serves while every iframe 403s.
115
+ const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
116
+ const extraHosts = new Set(parseList(ALLOWED_HOSTS));
117
+
118
+ // The hub frames each sub-app, so its origin must be accepted as an Origin and
119
+ // permitted as a framing ancestor.
120
+ let hubOrigin = null;
121
+ if (process.env.HUB_URL) {
122
+ try { hubOrigin = new URL(process.env.HUB_URL).origin; } catch { /* ignore a malformed HUB_URL */ }
123
+ }
124
+
125
+ // Resolved only once listen() succeeds — the requested port may have been busy.
126
+ let actualPort = null;
127
+
128
+ function hostAllowed(host) {
129
+ if (!host) return false;
130
+ if (isLoopbackAddress(host)) return true;
131
+ if (extraHosts.has(host)) return true;
132
+ // Binding a specific non-loopback address implies that address is a valid name for us.
133
+ if (EXPOSED && host === BIND_HOST_LC) return true;
134
+ return false;
135
+ }
136
+
137
+ function hostGuard(req, res, next) {
138
+ const host = parseHostHeader(req.headers.host);
139
+ if (hostAllowed(host)) return next();
140
+ res.status(403).type('text/plain').send(
141
+ `403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
142
+ `${appName} only answers requests addressed to localhost. This protects you\n` +
143
+ `from DNS rebinding, where a website you visit re-points its own hostname at\n` +
144
+ `127.0.0.1 in order to read your local data.\n\n` +
145
+ `If you meant to reach this from another machine, restart with:\n` +
146
+ ` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
147
+ `Only do that on a network you trust - there is no authentication.\n`
148
+ );
149
+ }
150
+
151
+ // true = allow, false = block, null = no Origin header, caller decides.
152
+ function originVerdict(origin) {
153
+ if (!origin || origin === 'null') return null;
154
+ let parsed;
155
+ try { parsed = new URL(origin); } catch { return false; }
156
+ if (hubOrigin && parsed.origin === hubOrigin) return true;
157
+ if (!isLoopbackAddress(parsed.hostname)) return false;
158
+ // Same port, any loopback spelling. A different local port is a different app
159
+ // and has no business driving this one.
160
+ if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
161
+ return true;
162
+ }
163
+
164
+ function originGuard(req, res, next) {
165
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
166
+
167
+ const verdict = originVerdict(req.headers.origin);
168
+ if (verdict === true) return next();
169
+ if (verdict === false) {
170
+ return res.status(403).type('text/plain')
171
+ .send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
172
+ }
173
+
174
+ // No Origin header: a non-browser client (curl, the CLI, the hub's own fetch).
175
+ // Trust it unless the browser explicitly tells us otherwise.
176
+ const site = req.headers['sec-fetch-site'];
177
+ if (site && site !== 'same-origin' && site !== 'none') {
178
+ return res.status(403).type('text/plain')
179
+ .send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
180
+ }
181
+ return next();
182
+ }
183
+
184
+ // Constant for the life of the process, and frameGuard runs ahead of
185
+ // express.static — so this is built once rather than per asset fetch.
186
+ //
187
+ // Under the hub, X-Frame-Options is deliberately omitted: it has no multi-origin
188
+ // form, and sending SAMEORIGIN would break the hub's iframes.
189
+ //
190
+ // The port is wildcarded rather than pinned to HUB_URL's. The hub spawns its
191
+ // children before it binds, so if its own preferred port is busy it hands them a
192
+ // HUB_URL with the wrong port and every iframe would break. Any loopback port may
193
+ // frame us; that is not the threat being addressed here — a remote page still
194
+ // cannot, which is what matters.
195
+ //
196
+ // No IPv6 literal: Chrome rejects `http://[::1]:*` as a source expression and
197
+ // logs it on every framed load. The hub's own origin is added verbatim instead,
198
+ // which covers a hub reached over [::1].
199
+ let CSP;
200
+ if (hubOrigin) {
201
+ const ancestors = ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'];
202
+ if (!/^http:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(hubOrigin)) ancestors.push(hubOrigin);
203
+ CSP = `frame-ancestors ${ancestors.join(' ')}`;
204
+ } else {
205
+ CSP = "frame-ancestors 'none'";
206
+ }
207
+
208
+ function frameGuard(_req, res, next) {
209
+ res.setHeader('X-Content-Type-Options', 'nosniff');
210
+ res.setHeader('Referrer-Policy', 'no-referrer');
211
+ res.setHeader('Content-Security-Policy', CSP);
212
+ if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
213
+ next();
214
+ }
215
+
216
+ /**
217
+ * Bind BIND_HOST, then opportunistically serve the other loopback family on the
218
+ * same resolved port via a second server sharing the same request handler.
219
+ *
220
+ * That second bind is what keeps `http://localhost:<port>` working no matter how
221
+ * the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
222
+ * where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
223
+ * port means every existing URL, startup banner and postMessage origin is
224
+ * unchanged. Failures are swallowed: it is an optimization, not a requirement.
225
+ *
226
+ * Returns the primary server, so callers keep attaching their own 'error'
227
+ * handler for the EADDRINUSE fallback.
228
+ *
229
+ * @param {any} app express app (or any http request handler)
230
+ * @param {number} port 0 selects a free port
231
+ * @param {(port: number) => void} [onReady]
232
+ * @param {{maxHeaderSize?: number}} [opts]
233
+ */
234
+ function listenLoopback(app, port, onReady, opts = {}) {
235
+ const http = require('http');
236
+ const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
237
+ const makeServer = (handler) => http.createServer(serverOpts, handler);
238
+ const server = makeServer(app);
239
+ let secondary = null;
240
+
241
+ server.on('listening', () => {
242
+ actualPort = server.address().port;
243
+ if (!EXPOSED) {
244
+ const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
245
+ try {
246
+ secondary = makeServer(app);
247
+ secondary.on('error', () => {}); // that family may not exist on this host
248
+ secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
249
+ } catch { /* best effort */ }
250
+ }
251
+ if (onReady) onReady(actualPort);
252
+ });
253
+ server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
254
+
255
+ server.listen(port, BIND_HOST);
256
+ return server;
257
+ }
258
+
259
+ function exposureWarning() {
260
+ if (!EXPOSED) return null;
261
+ return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
262
+ }
263
+
264
+ return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
265
+ }
266
+
267
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // Launch a GUI editor without ever handing a string to a shell.
4
+ //
5
+ // The naive fix for command injection here — dropping `shell: true` — does not
6
+ // work on Windows, and fails in two different ways:
7
+ //
8
+ // * `spawn('code', args)` throws ENOENT, because the launcher on PATH is
9
+ // `code.cmd` and Node does not consult PATHEXT.
10
+ // * Resolving to `code.cmd` and spawning that throws EINVAL, because since the
11
+ // CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.3+) a .bat/.cmd target
12
+ // may only be spawned with `shell: true` — the very thing we are removing.
13
+ //
14
+ // So on Windows we resolve the real .exe sitting behind the .cmd shim and spawn
15
+ // that directly. Only when no such .exe exists (npm-style shims, which are node
16
+ // scripts with no binary twin) do we fall back to cmd.exe, and that path applies
17
+ // a character allowlist so the quoting cannot be broken out of.
18
+ //
19
+ // CANONICAL COPY: scripts/security-lib/open-editor.js in the claude-code-hub repo.
20
+ // Duplicated verbatim into cck/, marketplace/ and memory/ — they are independent
21
+ // npm packages and cannot import from each other. Run scripts/sync-security-lib.sh
22
+ // after editing.
23
+
24
+ const { spawn } = require('child_process');
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+
28
+ const IS_WIN = process.platform === 'win32';
29
+
30
+ // Editors whose CLI understands `-n` (open in a new window). Passing it to
31
+ // vim/nano/emacs would be wrong, so it is only injected for this family.
32
+ const VSCODE_FAMILY = new Set([
33
+ 'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf', 'positron', 'trae',
34
+ ]);
35
+
36
+ // Characters that survive cmd.exe's double quotes and would let an argument
37
+ // escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
38
+ // matter: '"' ends the quoted run, '%' triggers variable expansion whose value
39
+ // could itself contain a quote. Neither is legal in an NTFS path anyway.
40
+ const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
41
+
42
+ function fail(message, status) {
43
+ return Object.assign(new Error(message), { status });
44
+ }
45
+
46
+ // Split an EDITOR value into argv. Handles `EDITOR="code -w"` and quoted paths
47
+ // such as `"C:\Program Files\App\app.exe" --flag`. No expansion, no globbing —
48
+ // EDITOR is operator-supplied, not request-supplied.
49
+ function splitCommandLine(str) {
50
+ if (typeof str !== 'string') return []; // String(undefined) would yield an "undefined" argv entry
51
+ const out = [];
52
+ let cur = '';
53
+ let quote = null;
54
+ let quoted = false;
55
+ for (const ch of String(str)) {
56
+ if (quote) {
57
+ if (ch === quote) quote = null;
58
+ else cur += ch;
59
+ continue;
60
+ }
61
+ if (ch === '"' || ch === "'") { quote = ch; quoted = true; continue; }
62
+ if (/\s/.test(ch)) {
63
+ if (cur || quoted) { out.push(cur); cur = ''; quoted = false; }
64
+ continue;
65
+ }
66
+ cur += ch;
67
+ }
68
+ if (cur || quoted) out.push(cur);
69
+ return out;
70
+ }
71
+
72
+ // The PATH walk below stats PATH.length × PATHEXT.length candidates — on a
73
+ // typical Windows box ~1200 stats, several milliseconds. The answer cannot change
74
+ // within a process unless PATH itself does, so it is memoized on both.
75
+ const whichCache = new Map();
76
+
77
+ // which(1). On Windows this walks PATHEXT so we learn the resolved extension —
78
+ // that .cmd-vs-.exe distinction is what selects the launch strategy below.
79
+ function whichSync(cmd) {
80
+ if (!cmd) return null;
81
+ if (cmd.includes('/') || cmd.includes('\\')) {
82
+ const abs = path.resolve(cmd);
83
+ try { return fs.statSync(abs).isFile() ? abs : null; } catch { return null; }
84
+ }
85
+ const key = `${process.env.PATH || ''}\u0000${process.env.PATHEXT || ''}\u0000${cmd}`;
86
+ if (whichCache.has(key)) return whichCache.get(key);
87
+ const found = whichUncached(cmd);
88
+ whichCache.set(key, found);
89
+ return found;
90
+ }
91
+
92
+ function whichUncached(cmd) {
93
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
94
+ const exts = IS_WIN
95
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
96
+ : [''];
97
+ for (const dir of dirs) {
98
+ for (const ext of exts) {
99
+ const candidate = path.join(dir, cmd + ext);
100
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // A GUI editor's .cmd shim normally lives at <install>/bin/foo.cmd next to
107
+ // <install>/Foo.exe. Returns that .exe so we can skip the shell entirely.
108
+ // npm-installed shims have no such twin and correctly return null here.
109
+ function exeBehindShim(shimPath) {
110
+ const bin = path.dirname(shimPath);
111
+ const root = path.dirname(bin);
112
+ const stem = path.basename(shimPath, path.extname(shimPath));
113
+ const candidates = [
114
+ path.join(root, `${stem}.exe`),
115
+ path.join(bin, `${stem}.exe`),
116
+ path.join(root, 'Code.exe'),
117
+ path.join(root, 'Cursor.exe'),
118
+ path.join(root, 'Windsurf.exe'),
119
+ path.join(root, 'VSCodium.exe'),
120
+ path.join(root, 'Positron.exe'),
121
+ ];
122
+ for (const candidate of candidates) {
123
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function launchViaCmd(exe, args) {
129
+ for (const arg of [exe, ...args]) {
130
+ if (WIN_CMD_FORBIDDEN.test(arg)) {
131
+ throw fail('Path contains a character that cannot be passed safely to this editor', 400);
132
+ }
133
+ }
134
+ const line = [exe, ...args].map((a) => `"${a}"`).join(' ');
135
+ return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], {
136
+ windowsVerbatimArguments: true,
137
+ windowsHide: true,
138
+ stdio: 'ignore',
139
+ detached: true,
140
+ });
141
+ }
142
+
143
+ let cachedEditor;
144
+
145
+ function resolveEditor() {
146
+ const spec = (process.env.EDITOR || '').trim() || 'code';
147
+ if (cachedEditor && cachedEditor.spec === spec) return cachedEditor;
148
+ const parts = splitCommandLine(spec);
149
+ const cmd = parts[0];
150
+ if (!cmd) throw fail('No editor configured (set EDITOR)', 500);
151
+ const resolved = whichSync(cmd);
152
+ if (!resolved) throw fail(`Editor not found on PATH: ${cmd}`, 500);
153
+ cachedEditor = { spec, resolved, preArgs: parts.slice(1) };
154
+ return cachedEditor;
155
+ }
156
+
157
+ /**
158
+ * Validate a request-supplied path before it becomes an editor argument, and
159
+ * return its resolved absolute form. Lives here rather than in each server so the
160
+ * rule cannot drift between the apps that share this helper.
161
+ *
162
+ * Deliberately not a containment check: opening an arbitrary project directory is
163
+ * the feature these endpoints exist for. The network boundary is the control.
164
+ *
165
+ * @param {unknown} value
166
+ * @param {string} label used in the error message
167
+ * @returns {string} resolved absolute path
168
+ */
169
+ function assertOpenTarget(value, label = 'path') {
170
+ if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
171
+ if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
172
+ const resolved = path.resolve(value);
173
+ if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
174
+ return resolved;
175
+ }
176
+
177
+ /**
178
+ * Open one or more already-validated absolute paths in the user's editor.
179
+ * @param {string[]} targets
180
+ * @param {{newWindow?: boolean}} [opts]
181
+ */
182
+ function openInEditor(targets, opts = {}) {
183
+ const list = (Array.isArray(targets) ? targets : [targets]).filter(Boolean);
184
+ if (!list.length) throw fail('Nothing to open', 400);
185
+
186
+ const { resolved, preArgs } = resolveEditor();
187
+ const stem = path.basename(resolved, path.extname(resolved)).toLowerCase();
188
+
189
+ const flags = [...preArgs];
190
+ if (opts.newWindow !== false && VSCODE_FAMILY.has(stem) && !flags.includes('-n')) {
191
+ flags.unshift('-n');
192
+ }
193
+ const argv = [...flags, ...list];
194
+
195
+ const ext = path.extname(resolved).toLowerCase();
196
+ if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
197
+ const exe = exeBehindShim(resolved);
198
+ if (exe) return spawn(exe, argv, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
199
+ return launchViaCmd(resolved, argv).unref();
200
+ }
201
+
202
+ return spawn(resolved, argv, { stdio: 'ignore', detached: true }).unref();
203
+ }
204
+
205
+ /**
206
+ * Run a command without a shell and capture its output. Same Windows .cmd
207
+ * strategy as openInEditor: prefer the real .exe, fall back to an explicitly
208
+ * quoted cmd.exe line guarded by the character allowlist. Callers must still
209
+ * validate every argument — this stops shell metacharacters, not bad flags.
210
+ * @param {string} cmd
211
+ * @param {string[]} args
212
+ * @param {{cwd?: string, timeout?: number}} [opts]
213
+ * @returns {Promise<{stdout: string, stderr: string}>}
214
+ */
215
+ function execNoShell(cmd, args, opts = {}) {
216
+ return new Promise((resolve, reject) => {
217
+ const resolved = whichSync(cmd);
218
+ if (!resolved) return reject(fail(`Command not found on PATH: ${cmd}`, 500));
219
+
220
+ let file = resolved;
221
+ let argv = args;
222
+ const spawnOpts = {
223
+ cwd: opts.cwd || undefined,
224
+ timeout: opts.timeout,
225
+ windowsHide: true,
226
+ stdio: ['ignore', 'pipe', 'pipe'],
227
+ };
228
+
229
+ const ext = path.extname(resolved).toLowerCase();
230
+ if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
231
+ const exe = exeBehindShim(resolved);
232
+ if (exe) {
233
+ file = exe;
234
+ } else {
235
+ for (const arg of [resolved, ...args]) {
236
+ if (WIN_CMD_FORBIDDEN.test(String(arg))) {
237
+ return reject(fail('Argument contains a character that cannot be passed safely', 400));
238
+ }
239
+ }
240
+ const line = [resolved, ...args].map((a) => `"${a}"`).join(' ');
241
+ file = process.env.ComSpec || 'cmd.exe';
242
+ argv = ['/d', '/s', '/c', `"${line}"`];
243
+ spawnOpts.windowsVerbatimArguments = true;
244
+ }
245
+ }
246
+
247
+ let child;
248
+ try {
249
+ child = spawn(file, argv, spawnOpts);
250
+ } catch (err) {
251
+ return reject(err);
252
+ }
253
+
254
+ let stdout = '';
255
+ let stderr = '';
256
+ child.stdout.on('data', (d) => { stdout += d; });
257
+ child.stderr.on('data', (d) => { stderr += d; });
258
+ child.on('error', reject);
259
+ child.on('close', (code, signal) => {
260
+ if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
261
+ if (code !== 0) return reject(new Error(stderr.trim() || `Command exited with code ${code}`));
262
+ resolve({ stdout, stderr });
263
+ });
264
+ });
265
+ }
266
+
267
+ module.exports = { assertOpenTarget, openInEditor, execNoShell, whichSync, splitCommandLine, exeBehindShim };
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "claude-code-memory-explorer": "./server.js"
7
+ "claude-code-memory-explorer": "server.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node server.js --open",
12
- "prepare": "husky || true"
12
+ "prepare": "husky || true",
13
+ "test": "node --test test/*.test.js"
13
14
  },
14
15
  "engines": {
15
16
  "node": ">=20.0.0"
@@ -44,6 +45,7 @@
44
45
  "license": "MIT",
45
46
  "files": [
46
47
  "server.js",
48
+ "lib/",
47
49
  "public/",
48
50
  "README.md",
49
51
  "LICENSE"