@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/browser-login.js +8 -3
- package/dist/build-channel.js +2 -2
- package/dist/claude-bridge.js +12 -9
- package/dist/clearly.js +4 -3
- package/dist/cli.js +8 -7
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/self-update.js +32 -5
- package/dist/commands/workspace.js +3 -3
- package/dist/daemon-client.js +10 -6
- package/dist/daemon-entry.js +1 -0
- package/dist/daemon-process.js +125 -0
- package/dist/daemon.js +107 -52
- package/dist/ipc-auth.js +3 -1
- package/dist/ipc-client.js +7 -6
- package/dist/ipc-path.js +92 -0
- package/dist/ipc-server.js +22 -25
- package/dist/npm-update.js +138 -0
- package/dist/setup-service.js +9 -4
- package/dist/watcher.js +9 -23
- package/dist/windows-hide.js +77 -0
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +6 -0
- package/skills/brand-identity/SKILL.md +21 -5
- package/skills/clearly-canvas/SKILL.md +16 -4
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/clearly-docs/SKILL.md +151 -0
- package/skills/clearly-init/SKILL.md +135 -0
- package/skills/clearly-workflows/SKILL.md +89 -0
- package/skills/index.json +37 -2
- package/skills/sticker-pack/SKILL.md +132 -0
- package/skills/visualize/SKILL.md +188 -0
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
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
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.
|
package/dist/ipc-client.js
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
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 &&
|
|
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
|
-
(
|
|
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. ' +
|
package/dist/ipc-path.js
ADDED
|
@@ -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
|
+
}
|
package/dist/ipc-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
118
|
+
const path = ipcSocketPath();
|
|
119
|
+
if (!socketMayExist(path))
|
|
118
120
|
return resolve(false);
|
|
119
|
-
const client = createConnection(
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/setup-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
413
|
+
unlinkSync(join(eventsDir, f));
|
|
409
414
|
}
|
|
410
415
|
catch { }
|
|
411
416
|
}
|