claude-code-memory-explorer 0.9.0 → 0.10.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.
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/package.json +5 -3
- package/public/app.js +40 -23
- package/server.js +26 -22
package/lib/net-guard.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Memory explorer dashboard for Claude Code",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"claude-code-memory-explorer": "
|
|
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"
|
package/public/app.js
CHANGED
|
@@ -9,12 +9,23 @@ let selectedFileId = null;
|
|
|
9
9
|
|
|
10
10
|
// #region UTILS
|
|
11
11
|
|
|
12
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
13
|
+
|
|
12
14
|
function esc(s) {
|
|
13
|
-
|
|
15
|
+
if (s == null) return '';
|
|
16
|
+
return String(s).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
// For a value landing inside a quoted JS string inside an HTML attribute —
|
|
20
|
+
// onclick="fn('${escAttrJs(x)}')". The browser HTML-decodes the attribute before
|
|
21
|
+
// the JS parser sees it, so the JS escape must happen first and be escaped in turn.
|
|
22
|
+
function escAttrJs(value) {
|
|
23
|
+
const js = String(value == null ? '' : value)
|
|
24
|
+
.replace(/\\/g, '\\\\')
|
|
25
|
+
.replace(/'/g, "\\'")
|
|
26
|
+
.replace(/\r/g, '\\r')
|
|
27
|
+
.replace(/\n/g, '\\n');
|
|
28
|
+
return esc(js);
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
// #endregion UTILS
|
|
@@ -64,7 +75,7 @@ function linkifyImports(text, _sourceId) {
|
|
|
64
75
|
const ph = (child, display) => {
|
|
65
76
|
const token = `\x00LINK${placeholders.length}\x00`;
|
|
66
77
|
placeholders.push(
|
|
67
|
-
`<a class="inline-import" href="#" onclick="selectFile('${
|
|
78
|
+
`<a class="inline-import" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(display)}</a>`,
|
|
68
79
|
);
|
|
69
80
|
return token;
|
|
70
81
|
};
|
|
@@ -273,9 +284,9 @@ function renderRecentProjects() {
|
|
|
273
284
|
recent
|
|
274
285
|
.map(
|
|
275
286
|
(p) =>
|
|
276
|
-
`<div class="recent-project-item" onclick="_selectRecentProject('${
|
|
287
|
+
`<div class="recent-project-item" onclick="_selectRecentProject('${escAttrJs(p)}')">` +
|
|
277
288
|
`<span>${esc(p)}</span>` +
|
|
278
|
-
`<button class="recent-project-remove" onclick="_removeRecentProject('${
|
|
289
|
+
`<button class="recent-project-remove" onclick="_removeRecentProject('${escAttrJs(p)}', event)" title="Remove">✕</button>` +
|
|
279
290
|
`</div>`,
|
|
280
291
|
)
|
|
281
292
|
.join('');
|
|
@@ -385,9 +396,9 @@ function renderTree() {
|
|
|
385
396
|
const children = childrenOf[item.id];
|
|
386
397
|
const expanded = children && isItemExpanded(item.id);
|
|
387
398
|
const chevron = children
|
|
388
|
-
? `<span class="tree-chevron" onclick="_toggleItem(event,'${
|
|
399
|
+
? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
|
|
389
400
|
: '<span class="tree-chevron-spacer"></span>';
|
|
390
|
-
let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${
|
|
401
|
+
let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
|
|
391
402
|
h += chevron;
|
|
392
403
|
h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
|
|
393
404
|
h += `<span class="file-name">${esc(item.name)}</span>`;
|
|
@@ -408,7 +419,7 @@ function renderTree() {
|
|
|
408
419
|
const collapsed = collapsible && isGroupCollapsed(scope);
|
|
409
420
|
const chevron = collapsible ? `<span class="group-chevron">${collapsed ? '\u25B8' : '\u25BE'}</span>` : '';
|
|
410
421
|
const headerClass = collapsible ? 'tree-group-header tree-group-clickable' : 'tree-group-header';
|
|
411
|
-
const onclick = collapsible ? ` onclick="_toggleGroup('${
|
|
422
|
+
const onclick = collapsible ? ` onclick="_toggleGroup('${escAttrJs(scope)}')"` : '';
|
|
412
423
|
html += `<div class="${headerClass}" data-scope="${esc(scope)}"${onclick}>${chevron}<span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
|
|
413
424
|
if (collapsed) continue;
|
|
414
425
|
if (scope === 'agent-memory') {
|
|
@@ -505,7 +516,7 @@ function selectFile(id, pushState = true) {
|
|
|
505
516
|
}
|
|
506
517
|
|
|
507
518
|
function scrollToSelected() {
|
|
508
|
-
const el = document.querySelector(`.tree-item[data-id="${selectedFileId}"]`);
|
|
519
|
+
const el = document.querySelector(`.tree-item[data-id="${CSS.escape(selectedFileId ?? '')}"]`);
|
|
509
520
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
510
521
|
}
|
|
511
522
|
|
|
@@ -608,7 +619,7 @@ function renderMemoryIndexTable(entries) {
|
|
|
608
619
|
const type = typeMatch ? typeMatch[1] : 'memory';
|
|
609
620
|
const scopeColor = TYPE_COLORS[type] || 'memory';
|
|
610
621
|
const nameHtml = child
|
|
611
|
-
? `<a class="import-link" href="#" onclick="selectFile('${
|
|
622
|
+
? `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(entry.name)}</a>`
|
|
612
623
|
: `<span class="import-link unresolved" title="Not found: ${esc(entry.file)}">⚠ ${esc(entry.name)}</span>`;
|
|
613
624
|
html += `<div class="memory-index-row">`;
|
|
614
625
|
html += `<span class="scope-badge scope-${scopeColor} memory-index-type">${esc(type)}</span>`;
|
|
@@ -649,11 +660,11 @@ async function renderPreview() {
|
|
|
649
660
|
if (isMemoryIndex(source)) {
|
|
650
661
|
const broken = (source.unresolvedImports || []).length;
|
|
651
662
|
if (broken) {
|
|
652
|
-
html += `<button class="action-btn small" onclick="_cleanupOrphans('${
|
|
663
|
+
html += `<button class="action-btn small" onclick="_cleanupOrphans('${escAttrJs(source.path)}')" title="Cleanup ${broken} orphaned refs"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M12 11v6"/></svg><span style="margin-left:3px">${broken}</span></button>`;
|
|
653
664
|
}
|
|
654
665
|
}
|
|
655
|
-
html += `<button class="action-btn small" onclick="openInEditor('${
|
|
656
|
-
html += `<button class="action-btn small" onclick="_confirmDeleteFile('${
|
|
666
|
+
html += `<button class="action-btn small" onclick="openInEditor('${escAttrJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
|
|
667
|
+
html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
|
|
657
668
|
html += '</div></div>';
|
|
658
669
|
|
|
659
670
|
// Badges row
|
|
@@ -677,7 +688,7 @@ async function renderPreview() {
|
|
|
677
688
|
if (!showMemoryTable && (children.length || unresolved.length)) {
|
|
678
689
|
html += '<div class="preview-imports">';
|
|
679
690
|
for (const child of children) {
|
|
680
|
-
html += `<a class="import-link" href="#" onclick="selectFile('${
|
|
691
|
+
html += `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
|
|
681
692
|
}
|
|
682
693
|
for (const u of unresolved) {
|
|
683
694
|
html += `<span class="import-link unresolved" title="Not found: ${esc(u)}">⚠ ${esc(u)}</span>`;
|
|
@@ -981,11 +992,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
981
992
|
.catch(() => ({}));
|
|
982
993
|
if (!cfg.enabled) return;
|
|
983
994
|
window.__HUB__ = cfg;
|
|
984
|
-
const fwd = (e) =>
|
|
985
|
-
window.parent?.postMessage(
|
|
986
|
-
{ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey },
|
|
987
|
-
'*',
|
|
988
|
-
);
|
|
995
|
+
const fwd = (e) => hubPost({ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
989
996
|
document.addEventListener('keydown', (e) => {
|
|
990
997
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
991
998
|
e.preventDefault();
|
|
@@ -1005,12 +1012,19 @@ document.addEventListener('keydown', (e) => {
|
|
|
1005
1012
|
|
|
1006
1013
|
function _hubNavigate(app, url) {
|
|
1007
1014
|
if (!window.__HUB__?.enabled) return;
|
|
1008
|
-
|
|
1015
|
+
hubPost({ type: 'hub:navigate', app, url });
|
|
1009
1016
|
}
|
|
1010
1017
|
|
|
1011
1018
|
// Hoisted out of initHubTheme so initHubProject can share it.
|
|
1012
1019
|
const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
|
|
1013
1020
|
|
|
1021
|
+
// Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
|
|
1022
|
+
// framed this app also received the forwarded keystrokes and navigation intents.
|
|
1023
|
+
function hubPost(message) {
|
|
1024
|
+
const origin = hubOrigin();
|
|
1025
|
+
if (origin) window.parent?.postMessage(message, origin);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1014
1028
|
(function initHubTheme() {
|
|
1015
1029
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
1016
1030
|
const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
|
|
@@ -1036,8 +1050,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
|
|
|
1036
1050
|
if (t === lastTheme && ct === lastColorTheme) return;
|
|
1037
1051
|
lastTheme = t;
|
|
1038
1052
|
lastColorTheme = ct;
|
|
1039
|
-
|
|
1040
|
-
if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
|
|
1053
|
+
hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
|
|
1041
1054
|
}).observe(document.body, {
|
|
1042
1055
|
attributes: true,
|
|
1043
1056
|
attributeFilter: ['class', 'data-color-theme'],
|
|
@@ -1062,6 +1075,10 @@ let hubProjectPath = null;
|
|
|
1062
1075
|
lastApplied = dirPath;
|
|
1063
1076
|
try {
|
|
1064
1077
|
await putProject(dirPath);
|
|
1078
|
+
// The hub does not persist its own scope, so a hub-pushed project must become the recent
|
|
1079
|
+
// head here: after a hard refresh the boot path PUTs getRecentProjects()[0], which would
|
|
1080
|
+
// otherwise clobber the server's hub-scoped project with the previous one.
|
|
1081
|
+
addRecentProject(dirPath);
|
|
1065
1082
|
await Promise.all([loadProject(), loadData()]);
|
|
1066
1083
|
} catch (err) {
|
|
1067
1084
|
lastApplied = null;
|
package/server.js
CHANGED
|
@@ -6,6 +6,8 @@ const path = require('path');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const { execFileSync } = require('child_process');
|
|
9
|
+
const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
|
|
10
|
+
const { createNetGuard } = require('./lib/net-guard');
|
|
9
11
|
|
|
10
12
|
// #region CLI_ARGS
|
|
11
13
|
|
|
@@ -17,7 +19,7 @@ function getArg(name) {
|
|
|
17
19
|
return process.argv[eqIdx + 1] || null;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const expandHome = (p) => (p
|
|
22
|
+
const expandHome = (p) => (typeof p === 'string' ? p : '').replace(/^~/, os.homedir());
|
|
21
23
|
|
|
22
24
|
const PORT = getArg('port') || process.env.PORT || 3544;
|
|
23
25
|
const AUTO_OPEN = process.argv.includes('--open');
|
|
@@ -441,7 +443,10 @@ function findMemoryDir(projectPath) {
|
|
|
441
443
|
|
|
442
444
|
function findMainWorktreePath(projectPath) {
|
|
443
445
|
try {
|
|
444
|
-
|
|
446
|
+
// cwd rather than `-C <path>`: a projectPath beginning with a dash would
|
|
447
|
+
// otherwise be parsed by git as a flag instead of a directory.
|
|
448
|
+
const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
449
|
+
cwd: projectPath,
|
|
445
450
|
encoding: 'utf8',
|
|
446
451
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
447
452
|
timeout: 2000,
|
|
@@ -512,6 +517,13 @@ function stripContent(source) {
|
|
|
512
517
|
// #region EXPRESS
|
|
513
518
|
|
|
514
519
|
const app = express();
|
|
520
|
+
|
|
521
|
+
// Mounted before express.json() so a rejected request never buffers a body.
|
|
522
|
+
const net = createNetGuard({ appName: 'Claude Code Memory' });
|
|
523
|
+
app.use(net.hostGuard);
|
|
524
|
+
app.use(net.frameGuard);
|
|
525
|
+
app.use(net.originGuard);
|
|
526
|
+
|
|
515
527
|
app.use(express.json());
|
|
516
528
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
517
529
|
|
|
@@ -606,15 +618,12 @@ app.post('/api/memory/cleanup-orphans', (req, res) => {
|
|
|
606
618
|
});
|
|
607
619
|
|
|
608
620
|
app.post('/api/open-in-editor', (req, res) => {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const resolved = expandHome(filePath);
|
|
612
|
-
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'file not found' });
|
|
613
|
-
const { exec } = require('child_process');
|
|
614
|
-
exec(`code "${resolved}"`, (err) => {
|
|
615
|
-
if (err) return res.status(500).json({ error: err.message });
|
|
621
|
+
try {
|
|
622
|
+
openInEditor([assertOpenTarget(expandHome(req.body.path))]);
|
|
616
623
|
res.json({ ok: true });
|
|
617
|
-
})
|
|
624
|
+
} catch (err) {
|
|
625
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
626
|
+
}
|
|
618
627
|
});
|
|
619
628
|
|
|
620
629
|
app.get('/api/summary', (_req, res) => {
|
|
@@ -696,27 +705,22 @@ app.get('/api/imports', (req, res) => {
|
|
|
696
705
|
|
|
697
706
|
// #region STARTUP
|
|
698
707
|
|
|
699
|
-
const
|
|
700
|
-
const addr = server.address();
|
|
701
|
-
const port = typeof addr === 'object' ? addr.port : PORT;
|
|
708
|
+
const onReady = (port) => {
|
|
702
709
|
console.log(`Claude Code Memory running at http://localhost:${port}`);
|
|
703
710
|
console.log(`Project: ${currentProjectPath}`);
|
|
711
|
+
const warning = net.exposureWarning();
|
|
712
|
+
if (warning) console.log(warning);
|
|
704
713
|
if (AUTO_OPEN) {
|
|
705
714
|
import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
|
|
706
715
|
}
|
|
707
|
-
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const server = net.listenLoopback(app, PORT, onReady);
|
|
708
719
|
|
|
709
720
|
server.on('error', (err) => {
|
|
710
721
|
if (err.code === 'EADDRINUSE') {
|
|
711
722
|
console.log(`Port ${PORT} busy, trying random port...`);
|
|
712
|
-
|
|
713
|
-
const addr = retry.address();
|
|
714
|
-
const port = typeof addr === 'object' ? addr.port : '?';
|
|
715
|
-
console.log(`Claude Code Memory running at http://localhost:${port}`);
|
|
716
|
-
if (AUTO_OPEN) {
|
|
717
|
-
import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
|
|
718
|
-
}
|
|
719
|
-
});
|
|
723
|
+
net.listenLoopback(app, 0, onReady);
|
|
720
724
|
}
|
|
721
725
|
});
|
|
722
726
|
|