@nopeek/agent-bridge 0.1.0 → 0.2.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.
@@ -0,0 +1,174 @@
1
+ // `nopeek-agent-bridge install` — the ONE terminal command. Installs the bridge
2
+ // as an always-on background service (launchd on macOS, systemd --user on
3
+ // Linux) that starts at login and waits, unpaired, for the NoPeek app to
4
+ // connect it. Everything after this happens in the app.
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { mkdirSync, realpathSync, writeFileSync, rmSync, existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ const LABEL = "com.nopeek.agent-bridge";
10
+ const UNIT = "nopeek-agent-bridge.service";
11
+ function xmlEscape(s) {
12
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+ }
14
+ /**
15
+ * Resolve the entrypoint the service should run. If we're executing out of the
16
+ * npx cache (wiped on `npm cache clean` / eviction), install a private copy
17
+ * under <home>/app first so the service survives.
18
+ */
19
+ function resolveEntrypoint(homeDir) {
20
+ const self = realpathSync(process.argv[1]);
21
+ if (!/[\\/]_npx[\\/]/.test(self))
22
+ return self;
23
+ console.log(`[install] running from the npx cache — installing a permanent copy into ${join(homeDir, "app")}`);
24
+ mkdirSync(join(homeDir, "app"), { recursive: true });
25
+ const r = spawnSync("npm", ["install", "--prefix", join(homeDir, "app"), "@nopeek/agent-bridge@latest"], {
26
+ stdio: "inherit",
27
+ });
28
+ if (r.status !== 0)
29
+ throw new Error(`npm install into ${join(homeDir, "app")} failed (exit ${r.status})`);
30
+ const entry = join(homeDir, "app", "node_modules", "@nopeek/agent-bridge", "dist", "cli.js");
31
+ if (!existsSync(entry))
32
+ throw new Error(`expected ${entry} after install — not found`);
33
+ return entry;
34
+ }
35
+ function launchctl(args, ignoreFailure = false) {
36
+ try {
37
+ execFileSync("launchctl", args, { stdio: "pipe" });
38
+ }
39
+ catch (err) {
40
+ if (!ignoreFailure)
41
+ throw err;
42
+ }
43
+ }
44
+ export async function installService(cfg) {
45
+ if (process.platform !== "darwin" && process.platform !== "linux") {
46
+ throw new Error(`automatic service install supports macOS and Linux. On this platform, run the bridge with any process manager:\n nopeek-agent-bridge run`);
47
+ }
48
+ mkdirSync(join(cfg.homeDir, "logs"), { recursive: true });
49
+ const entry = resolveEntrypoint(cfg.homeDir);
50
+ const logFile = join(cfg.homeDir, "logs", "bridge.log");
51
+ if (process.platform === "darwin") {
52
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
53
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
54
+ // PATH is captured at install time: launchd's default PATH won't find
55
+ // hermes/claude/… installed via Homebrew or nvm.
56
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
57
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
+ <plist version="1.0">
59
+ <dict>
60
+ <key>Label</key><string>${LABEL}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ <string>${xmlEscape(process.execPath)}</string>
64
+ <string>${xmlEscape(entry)}</string>
65
+ <string>run</string>
66
+ </array>
67
+ <key>WorkingDirectory</key><string>${xmlEscape(cfg.homeDir)}</string>
68
+ <key>EnvironmentVariables</key>
69
+ <dict>
70
+ <key>NOPEEK_BRIDGE_HOME</key><string>${xmlEscape(cfg.homeDir)}</string>
71
+ <key>PATH</key><string>${xmlEscape(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin")}</string>
72
+ </dict>
73
+ <key>RunAtLoad</key><true/>
74
+ <key>KeepAlive</key><true/>
75
+ <key>StandardOutPath</key><string>${xmlEscape(logFile)}</string>
76
+ <key>StandardErrorPath</key><string>${xmlEscape(logFile)}</string>
77
+ </dict>
78
+ </plist>
79
+ `;
80
+ writeFileSync(plistPath, plist);
81
+ const domain = `gui/${process.getuid?.() ?? 501}`;
82
+ launchctl(["bootout", `${domain}/${LABEL}`], true); // idempotent reinstall
83
+ try {
84
+ launchctl(["bootstrap", domain, plistPath]);
85
+ }
86
+ catch {
87
+ launchctl(["load", "-w", plistPath]); // pre-Catalina fallback
88
+ }
89
+ console.log(`[install] launchd service installed: ${plistPath}`);
90
+ }
91
+ else {
92
+ const unitDir = join(homedir(), ".config", "systemd", "user");
93
+ mkdirSync(unitDir, { recursive: true });
94
+ const unitPath = join(unitDir, UNIT);
95
+ writeFileSync(unitPath, `[Unit]
96
+ Description=NoPeek agent bridge (E2EE bots)
97
+ After=network-online.target
98
+
99
+ [Service]
100
+ ExecStart=${process.execPath} ${entry} run
101
+ WorkingDirectory=${cfg.homeDir}
102
+ Environment=NOPEEK_BRIDGE_HOME=${cfg.homeDir}
103
+ Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
104
+ Restart=always
105
+ RestartSec=3
106
+
107
+ [Install]
108
+ WantedBy=default.target
109
+ `);
110
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
111
+ execFileSync("systemctl", ["--user", "enable", "--now", UNIT], { stdio: "pipe" });
112
+ console.log(`[install] systemd user service installed: ${unitPath}`);
113
+ console.log(`[install] tip: 'loginctl enable-linger ${process.env.USER ?? "$USER"}' keeps it running while logged out`);
114
+ }
115
+ // Confirm it actually came up.
116
+ const status = await waitForBridge(cfg.port, 15_000);
117
+ if (status) {
118
+ console.log(`[install] bridge is running (v${String(status.version)}, ${status.paired ? "paired" : "not paired yet"})`);
119
+ }
120
+ else {
121
+ console.warn(`[install] service installed but not answering on :${cfg.port} yet — check ${logFile}`);
122
+ }
123
+ console.log(`
124
+ Done. Next step — in the NoPeek app on THIS computer:
125
+ Contacts -> My Bots -> Connect this computer
126
+ Pairing, choosing your agent (Hermes, …) and everything else happens in the app.
127
+ Logs: ${logFile}`);
128
+ }
129
+ export function uninstallService() {
130
+ if (process.platform === "darwin") {
131
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
132
+ launchctl(["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`], true);
133
+ rmSync(plistPath, { force: true });
134
+ console.log(`[uninstall] launchd service removed (settings and device keys kept in ~/.nopeek-bridge)`);
135
+ }
136
+ else if (process.platform === "linux") {
137
+ try {
138
+ execFileSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "pipe" });
139
+ }
140
+ catch {
141
+ /* not installed */
142
+ }
143
+ rmSync(join(homedir(), ".config", "systemd", "user", UNIT), { force: true });
144
+ console.log(`[uninstall] systemd user service removed (settings and device keys kept)`);
145
+ }
146
+ else {
147
+ console.log(`[uninstall] no service support on this platform`);
148
+ }
149
+ }
150
+ export async function printStatus(port) {
151
+ const status = await waitForBridge(port, 1_500);
152
+ if (!status) {
153
+ console.log(`no bridge answering on http://127.0.0.1:${port}/ — is the service installed and running?`);
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ console.log(JSON.stringify(status, null, 2));
158
+ }
159
+ async function waitForBridge(port, timeoutMs) {
160
+ const deadline = Date.now() + timeoutMs;
161
+ for (;;) {
162
+ try {
163
+ const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1_000) });
164
+ if (res.ok)
165
+ return (await res.json());
166
+ }
167
+ catch {
168
+ /* not up yet */
169
+ }
170
+ if (Date.now() >= deadline)
171
+ return null;
172
+ await new Promise((r) => setTimeout(r, 500));
173
+ }
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with a one-time code, runs every bot you own, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,8 +22,15 @@
22
22
  "engines": {
23
23
  "node": ">=22"
24
24
  },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "prepack": "tsc -p tsconfig.json",
28
+ "start": "node dist/cli.js",
29
+ "dev": "tsx src/cli.ts",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit"
31
+ },
25
32
  "dependencies": {
26
- "@nopeek/chat": "0.1.0"
33
+ "@nopeek/chat": "workspace:*"
27
34
  },
28
35
  "devDependencies": {
29
36
  "@types/node": "^22.10.0",
@@ -40,11 +47,5 @@
40
47
  ],
41
48
  "publishConfig": {
42
49
  "access": "public"
43
- },
44
- "scripts": {
45
- "build": "tsc -p tsconfig.json",
46
- "start": "node dist/cli.js",
47
- "dev": "tsx src/cli.ts",
48
- "typecheck": "tsc -p tsconfig.json --noEmit"
49
50
  }
50
- }
51
+ }