@clearlysh/beehaven 0.8.29 → 0.8.31

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/daemon.js CHANGED
@@ -13,6 +13,8 @@ import { DaemonRepl } from './repl.js';
13
13
  import { browsePath } from './file-browser.js';
14
14
  import { CloudBeeProxy } from './bee/cloud-proxy.js';
15
15
  import { IpcServer, probeIpcSocket } from './ipc-server.js';
16
+ import { ipcSocketPath } from './ipc-path.js';
17
+ import { isBeehavenCommandLine, windowsNodeProcesses, selectWindowsDaemons } from './daemon-process.js';
16
18
  import { mintIpcAuthTokens, clearIpcAuthToken } from './ipc-auth.js';
17
19
  /**
18
20
  * What THIS daemon minted, so shutdown can avoid scrubbing a successor's token.
@@ -44,6 +46,7 @@ import { homedir, tmpdir } from 'os';
44
46
  import { join, dirname, relative } from 'path';
45
47
  import { fileURLToPath } from 'url';
46
48
  import { compareVersions, usableUpdateDigest } from './version-utils.js';
49
+ import { npmUpdatePlan, installNpmVersion } from './npm-update.js';
47
50
  import { installKind } from './commands/self-update.js';
48
51
  const __dirname = dirname(fileURLToPath(import.meta.url));
49
52
  const CONFIG_DIR = join(homedir(), '.beehaven');
@@ -961,9 +964,85 @@ function updatesAreManagedExternally() {
961
964
  *
962
965
  * ⚠ NOTHING ELSE CALLS IT. The daemon's timer is still the only production caller.
963
966
  */
967
+ /** Said once per daemon, not hourly — see the managed-updates note below. */
968
+ let announcedNpmUpdateBlocked = false;
969
+ /**
970
+ * Start the freshly installed version and leave (shared by the curl and npm update paths).
971
+ * A CLI-spawned headless daemon is detached, with no supervisor to interpret exit 75, so it starts
972
+ * the new entry in a helper that waits until this process releases the IPC socket.
973
+ */
974
+ async function restartForUpdate() {
975
+ if (process.env.BEEHAVEN_CLI_OWNED_DAEMON === '1') {
976
+ const child = spawn(process.execPath, [join(__dirname, 'daemon-entry.js'), '--restart-after-update', String(process.pid)], {
977
+ detached: true,
978
+ stdio: 'ignore',
979
+ env: process.env,
980
+ });
981
+ child.on('error', (error) => console.error(` [update] Restart helper failed: ${error.message}`));
982
+ if (!child.pid)
983
+ throw new Error('could not start update restart helper');
984
+ child.unref();
985
+ }
986
+ if (daemonRelay?.isConnected())
987
+ await new Promise((r) => setTimeout(r, 500));
988
+ process.exit(75);
989
+ }
990
+ /**
991
+ * ⚠⚠ npm INSTALLS (THE ONLY WINDOWS INSTALL) NEVER AUTO-UPDATED (CAN2-57). This routine re-ran the
992
+ * bash installer, so it skipped every npm install and a Windows user stayed on their first version.
993
+ * They now update with npm itself — to the version the release manifest names (stable only) — when
994
+ * this copy IS npm's global install and its folder is writable; otherwise one line says why and
995
+ * what to run. No rollback file: `install.sh` cannot reinstall an npm package; a bad release is
996
+ * undone with `npm install -g @clearlysh/beehaven@<previous>`.
997
+ */
998
+ async function checkAndAutoUpdateNpm() {
999
+ // A headless daemon's stdout goes nowhere, so the update story also goes to daemon.log.
1000
+ const say = (m, level = 'info') => {
1001
+ (level === 'error' ? console.error : console.log)(` [update] ${m}`);
1002
+ try {
1003
+ logger[level]('daemon', `[update] ${m}`);
1004
+ }
1005
+ catch { /* logging never blocks an update */ }
1006
+ };
1007
+ try {
1008
+ const controller = new AbortController();
1009
+ const timeout = setTimeout(() => controller.abort(), 5000);
1010
+ const res = await fetch(LATEST_MANIFEST, { signal: controller.signal });
1011
+ clearTimeout(timeout);
1012
+ if (!res.ok)
1013
+ return;
1014
+ const latest = (await res.json())?.version;
1015
+ if (typeof latest !== 'string')
1016
+ return;
1017
+ const current = getCurrentVersion();
1018
+ if (compareVersions(latest, current) <= 0)
1019
+ return;
1020
+ const plan = npmUpdatePlan(join(__dirname, '..'));
1021
+ if (!plan.ok) {
1022
+ if (!announcedNpmUpdateBlocked) {
1023
+ announcedNpmUpdateBlocked = true;
1024
+ say(`${latest} is available, but this npm install cannot update itself: ${plan.reason}. Run: npm install -g @clearlysh/beehaven@latest`);
1025
+ }
1026
+ return;
1027
+ }
1028
+ say(`Updating ${current} → ${latest} with npm...`);
1029
+ const r = await installNpmVersion(plan, latest);
1030
+ if (!r.ok)
1031
+ throw new Error(r.message);
1032
+ if (getCurrentVersion() !== latest)
1033
+ throw new Error(`npm did not install ${latest}`);
1034
+ say(`Updated to v${latest} — restarting...`);
1035
+ await restartForUpdate();
1036
+ }
1037
+ catch (error) {
1038
+ say(`Update failed: ${error?.message ?? error}`, 'error');
1039
+ }
1040
+ }
964
1041
  export async function checkAndAutoUpdate() {
965
1042
  const owner = updatesAreManagedExternally();
966
1043
  const runningKind = installKind(join(__dirname, 'daemon-entry.js'));
1044
+ if (!owner && runningKind === 'npm')
1045
+ return checkAndAutoUpdateNpm();
967
1046
  if (owner || runningKind !== 'user') {
968
1047
  // Said once at startup, not hourly — a line every hour about a thing that is working as
969
1048
  // designed is how a log stops being read.
@@ -1015,19 +1094,6 @@ export async function checkAndAutoUpdate() {
1015
1094
  throw new Error(`installer did not install ${latest}`);
1016
1095
  }
1017
1096
  console.log(` [update] Updated to v${latest} — restarting...`);
1018
- if (process.env.BEEHAVEN_CLI_OWNED_DAEMON === '1') {
1019
- // A CLI-spawned headless daemon is detached, with no supervisor to interpret exit 75.
1020
- // Start the new entry in a helper that waits until this process releases the IPC socket.
1021
- const child = spawn(process.execPath, [join(__dirname, 'daemon-entry.js'), '--restart-after-update', String(process.pid)], {
1022
- detached: true,
1023
- stdio: 'ignore',
1024
- env: process.env,
1025
- });
1026
- child.on('error', (error) => console.error(` [update] Restart helper failed: ${error.message}`));
1027
- if (!child.pid)
1028
- throw new Error('could not start update restart helper');
1029
- child.unref();
1030
- }
1031
1097
  // Drop the temp tarball BEFORE exiting. `process.exit` skips `finally`,
1032
1098
  // so cleaning up only there would leak the whole release into /tmp on every
1033
1099
  // SUCCESSFUL update — the one path that always runs.
@@ -1044,13 +1110,9 @@ export async function checkAndAutoUpdate() {
1044
1110
  catch { }
1045
1111
  installerPath = null;
1046
1112
  }
1047
- // Give relay a moment to flush
1048
- if (daemonRelay?.isConnected()) {
1049
- await new Promise((r) => setTimeout(r, 500));
1050
- }
1051
- // Exit with non-zero so launchd/systemd restarts with the new version
1052
- // (KeepAlive > SuccessfulExit > false means exit 0 stays down)
1053
- process.exit(75);
1113
+ // Start the new version and exit non-zero, so launchd/systemd also restarts with it
1114
+ // (KeepAlive > SuccessfulExit > false means exit 0 stays down).
1115
+ await restartForUpdate();
1054
1116
  }
1055
1117
  catch (error) {
1056
1118
  console.error(` [update] Update failed: ${error?.message ?? error}`);
@@ -1104,41 +1166,17 @@ function isBeehavenProcess(pid) {
1104
1166
  timeout: 3000,
1105
1167
  }).trim();
1106
1168
  }
1169
+ else if (process.platform === 'win32') {
1170
+ // No `ps` and no /proc on Windows: read the command line from CIM (CAN2-56).
1171
+ cmd = windowsNodeProcesses(pid)?.find(([p]) => p === pid)?.[1] ?? '';
1172
+ }
1107
1173
  else {
1108
1174
  // Linux: /proc/PID/cmdline uses NULs; normalize to spaces
1109
1175
  cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
1110
1176
  }
1111
- if (!cmd)
1112
- return false;
1113
- // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary.
1114
- // Anchoring with ^ so a shell running a heredoc that contains
1115
- // "node .../cli.js" deeper in its argv doesn't false-positive.
1116
- const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
1117
- /^(\S*\/)?tsx(\s|$)/.test(cmd) ||
1118
- /^(\S*\/)?beehaven(\s|$)/.test(cmd);
1119
- if (!argv0Ok)
1120
- return false;
1121
- // Condition 2: a subsequent argument must reference a beehaven script.
1122
- //
1123
- // NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE
1124
- // `.beehaven/`. `public/install.sh` installs to `~/.beehaven/cli/`, which is
1125
- // the canonical location for every `curl … | sh` user, and these patterns
1126
- // used to require a path segment of exactly "beehaven". So the CLI could not
1127
- // recognise its OWN standard install: `stop` reported "No daemon running"
1128
- // while one was running, `restart` printed a fresh PID whose process then
1129
- // died with "Refusing to start — IPC socket already serving connections",
1130
- // and the real daemon was effectively unkillable from the CLI. Only
1131
- // monorepo (`beehaven_sdk/`) and Mac-app installs were ever detected.
1132
- const scriptOk =
1133
- // Dedicated daemon entry — what the Mac app tray spawns.
1134
- /[/\\]\.?beehaven(_sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
1135
- /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
1136
- // Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
1137
- /[/\\]\.?beehaven(_sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
1138
- /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
1139
- // Bare `beehaven start` — wrapper already matched argv[0].
1140
- /^(\S*\/)?beehaven\s+start\b/.test(cmd);
1141
- return scriptOk;
1177
+ // The two-condition rule (argv[0] is node/tsx/beehaven AND an argument is a beehaven script)
1178
+ // lives in daemon-process.ts, where it also understands Windows command lines.
1179
+ return isBeehavenCommandLine(cmd);
1142
1180
  }
1143
1181
  catch {
1144
1182
  return false; // Can't read → not our process or already dead
@@ -1151,6 +1189,23 @@ function isBeehavenProcess(pid) {
1151
1189
  */
1152
1190
  export function findLiveDaemons() {
1153
1191
  const found = new Set();
1192
+ // Windows: no PID-file trust without a command line, and no pgrep — ONE CIM query of node
1193
+ // processes answers both "is the PID file's process ours" and "are there orphans" (CAN2-56).
1194
+ if (process.platform === 'win32') {
1195
+ let pidFilePid = null;
1196
+ try {
1197
+ pidFilePid = existsSync(PID_FILE) ? parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10) || null : null;
1198
+ }
1199
+ catch { }
1200
+ const alive = (pid) => { try {
1201
+ process.kill(pid, 0);
1202
+ return true;
1203
+ }
1204
+ catch {
1205
+ return false;
1206
+ } };
1207
+ return selectWindowsDaemons(windowsNodeProcesses(), { selfPid: process.pid, pidFilePid, isAlive: alive });
1208
+ }
1154
1209
  // 1. Check PID file first — the canonical source.
1155
1210
  if (existsSync(PID_FILE)) {
1156
1211
  try {
@@ -1294,7 +1349,7 @@ export async function startDaemon(opts) {
1294
1349
  // live server answering here means some daemon is alive that findLiveDaemons
1295
1350
  // missed (e.g. different binary path, permissions edge case).
1296
1351
  if (await probeIpcSocket()) {
1297
- console.error(`\n ✗ Another beehaven daemon is listening on /tmp/beehaven-ipc.sock.`);
1352
+ console.error(`\n ✗ Another beehaven daemon is listening on ${ipcSocketPath()}.`);
1298
1353
  console.error(` ✗ Quit the Clearly Dev Mac app tray to stop the existing daemon, then restart.\n`);
1299
1354
  try {
1300
1355
  logger.error('daemon', 'Refusing to start — IPC socket already serving connections');
package/dist/ipc-auth.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * IPC auth — scope-aware shared secrets between daemon and local clients.
3
3
  *
4
4
  * Threat model: any process running as the same user can connect to
5
- * /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users).
5
+ * /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users). On
6
+ * Windows the daemon listens on a per-user named pipe, which has no file mode at all — so on
7
+ * that platform these tokens ARE the boundary for same-user callers (ipc-path.ts).
6
8
  * Without per-scope auth, any same-user process can invoke privileged
7
9
  * verbs — extract the Firebase ID token via `get-token`, run arbitrary
8
10
  * SQL, install marketplace packages with code execution.
@@ -4,14 +4,15 @@
4
4
  * Used by CLI commands (actions, call, pipe, watch, ls, cat) to communicate
5
5
  * with the running daemon, which forwards requests to the cloud DO.
6
6
  *
7
- * Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock
7
+ * Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock (a named pipe on Windows — ipc-path.ts)
8
8
  */
9
- import { existsSync } from 'node:fs';
10
9
  import { createConnection } from 'net';
11
10
  import { EventEmitter } from 'events';
12
11
  import { resolveClientAuthToken } from './ipc-auth.js';
12
+ import { ipcSocketPath, socketMayExist } from './ipc-path.js';
13
13
  import { activeAgentWrap } from './agent-attribution.js';
14
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
14
+ /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
15
+ const IPC_SOCKET_PATH = ipcSocketPath();
15
16
  /** Default timeout for RPC calls (ms) */
16
17
  const DEFAULT_TIMEOUT = 30_000;
17
18
  /**
@@ -49,7 +50,7 @@ export class IpcClient extends EventEmitter {
49
50
  * — the only way to guard the CALL SITE rather than the helper it calls, which is the exact
50
51
  * distinction that let 28 verbs ship un-attributed. Unset, nothing changes.
51
52
  */
52
- this.socketPath = opts?.socketPath ?? process.env.BEEHAVEN_IPC_SOCKET ?? IPC_SOCKET_PATH;
53
+ this.socketPath = opts?.socketPath ?? ipcSocketPath(); // honours $BEEHAVEN_IPC_SOCKET
53
54
  this.defaultTimeout = opts?.timeout ?? DEFAULT_TIMEOUT;
54
55
  this.explicitAuthToken = opts?.authToken;
55
56
  }
@@ -113,7 +114,7 @@ export class IpcClient extends EventEmitter {
113
114
  ? resolveClientAuthToken()
114
115
  : this.explicitAuthToken;
115
116
  let token = readToken();
116
- if (!token && this.explicitAuthToken === undefined && existsSync(this.socketPath)) {
117
+ if (!token && this.explicitAuthToken === undefined && socketMayExist(this.socketPath)) {
117
118
  const deadline = Date.now() + TOKEN_MINT_WAIT_MS;
118
119
  while (!token && Date.now() < deadline) {
119
120
  /**
@@ -143,7 +144,7 @@ export class IpcClient extends EventEmitter {
143
144
  * ⚠ THE SOCKET IS THE DISCRIMINATOR: up means "still starting, wait"; absent
144
145
  * means "nothing is running, start one". Opposite actions.
145
146
  */
146
- (existsSync(this.socketPath)
147
+ (socketMayExist(this.socketPath)
147
148
  ? 'The daemon is still starting — its socket is up but it has not minted an IPC token yet. ' +
148
149
  'Wait a second and retry. (Do NOT restart: `stop` scrubs the token, which re-enters this window.)'
149
150
  : 'No IPC auth token and no daemon socket — nothing is running. ' +
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ipc-path — where the daemon's local sockets live, on every platform (CAN2-56).
3
+ *
4
+ * ⚠⚠ THE ONE PLACE A SOCKET ADDRESS IS DECIDED. `/tmp/beehaven-ipc.sock` was typed out in five CLI
5
+ * files (server, three clients, the claude bridge) and `/tmp/beehaven.sock` in two more, each
6
+ * gating its connect on `existsSync`, unlinking stale files and chmod-ing to 0600. On Windows none
7
+ * of that works: Node's `net` listens on a NAMED PIPE (`\\.\pipe\…`), there is no `/tmp`, a pipe is
8
+ * not a file you can stat, unlink or chmod — so the daemon could not bind and no client could
9
+ * reach it. Every caller now asks this module, and the Unix addresses are byte-for-byte unchanged
10
+ * (the Mac app, the mwah CLI and the Electron daemon client still dial `/tmp/beehaven-ipc.sock`).
11
+ *
12
+ * ⚠ A PIPE NAME IS MACHINE-GLOBAL, so it carries the user name: two people signed in to one
13
+ * Windows machine get two daemons, as two Unix users do. Same-user is still the boundary that
14
+ * matters, and the IPC auth token (`ipc-auth.ts`) is what gates verbs on both platforms — the
15
+ * Unix 0600 mode is defense in depth that a pipe does not have a file-mode equivalent for.
16
+ *
17
+ * ⚠ `$BEEHAVEN_IPC_SOCKET` / `$BEEHAVEN_WATCHER_SOCKET` override the address for BOTH ends (server
18
+ * and clients), so a test can stand a real daemon and a real client on a private path. Read at call
19
+ * time, not import time, for the same reason.
20
+ */
21
+ import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
22
+ import { tmpdir, userInfo } from 'os';
23
+ import { join } from 'path';
24
+ export const UNIX_IPC_SOCKET = '/tmp/beehaven-ipc.sock';
25
+ export const UNIX_WATCHER_SOCKET = '/tmp/beehaven.sock';
26
+ export const UNIX_EVENTS_FILE = '/tmp/beehaven-events.jsonl';
27
+ /** `\\.\pipe\…` or `\\?\pipe\…` — the forms Node accepts for a Windows named pipe. */
28
+ export function isNamedPipe(p) {
29
+ return /^\\\\[.?]\\pipe\\/i.test(p);
30
+ }
31
+ /** The signed-in user's name, made safe for a pipe name. */
32
+ function pipeUser(env) {
33
+ let name = '';
34
+ try {
35
+ name = userInfo().username;
36
+ }
37
+ catch { /* no passwd entry / sandbox */ }
38
+ if (!name)
39
+ name = env.USERNAME || env.USER || 'user';
40
+ return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'user';
41
+ }
42
+ const pipe = (name, env) => `\\\\.\\pipe\\${name}-${pipeUser(env)}`;
43
+ /** The daemon's IPC address: the Unix socket, or this user's named pipe on Windows. */
44
+ export function ipcSocketPath(platform = process.platform, env = process.env) {
45
+ if (env.BEEHAVEN_IPC_SOCKET)
46
+ return env.BEEHAVEN_IPC_SOCKET;
47
+ return platform === 'win32' ? pipe('beehaven-ipc', env) : UNIX_IPC_SOCKET;
48
+ }
49
+ /** The Claude Code event watcher's HTTP-over-socket address. */
50
+ export function watcherSocketPath(platform = process.platform, env = process.env) {
51
+ if (env.BEEHAVEN_WATCHER_SOCKET)
52
+ return env.BEEHAVEN_WATCHER_SOCKET;
53
+ return platform === 'win32' ? pipe('beehaven-watcher', env) : UNIX_WATCHER_SOCKET;
54
+ }
55
+ /** The watcher's JSONL fallback file. Windows has no `/tmp`; its temp dir is per-user already. */
56
+ export function eventsFilePath(platform = process.platform) {
57
+ return platform === 'win32' ? join(tmpdir(), 'beehaven-events.jsonl') : UNIX_EVENTS_FILE;
58
+ }
59
+ /**
60
+ * Could a listener be at `p`? A socket FILE can be checked without touching it. A named pipe
61
+ * cannot: it exists only while a server holds it, and probing it with a filesystem call opens an
62
+ * instance of it. So a pipe answers `true` and the connect attempt decides (`ENOENT` = nothing).
63
+ */
64
+ export function socketMayExist(p) {
65
+ return isNamedPipe(p) || existsSync(p);
66
+ }
67
+ /** Remove a stale socket file before binding. A named pipe leaves nothing behind to remove. */
68
+ export function clearStaleSocket(p) {
69
+ if (isNamedPipe(p))
70
+ return;
71
+ try {
72
+ if (existsSync(p))
73
+ unlinkSync(p);
74
+ }
75
+ catch { /* already gone */ }
76
+ }
77
+ /**
78
+ * Restrict a freshly bound socket file to its owner (0600) and report the mode it really has.
79
+ * `applicable: false` for a named pipe, which has no file mode — its boundary is the per-user name
80
+ * plus the IPC token.
81
+ */
82
+ export function restrictSocketToOwner(p) {
83
+ if (isNamedPipe(p))
84
+ return { applicable: false, mode: null };
85
+ try {
86
+ chmodSync(p, 0o600);
87
+ return { applicable: true, mode: statSync(p).mode & 0o777 };
88
+ }
89
+ catch (err) {
90
+ return { applicable: true, mode: null, error: String(err?.code ?? err?.message ?? err) };
91
+ }
92
+ }
@@ -2,7 +2,7 @@
2
2
  * IPC Server — bidirectional Unix socket for local clients (Electron, CLI tools).
3
3
  *
4
4
  * Provides request-response RPCs and broadcast subscriptions over a line-based
5
- * JSON protocol on /tmp/beehaven-ipc.sock.
5
+ * JSON protocol on /tmp/beehaven-ipc.sock (a per-user named pipe on Windows — see ipc-path.ts).
6
6
  *
7
7
  * Local clients send messages to the daemon, which forwards them to the DO
8
8
  * via the relay WebSocket. DO responses and broadcasts are forwarded back
@@ -24,10 +24,11 @@
24
24
  * { "type": "status", "connected": true, "daemonPid": 1234 }
25
25
  */
26
26
  import { createConnection, createServer } from 'net';
27
- import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
28
27
  import { EventEmitter } from 'events';
29
28
  import { resolveAuthScope } from './ipc-auth.js';
30
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
29
+ import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
30
+ /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
31
+ const IPC_SOCKET_PATH = ipcSocketPath();
31
32
  /**
32
33
  * Message types the daemon will process without authentication.
33
34
  *
@@ -114,9 +115,10 @@ export function actionChain(action, input, depth = 0) {
114
115
  */
115
116
  function probeIpcSocket(timeoutMs = 400) {
116
117
  return new Promise((resolve) => {
117
- if (!existsSync(IPC_SOCKET_PATH))
118
+ const path = ipcSocketPath();
119
+ if (!socketMayExist(path))
118
120
  return resolve(false);
119
- const client = createConnection(IPC_SOCKET_PATH);
121
+ const client = createConnection(path);
120
122
  const done = (alive) => {
121
123
  try {
122
124
  client.destroy();
@@ -138,6 +140,8 @@ function probeIpcSocket(timeoutMs = 400) {
138
140
  export { probeIpcSocket };
139
141
  export class IpcServer extends EventEmitter {
140
142
  server = null;
143
+ /** The address this server bound (resolved at `start`, so an env override set before start applies). */
144
+ path = null;
141
145
  clients = new Map();
142
146
  clientCounter = 0;
143
147
  virtualClientCounter = 0;
@@ -157,12 +161,10 @@ export class IpcServer extends EventEmitter {
157
161
  * at this point; a live server would have been caught upstream.
158
162
  */
159
163
  start() {
160
- // Clean up stale socket
161
- try {
162
- if (existsSync(IPC_SOCKET_PATH))
163
- unlinkSync(IPC_SOCKET_PATH);
164
- }
165
- catch { }
164
+ // Clean up a stale socket file (a named pipe leaves none).
165
+ const path = ipcSocketPath();
166
+ this.path = path;
167
+ clearStaleSocket(path);
166
168
  this.server = createServer((socket) => {
167
169
  const clientId = `ipc_${++this.clientCounter}`;
168
170
  // Connections start unauthenticated and scope=null. The client must
@@ -235,7 +237,7 @@ export class IpcServer extends EventEmitter {
235
237
  // process-wide umask — umask changes are global and race with any
236
238
  // other mkdirSync / writeFileSync happening in parallel (e.g.
237
239
  // LocalHive pre-creating building data dirs during startup).
238
- this.server.listen(IPC_SOCKET_PATH, () => {
240
+ this.server.listen(path, () => {
239
241
  /**
240
242
  * ⚠⚠ CHMOD, THEN **VERIFY**. This was `chmodSync(…, 0o600)` inside an empty `catch {}`, and
241
243
  * the mode it claims has been wrong on every start measured: the live socket comes up
@@ -266,19 +268,18 @@ export class IpcServer extends EventEmitter {
266
268
  * was learned once and not applied here. Import at the top; never reach for `require` in this
267
269
  * package.
268
270
  */
269
- let mode = null;
270
- try {
271
- chmodSync(IPC_SOCKET_PATH, 0o600);
272
- mode = statSync(IPC_SOCKET_PATH).mode & 0o777;
273
- }
274
- catch (err) {
275
- console.warn(`[ipc] SECURITY: could not set 0600 on ${IPC_SOCKET_PATH} — ${err?.code ?? err?.message ?? err}`);
271
+ // A Windows named pipe has no file mode (`applicable: false`): its boundary is the per-user
272
+ // pipe name plus the IPC token — see `ipc-path.ts`.
273
+ const restricted = restrictSocketToOwner(path);
274
+ const mode = restricted.mode;
275
+ if (restricted.error) {
276
+ console.warn(`[ipc] SECURITY: could not set 0600 on ${path} — ${restricted.error}`);
276
277
  }
277
278
  if (mode !== null && mode !== 0o600) {
278
279
  console.warn(`[ipc] SECURITY: socket mode is 0${mode.toString(8)}, expected 0600. Other-user connect is still `
279
280
  + `blocked (no write bit), but the documented guarantee does not hold — do not weaken it further.`);
280
281
  }
281
- console.log(`[ipc] Listening on ${IPC_SOCKET_PATH}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
282
+ console.log(`[ipc] Listening on ${path}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
282
283
  });
283
284
  this.server.on('error', (err) => {
284
285
  console.warn(`[ipc] Server error: ${err.message}`);
@@ -295,11 +296,7 @@ export class IpcServer extends EventEmitter {
295
296
  this.server.close();
296
297
  this.server = null;
297
298
  }
298
- try {
299
- if (existsSync(IPC_SOCKET_PATH))
300
- unlinkSync(IPC_SOCKET_PATH);
301
- }
302
- catch { }
299
+ clearStaleSocket(this.path ?? ipcSocketPath());
303
300
  }
304
301
  /** Broadcast a message from the DO to all subscribed local clients */
305
302
  /**
@@ -0,0 +1,138 @@
1
+ /**
2
+ * npm-update — updating a CLI that was installed with `npm install -g` (CAN2-57).
3
+ *
4
+ * ⚠⚠ npm IS THE ONLY WINDOWS INSTALL, AND IT NEVER UPDATED. The daemon's hourly updater re-runs the
5
+ * bash `install.sh`, so it served only `~/.beehaven/cli` installs; `beehaven update` refused npm
6
+ * installs and printed the npm command instead. A Windows user stayed on whatever version they
7
+ * first installed until they noticed the notice. Both paths now call this module.
8
+ *
9
+ * ⚠ IT UPDATES THE COPY THAT IS RUNNING, OR NOTHING. The npm that sits beside the running Node is
10
+ * asked for its global root; if this package is not the one installed there (an npx run, a second
11
+ * Node, a copied folder), installing would create a SECOND copy that shadows or is shadowed by
12
+ * this one — the failure `self-update.ts` documents for curl installs. So it refuses and says why.
13
+ *
14
+ * ⚠ A GLOBAL PREFIX THE USER CANNOT WRITE IS NOT "TRY sudo". Windows (`%AppData%\npm`) and
15
+ * Homebrew/nvm prefixes are user-writable; a system `/usr/local` usually is not, and an unattended
16
+ * daemon must never escalate. Not writable → the one-line manual command, nothing else.
17
+ *
18
+ * ⚠ THE EXACT VERSION, NOT `@latest`. The caller passes the version from the release manifest
19
+ * (`latest.json`, stable only), so the channel is decided by our publish script, never by a dist
20
+ * tag someone moved by hand.
21
+ */
22
+ import { execFile, execFileSync } from 'child_process';
23
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { dirname, join, resolve } from 'path';
26
+ export const PACKAGE = '@clearlysh/beehaven';
27
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
28
+ /** The npm that belongs to the Node running this process, else whatever `npm` is on PATH. */
29
+ export function npmCommand(platform = process.platform, execPath = process.execPath) {
30
+ const local = join(dirname(execPath), platform === 'win32' ? 'npm.cmd' : 'npm');
31
+ return existsSync(local) ? local : platform === 'win32' ? 'npm.cmd' : 'npm';
32
+ }
33
+ /** Windows `.cmd` shims cannot be spawned without a shell (Node refuses with EINVAL since 18.20/20.12). */
34
+ const needsShell = (cmd) => /\.cmd$/i.test(cmd);
35
+ const quoteForShell = (s) => (/[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
36
+ /** Run npm synchronously; returns stdout. Never inside the package directory (Windows rename locks). */
37
+ function npmSync(npm, args, timeoutMs) {
38
+ const shell = needsShell(npm);
39
+ return execFileSync(shell ? quoteForShell(npm) : npm, args, {
40
+ encoding: 'utf8',
41
+ cwd: homedir(),
42
+ timeout: timeoutMs,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ shell,
45
+ windowsHide: true,
46
+ });
47
+ }
48
+ /**
49
+ * Can this install update itself with npm? `pkgRoot` is the running package's root (the folder
50
+ * holding its package.json).
51
+ */
52
+ export function npmUpdatePlan(pkgRoot, platform = process.platform) {
53
+ const npm = npmCommand(platform);
54
+ let globalRoot;
55
+ try {
56
+ globalRoot = npmSync(npm, ['root', '-g'], 30_000).trim();
57
+ }
58
+ catch (e) {
59
+ return { ok: false, npm, reason: `npm is not available (${String(e?.code ?? e?.message ?? e).slice(0, 80)})` };
60
+ }
61
+ const real = (p) => { try {
62
+ return realpathSync(p);
63
+ }
64
+ catch {
65
+ return resolve(p);
66
+ } };
67
+ const installed = join(globalRoot, ...PACKAGE.split('/'));
68
+ if (real(installed).toLowerCase() !== real(pkgRoot).toLowerCase()) {
69
+ return { ok: false, npm, globalRoot, reason: `this copy (${pkgRoot}) is not npm's global install (${installed})` };
70
+ }
71
+ try {
72
+ accessSync(globalRoot, constants.W_OK);
73
+ accessSync(installed, constants.W_OK);
74
+ }
75
+ catch {
76
+ return { ok: false, npm, globalRoot, reason: `npm's global folder is not writable by you (${globalRoot})` };
77
+ }
78
+ return { ok: true, npm, globalRoot };
79
+ }
80
+ /** The version npm's global install of this package reports, read from disk. */
81
+ export function installedNpmVersion(globalRoot) {
82
+ try {
83
+ return JSON.parse(readFileSync(join(globalRoot, ...PACKAGE.split('/'), 'package.json'), 'utf8')).version ?? null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** The newest published version, from npm (for `beehaven update`; the daemon uses the release manifest). */
90
+ export function latestNpmVersion(npm) {
91
+ try {
92
+ const v = npmSync(npm, ['view', PACKAGE, 'version', '--registry=https://registry.npmjs.org'], 30_000).trim();
93
+ return VERSION_RE.test(v) ? v : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** The arguments that install exactly `version` globally — exported so the shape is pinned. */
100
+ export function npmInstallArgs(version) {
101
+ if (!VERSION_RE.test(version))
102
+ throw new Error(`not a release version: ${version}`);
103
+ return ['install', '-g', `${PACKAGE}@${version}`, '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'];
104
+ }
105
+ /** Install `version` (blocking — the CLI's `update`). Verifies what landed on disk. */
106
+ export function installNpmVersionSync(plan, version) {
107
+ if (!plan.ok || !plan.globalRoot)
108
+ return { ok: false, message: plan.reason ?? 'npm update not possible here' };
109
+ try {
110
+ npmSync(plan.npm, npmInstallArgs(version), 5 * 60_000);
111
+ }
112
+ catch (e) {
113
+ const detail = String(e?.stderr || e?.message || e).trim().split('\n').slice(-3).join(' ').slice(0, 240);
114
+ return { ok: false, message: `npm install failed: ${detail}` };
115
+ }
116
+ const got = installedNpmVersion(plan.globalRoot);
117
+ return got === version
118
+ ? { ok: true, message: `Updated to ${version}.` }
119
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` };
120
+ }
121
+ /** Install `version` without blocking the event loop (the daemon's hourly updater). */
122
+ export function installNpmVersion(plan, version) {
123
+ return new Promise((resolveP) => {
124
+ if (!plan.ok || !plan.globalRoot)
125
+ return resolveP({ ok: false, message: plan.reason ?? 'npm update not possible here' });
126
+ const shell = needsShell(plan.npm);
127
+ execFile(shell ? quoteForShell(plan.npm) : plan.npm, npmInstallArgs(version), {
128
+ cwd: homedir(), timeout: 5 * 60_000, shell, windowsHide: true,
129
+ }, (err, _out, stderr) => {
130
+ if (err)
131
+ return resolveP({ ok: false, message: `npm install failed: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ').slice(0, 240)}` });
132
+ const got = installedNpmVersion(plan.globalRoot);
133
+ resolveP(got === version
134
+ ? { ok: true, message: `Updated to ${version}.` }
135
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` });
136
+ });
137
+ });
138
+ }
@@ -391,8 +391,12 @@ export async function cleanupAll() {
391
391
  }
392
392
  // 3. Socket + event log
393
393
  const { existsSync, unlinkSync, readdirSync, rmSync, readFileSync, writeFileSync } = await import('node:fs');
394
- const { join } = await import('node:path');
395
- for (const p of ['/tmp/beehaven.sock', '/tmp/beehaven-ipc.sock', '/tmp/beehaven-events.jsonl']) {
394
+ const { join, dirname } = await import('node:path');
395
+ const { watcherSocketPath, ipcSocketPath, eventsFilePath, isNamedPipe } = await import('./ipc-path.js');
396
+ const eventsFile = eventsFilePath();
397
+ for (const p of [watcherSocketPath(), ipcSocketPath(), eventsFile]) {
398
+ if (isNamedPipe(p))
399
+ continue; // a Windows pipe disappears with the daemon that held it
396
400
  try {
397
401
  if (existsSync(p)) {
398
402
  unlinkSync(p);
@@ -402,10 +406,11 @@ export async function cleanupAll() {
402
406
  catch { }
403
407
  }
404
408
  try {
405
- for (const f of readdirSync('/tmp')) {
409
+ const eventsDir = dirname(eventsFile);
410
+ for (const f of readdirSync(eventsDir)) {
406
411
  if (f.startsWith('beehaven-events.jsonl.') && f.endsWith('.old')) {
407
412
  try {
408
- unlinkSync(join('/tmp', f));
413
+ unlinkSync(join(eventsDir, f));
409
414
  }
410
415
  catch { }
411
416
  }