bridge-agent 0.7.1 → 0.7.2

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
@@ -445,4 +445,4 @@ ${a} </dict>
445
445
  </plist>
446
446
  `;try{return(0,T.writeFileSync)(t,f,"utf-8"),!0}catch(h){return console.warn("[bridge] launchd.plist.write.failed",{error:String(h)}),!1}}function kc(){let r=xe(),e=L.default.join(jt,r);try{return(0,pe.execSync)(`launchctl kickstart -kp gui/$(id -u)/${r} 2>/dev/null; launchctl unload "${e}" 2>/dev/null; launchctl load "${e}"`,{stdio:"pipe"}),{ok:!0,permissionDenied:!1}}catch(t){let n=String(t);return{ok:!1,permissionDenied:n.includes("Permission denied")||n.includes("not allowed")||n.includes("bootstrap")}}}function Ec(r){let{out:e,err:t}=Pt();try{let n=(0,pe.spawn)(r,["start"],{detached:!0,stdio:"ignore",env:{...process.env,PATH:di(),BRIDGE_DAEMON:"1"}});n.unref(),setTimeout(()=>{n.pid&&process.kill(n.pid,0)?(console.log("[bridge] daemon.pid",{pid:n.pid}),console.log("[bridge] background.ok",{log:e})):console.error("[bridge] background.failed \u2014 check: tail -f",{log:t})},2e3)}catch(n){console.error("[bridge] background.spawn.failed",{error:String(n)})}}function Ic(){let r=parseInt(process.env.HEALTH_PORT??"3101",10),e=Date.now()+6e3,t=()=>{if(Date.now()>e){console.error("[bridge] health.verify.timeout \u2014 daemon may have crashed immediately");return}try{let s=require("node:http").get(`http://127.0.0.1:${r}/health`,i=>{i.statusCode===200?console.log("[bridge] health.verify.ok"):setTimeout(t,500)});s.on("error",()=>{setTimeout(t,500)}),s.setTimeout(1e3,()=>{s.destroy(),setTimeout(t,500)})}catch{setTimeout(t,500)}};setTimeout(t,1e3)}function xc(){li();let r=new lt;ai(r),r.startLivenessCheck(6e4);let e=parseInt(process.env.HEALTH_PORT??"3101",10),t=(0,ci.createServer)((n,s)=>{let i=oi(),o=JSON.stringify({status:"ok",connected:i,uptime:process.uptime()});s.writeHead(i?200:503,{"Content-Type":"application/json"}),s.end(o)});t.listen(e,"127.0.0.1",()=>{console.log(`[bridge] health. listening on 127.0.0.1:${e}`)}),t.on("error",n=>{console.error("[bridge] health.error",{error:n.message})})}function ui(){let r=process.env.BRIDGE_DAEMON==="1"||process.argv.includes("--daemon");if(!r&&!process.env.BRIDGE_PROFILE){let a=process.argv[1]??"";(a.includes("packages/daemon/dist")||a.includes("packages/daemon/src"))&&(console.warn("[bridge] WARNING: running monorepo daemon without --profile \u2014 will use prod config (~/.jerico/settings.json)."),console.warn("[bridge] If this is unintentional, stop and rerun with: node packages/daemon/dist/index.js --profile dev start"))}if(console.log("[bridge] Starting bridge-agent daemon..."),r){xc();return}Sc()||(console.warn("[bridge] start.aborted.already.running"),process.exit(1));let e=wc(),t=vc(e),{ok:n,permissionDenied:s}=t?kc():{ok:!1,permissionDenied:!1},i=L.default.join(jt,xe()),{out:o,err:c}=Pt();if(n){console.log("[bridge] launchd.ok \u2014 managed, auto-restart enabled"),console.log("[bridge] logs: tail -f",{out:o,err:c}),Ic(),process.exit(0);return}s&&(console.warn("[bridge] launchd.permission.denied"),console.warn("[bridge] \u2192 Auto-start on login requires:"),console.warn(`[bridge] sudo launchctl bootstrap gui/$(id -u) "${i}"`),console.warn(`[bridge] Falling back to background process...
447
447
  `)),Ec(e),process.exit(0)}var pi=_(require("https")),hi=_(require("http"));Oe();var Oc="https://lcars.jerico.appnova.io";function Cc(r){return(r??"").trim()}async function fi(r,e=!1,t){let n=!(r&&r.trim()),s=n?Oc:r.trim();console.log("[bridge] Starting auth flow..."),console.log(`[bridge] Server: ${s}${n?" (default)":""}`),console.log("[bridge] Open this URL to generate a daemon token:"),console.log(` ${s}/connect`);let i=Cc(t);i&&console.log("[bridge] Using token from --token"),e&&(i?console.log("[bridge] --no-browser ignored because --token is provided."):(console.log("[bridge] --no-browser: exiting after printing URL."),process.exit(0)));let o=i;o||(console.log(),console.log("[bridge] After authenticating, paste your token here:"),o=await Pc()),o||(console.error("[bridge] No token provided. Exiting."),process.exit(1)),await Ac(s,o)||(console.error("[bridge] Token validation failed. Please try again."),process.exit(1));let l=s.replace(/^https?:\/\//,d=>d.startsWith("https")?"wss://":"ws://").replace(/\/?$/,"/ws/daemon");de({server:l,token:o,name:process.env.HOSTNAME??"My Machine"}),console.log("[bridge] Auth successful! Config saved to ~/.bridge/config.json"),console.log("[bridge] Run: bridge-agent start"),process.exit(0)}async function Pc(){return new Promise(r=>{process.stdout.write("Token: ");let e="";process.stdin.setEncoding("utf-8"),process.stdin.on("data",t=>{e+=t,e.includes(`
448
- `)&&(process.stdin.pause(),r(e.trim()))}),process.stdin.resume()})}async function Ac(r,e){return new Promise(t=>{let n=new URL("/api/tokens/validate",r),s=n.protocol==="https:",i=s?pi.default:hi.default,o={hostname:n.hostname,port:n.port||(s?443:80),path:n.pathname,method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`}},c=i.request(o,a=>{t(a.statusCode===200)});c.on("error",()=>t(!1)),c.end()})}var gi=_(require("https")),mi=_(require("http")),$r=_(require("fs")),Ur=_(require("path")),_i=require("node:crypto");Oe();function Tc(r){return r.replace(/^wss?:/,e=>e==="wss:"?"https:":"http:").replace(/\/ws(\/.*)?$/,"")}async function yi(r,e,t){let n=le(),s=(0,_i.createHash)("sha256").update(n.token).digest("hex"),i=Tc(n.server),o=Ur.default.resolve(t);Ur.default.isAbsolute(o)||(console.error("[bridge] link-project: path must be absolute"),process.exit(1)),$r.default.existsSync(o)||(console.error("[bridge] link-project: path does not exist:",o),process.exit(1)),$r.default.statSync(o).isDirectory()||(console.error("[bridge] link-project: path must be a directory:",o),process.exit(1));let a=new URL(`/api/workspaces/${r}/projects/${e}/machine-paths`,i),l=a.protocol==="https:",d=l?gi.default:mi.default;n.projectPaths={...n.projectPaths??{},[e]:o},de(n),console.log("[cli] link-project.local_json_written",{projectId:e,path:o});let u=JSON.stringify({daemonId:s,localPath:o,machineFingerprint:Br()}),f=await new Promise((h,g)=>{let p=d.request({hostname:a.hostname,port:a.port||(l?443:80),path:a.pathname,method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n.token}`,"Content-Length":Buffer.byteLength(u)}},m=>{let I="";m.on("data",S=>{I+=S}),m.on("end",()=>{if(m.statusCode===200)h(200);else{try{let S=JSON.parse(I);console.error("[bridge] link-project failed:",S.error??`HTTP ${m.statusCode}`)}catch{console.error("[bridge] link-project failed:",`HTTP ${m.statusCode}`)}h(m.statusCode??0)}})});p.on("error",m=>{g(m)}),p.write(u),p.end()});f===200?(console.log("[cli] link-project.server_success",{projectId:e}),console.log("[cli] link-project.success (dual-write)"),console.log(` workspace: ${r}`),console.log(` project: ${e}`),console.log(` daemon: ${s.slice(0,16)}\u2026`),console.log(` path: ${o}`),console.log("[cli] Next spawn for this project will use the linked path."),process.exit(0)):(console.warn("[cli] link-project.server_fail",{projectId:e,statusCode:f}),console.log("[cli] Local override still active \u2014 path will work on this machine"),process.exit(0))}Oe();function Nc(r){return r.replace(/^wss?:/,e=>e==="wss:"?"https:":"http:").replace(/\/ws(\/.*)?$/,"")}async function bi(){let r=le(),e=Nc(r.server),t=await fetch(`${e}/api/admin/cleanup-orphans`,{method:"POST",headers:{Authorization:`Bearer ${r.token}`,"Content-Type":"application/json"},body:"{}"});t.ok||(console.error(`[cli] cleanup-orphans: HTTP ${t.status}`),process.exit(1));let{deleted:n}=await t.json();console.log(`[cli] cleanup-orphans: deleted ${n} orphaned path ${n===1?"entry":"entries"}`),process.exit(0)}var Wr=require("node:child_process"),rt=require("node:fs"),Fr=_(require("path"));qe();var wi=require("node:os"),Rc=Fr.default.join((0,wi.homedir)(),"Library","LaunchAgents");function Si(){let r=xe(),e=r.replace(".plist",""),t=Fr.default.join(Rc,r);try{(0,Wr.execSync)(`launchctl bootout gui/$(id -u)/${e}`,{stdio:"pipe"}),console.log("[bridge] launchd.stopped \u2014 daemon unloaded")}catch{try{(0,rt.existsSync)(t)?((0,Wr.execSync)(`launchctl unload "${t}"`,{stdio:"pipe"}),console.log("[bridge] launchd.unloaded \u2014 daemon stopped")):console.warn("[bridge] launchd.stop.failed \u2014 plist not found, daemon may not be running via launchd")}catch{console.warn("[bridge] launchd.stop.failed \u2014 daemon may not be running via launchd"),console.warn(`[bridge] Manual: launchctl bootout gui/$(id -u)/${e}`)}}let n=He();if((0,rt.existsSync)(n))try{(0,rt.unlinkSync)(n),console.log("[bridge] lock.cleaned")}catch{}}var te=new nn;te.name("bridge-agent").description("Bridge local agent \u2014 connects your AI tools to Jerico").version("0.7.1").option("--profile <name>","Config profile name (e.g. dev). Isolates config, lock, and fingerprint from the default prod profile.").hook("preAction",r=>{let e=r.opts().profile;e&&(process.env.BRIDGE_PROFILE=e)});te.command("start").description("Start the bridge-agent daemon").option("--health-port <port>","Health check HTTP port (default: 3101, or 3101+offset per profile)").action(r=>{r.healthPort&&(process.env.HEALTH_PORT=r.healthPort),ui()});te.command("auth").description("Authenticate with Bridge server").option("-s, --server <url>","Server URL (default: https://lcars.jerico.appnova.io)").option("-t, --token <token>","Use token non-interactively").option("--no-browser","Print auth URL without opening browser or interactive prompt").action(r=>{fi(r.server,!r.browser,r.token)});te.command("link-project <workspace-id> <project-id> <local-path>").description("Link a local directory to a project for this machine (Issue #152)").action((r,e,t)=>{yi(r,e,t)});te.command("cleanup-orphans").description("Remove orphaned daemon_project_paths rows for this user").action(()=>{bi()});te.command("status").description("Show connection status").action(async()=>{try{let{loadConfig:r}=await Promise.resolve().then(()=>(Oe(),Ys)),e=r();console.log("[bridge] Config found"),console.log(" Server:",e.server),console.log(" Name:",e.name)}catch{console.log("[bridge] Not authenticated. Run: bridge-agent auth")}});te.command("stop").description("Stop the bridge-agent daemon").action(()=>{Si()});te.parse();
448
+ `)&&(process.stdin.pause(),r(e.trim()))}),process.stdin.resume()})}async function Ac(r,e){return new Promise(t=>{let n=new URL("/api/tokens/validate",r),s=n.protocol==="https:",i=s?pi.default:hi.default,o={hostname:n.hostname,port:n.port||(s?443:80),path:n.pathname,method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`}},c=i.request(o,a=>{t(a.statusCode===200)});c.on("error",()=>t(!1)),c.end()})}var gi=_(require("https")),mi=_(require("http")),$r=_(require("fs")),Ur=_(require("path")),_i=require("node:crypto");Oe();function Tc(r){return r.replace(/^wss?:/,e=>e==="wss:"?"https:":"http:").replace(/\/ws(\/.*)?$/,"")}async function yi(r,e,t){let n=le(),s=(0,_i.createHash)("sha256").update(n.token).digest("hex"),i=Tc(n.server),o=Ur.default.resolve(t);Ur.default.isAbsolute(o)||(console.error("[bridge] link-project: path must be absolute"),process.exit(1)),$r.default.existsSync(o)||(console.error("[bridge] link-project: path does not exist:",o),process.exit(1)),$r.default.statSync(o).isDirectory()||(console.error("[bridge] link-project: path must be a directory:",o),process.exit(1));let a=new URL(`/api/workspaces/${r}/projects/${e}/machine-paths`,i),l=a.protocol==="https:",d=l?gi.default:mi.default;n.projectPaths={...n.projectPaths??{},[e]:o},de(n),console.log("[cli] link-project.local_json_written",{projectId:e,path:o});let u=JSON.stringify({daemonId:s,localPath:o,machineFingerprint:Br()}),f=await new Promise((h,g)=>{let p=d.request({hostname:a.hostname,port:a.port||(l?443:80),path:a.pathname,method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n.token}`,"Content-Length":Buffer.byteLength(u)}},m=>{let I="";m.on("data",S=>{I+=S}),m.on("end",()=>{if(m.statusCode===200)h(200);else{try{let S=JSON.parse(I);console.error("[bridge] link-project failed:",S.error??`HTTP ${m.statusCode}`)}catch{console.error("[bridge] link-project failed:",`HTTP ${m.statusCode}`)}h(m.statusCode??0)}})});p.on("error",m=>{g(m)}),p.write(u),p.end()});f===200?(console.log("[cli] link-project.server_success",{projectId:e}),console.log("[cli] link-project.success (dual-write)"),console.log(` workspace: ${r}`),console.log(` project: ${e}`),console.log(` daemon: ${s.slice(0,16)}\u2026`),console.log(` path: ${o}`),console.log("[cli] Next spawn for this project will use the linked path."),process.exit(0)):(console.warn("[cli] link-project.server_fail",{projectId:e,statusCode:f}),console.log("[cli] Local override still active \u2014 path will work on this machine"),process.exit(0))}Oe();function Nc(r){return r.replace(/^wss?:/,e=>e==="wss:"?"https:":"http:").replace(/\/ws(\/.*)?$/,"")}async function bi(){let r=le(),e=Nc(r.server),t=await fetch(`${e}/api/admin/cleanup-orphans`,{method:"POST",headers:{Authorization:`Bearer ${r.token}`,"Content-Type":"application/json"},body:"{}"});t.ok||(console.error(`[cli] cleanup-orphans: HTTP ${t.status}`),process.exit(1));let{deleted:n}=await t.json();console.log(`[cli] cleanup-orphans: deleted ${n} orphaned path ${n===1?"entry":"entries"}`),process.exit(0)}var Wr=require("node:child_process"),rt=require("node:fs"),Fr=_(require("path"));qe();var wi=require("node:os"),Rc=Fr.default.join((0,wi.homedir)(),"Library","LaunchAgents");function Si(){let r=xe(),e=r.replace(".plist",""),t=Fr.default.join(Rc,r);try{(0,Wr.execSync)(`launchctl bootout gui/$(id -u)/${e}`,{stdio:"pipe"}),console.log("[bridge] launchd.stopped \u2014 daemon unloaded")}catch{try{(0,rt.existsSync)(t)?((0,Wr.execSync)(`launchctl unload "${t}"`,{stdio:"pipe"}),console.log("[bridge] launchd.unloaded \u2014 daemon stopped")):console.warn("[bridge] launchd.stop.failed \u2014 plist not found, daemon may not be running via launchd")}catch{console.warn("[bridge] launchd.stop.failed \u2014 daemon may not be running via launchd"),console.warn(`[bridge] Manual: launchctl bootout gui/$(id -u)/${e}`)}}let n=He();if((0,rt.existsSync)(n))try{(0,rt.unlinkSync)(n),console.log("[bridge] lock.cleaned")}catch{}}var te=new nn;te.name("bridge-agent").description("Bridge local agent \u2014 connects your AI tools to Jerico").version("0.7.2").option("--profile <name>","Config profile name (e.g. dev). Isolates config, lock, and fingerprint from the default prod profile.").hook("preAction",r=>{let e=r.opts().profile;e&&(process.env.BRIDGE_PROFILE=e)});te.command("start").description("Start the bridge-agent daemon").option("--health-port <port>","Health check HTTP port (default: 3101, or 3101+offset per profile)").action(r=>{r.healthPort&&(process.env.HEALTH_PORT=r.healthPort),ui()});te.command("auth").description("Authenticate with Bridge server").option("-s, --server <url>","Server URL (default: https://lcars.jerico.appnova.io)").option("-t, --token <token>","Use token non-interactively").option("--no-browser","Print auth URL without opening browser or interactive prompt").action(r=>{fi(r.server,!r.browser,r.token)});te.command("link-project <workspace-id> <project-id> <local-path>").description("Link a local directory to a project for this machine (Issue #152)").action((r,e,t)=>{yi(r,e,t)});te.command("cleanup-orphans").description("Remove orphaned daemon_project_paths rows for this user").action(()=>{bi()});te.command("status").description("Show connection status").action(async()=>{try{let{loadConfig:r}=await Promise.resolve().then(()=>(Oe(),Ys)),e=r();console.log("[bridge] Config found"),console.log(" Server:",e.server),console.log(" Name:",e.name)}catch{console.log("[bridge] Not authenticated. Run: bridge-agent auth")}});te.command("stop").description("Stop the bridge-agent daemon").action(()=>{Si()});te.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bridge-agent",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Bridge local agent — connects your AI tools to Jerico",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1 +0,0 @@
1
- export {};
@@ -1,75 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export declare function runAuth(serverUrl: string, noBrowser?: boolean, providedToken?: string): Promise<void>;
@@ -1,88 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export declare function runStart(): void;
@@ -1,258 +0,0 @@
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 DELETED
@@ -1,25 +0,0 @@
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 DELETED
@@ -1,85 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/metrics.d.ts DELETED
@@ -1,11 +0,0 @@
1
- export interface SystemMetrics {
2
- cpu: number;
3
- ramUsedMb: number;
4
- ramTotalMb: number;
5
- ramCachedMb: number;
6
- battery?: {
7
- percent: number;
8
- charging: boolean;
9
- };
10
- }
11
- export declare function startMetricsRelay(sendFn: (metrics: SystemMetrics) => void): () => void;