@chatpanel/bridge 0.2.11 → 0.2.13
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 +3 -5
- package/src/engines/claude.js +147 -153
- package/src/server.js +37 -8
- package/src/service.js +35 -1
- package/src/update.js +154 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (
|
|
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": [
|
|
7
7
|
"chatpanel",
|
|
8
8
|
"claude-code",
|
|
@@ -36,9 +36,7 @@
|
|
|
36
36
|
"dev": "node --watch src/server.js",
|
|
37
37
|
"build:bin": "bash scripts/build-binaries.sh"
|
|
38
38
|
},
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"@anthropic-ai/claude-agent-sdk": "^0.1.0"
|
|
41
|
-
},
|
|
39
|
+
"dependencies": {},
|
|
42
40
|
"publishConfig": {
|
|
43
41
|
"registry": "https://registry.npmjs.org/",
|
|
44
42
|
"access": "public"
|
package/src/engines/claude.js
CHANGED
|
@@ -1,195 +1,189 @@
|
|
|
1
|
-
// Claude Code engine —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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).
|
|
4
6
|
//
|
|
5
|
-
// By default the agent can READ your code
|
|
6
|
-
// write or run shell commands unless the
|
|
7
|
-
// 'acceptEdits' or 'bypassPermissions' in ChatPanel
|
|
8
|
-
// directory comes from the agent config
|
|
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.
|
|
9
11
|
|
|
10
|
-
import
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
11
13
|
import os from 'node:os';
|
|
12
|
-
import
|
|
13
|
-
import { findAgentBin
|
|
14
|
-
|
|
15
|
-
let sdkPromise = null;
|
|
16
|
-
function loadSdk() {
|
|
17
|
-
if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk').catch(() => null);
|
|
18
|
-
return sdkPromise;
|
|
19
|
-
}
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { findAgentBin } from '../env.js';
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
// the npm shim (modern Node won't spawn a .cmd directly).
|
|
26
|
-
function claudeExecutable() {
|
|
27
|
-
if (process.env.CHATPANEL_CLAUDE_PATH) return process.env.CHATPANEL_CLAUDE_PATH;
|
|
28
|
-
if (!isCompiledBinary()) return undefined; // under node/bun the bundled cli.js works
|
|
29
|
-
const bin = findAgentBin('claude');
|
|
30
|
-
if (!bin) return undefined;
|
|
31
|
-
const dir = path.dirname(bin);
|
|
32
|
-
// The npm install ships cli.js; the native installer ships cli-wrapper.cjs
|
|
33
|
-
// (a JS entry next to a platform binary). Prefer either runnable JS entry over
|
|
34
|
-
// the native `claude` binary, since the SDK runs it with its own JS runtime.
|
|
35
|
-
const pkgDirs = [
|
|
36
|
-
path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code'),
|
|
37
|
-
path.join(dir, '..', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
|
|
38
|
-
// npm global on macOS/Homebrew symlinks bin/claude → ../lib/node_modules/...,
|
|
39
|
-
// so dir is already the package's own bin/ in the native install.
|
|
40
|
-
path.join(dir, '..'),
|
|
41
|
-
];
|
|
42
|
-
for (const p of pkgDirs) {
|
|
43
|
-
for (const entry of ['cli.js', 'cli-wrapper.cjs']) {
|
|
44
|
-
const c = path.join(p, entry);
|
|
45
|
-
if (existsSync(c)) return c;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return bin;
|
|
49
|
-
}
|
|
17
|
+
const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
|
|
18
|
+
// Read-only tools allowed without approval in headless mode; writes/shell are
|
|
19
|
+
// gated behind the agent's permission mode.
|
|
20
|
+
const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
|
|
50
21
|
|
|
22
|
+
let installed = false;
|
|
23
|
+
let lastProbe = 0;
|
|
51
24
|
export async function available() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
reason: 'Claude Code not found. Install it (npm i -g @anthropic-ai/claude-code), or run the bridge with `npx @chatpanel/bridge`.',
|
|
62
|
-
};
|
|
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) {
|
|
28
|
+
lastProbe = Date.now();
|
|
29
|
+
try {
|
|
30
|
+
installed = !!findAgentBin('claude');
|
|
31
|
+
} catch {
|
|
32
|
+
installed = false;
|
|
33
|
+
}
|
|
63
34
|
}
|
|
64
|
-
return
|
|
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.' };
|
|
65
38
|
}
|
|
66
39
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Build a single prompt that carries the conversation history. The bridge is
|
|
70
|
-
// stateless, so we replay the chat each turn.
|
|
40
|
+
// The bridge is stateless, so we replay the conversation as a single prompt.
|
|
71
41
|
function buildPrompt(messages) {
|
|
72
42
|
const history = messages.slice(0, -1);
|
|
73
43
|
const last = messages[messages.length - 1];
|
|
74
44
|
let prompt = '';
|
|
75
45
|
if (history.length) {
|
|
76
46
|
prompt += 'Conversation so far:\n';
|
|
77
|
-
for (const m of history) {
|
|
78
|
-
prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
|
|
79
|
-
}
|
|
47
|
+
for (const m of history) prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
|
|
80
48
|
prompt += '---\n\n';
|
|
81
49
|
}
|
|
82
50
|
prompt += last ? last.content : '';
|
|
83
51
|
return prompt;
|
|
84
52
|
}
|
|
85
53
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
57
|
+
function runClaude({ prompt, args, cwd, emit }) {
|
|
58
|
+
const bin = findAgentBin('claude') || 'claude';
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return reject(new Error(`Failed to start claude: ${e.message}`));
|
|
65
|
+
}
|
|
90
66
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return { behavior: 'deny', message: `${toolName} blocked — set this agent's permission mode to acceptEdits/bypassPermissions in ChatPanel to enable it.` };
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
let streamedAny = false;
|
|
104
|
-
let resultText = '';
|
|
105
|
-
|
|
106
|
-
const iterator = query({
|
|
107
|
-
prompt: buildPrompt(messages),
|
|
108
|
-
options: {
|
|
109
|
-
cwd,
|
|
110
|
-
permissionMode,
|
|
111
|
-
includePartialMessages: true,
|
|
112
|
-
canUseTool,
|
|
113
|
-
// Default: load your ~/.claude + project settings so your skills, MCP
|
|
114
|
-
// servers and CLAUDE.md apply. Turn the agent's "Use my local skills &
|
|
115
|
-
// config" off to run clean.
|
|
116
|
-
settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
|
|
117
|
-
// Native Claude Code system prompt. Only append the user's OWN system
|
|
118
|
-
// prompt if they set one — no ChatPanel persona is injected, so the agent
|
|
119
|
-
// is exactly as capable as it is in the terminal.
|
|
120
|
-
systemPrompt: system
|
|
121
|
-
? { type: 'preset', preset: 'claude_code', append: system }
|
|
122
|
-
: { type: 'preset', preset: 'claude_code' },
|
|
123
|
-
...(options.model ? { model: options.model } : {}),
|
|
124
|
-
...(claudeExecutable() ? { pathToClaudeCodeExecutable: claudeExecutable() } : {}),
|
|
125
|
-
...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
|
|
126
|
-
},
|
|
127
|
-
});
|
|
67
|
+
let stdout = '';
|
|
68
|
+
let stderr = '';
|
|
69
|
+
let streamedAny = false;
|
|
70
|
+
let resultText = '';
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
child.kill('SIGKILL');
|
|
74
|
+
reject(new Error(`Claude Code timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
|
|
75
|
+
}, TIMEOUT_MS);
|
|
128
76
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
77
|
+
child.stdout.on('data', (d) => {
|
|
78
|
+
stdout += d.toString();
|
|
79
|
+
let nl;
|
|
80
|
+
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
81
|
+
const line = stdout.slice(0, nl).trim();
|
|
82
|
+
stdout = stdout.slice(nl + 1);
|
|
83
|
+
if (!line.startsWith('{')) continue;
|
|
84
|
+
let msg;
|
|
85
|
+
try {
|
|
86
|
+
msg = JSON.parse(line);
|
|
87
|
+
} catch {
|
|
88
|
+
continue; // not a JSON event line
|
|
139
89
|
}
|
|
90
|
+
const r = handleMessage(msg, emit, streamedAny);
|
|
91
|
+
if (r.streamed) streamedAny = true;
|
|
92
|
+
if (r.result != null) resultText = r.result;
|
|
140
93
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
94
|
+
});
|
|
95
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
96
|
+
child.on('error', (e) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
reject(e);
|
|
99
|
+
});
|
|
100
|
+
child.on('close', (code) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (code === 0) resolve({ streamedAny, resultText });
|
|
103
|
+
else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.stdin.write(prompt);
|
|
107
|
+
child.stdin.end();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Map one stream-json message to emit() calls. Returns { streamed, result }.
|
|
112
|
+
// The CLI's stream-json mirrors the SDK message shapes.
|
|
113
|
+
function handleMessage(msg, emit, alreadyStreamed) {
|
|
114
|
+
const out = { streamed: false, result: null };
|
|
115
|
+
if (msg.type === 'stream_event') {
|
|
116
|
+
const ev = msg.event;
|
|
117
|
+
if (ev?.type === 'content_block_delta') {
|
|
118
|
+
if (ev.delta?.type === 'text_delta') {
|
|
119
|
+
out.streamed = true;
|
|
120
|
+
emit({ type: 'delta', text: ev.delta.text });
|
|
121
|
+
} else if (ev.delta?.type === 'thinking_delta') {
|
|
122
|
+
emit({ type: 'reasoning', text: ev.delta.thinking || '' });
|
|
150
123
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
124
|
+
}
|
|
125
|
+
} else if (msg.type === 'assistant') {
|
|
126
|
+
for (const block of msg.message?.content || []) {
|
|
127
|
+
if (block.type === 'tool_use') {
|
|
128
|
+
emit({ type: 'tool', name: block.name, summary: toolSummary(block) });
|
|
129
|
+
} else if (block.type === 'text' && !alreadyStreamed) {
|
|
130
|
+
out.streamed = true;
|
|
131
|
+
emit({ type: 'delta', text: block.text });
|
|
155
132
|
}
|
|
156
133
|
}
|
|
134
|
+
} else if (msg.type === 'result') {
|
|
135
|
+
if (msg.subtype === 'success') out.result = msg.result || '';
|
|
136
|
+
else emit({ type: 'status', text: `(${msg.subtype})` });
|
|
157
137
|
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function chat({ messages, system, options }, emit) {
|
|
142
|
+
const permissionMode = options.permissionMode || 'default';
|
|
143
|
+
const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
|
|
144
|
+
|
|
145
|
+
const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
|
|
146
|
+
|
|
147
|
+
// Gate writes/shell behind the chosen mode; otherwise restrict to read-only
|
|
148
|
+
// tools so headless runs never block on an approval prompt.
|
|
149
|
+
if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
|
|
150
|
+
else if (permissionMode === 'acceptEdits') args.push('--permission-mode', 'acceptEdits');
|
|
151
|
+
else args.push('--allowedTools', ...READONLY_TOOLS);
|
|
152
|
+
|
|
153
|
+
// Native Claude Code behavior; append the user's own system prompt if they set
|
|
154
|
+
// one (no ChatPanel persona injected).
|
|
155
|
+
if (system) args.push('--append-system-prompt', system);
|
|
156
|
+
if (options.model) args.push('--model', options.model);
|
|
157
|
+
// Default loads your ~/.claude + project settings (skills, MCP, CLAUDE.md).
|
|
158
|
+
// "Use my local skills & config" off → run clean.
|
|
159
|
+
if (options.useLocalConfig === false) args.push('--setting-sources', '');
|
|
158
160
|
|
|
161
|
+
const { streamedAny, resultText } = await runClaude({ prompt: buildPrompt(messages), args, cwd, emit });
|
|
159
162
|
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
// A fast, tool-free single-shot completion — used for prompt autocomplete. No
|
|
163
|
-
//
|
|
164
|
-
// from a fast model (Haiku by default). Returns the completion string.
|
|
166
|
+
// tools, no local config: just a quick text continuation from a fast model.
|
|
165
167
|
export async function complete({ prompt, system, model }) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
const args = [
|
|
169
|
+
'--print',
|
|
170
|
+
'--output-format', 'stream-json',
|
|
171
|
+
'--verbose',
|
|
172
|
+
'--disallowedTools', 'Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Bash', 'Edit', 'Write', 'Task', 'TodoWrite',
|
|
173
|
+
'--setting-sources', '',
|
|
174
|
+
'--model', model || 'haiku',
|
|
175
|
+
'--system-prompt', system || "Continue the user's text briefly. Reply with only the continuation.",
|
|
176
|
+
];
|
|
169
177
|
let text = '';
|
|
170
|
-
const
|
|
178
|
+
const { resultText } = await runClaude({
|
|
171
179
|
prompt,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
maxTurns: 1,
|
|
177
|
-
settingSources: [], // skip CLAUDE.md / MCP for a tiny completion
|
|
178
|
-
systemPrompt: system || "Continue the user's text briefly. Reply with only the continuation.",
|
|
179
|
-
model: model || 'haiku',
|
|
180
|
-
...(claudeExecutable() ? { pathToClaudeCodeExecutable: claudeExecutable() } : {}),
|
|
180
|
+
args,
|
|
181
|
+
cwd: os.homedir(),
|
|
182
|
+
emit: (e) => {
|
|
183
|
+
if (e.type === 'delta') text += e.text;
|
|
181
184
|
},
|
|
182
185
|
});
|
|
183
|
-
|
|
184
|
-
if (message.type === 'assistant') {
|
|
185
|
-
for (const block of message.message.content) {
|
|
186
|
-
if (block.type === 'text') text += block.text;
|
|
187
|
-
}
|
|
188
|
-
} else if (message.type === 'result' && message.subtype === 'success' && !text) {
|
|
189
|
-
text = message.result || '';
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return text.trim();
|
|
186
|
+
return (text || resultText || '').trim();
|
|
193
187
|
}
|
|
194
188
|
|
|
195
189
|
function toolSummary(block) {
|
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
|
|
4
|
-
//
|
|
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: [{
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|