@chatpanel/bridge 0.2.12 → 0.2.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -37,6 +37,9 @@
37
37
  "build:bin": "bash scripts/build-binaries.sh"
38
38
  },
39
39
  "dependencies": {},
40
+ "optionalDependencies": {
41
+ "@anthropic-ai/claude-agent-sdk": "^0.1.0"
42
+ },
40
43
  "publishConfig": {
41
44
  "registry": "https://registry.npmjs.org/",
42
45
  "access": "public"
@@ -1,40 +1,54 @@
1
- // Claude Code engine — drives the Claude Code CLI (`claude --print`) directly,
2
- // the SAME way the Codex engine drives `codex exec`. No Agent SDK, no bundled
3
- // cli.js, no native `sharp` dependency — so it works in any environment that has
4
- // `claude` on PATH (npm install, native installer, or `npx`), and there's nothing
5
- // to resolve inside a compiled binary (the old "/$bunfs/root/cli.js" failure).
1
+ // Claude Code engine — drives the Claude Code CLI (`claude --print`) the SAME
2
+ // way the Codex engine drives `codex exec`, but with cross-platform launching so
3
+ // it works no matter where `claude` lives:
6
4
  //
7
- // It uses your *local* Claude Code login. By default the agent can READ your code
8
- // (Read/Grep/Glob/WebFetch/…) but cannot write or run shell commands unless the
9
- // agent's permissionMode is 'acceptEdits' or 'bypassPermissions' in ChatPanel
10
- // Settings. The working directory comes from the agent config.
5
+ // macOS / Linux / WSL-native Node spawn the native `claude` on PATH.
6
+ // Windows (native install) run the package's cli.js with our own Node/Bun
7
+ // (npm's claude.cmd/.ps1 shims aren't directly spawnable "spawn …ENOENT").
8
+ // Windows host + claude only in WSL — cross the boundary via `wsl.exe`.
9
+ // • Last resort — the in-process Claude Agent SDK (bundled cli.js), if present.
10
+ //
11
+ // Resolution lives in env.js (resolveClaude); set CHATPANEL_CLAUDE_PATH to force
12
+ // a specific executable. It uses your *local* Claude Code login. By default the
13
+ // agent can READ your code but cannot write/run shell unless the agent's
14
+ // permissionMode is 'acceptEdits'/'bypassPermissions' in ChatPanel Settings.
11
15
 
12
16
  import { spawn } from 'node:child_process';
13
17
  import os from 'node:os';
14
18
  import path from 'node:path';
15
- import { findAgentBin } from '../env.js';
19
+ import { resolveClaude, toWslPath, isCompiledBinary } from '../env.js';
16
20
 
17
21
  const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
18
22
  // Read-only tools allowed without approval in headless mode; writes/shell are
19
23
  // gated behind the agent's permission mode.
20
24
  const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
21
25
 
22
- let installed = false;
26
+ let lastReason = 'Claude Code not found.';
23
27
  let lastProbe = 0;
28
+ let cachedOk = false;
24
29
  export async function available() {
25
- // Availability = "is claude findable on PATH" (mirrors the codex engine), NOT
26
- // "does `claude --version` exit 0" (which fails when it just needs login).
27
- if (!installed && Date.now() - lastProbe > 4000) {
30
+ // Availability = "can we launch claude somehow" (native / cli.js / WSL / SDK),
31
+ // NOT "does `claude --version` exit 0" (which fails when it just needs login).
32
+ if (!cachedOk && Date.now() - lastProbe > 4000) {
28
33
  lastProbe = Date.now();
29
34
  try {
30
- installed = !!findAgentBin('claude');
35
+ const spec = resolveClaude();
36
+ if (spec) {
37
+ cachedOk = true;
38
+ } else if (!isCompiledBinary() && (await loadSdk())) {
39
+ cachedOk = true;
40
+ } else {
41
+ cachedOk = false;
42
+ lastReason =
43
+ process.platform === 'win32'
44
+ ? 'Claude Code not found on Windows PATH or in WSL. Install it (npm i -g @anthropic-ai/claude-code) and run `claude` once to log in — in Windows or in your WSL distro.'
45
+ : 'Claude Code not found on PATH. Install it (npm i -g @anthropic-ai/claude-code) and run `claude` once to log in.';
46
+ }
31
47
  } catch {
32
- installed = false;
48
+ cachedOk = false;
33
49
  }
34
50
  }
35
- return installed
36
- ? { ok: true }
37
- : { ok: false, reason: 'Claude Code not found on PATH. Install it (npm i -g @anthropic-ai/claude-code) and run `claude` once to log in.' };
51
+ return cachedOk ? { ok: true } : { ok: false, reason: lastReason };
38
52
  }
39
53
 
40
54
  // The bridge is stateless, so we replay the conversation as a single prompt.
@@ -51,15 +65,46 @@ function buildPrompt(messages) {
51
65
  return prompt;
52
66
  }
53
67
 
54
- // Spawn `claude` and stream its stream-json output, forwarding events via `emit`.
55
- // `extraArgs` lets complete() run a tool-free single shot. Resolves with the final
56
- // result text once the process closes 0.
68
+ // Turn a launch spec + the claude CLI args into a concrete [bin, argv, options]
69
+ // for spawn(). `cwd` is the resolved working dir (Windows path on win32), or null
70
+ // to use the home directory.
71
+ function buildSpawn(spec, args, cwd) {
72
+ if (spec.kind === 'wsl') {
73
+ // Run claude inside WSL's login shell so nvm/etc. PATH resolves it. The
74
+ // `'exec claude "$@"'` + 'chatpanel' ($0) trick passes our args through as a
75
+ // proper argv array — no manual quoting, even for multi-line system prompts.
76
+ const pre = [];
77
+ if (cwd) {
78
+ const wslCwd = toWslPath(cwd);
79
+ if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
80
+ }
81
+ const argv = [...pre, '-e', 'bash', '-lic', 'exec claude "$@"', 'chatpanel', ...args];
82
+ return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
83
+ }
84
+
85
+ const spawnCwd = cwd || os.homedir();
86
+ const opts = { cwd: spawnCwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
87
+ if (spec.kind === 'script') {
88
+ // Run cli.js with the interpreter already running the bridge (node/bun).
89
+ return [process.execPath, [spec.script, ...args], opts];
90
+ }
91
+ // kind === 'native' — direct executable (.exe / mac+linux binary), or a .cmd
92
+ // shim via the shell on Windows.
93
+ return [spec.bin, args, { ...opts, shell: !!spec.shell }];
94
+ }
95
+
96
+ // Spawn claude (however it resolves) and stream its stream-json output via
97
+ // `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
98
+ // null (no spawn) when claude can't be resolved, so the caller can fall back.
57
99
  function runClaude({ prompt, args, cwd, emit }) {
58
- const bin = findAgentBin('claude') || 'claude';
100
+ const spec = resolveClaude();
101
+ if (!spec) return null;
102
+ const [bin, argv, opts] = buildSpawn(spec, args, cwd);
103
+
59
104
  return new Promise((resolve, reject) => {
60
105
  let child;
61
106
  try {
62
- child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env });
107
+ child = spawn(bin, argv, opts);
63
108
  } catch (e) {
64
109
  return reject(new Error(`Failed to start claude: ${e.message}`));
65
110
  }
@@ -95,7 +140,7 @@ function runClaude({ prompt, args, cwd, emit }) {
95
140
  child.stderr.on('data', (d) => (stderr += d.toString()));
96
141
  child.on('error', (e) => {
97
142
  clearTimeout(timer);
98
- reject(e);
143
+ reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
99
144
  });
100
145
  child.on('close', (code) => {
101
146
  clearTimeout(timer);
@@ -140,7 +185,8 @@ function handleMessage(msg, emit, alreadyStreamed) {
140
185
 
141
186
  export async function chat({ messages, system, options }, emit) {
142
187
  const permissionMode = options.permissionMode || 'default';
143
- const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
188
+ // Explicit project dir, else null → CLI runs in home (or WSL home).
189
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
144
190
 
145
191
  const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
146
192
 
@@ -158,7 +204,9 @@ export async function chat({ messages, system, options }, emit) {
158
204
  // "Use my local skills & config" off → run clean.
159
205
  if (options.useLocalConfig === false) args.push('--setting-sources', '');
160
206
 
161
- const { streamedAny, resultText } = await runClaude({ prompt: buildPrompt(messages), args, cwd, emit });
207
+ const run = runClaude({ prompt: buildPrompt(messages), args, cwd, emit });
208
+ if (run === null) return sdkChat({ messages, system, options }, emit); // no CLI → SDK
209
+ const { streamedAny, resultText } = await run;
162
210
  emit({ type: 'done', text: streamedAny ? '' : resultText });
163
211
  }
164
212
 
@@ -175,14 +223,16 @@ export async function complete({ prompt, system, model }) {
175
223
  '--system-prompt', system || "Continue the user's text briefly. Reply with only the continuation.",
176
224
  ];
177
225
  let text = '';
178
- const { resultText } = await runClaude({
226
+ const run = runClaude({
179
227
  prompt,
180
228
  args,
181
- cwd: os.homedir(),
229
+ cwd: null,
182
230
  emit: (e) => {
183
231
  if (e.type === 'delta') text += e.text;
184
232
  },
185
233
  });
234
+ if (run === null) return sdkComplete({ prompt, system, model }); // no CLI → SDK
235
+ const { resultText } = await run;
186
236
  return (text || resultText || '').trim();
187
237
  }
188
238
 
@@ -194,3 +244,82 @@ function toolSummary(block) {
194
244
  if (i.url) return i.url;
195
245
  return '';
196
246
  }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Fallback: in-process Claude Agent SDK. Only reached when no native/WSL claude
250
+ // CLI is resolvable (and we're not a compiled binary, where its bundled cli.js
251
+ // is unreachable). The SDK ships its own cli.js and uses your ~/.claude login.
252
+
253
+ let sdkPromise = null;
254
+ function loadSdk() {
255
+ // Optional dependency — absent in lean/compiled installs; import().catch makes
256
+ // that a graceful "no fallback available" rather than a crash.
257
+ if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk').catch(() => null);
258
+ return sdkPromise;
259
+ }
260
+
261
+ async function sdkChat({ messages, system, options }, emit) {
262
+ const sdk = await loadSdk();
263
+ if (!sdk) throw new Error(lastReason);
264
+ const { query } = sdk;
265
+
266
+ const permissionMode = options.permissionMode || 'default';
267
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
268
+ const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
269
+ const readonly = new Set(READONLY_TOOLS);
270
+ const canUseTool = async (toolName) =>
271
+ readonly.has(toolName) || writesAllowed
272
+ ? { behavior: 'allow', updatedInput: undefined }
273
+ : { behavior: 'deny', message: `${toolName} blocked — set this agent's permission mode in ChatPanel to enable it.` };
274
+
275
+ let streamedAny = false;
276
+ let resultText = '';
277
+ const iterator = query({
278
+ prompt: buildPrompt(messages),
279
+ options: {
280
+ cwd,
281
+ permissionMode,
282
+ includePartialMessages: true,
283
+ canUseTool,
284
+ settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
285
+ systemPrompt: system
286
+ ? { type: 'preset', preset: 'claude_code', append: system }
287
+ : { type: 'preset', preset: 'claude_code' },
288
+ ...(options.model ? { model: options.model } : {}),
289
+ ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
290
+ },
291
+ });
292
+ for await (const message of iterator) {
293
+ const r = handleMessage(message, emit, streamedAny);
294
+ if (r.streamed) streamedAny = true;
295
+ if (r.result != null) resultText = r.result;
296
+ }
297
+ emit({ type: 'done', text: streamedAny ? '' : resultText });
298
+ }
299
+
300
+ async function sdkComplete({ prompt, system, model }) {
301
+ const sdk = await loadSdk();
302
+ if (!sdk) throw new Error(lastReason);
303
+ const { query } = sdk;
304
+ let text = '';
305
+ const iterator = query({
306
+ prompt,
307
+ options: {
308
+ cwd: os.homedir(),
309
+ permissionMode: 'default',
310
+ allowedTools: [],
311
+ maxTurns: 1,
312
+ settingSources: [],
313
+ systemPrompt: system || "Continue the user's text briefly. Reply with only the continuation.",
314
+ model: model || 'haiku',
315
+ },
316
+ });
317
+ for await (const message of iterator) {
318
+ if (message.type === 'assistant') {
319
+ for (const block of message.message.content) if (block.type === 'text') text += block.text;
320
+ } else if (message.type === 'result' && message.subtype === 'success' && !text) {
321
+ text = message.result || '';
322
+ }
323
+ }
324
+ return text.trim();
325
+ }
package/src/env.js CHANGED
@@ -12,8 +12,9 @@ import { readdirSync, existsSync } from 'node:fs';
12
12
 
13
13
  let enriched = false;
14
14
 
15
- // The agent CLIs the bridge shells out to (Claude is the in-process SDK).
16
- const AGENT_CLIS = ['codex', 'gemini'];
15
+ // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
+ // (resolveClaude: native / cli.js / WSL / SDK) below.
17
+ const AGENT_CLIS = ['codex', 'gemini', 'claude'];
17
18
 
18
19
  // Is `name` executable somewhere on the current PATH?
19
20
  function onPath(name) {
@@ -64,6 +65,125 @@ function shellWhich(name) {
64
65
  }
65
66
  }
66
67
 
68
+ // ---------------------------------------------------------------------------
69
+ // Claude Code launcher resolution.
70
+ //
71
+ // The Codex/Gemini engines can assume `spawn('codex', …)` runs a directly
72
+ // executable file on the current OS's PATH. Claude needs more care:
73
+ // • On Windows, npm installs `claude.cmd` / `claude.ps1` / an extensionless
74
+ // bash shim — NONE of which Node's spawn() can execute directly (that's the
75
+ // "spawn C:\… ENOENT"). The runnable thing is the package's `cli.js`, which
76
+ // we run with our own Node/Bun.
77
+ // • A very common setup is "Windows host, `claude` only installed inside WSL."
78
+ // A Windows process can't see WSL's filesystem or PATH, so we cross the
79
+ // boundary explicitly via `wsl.exe`.
80
+ // On macOS/Linux (and WSL-native Node) none of this applies: we return the same
81
+ // native binary the old code spawned, so behavior there is unchanged.
82
+ //
83
+ // Returns one of:
84
+ // { kind: 'native', bin, shell } → spawn(bin, args, { shell })
85
+ // { kind: 'script', script } → spawn(process.execPath, [script, ...args])
86
+ // { kind: 'wsl' } → spawn('wsl.exe', [wsl prefix, ...args])
87
+ // null → not found (caller may fall back to SDK)
88
+ export function resolveClaude() {
89
+ const override = process.env.CHATPANEL_CLAUDE_PATH;
90
+ if (override) {
91
+ const ext = path.extname(override).toLowerCase();
92
+ if (!isCompiledBinary() && /\.(c?js|mjs)$/.test(ext)) return { kind: 'script', script: override };
93
+ return { kind: 'native', bin: override, shell: process.platform === 'win32' && (ext === '.cmd' || ext === '.bat') };
94
+ }
95
+
96
+ if (process.platform === 'win32') {
97
+ const win = findClaudeWindows();
98
+ if (win) return win;
99
+ if (claudeInWsl()) return { kind: 'wsl' };
100
+ return null;
101
+ }
102
+
103
+ // macOS / Linux / WSL-native: same resolution the engine used before.
104
+ const bin = findAgentBin('claude');
105
+ return bin ? { kind: 'native', bin, shell: false } : null;
106
+ }
107
+
108
+ // Locate a runnable Claude Code on Windows. Prefer the package's cli.js (run
109
+ // with our own Node/Bun — clean arg passing, no cmd.exe quoting), then a real
110
+ // .exe, then a .cmd shim via the shell as a last resort.
111
+ function findClaudeWindows() {
112
+ const dirs = (process.env.PATH || '').split(path.delimiter);
113
+ for (const d of dirs) {
114
+ if (!d) continue;
115
+ const hasShim = ['claude', 'claude.cmd', 'claude.exe', 'claude.ps1', 'claude.bat'].some((n) =>
116
+ existsSync(path.join(d, n)),
117
+ );
118
+ if (!hasShim) continue;
119
+ // Running cli.js with our own interpreter only works under a real Node/Bun,
120
+ // not inside a compiled single-file binary (which is not a JS interpreter).
121
+ if (!isCompiledBinary()) {
122
+ const js = claudeCliJs(d);
123
+ if (js) return { kind: 'script', script: js };
124
+ }
125
+ if (existsSync(path.join(d, 'claude.exe'))) return { kind: 'native', bin: path.join(d, 'claude.exe'), shell: false };
126
+ if (existsSync(path.join(d, 'claude.cmd'))) return { kind: 'native', bin: path.join(d, 'claude.cmd'), shell: true };
127
+ if (existsSync(path.join(d, 'claude.bat'))) return { kind: 'native', bin: path.join(d, 'claude.bat'), shell: true };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // The npm shim sits next to (or one level up from) the claude-code package.
133
+ function claudeCliJs(dir) {
134
+ const rels = [
135
+ ['node_modules', '@anthropic-ai', 'claude-code', 'cli.js'],
136
+ ['..', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'],
137
+ ['..', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'],
138
+ ];
139
+ for (const r of rels) {
140
+ const c = path.join(dir, ...r);
141
+ if (existsSync(c)) return c;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ // Is `claude` reachable inside the default WSL distro's login shell? Cached;
147
+ // re-probed (throttled) while not found so it self-heals once WSL/claude appear.
148
+ let wslClaude = null;
149
+ let wslProbe = 0;
150
+ function claudeInWsl() {
151
+ if (wslClaude === null || (!wslClaude && Date.now() - wslProbe > 4000)) {
152
+ wslProbe = Date.now();
153
+ try {
154
+ const r = spawnSync('wsl.exe', ['-e', 'bash', '-lic', 'command -v claude'], {
155
+ encoding: 'utf8',
156
+ timeout: 8000,
157
+ windowsHide: true,
158
+ });
159
+ wslClaude = r.status === 0 && /\S/.test(stripBom(r.stdout || ''));
160
+ } catch {
161
+ wslClaude = false;
162
+ }
163
+ }
164
+ return wslClaude;
165
+ }
166
+
167
+ // Translate a Windows path to its WSL (/mnt/c/…) equivalent. Returns null on
168
+ // failure so the caller can just run in WSL's home instead.
169
+ export function toWslPath(winPath) {
170
+ try {
171
+ const r = spawnSync('wsl.exe', ['-e', 'wslpath', '-a', winPath], {
172
+ encoding: 'utf8',
173
+ timeout: 5000,
174
+ windowsHide: true,
175
+ });
176
+ const out = stripBom(r.stdout || '').trim();
177
+ return out.startsWith('/') ? out : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function stripBom(s) {
184
+ return s.replace(/^/, '').trim();
185
+ }
186
+
67
187
  // Version managers install CLIs under versioned bin dirs that a lazy-loaded
68
188
  // shell (nvm/fnm) doesn't export into a non-interactive service PATH. Add them.
69
189
  function versionManagerBins(home) {
package/src/server.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // ChatPanel Bridge — a tiny localhost server that exposes the coding agents
3
- // running on this machine (Claude Code via the Agent SDK, Codex and Gemini via
4
- // their CLIs) to the ChatPanel Chrome extension. Zero runtime dependencies
5
- // beyond the optional Claude Agent SDK.
3
+ // running on this machine (Claude Code, Codex and Gemini, each via its CLI) to
4
+ // the ChatPanel Chrome extension. Zero runtime dependencies.
6
5
  //
7
- // GET /health → { ok, version, agents: [{id,label,available,reason}] }
6
+ // GET /health → { ok, version, agents: [...], update: {current,latest,…} }
7
+ // POST /update → self-update to the latest release (compiled binary installs)
8
8
  // POST /chat → Server-Sent Events stream of { type, ... }:
9
9
  // {type:'delta', text} incremental assistant text
10
10
  // {type:'tool', name, summary}
@@ -19,10 +19,11 @@ import os from 'node:os';
19
19
  import * as claude from './engines/claude.js';
20
20
  import * as codex from './engines/codex.js';
21
21
  import * as gemini from './engines/gemini.js';
22
- import { installService, uninstallService, serviceStatus } from './service.js';
22
+ import { installService, uninstallService, serviceStatus, restartService } from './service.js';
23
23
  import { enrichPath, findAgentBin } from './env.js';
24
+ import { checkForUpdate, selfUpdate } from './update.js';
24
25
 
25
- const VERSION = '0.2.12';
26
+ const VERSION = '0.2.13';
26
27
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
27
28
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
28
29
 
@@ -81,7 +82,20 @@ async function handleHealth(res) {
81
82
  return { id, label, available: a.ok, reason: a.reason };
82
83
  }),
83
84
  );
84
- json(res, 200, { ok: true, version: VERSION, agents });
85
+ const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
86
+ json(res, 200, { ok: true, version: VERSION, agents, update });
87
+ }
88
+
89
+ // POST /update — self-update (compiled-binary installs). Swaps the binary, replies,
90
+ // then restarts the service into the new version. npm installs get instructions.
91
+ async function handleUpdate(res) {
92
+ try {
93
+ const result = await selfUpdate(VERSION); // throws on npm install / no update / failure
94
+ json(res, 200, { ok: true, updated: true, from: result.from, to: result.to });
95
+ res.on('finish', () => setTimeout(() => restartService(), 400));
96
+ } catch (e) {
97
+ json(res, 400, { ok: false, error: String(e?.message || e) });
98
+ }
85
99
  }
86
100
 
87
101
  async function handleChat(req, res) {
@@ -187,6 +201,7 @@ const server = createServer(async (req, res) => {
187
201
  }
188
202
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
189
203
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
204
+ if (req.method === 'POST' && url.pathname === '/update') return handleUpdate(res);
190
205
  json(res, 404, { error: 'Not found' });
191
206
  } catch (e) {
192
207
  json(res, 500, { error: e?.message || String(e) });
@@ -218,6 +233,7 @@ Usage:
218
233
  chatpanel-bridge --install run automatically at login, in the background
219
234
  chatpanel-bridge --uninstall remove the login auto-start
220
235
  chatpanel-bridge --status show whether auto-start is set up
236
+ chatpanel-bridge --update download & install the latest version, then restart
221
237
  chatpanel-bridge --version print the version
222
238
 
223
239
  Env: CHATPANEL_BRIDGE_HOST, CHATPANEL_BRIDGE_PORT`);
@@ -269,4 +285,17 @@ function runCli() {
269
285
  return false;
270
286
  }
271
287
 
272
- if (!runCli()) startServer();
288
+ if (process.argv.includes('--update')) {
289
+ (async () => {
290
+ try {
291
+ const r = await selfUpdate(VERSION);
292
+ log('info', `Updated v${r.from} → v${r.to}. Restarting the background service…`);
293
+ restartService();
294
+ } catch (e) {
295
+ log('error', 'Update failed: ' + (e?.message || e));
296
+ process.exitCode = 1;
297
+ }
298
+ })();
299
+ } else if (!runCli()) {
300
+ startServer();
301
+ }
package/src/service.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
15
- import { spawnSync } from 'node:child_process';
15
+ import { spawn, spawnSync } from 'node:child_process';
16
16
 
17
17
  const LABEL = 'net.chatpanel.bridge';
18
18
  const DISPLAY = 'ChatPanel Bridge';
@@ -157,3 +157,37 @@ export function uninstallService() {
157
157
  export function serviceStatus() {
158
158
  return byPlatform(macStatus, winStatus, linStatus);
159
159
  }
160
+
161
+ // Restart the installed service into a freshly-swapped binary (used by self-
162
+ // update). Detached so it survives the restart killing the caller — works whether
163
+ // invoked from inside the service (POST /update) or a CLI `--update`.
164
+ // • macOS — `launchctl kickstart -k` kills + relaunches the LaunchAgent.
165
+ // • Linux — `systemctl --user restart`.
166
+ // • Windows — kill the running bridge, wait ~2s (port frees), relaunch via the
167
+ // hidden VBS, then delete the renamed old-*.exe.
168
+ export function restartService() {
169
+ try {
170
+ if (process.platform === 'darwin') {
171
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
172
+ spawn('launchctl', ['kickstart', '-k', `gui/${uid}/${LABEL}`], { detached: true, stdio: 'ignore' }).unref();
173
+ return true;
174
+ }
175
+ if (process.platform === 'linux') {
176
+ spawn('systemctl', ['--user', 'restart', 'chatpanel-bridge'], { detached: true, stdio: 'ignore' }).unref();
177
+ return true;
178
+ }
179
+ if (process.platform === 'win32') {
180
+ const vbs = winVbs();
181
+ const dir = path.dirname(process.execPath);
182
+ const cmd =
183
+ `taskkill /IM chatpanel-bridge.exe /F >nul 2>&1 & ` +
184
+ `timeout /t 2 >nul & wscript.exe "${vbs}" & ` +
185
+ `del /q "${path.join(dir, 'chatpanel-bridge.old-*.exe')}" >nul 2>&1`;
186
+ spawn('cmd', ['/c', cmd], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
187
+ return true;
188
+ }
189
+ } catch {
190
+ /* fall through */
191
+ }
192
+ return false;
193
+ }
package/src/update.js ADDED
@@ -0,0 +1,154 @@
1
+ // In-app updater for the standalone binary.
2
+ //
3
+ // The bridge is a background service the user never opens, so the EXTENSION
4
+ // surfaces "update available" (from /health) and offers a one-click Update that
5
+ // calls POST /update. The bridge downloads the new binary, swaps it in, and the
6
+ // service relaunches into the new version.
7
+ //
8
+ // Cross-platform swap + relaunch (users come from anywhere):
9
+ // • macOS — atomic rename over the running file; KeepAlive relaunches on exit.
10
+ // • Linux — atomic rename; `systemctl --user restart` relaunches.
11
+ // • Windows — can't overwrite a running .exe, so we RENAME the running exe aside
12
+ // and drop the new one in its place, then a detached helper waits for
13
+ // this process to exit (freeing the port) and relaunches it.
14
+ //
15
+ // No-ops for npx/node installs — npm owns those; only compiled binaries self-update.
16
+
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { chmod, rename, mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import { isCompiledBinary } from './env.js';
21
+
22
+ const REPO = 'chatpanel/chatpanel-bridge';
23
+ const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`;
24
+ const CHECK_EVERY_MS = 6 * 60 * 60 * 1000; // 6h
25
+ const CACHE = path.join(os.homedir(), '.chatpanel', 'update-check.json');
26
+ const UA = { 'User-Agent': 'chatpanel-bridge-updater' };
27
+
28
+ // Release asset name for THIS platform (matches release-binaries.yml outputs).
29
+ // macOS publishes arm64 only; Intel Macs use `npx` (managed → no self-update).
30
+ function assetName() {
31
+ if (process.platform === 'darwin') return process.arch === 'arm64' ? 'chatpanel-bridge-macos-arm64' : null;
32
+ if (process.platform === 'linux') return 'chatpanel-bridge-linux-x64';
33
+ if (process.platform === 'win32') return 'chatpanel-bridge-windows-x64.exe';
34
+ return null;
35
+ }
36
+
37
+ function parseVersion(s = '') {
38
+ const m = /(\d+(?:\.\d+){0,3})/.exec(s || '');
39
+ return m ? m[1] : null;
40
+ }
41
+ // >0 if a is newer than b.
42
+ function cmp(a, b) {
43
+ const pa = String(a).split('.').map(Number);
44
+ const pb = String(b).split('.').map(Number);
45
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
46
+ const d = (pa[i] || 0) - (pb[i] || 0);
47
+ if (d) return d > 0 ? 1 : -1;
48
+ }
49
+ return 0;
50
+ }
51
+
52
+ async function readCache() {
53
+ try {
54
+ return JSON.parse(await readFile(CACHE, 'utf8'));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ async function writeCache(obj) {
60
+ try {
61
+ await mkdir(path.dirname(CACHE), { recursive: true });
62
+ await writeFile(CACHE, JSON.stringify(obj));
63
+ } catch {
64
+ /* best effort */
65
+ }
66
+ }
67
+
68
+ // Returns { current, latest, updateAvailable, mode, canSelfUpdate, assetUrl, npmCommand }.
69
+ // mode 'binary' → compiled standalone build (macOS/Linux curl install, or the
70
+ // optional Windows .exe). POST /update self-replaces in place.
71
+ // mode 'npm' → npx/node install (the default on Windows). The bridge can't
72
+ // swap its own files; the extension shows `npmCommand` instead.
73
+ // Both report updateAvailable so the user always gets the notice — only the action
74
+ // differs. Throttled to CHECK_EVERY_MS unless `force`.
75
+ export async function checkForUpdate(current, { force = false } = {}) {
76
+ const mode = isCompiledBinary() ? 'binary' : 'npm';
77
+ const want = assetName();
78
+
79
+ let latest = null;
80
+ let assetUrl = null;
81
+ const cache = await readCache();
82
+ if (!force && cache && Date.now() - cache.checkedAt < CHECK_EVERY_MS) {
83
+ latest = cache.latest;
84
+ assetUrl = cache.assetUrl;
85
+ } else {
86
+ try {
87
+ const res = await fetch(LATEST_API, { headers: { Accept: 'application/vnd.github+json', ...UA } });
88
+ if (res.ok) {
89
+ const data = await res.json();
90
+ latest = parseVersion(data.tag_name) || parseVersion(data.name);
91
+ assetUrl = want ? (data.assets || []).find((a) => a.name === want)?.browser_download_url || null : null;
92
+ await writeCache({ checkedAt: Date.now(), latest, assetUrl });
93
+ } else {
94
+ latest = cache?.latest || null;
95
+ assetUrl = cache?.assetUrl || null;
96
+ }
97
+ } catch {
98
+ latest = cache?.latest || null;
99
+ assetUrl = cache?.assetUrl || null;
100
+ }
101
+ }
102
+ const updateAvailable = !!latest && cmp(latest, current) > 0;
103
+ // One-click in-place update is only possible for a compiled binary with a
104
+ // matching release asset; npm installs update via the command.
105
+ const canSelfUpdate = mode === 'binary' && !!assetUrl;
106
+ return {
107
+ current,
108
+ latest,
109
+ updateAvailable,
110
+ mode,
111
+ canSelfUpdate,
112
+ assetUrl,
113
+ npmCommand: mode === 'npm' ? 'npm i -g @chatpanel/bridge@latest' : null,
114
+ };
115
+ }
116
+
117
+ // Download the latest binary and swap it in. Does NOT restart — the caller sends
118
+ // its HTTP response first, then triggers restartService(). Throws on any failure,
119
+ // leaving the running binary untouched.
120
+ export async function selfUpdate(current) {
121
+ if (!isCompiledBinary()) {
122
+ throw new Error('Self-update applies only to the standalone binary. Update the npm/npx version with npm.');
123
+ }
124
+ const info = await checkForUpdate(current, { force: true });
125
+ if (!info.assetUrl) throw new Error('No downloadable build for this platform — use `npx @chatpanel/bridge`.');
126
+ if (!info.updateAvailable) {
127
+ throw new Error(info.latest ? `Already on the latest version (v${current}).` : 'Could not reach the update server.');
128
+ }
129
+
130
+ const target = process.execPath; // the running binary's own path
131
+ const dir = path.dirname(target);
132
+ const tmp = path.join(dir, `.chatpanel-bridge.new-${Date.now()}`);
133
+
134
+ const res = await fetch(info.assetUrl, { headers: UA });
135
+ if (!res.ok || !res.body) throw new Error(`Download failed (${res.status}).`);
136
+ const buf = Buffer.from(await res.arrayBuffer());
137
+ if (buf.length < 1_000_000) throw new Error('Downloaded file looks too small — aborting to avoid a broken bridge.');
138
+
139
+ await writeFile(tmp, buf);
140
+ if (process.platform !== 'win32') await chmod(tmp, 0o755);
141
+
142
+ if (process.platform === 'win32') {
143
+ // A running .exe can't be overwritten, but it CAN be renamed. Move it aside,
144
+ // drop the new one in place; the old (renamed) file is cleaned up after exit.
145
+ const aside = path.join(dir, `chatpanel-bridge.old-${Date.now()}.exe`);
146
+ await rename(target, aside);
147
+ await rename(tmp, target);
148
+ } else {
149
+ // POSIX: atomic rename over the running file. The live process keeps the old
150
+ // inode; the path now points at the new binary.
151
+ await rename(tmp, target);
152
+ }
153
+ return { ok: true, from: current, to: info.latest };
154
+ }