bridge-agent 0.2.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/pty-manager.test.d.ts +1 -0
- package/dist/__tests__/pty-manager.test.js +75 -0
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +88 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +258 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +85 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +38 -38
- package/dist/metrics.d.ts +11 -0
- package/dist/metrics.js +123 -0
- package/dist/pty/agents.d.ts +30 -0
- package/dist/pty/agents.js +228 -0
- package/dist/pty/claude-quota.d.ts +12 -0
- package/dist/pty/claude-quota.js +114 -0
- package/dist/pty/claude-usage.d.ts +5 -0
- package/dist/pty/claude-usage.js +92 -0
- package/dist/pty/manager.d.ts +22 -0
- package/dist/pty/manager.js +144 -0
- package/dist/pty/spawn-helper-health.d.ts +13 -0
- package/dist/pty/spawn-helper-health.js +54 -0
- package/dist/shared/types.d.ts +87 -0
- package/dist/shared/types.js +4 -0
- package/dist/ws/client.d.ts +3 -0
- package/dist/ws/client.js +989 -0
- package/package.json +3 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
const killCalls = [];
|
|
4
|
+
const spawned = [];
|
|
5
|
+
class FakePty extends EventEmitter {
|
|
6
|
+
pid;
|
|
7
|
+
writes = [];
|
|
8
|
+
resizes = [];
|
|
9
|
+
constructor(pid) {
|
|
10
|
+
super();
|
|
11
|
+
this.pid = pid;
|
|
12
|
+
}
|
|
13
|
+
write(data) {
|
|
14
|
+
this.writes.push(data);
|
|
15
|
+
}
|
|
16
|
+
resize(cols, rows) {
|
|
17
|
+
this.resizes.push({ cols, rows });
|
|
18
|
+
}
|
|
19
|
+
kill() {
|
|
20
|
+
this.emit('exit', { exitCode: null, signal: 'SIGTERM' });
|
|
21
|
+
}
|
|
22
|
+
onData(handler) {
|
|
23
|
+
this.on('data', handler);
|
|
24
|
+
}
|
|
25
|
+
onExit(handler) {
|
|
26
|
+
this.on('exit', handler);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
mock.module('node-pty', () => ({
|
|
30
|
+
spawn: mock(() => {
|
|
31
|
+
const pty = new FakePty(1000 + spawned.length);
|
|
32
|
+
spawned.push(pty);
|
|
33
|
+
return pty;
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
const realKill = process.kill;
|
|
37
|
+
const killMock = mock((pid, signal) => {
|
|
38
|
+
killCalls.push({ pid, signal });
|
|
39
|
+
});
|
|
40
|
+
const { PtyManager } = await import('../pty/manager.js');
|
|
41
|
+
describe('PtyManager duplicate spawn recovery', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
spawned.length = 0;
|
|
44
|
+
killCalls.length = 0;
|
|
45
|
+
process.kill = killMock;
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
process.kill = realKill;
|
|
49
|
+
killMock.mockClear();
|
|
50
|
+
});
|
|
51
|
+
test('replaces an existing handle instead of rejecting the second spawn', () => {
|
|
52
|
+
const manager = new PtyManager();
|
|
53
|
+
const outputs = [];
|
|
54
|
+
const exits = [];
|
|
55
|
+
const first = manager.spawn('panel-1', 'claude', '/bin/claude', [], 80, 24, data => outputs.push(`first:${data}`), (exitCode, signal) => exits.push({ exitCode, signal }));
|
|
56
|
+
const second = manager.spawn('panel-1', 'claude', '/bin/claude', [], 100, 30, data => outputs.push(`second:${data}`), (exitCode, signal) => exits.push({ exitCode, signal }));
|
|
57
|
+
expect(first).toBe(true);
|
|
58
|
+
expect(second).toBe(true);
|
|
59
|
+
expect(spawned.length).toBe(2);
|
|
60
|
+
expect(killCalls.length).toBeGreaterThan(0);
|
|
61
|
+
expect(killCalls[0]?.pid).toBe(-1000);
|
|
62
|
+
spawned[0].emit('data', 'old-output');
|
|
63
|
+
spawned[0].emit('exit', { exitCode: 0, signal: null });
|
|
64
|
+
spawned[1].emit('data', 'new-output');
|
|
65
|
+
expect(outputs).toEqual([Buffer.from('new-output').toString('base64')].map(v => `second:${v}`));
|
|
66
|
+
expect(exits).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
test('kill removes the active handle so a later spawn stays clean', () => {
|
|
69
|
+
const manager = new PtyManager();
|
|
70
|
+
expect(manager.spawn('panel-2', 'sh', '/bin/sh', [], 80, 24, () => { }, () => { })).toBe(true);
|
|
71
|
+
manager.kill('panel-2', true);
|
|
72
|
+
expect(manager.spawn('panel-2', 'sh', '/bin/sh', [], 80, 24, () => { }, () => { })).toBe(true);
|
|
73
|
+
expect(spawned.length).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runAuth(serverUrl: string, noBrowser?: boolean, providedToken?: string): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { saveConfig } from '../config.js';
|
|
4
|
+
function sanitizeToken(raw) {
|
|
5
|
+
return (raw ?? '').trim();
|
|
6
|
+
}
|
|
7
|
+
export async function runAuth(serverUrl, noBrowser = false, providedToken) {
|
|
8
|
+
console.log('[bridge] Starting auth flow...');
|
|
9
|
+
console.log(`[bridge] Server: ${serverUrl}`);
|
|
10
|
+
console.log('[bridge] Open this URL to generate a daemon token:');
|
|
11
|
+
console.log(` ${serverUrl}/connect`);
|
|
12
|
+
const inlineToken = sanitizeToken(providedToken);
|
|
13
|
+
if (inlineToken) {
|
|
14
|
+
console.log('[bridge] Using token from --token');
|
|
15
|
+
}
|
|
16
|
+
if (noBrowser) {
|
|
17
|
+
if (inlineToken) {
|
|
18
|
+
console.log('[bridge] --no-browser ignored because --token is provided.');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log('[bridge] --no-browser: exiting after printing URL.');
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
let token = inlineToken;
|
|
26
|
+
if (!token) {
|
|
27
|
+
console.log();
|
|
28
|
+
console.log('[bridge] After authenticating, paste your token here:');
|
|
29
|
+
token = await promptToken();
|
|
30
|
+
}
|
|
31
|
+
if (!token) {
|
|
32
|
+
console.error('[bridge] No token provided. Exiting.');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
// Validate token with server
|
|
36
|
+
const isValid = await validateToken(serverUrl, token);
|
|
37
|
+
if (!isValid) {
|
|
38
|
+
console.error('[bridge] Token validation failed. Please try again.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const wsBase = serverUrl.replace(/^https?:\/\//, (match) => match.startsWith('https') ? 'wss://' : 'ws://');
|
|
42
|
+
const wsUrl = wsBase.replace(/\/?$/, '/ws/daemon');
|
|
43
|
+
saveConfig({
|
|
44
|
+
server: wsUrl,
|
|
45
|
+
token,
|
|
46
|
+
name: process.env['HOSTNAME'] ?? 'My Machine',
|
|
47
|
+
});
|
|
48
|
+
console.log('[bridge] Auth successful! Config saved to ~/.bridge/config.json');
|
|
49
|
+
console.log('[bridge] Run: bridge-agent start');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
async function promptToken() {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
process.stdout.write('Token: ');
|
|
55
|
+
let input = '';
|
|
56
|
+
process.stdin.setEncoding('utf-8');
|
|
57
|
+
process.stdin.on('data', (chunk) => {
|
|
58
|
+
input += chunk;
|
|
59
|
+
if (input.includes('\n')) {
|
|
60
|
+
process.stdin.pause();
|
|
61
|
+
resolve(input.trim());
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
process.stdin.resume();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function validateToken(serverUrl, token) {
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
const url = new URL('/api/tokens/validate', serverUrl);
|
|
70
|
+
const isHttps = url.protocol === 'https:';
|
|
71
|
+
const lib = isHttps ? https : http;
|
|
72
|
+
const options = {
|
|
73
|
+
hostname: url.hostname,
|
|
74
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
75
|
+
path: url.pathname,
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Authorization': `Bearer ${token}`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const req = lib.request(options, (res) => {
|
|
83
|
+
resolve(res.statusCode === 200);
|
|
84
|
+
});
|
|
85
|
+
req.on('error', () => resolve(false));
|
|
86
|
+
req.end();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runStart(): void;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { execSync, spawn } from 'node:child_process';
|
|
3
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { PtyManager } from '../pty/manager.js';
|
|
7
|
+
import { startDaemonConnection, isDaemonWsConnected } from '../ws/client.js';
|
|
8
|
+
const PLIST_NAME = 'com.jerico.bridge-agent.plist';
|
|
9
|
+
const LAUNCH_AGENTS = path.join(homedir(), 'Library', 'LaunchAgents');
|
|
10
|
+
const PLIST_PATH = path.join(LAUNCH_AGENTS, PLIST_NAME);
|
|
11
|
+
const LOG_OUT = path.join(homedir(), 'bridge-daemon.log');
|
|
12
|
+
const LOG_ERR = path.join(homedir(), 'bridge-daemon.err.log');
|
|
13
|
+
const LOCK_PATH = path.join(homedir(), '.bridge', 'daemon.lock');
|
|
14
|
+
function getDaemonEntry() {
|
|
15
|
+
// Prefer the global npm install — use npm root to find the canonical path.
|
|
16
|
+
// Falls back to argv[1] realpath (works when run directly by Node).
|
|
17
|
+
// Last resort: process.execPath (Node.js binary itself).
|
|
18
|
+
const candidates = [
|
|
19
|
+
...(process.env.npm_config_global_prefix
|
|
20
|
+
? [path.join(process.env.npm_config_global_prefix, 'lib', 'node_modules', 'bridge-agent', 'dist', 'index.js')]
|
|
21
|
+
: []),
|
|
22
|
+
...(process.argv[1] ? [process.argv[1]] : []),
|
|
23
|
+
process.execPath,
|
|
24
|
+
];
|
|
25
|
+
for (const p of candidates) {
|
|
26
|
+
try {
|
|
27
|
+
return require('node:fs').realpathSync(p);
|
|
28
|
+
}
|
|
29
|
+
catch { /* try next */ }
|
|
30
|
+
}
|
|
31
|
+
return process.execPath;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Acquire a lock to prevent multiple daemon instances.
|
|
35
|
+
* Uses a simple PID file — if the stored PID is still running, refuse to start.
|
|
36
|
+
* Returns true if lock acquired, false if another instance is already running.
|
|
37
|
+
*/
|
|
38
|
+
function acquireDaemonLock() {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(path.dirname(LOCK_PATH), { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
catch { /* exists */ }
|
|
43
|
+
if (existsSync(LOCK_PATH)) {
|
|
44
|
+
try {
|
|
45
|
+
const { pid } = JSON.parse(readFileSync(LOCK_PATH, 'utf8'));
|
|
46
|
+
if (pid && process.kill(pid, 0)) {
|
|
47
|
+
console.warn('[bridge] daemon.already.running', { pid, lock: LOCK_PATH });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { /* stale lock — overwrite */ }
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), 'utf8');
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
function getShellPath() {
|
|
57
|
+
// Get the user's full PATH by running 'which' for known binaries.
|
|
58
|
+
// This tells us where each binary lives, and we collect all those dirs.
|
|
59
|
+
// Works on any machine — we discover what's available rather than hardcoding.
|
|
60
|
+
const knownBinaries = ['claude', 'codex', 'qwen', 'ollama', 'aider', 'python3', 'node', 'bun', 'sh'];
|
|
61
|
+
const dirs = new Set();
|
|
62
|
+
dirs.add(path.join(homedir(), '.nvm', 'versions', 'node', `v${process.versions.node}`, 'bin'));
|
|
63
|
+
dirs.add(path.join(homedir(), '.local', 'bin'));
|
|
64
|
+
dirs.add('/opt/homebrew/bin');
|
|
65
|
+
dirs.add('/usr/local/bin');
|
|
66
|
+
dirs.add('/usr/bin');
|
|
67
|
+
dirs.add('/bin');
|
|
68
|
+
// Also add directories that appear in PATH at startup
|
|
69
|
+
const currentPath = process.env.PATH ?? '';
|
|
70
|
+
for (const d of currentPath.split(':')) {
|
|
71
|
+
if (d && !d.startsWith('/dev') && !d.startsWith('/tmp')) {
|
|
72
|
+
dirs.add(d);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Discover dirs by locating each known binary
|
|
76
|
+
for (const bin of knownBinaries) {
|
|
77
|
+
try {
|
|
78
|
+
const resolved = execSync(`which ${bin} 2>/dev/null`, { stdio: 'pipe' }).toString().trim();
|
|
79
|
+
if (resolved && resolved.startsWith('/')) {
|
|
80
|
+
dirs.add(path.dirname(resolved));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch { /* binary not found — skip */ }
|
|
84
|
+
}
|
|
85
|
+
// Claude Code often installed as VS Code extension — discover it
|
|
86
|
+
const vscodeExtensions = path.join(homedir(), '.vscode', 'extensions');
|
|
87
|
+
try {
|
|
88
|
+
const entries = execSync(`ls "${vscodeExtensions}" 2>/dev/null`, { stdio: 'pipe' }).toString().split('\n');
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.startsWith('anthropic.claude-code-')) {
|
|
91
|
+
const binDir = path.join(vscodeExtensions, entry, 'resources', 'native-binary');
|
|
92
|
+
if (existsSync(binDir))
|
|
93
|
+
dirs.add(binDir);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* vscode extensions not found — skip */ }
|
|
98
|
+
return [...dirs].join(':');
|
|
99
|
+
}
|
|
100
|
+
function setupLaunchd(daemonEntry) {
|
|
101
|
+
try {
|
|
102
|
+
execSync(`mkdir -p "${LAUNCH_AGENTS}"`, { stdio: 'pipe' });
|
|
103
|
+
}
|
|
104
|
+
catch { /* exists */ }
|
|
105
|
+
const shellPath = getShellPath();
|
|
106
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
107
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
108
|
+
<plist version="1.0">
|
|
109
|
+
<dict>
|
|
110
|
+
<key>Label</key>
|
|
111
|
+
<string>com.jerico.bridge-agent</string>
|
|
112
|
+
<key>ProgramArguments</key>
|
|
113
|
+
<array>
|
|
114
|
+
<string>${daemonEntry}</string>
|
|
115
|
+
<string>start</string>
|
|
116
|
+
</array>
|
|
117
|
+
<key>RunAtLoad</key>
|
|
118
|
+
<true/>
|
|
119
|
+
<key>KeepAlive</key>
|
|
120
|
+
<true/>
|
|
121
|
+
<key>StandardOutPath</key>
|
|
122
|
+
<string>${LOG_OUT}</string>
|
|
123
|
+
<key>StandardErrorPath</key>
|
|
124
|
+
<string>${LOG_ERR}</string>
|
|
125
|
+
<key>EnvironmentVariables</key>
|
|
126
|
+
<dict>
|
|
127
|
+
<key>PATH</key>
|
|
128
|
+
<string>${shellPath}</string>
|
|
129
|
+
<key>BRIDGE_DAEMON</key>
|
|
130
|
+
<string>1</string>
|
|
131
|
+
</dict>
|
|
132
|
+
</dict>
|
|
133
|
+
</plist>
|
|
134
|
+
`;
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(PLIST_PATH, plist, 'utf-8');
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
console.warn('[bridge] launchd.plist.write.failed', { error: String(err) });
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function tryLaunchd() {
|
|
145
|
+
try {
|
|
146
|
+
// Force-kill any stale entry then reload — kickstart is more reliable than unload for zombie cleanup
|
|
147
|
+
execSync(`launchctl kickstart -kp gui/$(id -u)/${PLIST_NAME} 2>/dev/null; launchctl unload "${PLIST_PATH}" 2>/dev/null; launchctl load "${PLIST_PATH}"`, { stdio: 'pipe' });
|
|
148
|
+
return { ok: true, permissionDenied: false };
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const msg = String(err);
|
|
152
|
+
const denied = msg.includes('Permission denied') || msg.includes('not allowed') || msg.includes('bootstrap');
|
|
153
|
+
return { ok: false, permissionDenied: denied };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function startAsDaemon(daemonEntry) {
|
|
157
|
+
try {
|
|
158
|
+
const child = spawn(daemonEntry, ['start'], {
|
|
159
|
+
detached: true,
|
|
160
|
+
stdio: 'ignore',
|
|
161
|
+
env: { ...process.env, PATH: getShellPath(), BRIDGE_DAEMON: '1' },
|
|
162
|
+
});
|
|
163
|
+
child.unref();
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
const running = child.pid && process.kill(child.pid, 0);
|
|
166
|
+
if (running) {
|
|
167
|
+
console.log('[bridge] daemon.pid', { pid: child.pid });
|
|
168
|
+
console.log('[bridge] background.ok', { log: LOG_OUT });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
console.error('[bridge] background.failed — check: tail -f', { log: LOG_ERR });
|
|
172
|
+
}
|
|
173
|
+
}, 2000);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.error('[bridge] background.spawn.failed', { error: String(err) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Verify the daemon is actually healthy after launchd reports success.
|
|
181
|
+
* launchd can say "loaded" but the daemon might exit 1 second later.
|
|
182
|
+
* Polls the health endpoint for up to 6 seconds before accepting success.
|
|
183
|
+
*/
|
|
184
|
+
function verifyDaemonHealth() {
|
|
185
|
+
const healthPort = parseInt(process.env['HEALTH_PORT'] ?? '3101', 10);
|
|
186
|
+
const deadline = Date.now() + 6000;
|
|
187
|
+
const tryConnect = () => {
|
|
188
|
+
if (Date.now() > deadline) {
|
|
189
|
+
console.error('[bridge] health.verify.timeout — daemon may have crashed immediately');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const http = require('node:http');
|
|
194
|
+
const req = http.get(`http://127.0.0.1:${healthPort}/health`, (res) => {
|
|
195
|
+
if (res.statusCode === 200) {
|
|
196
|
+
console.log('[bridge] health.verify.ok');
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
setTimeout(tryConnect, 500);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
req.on('error', () => { setTimeout(tryConnect, 500); });
|
|
203
|
+
req.setTimeout(1000, () => { req.destroy(); setTimeout(tryConnect, 500); });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
setTimeout(tryConnect, 500);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
setTimeout(tryConnect, 1000);
|
|
210
|
+
}
|
|
211
|
+
function runDaemonServices() {
|
|
212
|
+
const manager = new PtyManager();
|
|
213
|
+
startDaemonConnection(manager);
|
|
214
|
+
const healthPort = parseInt(process.env['HEALTH_PORT'] ?? '3101', 10);
|
|
215
|
+
const health = createServer((_, res) => {
|
|
216
|
+
const connected = isDaemonWsConnected();
|
|
217
|
+
const body = JSON.stringify({ status: 'ok', connected, uptime: process.uptime() });
|
|
218
|
+
res.writeHead(connected ? 200 : 503, { 'Content-Type': 'application/json' });
|
|
219
|
+
res.end(body);
|
|
220
|
+
});
|
|
221
|
+
health.listen(healthPort, '127.0.0.1', () => {
|
|
222
|
+
console.log(`[bridge] health. listening on 127.0.0.1:${healthPort}`);
|
|
223
|
+
});
|
|
224
|
+
health.on('error', (err) => {
|
|
225
|
+
console.error('[bridge] health.error', { error: err.message });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export function runStart() {
|
|
229
|
+
const isDaemon = process.env['BRIDGE_DAEMON'] === '1' || process.argv.includes('--daemon');
|
|
230
|
+
console.log('[bridge] Starting bridge-agent daemon...');
|
|
231
|
+
if (isDaemon) {
|
|
232
|
+
runDaemonServices();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Prevent multiple daemon instances on the same machine.
|
|
236
|
+
if (!acquireDaemonLock()) {
|
|
237
|
+
console.warn('[bridge] start.aborted.already.running');
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
const daemonEntry = getDaemonEntry();
|
|
241
|
+
const plistWritten = setupLaunchd(daemonEntry);
|
|
242
|
+
const { ok: launched, permissionDenied } = plistWritten ? tryLaunchd() : { ok: false, permissionDenied: false };
|
|
243
|
+
if (launched) {
|
|
244
|
+
console.log('[bridge] launchd.ok — managed, auto-restart enabled');
|
|
245
|
+
console.log('[bridge] logs: tail -f', { out: LOG_OUT, err: LOG_ERR });
|
|
246
|
+
verifyDaemonHealth();
|
|
247
|
+
process.exit(0);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (permissionDenied) {
|
|
251
|
+
console.warn('[bridge] launchd.permission.denied');
|
|
252
|
+
console.warn('[bridge] → Auto-start on login requires:');
|
|
253
|
+
console.warn(`[bridge] sudo launchctl bootstrap gui/$(id -u) "${PLIST_PATH}"`);
|
|
254
|
+
console.warn('[bridge] Falling back to background process...\n');
|
|
255
|
+
}
|
|
256
|
+
startAsDaemon(daemonEntry);
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface BridgeConfig {
|
|
2
|
+
server: string;
|
|
3
|
+
token: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** Global agent binary path overrides (key = agentKey, value = absolute path) */
|
|
6
|
+
agentPaths?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
/** Project-level settings from .jerico/settings.json in cwd */
|
|
9
|
+
export interface ProjectSettings {
|
|
10
|
+
/** Override agent binary paths (key = agentKey, value = absolute path) */
|
|
11
|
+
agentPaths?: Record<string, string>;
|
|
12
|
+
/** Override the agent binary to prefer in this project */
|
|
13
|
+
preferredAgent?: string;
|
|
14
|
+
/** Shell hooks — see lifecycle hooks (ISSUE 7) */
|
|
15
|
+
hooks?: Record<string, string>;
|
|
16
|
+
/** Additional env vars injected into spawned agents in this project */
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function loadConfig(): BridgeConfig;
|
|
20
|
+
export declare function saveConfig(config: BridgeConfig): void;
|
|
21
|
+
/**
|
|
22
|
+
* Load project-level settings from .jerico/settings.json in the given directory (or cwd).
|
|
23
|
+
* Returns empty object if the file does not exist or fails to parse.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadProjectSettings(cwd?: string): ProjectSettings;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
/** New global path; legacy ~/.bridge/config.json is still supported as fallback */
|
|
5
|
+
const JERICO_CONFIG_PATH = path.join(os.homedir(), '.jerico', 'settings.json');
|
|
6
|
+
const CONFIG_PATH = path.join(os.homedir(), '.bridge', 'config.json');
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
// Prefer new ~/.jerico/settings.json; fall back to legacy ~/.bridge/config.json
|
|
9
|
+
const configPath = fs.existsSync(JERICO_CONFIG_PATH) ? JERICO_CONFIG_PATH : CONFIG_PATH;
|
|
10
|
+
if (!fs.existsSync(configPath)) {
|
|
11
|
+
console.error('[bridge] Config not found. Run: bridge-agent auth');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
console.error('[bridge] Invalid config file at', CONFIG_PATH);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
24
|
+
console.error('[bridge] Config must be a JSON object. Run: bridge-agent auth');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const obj = parsed;
|
|
28
|
+
const server = typeof obj['server'] === 'string' ? obj['server'] : '';
|
|
29
|
+
const token = typeof obj['token'] === 'string' ? obj['token'] : '';
|
|
30
|
+
const name = typeof obj['name'] === 'string' ? obj['name'] : 'bridge-agent';
|
|
31
|
+
if (!server || !token) {
|
|
32
|
+
console.error('[bridge] Config missing server or token. Run: bridge-agent auth');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const config = { server, token, name };
|
|
36
|
+
if (obj['agentPaths'] && typeof obj['agentPaths'] === 'object' && !Array.isArray(obj['agentPaths'])) {
|
|
37
|
+
config.agentPaths = Object.fromEntries(Object.entries(obj['agentPaths'])
|
|
38
|
+
.filter(([, v]) => typeof v === 'string'));
|
|
39
|
+
}
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
export function saveConfig(config) {
|
|
43
|
+
// Always write to new ~/.jerico path
|
|
44
|
+
const dir = path.dirname(JERICO_CONFIG_PATH);
|
|
45
|
+
if (!fs.existsSync(dir)) {
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(JERICO_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Load project-level settings from .jerico/settings.json in the given directory (or cwd).
|
|
52
|
+
* Returns empty object if the file does not exist or fails to parse.
|
|
53
|
+
*/
|
|
54
|
+
export function loadProjectSettings(cwd) {
|
|
55
|
+
const settingsPath = path.join(cwd ?? process.cwd(), '.jerico', 'settings.json');
|
|
56
|
+
if (!fs.existsSync(settingsPath))
|
|
57
|
+
return {};
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
62
|
+
return {};
|
|
63
|
+
const obj = parsed;
|
|
64
|
+
const result = {};
|
|
65
|
+
if (typeof obj['preferredAgent'] === 'string')
|
|
66
|
+
result.preferredAgent = obj['preferredAgent'];
|
|
67
|
+
if (obj['hooks'] && typeof obj['hooks'] === 'object' && !Array.isArray(obj['hooks'])) {
|
|
68
|
+
result.hooks = Object.fromEntries(Object.entries(obj['hooks'])
|
|
69
|
+
.filter(([, v]) => typeof v === 'string'));
|
|
70
|
+
}
|
|
71
|
+
if (obj['env'] && typeof obj['env'] === 'object' && !Array.isArray(obj['env'])) {
|
|
72
|
+
result.env = Object.fromEntries(Object.entries(obj['env'])
|
|
73
|
+
.filter(([, v]) => typeof v === 'string'));
|
|
74
|
+
}
|
|
75
|
+
if (obj['agentPaths'] && typeof obj['agentPaths'] === 'object' && !Array.isArray(obj['agentPaths'])) {
|
|
76
|
+
result.agentPaths = Object.fromEntries(Object.entries(obj['agentPaths'])
|
|
77
|
+
.filter(([, v]) => typeof v === 'string'));
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
console.warn('[bridge] Failed to parse .jerico/settings.json, ignoring');
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/index.d.ts
ADDED