@gettrace/cli 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1396,6 +1396,19 @@ function fuzzyReplace(content, oldString, newString, replaceAll = false) {
1396
1396
  return { error: "Found multiple matches for oldString. Provide more surrounding context to make the match unique." };
1397
1397
  }
1398
1398
  program.name("trace").description("Trace IDE Bridge \u2014 connect your codebase and dev server to the Trace extension").version(VERSION);
1399
+ program.command("install-native").description("Register the Trace native messaging host so the Chrome extension can auto-launch trace dev").action(async () => {
1400
+ const { execFileSync } = await import("child_process");
1401
+ const { fileURLToPath: fileURLToPath2 } = await import("url");
1402
+ const { dirname: dirname2, join: join5 } = await import("path");
1403
+ const __dirname2 = dirname2(fileURLToPath2(import.meta.url));
1404
+ const script = join5(__dirname2, "..", "scripts", "postinstall.js");
1405
+ try {
1406
+ execFileSync(process.execPath, [script], { stdio: "inherit" });
1407
+ } catch (e) {
1408
+ console.error(chalk2.red("\u2717 Native host registration failed:"), e.message);
1409
+ process.exit(1);
1410
+ }
1411
+ });
1399
1412
  program.command("dev").description("Start dev server + IDE bridge together (recommended)").argument("[command]", 'Override the dev command (e.g. "npm run start:staging")').option("-p, --port <port>", "WebSocket port for IDE bridge", "8765").option("--no-ui", "Disable the branded TUI and stream raw logs (useful for piping/CI)").action(async (commandOverride, options) => {
1400
1413
  const port = parseInt(options.port);
1401
1414
  const projectPath = process.cwd();
@@ -1463,19 +1476,22 @@ program.command("dev").description("Start dev server + IDE bridge together (reco
1463
1476
  const result = _checkPkg(projectPath, commandOverride);
1464
1477
  if (result.ok)
1465
1478
  return result;
1466
- try {
1467
- const entries = fs4.readdirSync(projectPath, { withFileTypes: true });
1468
- for (const entry of entries) {
1469
- if (!entry.isDirectory())
1470
- continue;
1471
- if (entry.name.startsWith(".") || entry.name === "node_modules")
1472
- continue;
1473
- const sub = path4.join(projectPath, entry.name);
1474
- const r = _checkPkg(sub, void 0);
1475
- if (r.ok)
1476
- return r;
1479
+ const isGreenfield = projectPath.replace(/\/+$/, "").endsWith("/Trace/greenfield");
1480
+ if (!isGreenfield) {
1481
+ try {
1482
+ const entries = fs4.readdirSync(projectPath, { withFileTypes: true });
1483
+ for (const entry of entries) {
1484
+ if (!entry.isDirectory())
1485
+ continue;
1486
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
1487
+ continue;
1488
+ const sub = path4.join(projectPath, entry.name);
1489
+ const r = _checkPkg(sub, void 0);
1490
+ if (r.ok)
1491
+ return r;
1492
+ }
1493
+ } catch {
1477
1494
  }
1478
- } catch {
1479
1495
  }
1480
1496
  return { ok: false };
1481
1497
  }
@@ -1709,12 +1725,16 @@ program.command("dev").description("Start dev server + IDE bridge together (reco
1709
1725
  "/browser/network": "BROWSER_GET_NETWORK",
1710
1726
  "/browser/dom": "BROWSER_GET_DOM",
1711
1727
  "/browser/screenshot": "BROWSER_SCREENSHOT",
1712
- "/browser/verify-build": "BROWSER_VERIFY_BUILD"
1728
+ "/browser/verify-build": "BROWSER_VERIFY_BUILD",
1729
+ "/browser/find": "BROWSER_FIND",
1730
+ "/browser/click": "BROWSER_CLICK",
1731
+ "/browser/type": "BROWSER_TYPE",
1732
+ "/browser/wait-for": "BROWSER_WAIT_FOR"
1713
1733
  };
1714
1734
  const isBrowserRoute = url.pathname in browserQueryRoutes || url.pathname === "/browser/eval";
1715
1735
  if (!isBrowserRoute) {
1716
1736
  res.writeHead(404, { "Content-Type": "application/json" });
1717
- res.end(JSON.stringify({ error: "Not found. Available: /browser/{console,network,dom,eval,screenshot,verify-build}" }));
1737
+ res.end(JSON.stringify({ error: "Not found. Available: /browser/{console,network,dom,eval,screenshot,verify-build,find,click,type,wait-for}" }));
1718
1738
  return;
1719
1739
  }
1720
1740
  const client = [...connectedClients].find(
@@ -1745,14 +1765,23 @@ program.command("dev").description("Start dev server + IDE bridge together (reco
1745
1765
  const reqId = ++globalBrowserRequestId;
1746
1766
  const msgType = browserQueryRoutes[url.pathname] || "BROWSER_EVAL";
1747
1767
  const wsMsg = { id: reqId, type: msgType };
1748
- if (url.pathname === "/browser/eval")
1749
- wsMsg.code = body.code || "";
1768
+ const params = { ...body || {} };
1769
+ for (const [k, v] of url.searchParams.entries()) {
1770
+ if (!(k in params))
1771
+ params[k] = v;
1772
+ }
1773
+ for (const k of Object.keys(params)) {
1774
+ if (k === "id" || k === "type")
1775
+ continue;
1776
+ wsMsg[k] = params[k];
1777
+ }
1750
1778
  client.send(JSON.stringify(wsMsg));
1779
+ const browserQueryTimeoutMs = url.pathname === "/browser/screenshot" ? 15e3 : url.pathname === "/browser/wait-for" ? Math.min(Number(params.timeout) || 8e3, 3e4) + 4e3 : 5e3;
1751
1780
  const result = await new Promise((resolve3, reject) => {
1752
1781
  const timer = setTimeout(() => {
1753
1782
  globalBrowserPending.delete(reqId);
1754
- reject(new Error("Browser query timeout (5s). Extension may not be attached to a tab."));
1755
- }, 5e3);
1783
+ reject(new Error("Browser query timeout (" + Math.round(browserQueryTimeoutMs / 1e3) + "s). Extension may not be attached to a tab."));
1784
+ }, browserQueryTimeoutMs);
1756
1785
  globalBrowserPending.set(reqId, { resolve: resolve3, reject, timer });
1757
1786
  });
1758
1787
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -0,0 +1 @@
1
+ /Users/dakshsaini
@@ -0,0 +1,27 @@
1
+ #!/opt/homebrew/Cellar/node/24.4.1/bin/node
2
+ // Auto-generated by @gettrace/cli postinstall — do not edit manually
3
+ // Generated: 2026-06-25T19:19:52.878Z
4
+ // Node: /opt/homebrew/Cellar/node/24.4.1/bin/node
5
+ // Trace: /opt/homebrew/bin/trace
6
+ 'use strict';
7
+
8
+ // Fix PATH before anything else — Chrome launches with a minimal environment
9
+ process.env.PATH = '/opt/homebrew/bin:/opt/homebrew/Cellar/node/24.4.1/bin:' + (process.env.PATH || '/usr/bin:/bin');
10
+
11
+ // Diagnostic logging — written synchronously so we catch crashes before async
12
+ const fs = require('fs');
13
+ function log(msg) {
14
+ const line = new Date().toISOString() + ' ' + msg + '\n';
15
+ try { fs.appendFileSync('/Users/dakshsaini/trace-native-host.log', line); } catch(_) {}
16
+ try { fs.appendFileSync('/Users/dakshsaini/Desktop/trace-microservices/trace-x/trace-cli/native-host/native-host-debug.log', line); } catch(_) {}
17
+ }
18
+
19
+ log('[host-entry] started, node=' + process.version + ' pid=' + process.pid);
20
+
21
+ try {
22
+ require('/Users/dakshsaini/Desktop/trace-microservices/trace-x/trace-cli/native-host/host.cjs');
23
+ log('[host-entry] host.cjs loaded OK');
24
+ } catch (err) {
25
+ log('[host-entry] FATAL: ' + (err.stack || err));
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Trace Native Messaging Host
4
+ *
5
+ * Receives messages from the Chrome extension via stdio (Chrome's native
6
+ * messaging protocol: 4-byte little-endian length prefix + UTF-8 JSON).
7
+ * Spawns `trace dev` in the given project directory and streams stdout/stderr
8
+ * back so the extension knows when the server is ready.
9
+ *
10
+ * Messages from extension → host:
11
+ * { cmd: "spawn", cwd: "/abs/path/to/project" }
12
+ * { cmd: "stop" }
13
+ * { cmd: "status" }
14
+ * { cmd: "check_paths", paths: ["/path/a", "/path/b"] }
15
+ *
16
+ * Messages from host → extension:
17
+ * { type: "ready", port: 8766 }
18
+ * { type: "output", text: "..." }
19
+ * { type: "error", text: "..." }
20
+ * { type: "exit", code: 0 }
21
+ * { type: "status", running: true|false, pid: number|null }
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const { spawn } = require('child_process');
30
+
31
+ let traceProc = null;
32
+
33
+ // ── Process lifecycle cleanup ────────────────────────────────────────────────
34
+ // trace dev spawns its own child processes (the agent server on :8766, the
35
+ // dev server, the IDE bridge on :8765). If we only kill `traceProc` those
36
+ // children orphan and keep holding the ports — the next spawn then dies with
37
+ // EADDRINUSE. By launching trace dev `detached` (as a process-group leader)
38
+ // we can kill the ENTIRE group with a single negative-pid signal, freeing all
39
+ // ports synchronously.
40
+ // Send a signal to trace dev's whole process group (detached leader),
41
+ // falling back to the bare pid if the group is already gone.
42
+ function signalTraceGroup(pid, signal) {
43
+ try {
44
+ process.kill(-pid, signal);
45
+ } catch {
46
+ try { process.kill(pid, signal); } catch { /* already dead */ }
47
+ }
48
+ }
49
+
50
+ // Immediate, unconditional group kill — used only as the last-resort on the
51
+ // host's own 'exit'. Clears traceProc so we never double-signal a reused pid.
52
+ function killTraceProc(signal = 'SIGKILL') {
53
+ if (!traceProc) return;
54
+ const pid = traceProc.pid;
55
+ traceProc = null;
56
+ if (pid) signalTraceGroup(pid, signal);
57
+ }
58
+
59
+ // Graceful stop: SIGTERM the group FIRST so the agent gets a catchable signal
60
+ // and can run its own cleanup. This matters because the agent spawns dev
61
+ // servers DETACHED (their own process group), so they are NOT in trace dev's
62
+ // group — only the agent's SIGTERM handler kills them. We then escalate to
63
+ // SIGKILL after a short grace period to guarantee the agent + ports go away
64
+ // even if it hangs. (Sending SIGKILL up front — the old behavior — gave the
65
+ // agent no chance to reap its detached dev servers, so they orphaned.)
66
+ let _killEscalation = null;
67
+ function gracefulKillTraceProc(onDone) {
68
+ if (!traceProc) { if (onDone) onDone(); return; }
69
+ const pid = traceProc.pid;
70
+ traceProc = null;
71
+ if (!pid) { if (onDone) onDone(); return; }
72
+ signalTraceGroup(pid, 'SIGTERM');
73
+ if (_killEscalation) clearTimeout(_killEscalation);
74
+ _killEscalation = setTimeout(() => {
75
+ signalTraceGroup(pid, 'SIGKILL');
76
+ if (onDone) onDone();
77
+ }, 1500);
78
+ }
79
+
80
+ // Chrome sends SIGTERM (and closes stdin) when the extension disconnects the
81
+ // native port. Funnel every stop trigger through one guarded graceful kill so
82
+ // trace dev + its detached dev servers all go down with us and the ports free.
83
+ let _shuttingDown = false;
84
+ function shutdown() {
85
+ if (_shuttingDown) return;
86
+ _shuttingDown = true;
87
+ gracefulKillTraceProc(() => { try { process.exit(0); } catch { /* ignore */ } });
88
+ // Safety net: never let the host hang if the escalation callback is lost.
89
+ setTimeout(() => { try { process.exit(0); } catch { /* ignore */ } }, 3000);
90
+ }
91
+ process.on('SIGTERM', shutdown);
92
+ process.on('SIGINT', shutdown);
93
+ process.on('exit', () => killTraceProc('SIGKILL'));
94
+ // stdin closing == the extension disconnected the port
95
+ process.stdin.on('end', shutdown);
96
+ process.stdin.on('close', shutdown);
97
+
98
+ // Read the greenfield default workspace path written by postinstall
99
+ const INSTALL_DIR = path.join(os.homedir(), '.local', 'share', 'trace', 'native-host');
100
+ function readGreenfieldPath() {
101
+ try {
102
+ const p = fs.readFileSync(path.join(INSTALL_DIR, '.greenfield'), 'utf8').trim();
103
+ return p || null;
104
+ } catch { return null; }
105
+ }
106
+
107
+ // ── Chrome native messaging I/O ──────────────────────────────────────────────
108
+
109
+
110
+
111
+ function readMessage() {
112
+ return new Promise((resolve, reject) => {
113
+ const lenBuf = Buffer.alloc(4);
114
+ let bytesRead = 0;
115
+
116
+ function readLen() {
117
+ const chunk = process.stdin.read(4 - bytesRead);
118
+ if (!chunk) {
119
+ process.stdin.once('readable', readLen);
120
+ return;
121
+ }
122
+ chunk.copy(lenBuf, bytesRead);
123
+ bytesRead += chunk.length;
124
+ if (bytesRead < 4) { readLen(); return; }
125
+ const msgLen = lenBuf.readUInt32LE(0);
126
+ readBody(msgLen);
127
+ }
128
+
129
+ function readBody(len) {
130
+ let body = Buffer.alloc(0);
131
+ function tryRead() {
132
+ const chunk = process.stdin.read(len - body.length);
133
+ if (!chunk) { process.stdin.once('readable', tryRead); return; }
134
+ body = Buffer.concat([body, chunk]);
135
+ if (body.length < len) { tryRead(); return; }
136
+ try { resolve(JSON.parse(body.toString('utf8'))); }
137
+ catch (e) { reject(e); }
138
+ }
139
+ tryRead();
140
+ }
141
+
142
+ process.stdin.once('readable', readLen);
143
+ });
144
+ }
145
+
146
+ function sendMessage(obj) {
147
+ const json = Buffer.from(JSON.stringify(obj), 'utf8');
148
+ const lenBuf = Buffer.alloc(4);
149
+ lenBuf.writeUInt32LE(json.length, 0);
150
+ process.stdout.write(Buffer.concat([lenBuf, json]));
151
+ }
152
+
153
+ // ── Spawn trace dev ───────────────────────────────────────────────────────────
154
+
155
+ // macOS quarantine PREVENTION (the bulletproof fix).
156
+ //
157
+ // Chrome's native-messaging host runs in Chrome's quarantine context, so every
158
+ // file written by the spawned process tree (trace dev → agent → bash → npm/pip/
159
+ // cargo) inherits `com.apple.quarantine`, and Gatekeeper then blocks dlopen()
160
+ // of the resulting native binaries (*.node/.so/.dylib) with a "Not Opened"
161
+ // popup. Stripping after the fact is racy and can't cover out-of-project libs.
162
+ //
163
+ // The fix: spawn `trace dev` THROUGH a tiny `disclaim` shim that clears this
164
+ // process's quarantine flags via libquarantine (qtn_proc_*) and then exec()s
165
+ // the real command. The cleared state is inherited by the whole child tree, so
166
+ // NOTHING any descendant writes is ever quarantined. exec() keeps the same
167
+ // PID / process-group / stdio, so the detached-group-kill and stdout-parsing
168
+ // logic below is unchanged.
169
+ //
170
+ // Best-effort: if the C toolchain (Xcode CLT) is absent or compilation fails,
171
+ // we fall back to spawning directly (the agent's runtime stripper still helps).
172
+ const DISCLAIM_VERSION = 2;
173
+ const DISCLAIM_C = `
174
+ #include <spawn.h>
175
+ #include <unistd.h>
176
+ #include <stdio.h>
177
+ #include <dlfcn.h>
178
+ extern char **environ;
179
+ int main(int argc, char *argv[]) {
180
+ if (argc < 2) { fprintf(stderr, "disclaim: missing command\\n"); return 2; }
181
+ posix_spawnattr_t attr;
182
+ posix_spawnattr_init(&attr);
183
+ /* Disclaim responsibility so this process (and its whole child tree) is its
184
+ * own responsible process rather than Chrome's — the modern macOS anchor
185
+ * for quarantine attribution. Resolved at runtime; no-op if unavailable. */
186
+ int (*setdisclaim)(posix_spawnattr_t*, int) =
187
+ (int (*)(posix_spawnattr_t*, int))dlsym(RTLD_DEFAULT, "responsibility_spawnattrs_setdisclaim");
188
+ if (setdisclaim) setdisclaim(&attr, 1);
189
+ /* SETEXEC: replace THIS image with the target, so the caller's child keeps
190
+ * the same pid / process-group / stdio (group-kill + stdout parsing stay
191
+ * valid). */
192
+ short flags = 0;
193
+ posix_spawnattr_getflags(&attr, &flags);
194
+ posix_spawnattr_setflags(&attr, (short)(flags | POSIX_SPAWN_SETEXEC));
195
+ pid_t pid;
196
+ posix_spawnp(&pid, argv[1], NULL, &attr, &argv[1], environ);
197
+ /* Only reached if spawn failed (SETEXEC otherwise replaced us). */
198
+ posix_spawnattr_destroy(&attr);
199
+ execvp(argv[1], &argv[1]);
200
+ perror("disclaim: exec failed");
201
+ return 127;
202
+ }
203
+ `;
204
+
205
+ let _disclaimChecked = false;
206
+ let _disclaimPath = null;
207
+ function ensureDisclaimHelper() {
208
+ if (process.platform !== 'darwin') return null;
209
+ if (_disclaimChecked) return _disclaimPath;
210
+ _disclaimChecked = true;
211
+ try {
212
+ const { execFileSync } = require('child_process');
213
+ const binPath = path.join(INSTALL_DIR, 'disclaim');
214
+ const verPath = path.join(INSTALL_DIR, '.disclaim-version');
215
+
216
+ let cachedVer = null;
217
+ try { cachedVer = fs.readFileSync(verPath, 'utf8').trim(); } catch { /* none */ }
218
+ if (fs.existsSync(binPath) && cachedVer === String(DISCLAIM_VERSION)) {
219
+ _disclaimPath = binPath;
220
+ return binPath;
221
+ }
222
+
223
+ // Need the C toolchain. Probe `xcode-select -p` first so we never
224
+ // trigger the "install Command Line Tools" popup when it's absent.
225
+ try { execFileSync('/usr/bin/xcode-select', ['-p'], { stdio: 'ignore' }); }
226
+ catch { return null; }
227
+
228
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
229
+ const srcPath = path.join(INSTALL_DIR, 'disclaim.c');
230
+ fs.writeFileSync(srcPath, DISCLAIM_C);
231
+ execFileSync('/usr/bin/cc', ['-O2', '-o', binPath, srcPath], { stdio: 'ignore', timeout: 30000 });
232
+
233
+ // The compiled helper itself inherits the host's quarantine flag, which
234
+ // would make Gatekeeper block IT on exec. Strip that one file directly —
235
+ // this is the only file we can't prevent (it's the bootstrap).
236
+ try { execFileSync('/usr/bin/xattr', ['-d', 'com.apple.quarantine', binPath], { stdio: 'ignore' }); }
237
+ catch { /* not present → fine */ }
238
+
239
+ if (fs.existsSync(binPath)) {
240
+ fs.writeFileSync(verPath, String(DISCLAIM_VERSION));
241
+ _disclaimPath = binPath;
242
+ return binPath;
243
+ }
244
+ } catch { /* best-effort — fall back to direct spawn */ }
245
+ return null;
246
+ }
247
+
248
+ function spawnTraceDev(cwd) {
249
+ if (traceProc) {
250
+ sendMessage({ type: 'status', running: true, pid: traceProc.pid });
251
+ return;
252
+ }
253
+
254
+ // Support local dev mode: TRACE_DEV_MODE=1 + TRACE_CLI_PATH=.../index.js
255
+ // In production (npm install -g), the global `trace` binary is used.
256
+ const devCliPath = process.env.TRACE_CLI_PATH;
257
+ const isDev = process.env.TRACE_DEV_MODE === '1' && devCliPath && fs.existsSync(devCliPath);
258
+ const traceBin = isDev ? process.execPath : (process.platform === 'win32' ? 'trace.cmd' : 'trace');
259
+ const traceArgs = isDev ? [devCliPath, 'dev'] : ['dev'];
260
+
261
+ // Resolve the working directory — fall back to homedir if none given
262
+ const resolvedCwd = cwd || os.homedir();
263
+
264
+ // Validate the directory exists before spawning — spawn() throws a cryptic
265
+ // ENOENT that looks like the binary is missing when actually the cwd is wrong.
266
+ // If it doesn't exist, try to create it (supports auto-creating workspaces).
267
+ if (!fs.existsSync(resolvedCwd)) {
268
+ try {
269
+ fs.mkdirSync(resolvedCwd, { recursive: true });
270
+ } catch (err) {
271
+ sendMessage({
272
+ type: 'error',
273
+ text: `Could not create project folder: ${resolvedCwd}\n\nError: ${err.message}`
274
+ });
275
+ sendMessage({ type: 'exit', code: 1 });
276
+ return;
277
+ }
278
+ }
279
+
280
+ // Send homedir + greenfield default alongside spawn confirmation
281
+ sendMessage({ type: 'output', text: `Spawning trace dev in ${resolvedCwd}` });
282
+ sendMessage({ type: 'homedir', path: os.homedir() });
283
+ const greenfield = readGreenfieldPath();
284
+ if (greenfield) sendMessage({ type: 'greenfield', path: greenfield });
285
+
286
+ const env = { ...process.env };
287
+
288
+ // Prevention: route the spawn through the `disclaim` shim (macOS) so the
289
+ // whole child tree never inherits Chrome's quarantine context. The shim
290
+ // exec()s traceBin, so traceProc keeps the same pid/pgid/stdio as a direct
291
+ // spawn — group-kill and stdout parsing below are unaffected. Falls back to
292
+ // a direct spawn when the shim is unavailable.
293
+ const disclaim = ensureDisclaimHelper();
294
+ const spawnBin = disclaim ? disclaim : traceBin;
295
+ const spawnArgs = disclaim ? [traceBin, ...traceArgs] : traceArgs;
296
+
297
+ traceProc = spawn(spawnBin, spawnArgs, {
298
+ cwd: resolvedCwd,
299
+
300
+ env,
301
+ // Launch as a process-group leader so killTraceProc() can take down
302
+ // trace dev AND all the children it spawns (agent server, dev server,
303
+ // bridge) in one shot — otherwise they orphan and hold the ports.
304
+ detached: true,
305
+ stdio: ['ignore', 'pipe', 'pipe'],
306
+ });
307
+
308
+ traceProc.stdout.on('data', (d) => {
309
+ const text = d.toString('utf8').trim();
310
+ sendMessage({ type: 'output', text });
311
+ // Detect when the agent server is up and ready to accept connections.
312
+ // trace dev prints "Trace Agent Server listening on port 8766" or
313
+ // "Agent Server ready" once the HTTP server binds successfully.
314
+ // We also accept the plain health probe — if 8766 responds to /session
315
+ // we know it's up regardless of what was printed.
316
+ if (/8766|Agent Server.*listen|Trace Agent.*ready|Agent.*port/i.test(text)) {
317
+ sendMessage({ type: 'ready', port: 8766 });
318
+ }
319
+ });
320
+
321
+ traceProc.stderr.on('data', (d) => {
322
+ sendMessage({ type: 'error', text: d.toString('utf8').trim() });
323
+ });
324
+
325
+ traceProc.on('exit', (code) => {
326
+ sendMessage({ type: 'exit', code });
327
+ traceProc = null;
328
+ });
329
+
330
+ traceProc.on('error', (err) => {
331
+ sendMessage({ type: 'error', text: `Failed to spawn trace: ${err.message}` });
332
+ traceProc = null;
333
+ });
334
+ }
335
+
336
+ // ── Message loop ─────────────────────────────────────────────────────────────
337
+
338
+ async function main() {
339
+ while (true) {
340
+ let msg;
341
+ try { msg = await readMessage(); }
342
+ catch { break; }
343
+
344
+ switch (msg.cmd) {
345
+ case 'spawn':
346
+ spawnTraceDev(msg.cwd || require('os').homedir());
347
+ break;
348
+ case 'stop':
349
+ // Acknowledge first, then run the same graceful shutdown as a
350
+ // port-close: SIGTERM trace dev (so the agent reaps its
351
+ // detached dev servers) → SIGKILL fallback → host exits.
352
+ sendMessage({ type: 'exit', code: 0 });
353
+ shutdown();
354
+ break;
355
+ case 'status':
356
+ sendMessage({ type: 'status', running: !!traceProc, pid: traceProc?.pid ?? null });
357
+ break;
358
+ case 'homedir':
359
+ // Return the real home dir — first try os.homedir() (most reliable),
360
+ // then fall back to a baked .homedir file written at install time.
361
+ try {
362
+ const home = require('os').homedir();
363
+ sendMessage({ type: 'homedir', path: home });
364
+ } catch {
365
+ try {
366
+ const homedirFile = require('path').join(__dirname, '.homedir');
367
+ const home = require('fs').readFileSync(homedirFile, 'utf8').trim();
368
+ sendMessage({ type: 'homedir', path: home });
369
+ } catch {
370
+ sendMessage({ type: 'error', text: 'Could not determine home directory' });
371
+ }
372
+ }
373
+ break;
374
+ case 'pick_folder':
375
+ try {
376
+ const { execFile } = require('child_process');
377
+ // Acknowledge immediately so the extension knows we're alive
378
+ sendMessage({ type: 'folder_ack' });
379
+ const promptText = (msg.prompt || 'Choose your project folder');
380
+
381
+ if (process.platform === 'win32') {
382
+ // Windows: Open a native folder picker via PowerShell
383
+ const psScript = `[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = '${promptText.replace(/'/g, "''")}'; $f.ShowNewFolderButton = $true; if ($f.ShowDialog() -eq 'OK') { Write-Output $f.SelectedPath } else { Write-Output 'CANCELLED' }`;
384
+
385
+ execFile('powershell', ['-NoProfile', '-Command', psScript], { timeout: 120000 }, (err, stdout) => {
386
+ if (err) {
387
+ sendMessage({ type: 'error', text: `pick_folder (Windows): ${err.message}` });
388
+ } else {
389
+ const out = stdout.trim();
390
+ if (out === 'CANCELLED' || !out) {
391
+ sendMessage({ type: 'folder_cancelled' });
392
+ } else {
393
+ sendMessage({ type: 'folder_picked', path: out });
394
+ }
395
+ }
396
+ });
397
+ } else if (process.platform === 'darwin') {
398
+ // macOS: Open a native folder picker via AppleScript
399
+ const promptTextEscaped = promptText.replace(/"/g, '\\"');
400
+ const script = `tell application "Finder" to activate
401
+ delay 0.15
402
+ set chosen to (choose folder with prompt "${promptTextEscaped}")
403
+ set posix to (POSIX path of chosen)
404
+ if posix ends with "/" then set posix to text 1 thru -2 of posix
405
+ posix`;
406
+ execFile('osascript', ['-e', script], { timeout: 120000 }, (err, stdout) => {
407
+ if (err) {
408
+ if (err.code === 1 || (err.message || '').includes('User canceled')) {
409
+ sendMessage({ type: 'folder_cancelled' });
410
+ } else {
411
+ sendMessage({ type: 'error', text: `pick_folder: ${err.message}` });
412
+ }
413
+ } else {
414
+ sendMessage({ type: 'folder_picked', path: stdout.trim() });
415
+ }
416
+ });
417
+ } else {
418
+ // Linux / Other: Fallback to manual entry since there is no standard GUI picker command
419
+ sendMessage({ type: 'error', text: 'Folder picker is not supported on this platform. Please enter the path manually.' });
420
+ }
421
+ } catch (e) {
422
+ sendMessage({ type: 'error', text: `pick_folder failed: ${e.message}` });
423
+ }
424
+ break;
425
+ case 'check_paths': {
426
+ // Batch filesystem existence check — extension passes an array
427
+ // of absolute paths; host responds with a map of path → boolean.
428
+ // Used by the workspace dropdown to auto-prune deleted folders.
429
+ const paths = Array.isArray(msg.paths) ? msg.paths : [];
430
+ const exists = {};
431
+ for (const p of paths) {
432
+ try { exists[p] = fs.existsSync(p); }
433
+ catch { exists[p] = false; }
434
+ }
435
+ sendMessage({ type: 'paths_checked', exists });
436
+ break;
437
+ }
438
+ default:
439
+ sendMessage({ type: 'error', text: `Unknown command: ${msg.cmd}` });
440
+ }
441
+ }
442
+ process.exit(0);
443
+ }
444
+
445
+ main();
@@ -0,0 +1,4 @@
1
+ 2026-06-25T19:19:53.055Z [host-entry] started, node=v24.4.1 pid=34002
2
+ 2026-06-25T19:19:53.058Z [host-entry] host.cjs loaded OK
3
+ 2026-06-25T19:25:54.655Z [host-entry] started, node=v24.4.1 pid=34709
4
+ 2026-06-25T19:25:54.659Z [host-entry] host.cjs loaded OK
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "@gettrace/native-host",
3
+ "type": "commonjs"
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gettrace/cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Trace IDE Bridge for connecting local filesystem to Trace browser extensions.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  "build": "esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js",
13
13
  "dev": "esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js --watch",
14
14
  "start": "node dist/index.js",
15
- "prepublishOnly": "npm run build"
15
+ "prepublishOnly": "npm run build",
16
+ "postinstall": "node scripts/postinstall.js || true"
16
17
  },
17
18
  "keywords": [
18
19
  "debugging",
@@ -40,7 +41,7 @@
40
41
  "@babel/traverse": "^7.24.0",
41
42
  "@babel/generator": "^7.24.0",
42
43
  "@babel/types": "^7.24.0",
43
- "@gettrace/agent": "^2.0.2"
44
+ "@gettrace/agent": "^2.0.4"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^20.0.0",
@@ -52,6 +53,8 @@
52
53
  },
53
54
  "files": [
54
55
  "dist",
56
+ "native-host",
57
+ "scripts",
55
58
  "README.md"
56
59
  ]
57
60
  }
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Trace CLI — postinstall
4
+ *
5
+ * Registers the native messaging host so the Chrome extension can
6
+ * spawn `trace dev` automatically when the user clicks "Connect".
7
+ *
8
+ * This runs automatically after `npm install -g @gettrace/cli`.
9
+ * It's idempotent — safe to run multiple times.
10
+ *
11
+ * How it works:
12
+ * 1. Detects the current Node.js binary path via `process.execPath`
13
+ * 2. Generates `native-host/host-entry` with a hardcoded shebang
14
+ * pointing to the exact node binary (e.g. #!/opt/homebrew/bin/node)
15
+ * 3. Registers `host-entry` in Chrome's NativeMessagingHosts manifest
16
+ *
17
+ * This is the same pattern used by 1Password, Bitwarden, and Claude
18
+ * Desktop — the installer generates a native host binary with all
19
+ * paths resolved at install time. No shell wrappers, no PATH issues.
20
+ *
21
+ * Chrome Web Store compliant: the host is registered by the CLI
22
+ * installer, not shipped inside the extension ZIP.
23
+ */
24
+
25
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from 'fs';
26
+ import { join, dirname, resolve } from 'path';
27
+ import { homedir, platform } from 'os';
28
+ import { fileURLToPath } from 'url';
29
+ import { execSync } from 'child_process';
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+
33
+ // ── Configuration ──────────────────────────────────────────────────────────────
34
+
35
+ // The extension IDs that are allowed to use this native host.
36
+ const ALLOWED_EXTENSION_IDS = [
37
+ // Development / unpacked extension ID
38
+ 'jefgjgdcelekglimbmgplkaaabdfpgfm',
39
+ // Add the published CWS extension ID here when you have it:
40
+ // 'PRODUCTION_EXTENSION_ID',
41
+ ];
42
+
43
+ // Allow users/CI to inject additional IDs at install time via environment variable
44
+ // e.g., TRACE_EXTENSION_IDS="abc,def" npm install -g @gettrace/cli
45
+ if (process.env.TRACE_EXTENSION_IDS) {
46
+ const extraIds = process.env.TRACE_EXTENSION_IDS.split(',').map(id => id.trim()).filter(Boolean);
47
+ ALLOWED_EXTENSION_IDS.push(...extraIds);
48
+ }
49
+
50
+ const HOST_NAME = 'dev.gettrace.host';
51
+
52
+ // Install native host files into ~/.local/share/trace/native-host/ so that
53
+ // Chrome (and other browsers) can always read them. Files on ~/Desktop,
54
+ // ~/Documents, ~/Downloads require explicit macOS TCC (Privacy) permission
55
+ // which Chrome does not have by default. ~/.local/share/ is always accessible.
56
+ const INSTALL_DIR = join(homedir(), '.local', 'share', 'trace', 'native-host');
57
+ const HOST_ENTRY = join(INSTALL_DIR, 'host-entry');
58
+ const HOST_CJS_DEST = join(INSTALL_DIR, 'host.cjs');
59
+
60
+ // Source files (from the npm package / local repo)
61
+ const NATIVE_HOST_SRC_DIR = join(__dirname, '..', 'native-host');
62
+ const HOST_CJS_SRC = join(NATIVE_HOST_SRC_DIR, 'host.cjs');
63
+
64
+ // Default project workspace — created at install time so first-time users
65
+ // have a ready-to-go folder without needing to configure anything.
66
+ // ~/Documents/Trace/greenfield is Finder-friendly and writable by Node.
67
+ const GREENFIELD_DIR = join(homedir(), 'Documents', 'Trace', 'greenfield');
68
+
69
+ // ── Step 1: Generate host-entry with hardcoded node shebang ────────────────────
70
+
71
+ function generateHostEntry() {
72
+ // process.execPath gives us the ABSOLUTE path to the node binary that
73
+ // is running right now — e.g. /opt/homebrew/Cellar/node/24.4.1/bin/node
74
+ // This is the most reliable way to find the correct node binary on any
75
+ // system, regardless of PATH, nvm, fnm, brew, etc.
76
+ const nodePath = process.execPath;
77
+
78
+ // Also resolve the absolute path to trace binary so host.cjs can use it
79
+ let tracePath = '';
80
+ try {
81
+ tracePath = execSync('which trace', { encoding: 'utf8' }).trim();
82
+ } catch {
83
+ // trace might not be in PATH yet during postinstall — that's okay,
84
+ // host.cjs will fall back to `trace` from the inherited PATH.
85
+ }
86
+
87
+ // Copy host.cjs to the install dir so everything is co-located in a path
88
+ // that Chrome can always read (not Desktop/Documents/Downloads).
89
+ mkdirSync(INSTALL_DIR, { recursive: true });
90
+ const hostCjsSrc = readFileSync(HOST_CJS_SRC);
91
+ writeFileSync(HOST_CJS_DEST, hostCjsSrc, { mode: 0o644 });
92
+
93
+ // Also copy the native-host package.json (forces CJS mode for host-entry)
94
+ const pkgSrc = join(NATIVE_HOST_SRC_DIR, 'package.json');
95
+ if (existsSync(pkgSrc)) {
96
+ writeFileSync(join(INSTALL_DIR, 'package.json'), readFileSync(pkgSrc), { mode: 0o644 });
97
+ }
98
+
99
+ // Log paths — write to home dir only (always writable by Chrome)
100
+ const logPath1 = `${homedir()}/trace-native-host.log`;
101
+
102
+ const content = [
103
+ `#!${nodePath}`,
104
+ `// Auto-generated by @gettrace/cli postinstall — do not edit manually`,
105
+ `// Generated: ${new Date().toISOString()}`,
106
+ `// Node: ${nodePath}`,
107
+ tracePath ? `// Trace: ${tracePath}` : `// Trace: (resolves from PATH)`,
108
+ `'use strict';`,
109
+ ``,
110
+ `// Fix PATH before anything else — Chrome launches with a minimal environment`,
111
+ tracePath
112
+ ? `process.env.PATH = '${dirname(tracePath)}:${dirname(nodePath)}:' + (process.env.PATH || '/usr/bin:/bin');`
113
+ : `process.env.PATH = '${dirname(nodePath)}:/opt/homebrew/bin:/usr/local/bin:' + (process.env.PATH || '/usr/bin:/bin');`,
114
+ ``,
115
+ // Inject dev mode env vars when the local CLI exists (dev workflow).
116
+ // In production these lines are absent — host.cjs uses the global trace binary.
117
+ ...(() => {
118
+ const localCli = resolve(__dirname, '..', 'dist', 'index.js');
119
+ if (existsSync(localCli)) {
120
+ return [
121
+ `// Dev mode: use local CLI build instead of the global npm package`,
122
+ `process.env.TRACE_DEV_MODE = '1';`,
123
+ `process.env.TRACE_CLI_PATH = '${localCli}';`,
124
+ ``,
125
+ ];
126
+ }
127
+ return [];
128
+ })(),
129
+ `// Diagnostic logging — written synchronously so we catch crashes before async`,
130
+ `const fs = require('fs');`,
131
+ `function log(msg) {`,
132
+ ` const line = new Date().toISOString() + ' ' + msg + '\\n';`,
133
+ ` try { fs.appendFileSync('${logPath1}', line); } catch(_) {}`,
134
+ `}`,
135
+ ``,
136
+ `log('[host-entry] started, node=' + process.version + ' pid=' + process.pid);`,
137
+ ``,
138
+ `try {`,
139
+ ` require('${HOST_CJS_DEST}');`,
140
+ ` log('[host-entry] host.cjs loaded OK');`,
141
+ `} catch (err) {`,
142
+ ` log('[host-entry] FATAL: ' + (err.stack || err));`,
143
+ ` process.exit(1);`,
144
+ `}`,
145
+ ].join('\n');
146
+
147
+ writeFileSync(HOST_ENTRY, content, { encoding: 'utf8', mode: 0o755 });
148
+ try { chmodSync(HOST_ENTRY, 0o755); } catch { /* non-fatal on Windows */ }
149
+
150
+ console.log(`✓ Generated native host entry: ${HOST_ENTRY}`);
151
+ console.log(` Node binary: ${nodePath}`);
152
+ if (tracePath) console.log(` Trace binary: ${tracePath}`);
153
+ }
154
+
155
+ // ── Step 2: Register native messaging manifest ─────────────────────────────────
156
+
157
+ function getNativeHostDir() {
158
+ switch (platform()) {
159
+ case 'darwin':
160
+ return join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts');
161
+ case 'linux':
162
+ return join(homedir(), '.config', 'google-chrome', 'NativeMessagingHosts');
163
+ case 'win32':
164
+ return null; // Windows uses registry
165
+ default:
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function buildManifest() {
171
+ return JSON.stringify({
172
+ name: HOST_NAME,
173
+ description: 'Trace native host — spawns trace dev in the user\'s project directory',
174
+ path: HOST_ENTRY, // Points to the generated entry file with hardcoded node path
175
+ type: 'stdio',
176
+ allowed_origins: ALLOWED_EXTENSION_IDS.map(id => `chrome-extension://${id}/`),
177
+ }, null, 2);
178
+ }
179
+
180
+ function registerOnWindows() {
181
+ const manifest = buildManifest();
182
+ const manifestPath = join(homedir(), 'AppData', 'Local', 'Trace', `${HOST_NAME}.json`);
183
+ mkdirSync(dirname(manifestPath), { recursive: true });
184
+ writeFileSync(manifestPath, manifest, 'utf8');
185
+ const regKey = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`;
186
+ try {
187
+ execSync(`reg add "${regKey}" /ve /t REG_SZ /d "${manifestPath}" /f`, { stdio: 'pipe' });
188
+ console.log(`✓ Native host registered (Windows registry)`);
189
+ } catch (e) {
190
+ console.warn('⚠ Could not register native host in Windows registry:', e.message);
191
+ }
192
+ }
193
+
194
+ function registerOnUnix() {
195
+ const dir = getNativeHostDir();
196
+ if (!dir) { console.warn('⚠ Unknown platform, skipping native host registration'); return; }
197
+
198
+ mkdirSync(dir, { recursive: true });
199
+ const manifestPath = join(dir, `${HOST_NAME}.json`);
200
+ writeFileSync(manifestPath, buildManifest(), 'utf8');
201
+ console.log(`✓ Native host manifest: ${manifestPath}`);
202
+ }
203
+
204
+ // ── Main ───────────────────────────────────────────────────────────────────────
205
+
206
+ // Verify the host logic script exists
207
+ if (!existsSync(HOST_CJS_SRC)) {
208
+ console.warn(`⚠ Native host script not found at ${HOST_CJS_SRC} — skipping registration`);
209
+ process.exit(0);
210
+ }
211
+
212
+ // 1. Generate the entry file
213
+ generateHostEntry();
214
+
215
+ // 2. Register with Chrome
216
+ if (platform() === 'win32') {
217
+ registerOnWindows();
218
+ } else {
219
+ registerOnUnix();
220
+ }
221
+
222
+ // 3. Create the default greenfield workspace if it doesn't already exist
223
+ try {
224
+ mkdirSync(GREENFIELD_DIR, { recursive: true });
225
+ // Write a README so the folder isn't confusingly empty
226
+ const readmePath = join(GREENFIELD_DIR, 'README.md');
227
+ if (!existsSync(readmePath)) {
228
+ writeFileSync(readmePath,
229
+ '# Trace Greenfield\n\n' +
230
+ 'This is your default Trace workspace for new projects.\n\n' +
231
+ 'Trace will create new projects here when you click **Connect** ' +
232
+ 'without selecting a specific folder.\n\n' +
233
+ 'To work on an existing project, open the Trace extension and go to ' +
234
+ 'Settings → Open Existing Project.\n',
235
+ 'utf8'
236
+ );
237
+ }
238
+ // Persist the path so the native host can send it to the extension on connect
239
+ writeFileSync(join(INSTALL_DIR, '.greenfield'), GREENFIELD_DIR, 'utf8');
240
+ console.log(`✓ Default workspace: ${GREENFIELD_DIR}`);
241
+ } catch (e) { console.warn('⚠ Could not create default workspace:', e.message); }
242
+
243
+ // 4. Store homedir for the host to read back
244
+ try {
245
+ const homedirFile = join(INSTALL_DIR, '.homedir');
246
+ writeFileSync(homedirFile, homedir(), 'utf8');
247
+ } catch { /* non-fatal */ }
248
+
249
+ console.log('✓ Trace ready — reload the extension and click Connect');