beehaven 0.8.16
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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* beehaven shell — Execute commands on a live Durable Object.
|
|
3
|
+
*
|
|
4
|
+
* The DO's pseudo-shell supports: ls, cat, grep, find, tree, echo, head, tail,
|
|
5
|
+
* wc, sort, jq, sql, mkdir, rm, mv, cp, pipes (|), redirects (> >>).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* beehaven shell <building-type> <building-id> "<command>"
|
|
9
|
+
* beehaven shell salon lunas "ls /folders/products/"
|
|
10
|
+
* beehaven shell salon lunas "cat /folders/products/haircut.json"
|
|
11
|
+
* beehaven shell salon lunas "sql 'SELECT * FROM products WHERE price > 50'"
|
|
12
|
+
* beehaven shell salon lunas "grep urgent /folders/notes/"
|
|
13
|
+
*
|
|
14
|
+
* Shorthand (when building is configured in beehaven.json):
|
|
15
|
+
* beehaven ls /folders/products/
|
|
16
|
+
* beehaven cat /folders/products/haircut.json
|
|
17
|
+
* beehaven sql "SELECT * FROM products"
|
|
18
|
+
* beehaven grep draft /folders/notes/
|
|
19
|
+
*
|
|
20
|
+
* Logs:
|
|
21
|
+
* beehaven logs <building-type> <building-id> # syslog
|
|
22
|
+
* beehaven logs <building-type> <building-id> --ws # WS message log
|
|
23
|
+
* beehaven logs <building-type> <building-id> --ops # operation audit
|
|
24
|
+
* beehaven logs <building-type> <building-id> --triggers # trigger metrics
|
|
25
|
+
*/
|
|
26
|
+
import WebSocket from 'ws';
|
|
27
|
+
import { readFileSync } from 'fs';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { homedir } from 'os';
|
|
30
|
+
import { daemonGetConnection } from '../daemon-client.js';
|
|
31
|
+
import { getTarget, HOME_ADDRESSES } from '../cli-state.js';
|
|
32
|
+
/**
|
|
33
|
+
* ⚠ A PURE LEAF, IMPORTED EAGERLY ON PURPOSE. `shell-output.ts` has no imports of its own and no
|
|
34
|
+
* side effects, and this module is itself lazily loaded by `cli.ts`, so there is nothing to defer.
|
|
35
|
+
* The eager-import budget is a rule about `cli.ts`, not about command modules.
|
|
36
|
+
*/
|
|
37
|
+
import { formatShellResult, formatFailure, breadcrumbAddress, grepAttributionFlags, } from './shell-output.js';
|
|
38
|
+
const DEFAULT_PORT = 8787;
|
|
39
|
+
/**
|
|
40
|
+
* Execute a single shell command on a DO.
|
|
41
|
+
* Connect → auth → shell:exec → print stdout → disconnect.
|
|
42
|
+
*/
|
|
43
|
+
export async function shell(args, opts = {}) {
|
|
44
|
+
if (args.length < 3) {
|
|
45
|
+
console.error('Usage: beehaven shell <building-type> <building-id> "<command>"');
|
|
46
|
+
console.error(' e.g. beehaven shell salon lunas "ls /folders/products/"');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const [buildingType, buildingId, ...commandParts] = args;
|
|
50
|
+
const command = commandParts.join(' ');
|
|
51
|
+
if (!command) {
|
|
52
|
+
console.error('Missing command. Usage: beehaven shell salon lunas "ls /folders/"');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const result = await execRemote(buildingType, buildingId, command, opts);
|
|
56
|
+
if (result.stdout)
|
|
57
|
+
process.stdout.write(result.stdout);
|
|
58
|
+
if (result.stderr)
|
|
59
|
+
process.stderr.write(result.stderr);
|
|
60
|
+
if (result.exitCode !== 0)
|
|
61
|
+
process.exit(result.exitCode);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fetch logs from a DO. Defaults to syslog (the `_log()` ring buffer that
|
|
65
|
+
* webhook handlers and worker-side RPCs write to via `this._log(level,
|
|
66
|
+
* event, detail)`). Other surfaces:
|
|
67
|
+
*
|
|
68
|
+
* --ops operation audit (admin-only, _opsAudit ring buffer)
|
|
69
|
+
* --ws WebSocket message log
|
|
70
|
+
* --triggers trigger metrics
|
|
71
|
+
*
|
|
72
|
+
* Target resolution: same as VFS shorthand — uses `beehaven cd` target if
|
|
73
|
+
* no <type> <id> args are given. So once you `beehaven cd home`, plain
|
|
74
|
+
* `beehaven logs` shows your home workspace's syslog (honey credits,
|
|
75
|
+
* Stripe events, errors).
|
|
76
|
+
*/
|
|
77
|
+
export async function logs(args, opts = {}) {
|
|
78
|
+
// Resolve target: positional <type> <id> wins, then fall through to
|
|
79
|
+
// the same resolver `beehaven ls` / `cat` / etc. use.
|
|
80
|
+
let buildingType;
|
|
81
|
+
let buildingId;
|
|
82
|
+
if (args.length >= 2) {
|
|
83
|
+
[buildingType, buildingId] = args;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const resolved = await resolveDefaultBuilding();
|
|
87
|
+
buildingType = resolved.buildingType;
|
|
88
|
+
buildingId = resolved.buildingId;
|
|
89
|
+
}
|
|
90
|
+
// Mode selection. Default = syslog for human-readable event log.
|
|
91
|
+
// --ops / --ws / --triggers fall back to the original debug protocol.
|
|
92
|
+
if (opts.ops || opts.ws || opts.triggers) {
|
|
93
|
+
let debugType = 'debug:audit';
|
|
94
|
+
if (opts.ws)
|
|
95
|
+
debugType = 'debug:messages';
|
|
96
|
+
if (opts.triggers)
|
|
97
|
+
debugType = 'debug:triggers';
|
|
98
|
+
const result = await execDebug(buildingType, buildingId, debugType, opts);
|
|
99
|
+
console.log(JSON.stringify(result, null, 2));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Syslog path — works for the home workspace and any DO that uses
|
|
103
|
+
// `_log()`. We read /var/log/syslog directly via the shell.exec
|
|
104
|
+
// path so this works whether or not the user is admin (cat respects
|
|
105
|
+
// the same VFS read perms as ls/grep).
|
|
106
|
+
const limit = parseInt(opts.limit ?? '50', 10) || 50;
|
|
107
|
+
// tail -n <limit> /var/log/syslog → human-readable trailing logs.
|
|
108
|
+
const cmd = `tail -n ${limit} /var/log/syslog`;
|
|
109
|
+
const result = await execRemote(buildingType, buildingId, cmd, opts);
|
|
110
|
+
if (result.exitCode === 0) {
|
|
111
|
+
if (result.stdout)
|
|
112
|
+
process.stdout.write(result.stdout);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Fallback: try the admin _admin:logs action via daemonRpc (works
|
|
116
|
+
// for the home workspace where we can't always shell-cat /var/log).
|
|
117
|
+
try {
|
|
118
|
+
const { daemonRpc, isDaemonRunning } = await import('../daemon-client.js');
|
|
119
|
+
if (await isDaemonRunning()) {
|
|
120
|
+
const r = buildingType === 'home'
|
|
121
|
+
? await daemonRpc('_admin:logs', { limit, level: opts.level })
|
|
122
|
+
: await daemonRpc(`admin:${buildingType}-rpc`, {
|
|
123
|
+
storeId: buildingId,
|
|
124
|
+
action: '_admin:logs',
|
|
125
|
+
input: { limit, level: opts.level },
|
|
126
|
+
});
|
|
127
|
+
const inner = r?.result ?? r;
|
|
128
|
+
const events = inner?.logs ?? [];
|
|
129
|
+
for (const e of events) {
|
|
130
|
+
const ts = new Date(e.timestamp).toISOString();
|
|
131
|
+
const lvl = (e.level ?? 'info').toUpperCase().padEnd(5);
|
|
132
|
+
const detail = e.detail ?? '';
|
|
133
|
+
console.log(`${ts} ${lvl} ${e.event}${detail ? ' ' + detail : ''}`);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* fall through to error */
|
|
140
|
+
}
|
|
141
|
+
if (result.stderr)
|
|
142
|
+
process.stderr.write(result.stderr);
|
|
143
|
+
process.exit(result.exitCode || 1);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Shorthand: beehaven ls /folders/ → shell exec "ls /folders/"
|
|
147
|
+
*
|
|
148
|
+
* Building resolution order (first hit wins):
|
|
149
|
+
* 1. Active `beehaven connect` session — via daemon IPC. This is the
|
|
150
|
+
* canonical path and matches what `beehaven actions` / `call` /
|
|
151
|
+
* `workspace` use. Per CLAUDE.md: "Once connected, every other
|
|
152
|
+
* command auto-routes to that target."
|
|
153
|
+
* 2. beehaven.json in the current directory (project-local default).
|
|
154
|
+
* 3. Legacy credentials.json (admin `--url/--secret` flow).
|
|
155
|
+
*
|
|
156
|
+
* Previously this only checked (2) and (3) — so `connect` state was
|
|
157
|
+
* silently dropped for every VFS command, and users got "Cannot
|
|
158
|
+
* determine building" even though they'd just connected successfully.
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* ── WHICH FILESYSTEM ANSWERS ──────────────────────────────────────────────────────────────────
|
|
162
|
+
*
|
|
163
|
+
* There are two, and until now these verbs only ever reached the wrong one for the common case.
|
|
164
|
+
*
|
|
165
|
+
* • The **VFS** (`shell:exec`) — mounts, block files, `/shop/tickets/`, `/folders/notes/`. Its
|
|
166
|
+
* note listing is uuids: `0044f8ac-….json 005a5ecc-….json … 929 more`.
|
|
167
|
+
* • The **notes shell** (`bash`) — the same notes as a filesystem where paths are TITLES:
|
|
168
|
+
* `ls ~` → `Batch demo/ Clearly SEO/ Q1/`, and `grep -ril pricing ~` works.
|
|
169
|
+
*
|
|
170
|
+
* `CLAUDE.md` used to explain that there was no notes terminal because "the prerequisite is a
|
|
171
|
+
* title-based namespace, not the shell". That namespace was built; nothing repointed these verbs
|
|
172
|
+
* at it, so `beehaven ls` kept printing uuids — a shell over 929 opaque names, which is exactly
|
|
173
|
+
* the thing that argument said was not worth shipping.
|
|
174
|
+
*
|
|
175
|
+
* ⚠ ROUTED BY PATH, NOT SWITCHED WHOLESALE. The VFS serves paths the notes shell knows nothing
|
|
176
|
+
* about, so a blanket repoint would break `/shop/...`, block files and `/folders/...`. A bare verb
|
|
177
|
+
* or a `~`-rooted / relative path is a question about NOTES; an absolute path outside the notes
|
|
178
|
+
* roots is a question about the VFS.
|
|
179
|
+
*
|
|
180
|
+
* ⚠ AND IT DEGRADES. `bash` only exists on a Clearly workspace; a generic hive has no such action.
|
|
181
|
+
* An "unknown action" answer falls back to the VFS rather than failing, so this is an upgrade for
|
|
182
|
+
* the DOs that have it and a no-op everywhere else.
|
|
183
|
+
*/
|
|
184
|
+
const NOTES_ROOTS = ['~', '/home', '/code', '/local'];
|
|
185
|
+
/**
|
|
186
|
+
* ⚠ THE SHELL EATS `~` BEFORE THE CLI EVER SEES IT. `beehaven ls ~/Q1` arrives as
|
|
187
|
+
* `ls /Users/you/Q1`, so the one spelling every tool description teaches lands as an absolute
|
|
188
|
+
* local path, routes to the VFS, and comes back "no such file" for a folder that plainly exists.
|
|
189
|
+
* Quoting fixes it and nobody thinks to.
|
|
190
|
+
*
|
|
191
|
+
* Rewriting it back is safe because a workspace path is never a real local path in these verbs —
|
|
192
|
+
* the machine has its own namespace (`/local`). Same class of repair `normalizePath` already makes
|
|
193
|
+
* server-side for `~/~/` and `\/`: it looks like leniency and is a bug fix, because the intent is
|
|
194
|
+
* not in doubt.
|
|
195
|
+
*/
|
|
196
|
+
export function unexpandHome(arg) {
|
|
197
|
+
const home = homedir();
|
|
198
|
+
if (!home || arg === '-' || arg.startsWith('-'))
|
|
199
|
+
return arg;
|
|
200
|
+
if (arg === home)
|
|
201
|
+
return '~';
|
|
202
|
+
return arg.startsWith(`${home}/`) ? `~${arg.slice(home.length)}` : arg;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* ⚠ THE PATH IS NOT ALWAYS THE FIRST ARGUMENT, and assuming it was mis-routed every `grep`.
|
|
206
|
+
* `grep PATTERN [PATH]` puts the pattern first, so `grep pricing /folders/notes/` read "pricing"
|
|
207
|
+
* as the path, saw a bare relative name, and sent a VFS question to the notes shell. Caught by the
|
|
208
|
+
* test below — which is the only place the difference is visible, because BOTH filesystems answer.
|
|
209
|
+
* One just answers about the wrong corpus.
|
|
210
|
+
*/
|
|
211
|
+
const PATTERN_FIRST = new Set(['grep', 'jq']);
|
|
212
|
+
export function wantsNotesShell(command, args) {
|
|
213
|
+
const positional = args.filter((a) => !a.startsWith('-'));
|
|
214
|
+
const path = PATTERN_FIRST.has(command) ? positional[1] : positional[0];
|
|
215
|
+
// No path at all is a question about NOTES: bare `ls`, or `grep foo` with nothing scoping it.
|
|
216
|
+
if (!path)
|
|
217
|
+
return true;
|
|
218
|
+
if (path.startsWith('~'))
|
|
219
|
+
return true;
|
|
220
|
+
if (!path.startsWith('/'))
|
|
221
|
+
return true; // relative — the notes shell normalizes it
|
|
222
|
+
return NOTES_ROOTS.some((r) => path === r || path.startsWith(`${r}/`));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* ⚠⚠ ONE ARGUMENT MUST SURVIVE AS ONE ARGUMENT. The caller's own shell has already stripped the
|
|
226
|
+
* quotes, so `beehaven cat "~/CLR-47 The Beaching.md"` reaches us as ONE argv entry containing
|
|
227
|
+
* spaces — and joining the array back into a command string hands the REMOTE shell three paths.
|
|
228
|
+
*
|
|
229
|
+
* Measured live on hive2 2026-08-19: `beehaven cat "/home/CLR-47 The Beaching.md"` answered
|
|
230
|
+
* `cat: The: No such file or directory`. It has always been broken for multi-word titles; it became
|
|
231
|
+
* broken for EVERY note the day filenames gained a key prefix, because `CLR-47 The Beaching.md`
|
|
232
|
+
* has a space in it by construction.
|
|
233
|
+
*
|
|
234
|
+
* ⚠ SINGLE QUOTES, WITH THE POSIX ESCAPE FOR AN EMBEDDED ONE (`'\''`). Double quotes would leave
|
|
235
|
+
* `$`, backticks and `\` live inside the string — a filename containing `$` is unusual but a
|
|
236
|
+
* filename that silently expands to something else is a data bug, not a display one.
|
|
237
|
+
*
|
|
238
|
+
* ⚠ ARGUMENTS THAT NEED NOTHING ARE LEFT BARE, so the routing breadcrumb and the notes-shell
|
|
239
|
+
* command still read like the command a person would type. Quoting everything would turn
|
|
240
|
+
* `ls ~` into `ls '~'` — and `~` must reach the remote tokenizer UNQUOTED or it stops expanding.
|
|
241
|
+
*/
|
|
242
|
+
/**
|
|
243
|
+
* Verbs whose argument is the REST OF THE LINE rather than a list of arguments.
|
|
244
|
+
*
|
|
245
|
+
* ⚠ A NAMED SET, NEVER A HEURISTIC ("does it look like SQL?"). Getting it wrong in one direction
|
|
246
|
+
* breaks a statement; in the other it un-quotes a filename with a space in it, which is the bug
|
|
247
|
+
* `shellQuoteArg` was written to fix.
|
|
248
|
+
*/
|
|
249
|
+
export const REST_OF_LINE_COMMANDS = new Set(['sql']);
|
|
250
|
+
export function shellQuoteArg(arg) {
|
|
251
|
+
if (arg === '')
|
|
252
|
+
return "''";
|
|
253
|
+
// Bare when it is plainly safe: no whitespace, no quote, no shell metacharacter.
|
|
254
|
+
if (/^[A-Za-z0-9_@%+=:,.\/~^-]+$/.test(arg))
|
|
255
|
+
return arg;
|
|
256
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The command line sent to the remote shell.
|
|
260
|
+
*
|
|
261
|
+
* ⚠ EXPORTED AND PURE SO THE BEHAVIOUR CAN BE TESTED, not just the lookup table. A first version
|
|
262
|
+
* left this inline and asserted only on `REST_OF_LINE_COMMANDS`; deleting the branch that READS
|
|
263
|
+
* that set then changed nothing any test could see. The set and the branch are one claim.
|
|
264
|
+
*/
|
|
265
|
+
/**
|
|
266
|
+
* Wrap a whole statement as ONE argument for the remote shell.
|
|
267
|
+
*
|
|
268
|
+
* ⚠⚠ DOUBLE QUOTES, ESCAPING ONLY `"` AND `\` — NOT `shellQuoteArg`. That helper wraps in SINGLE
|
|
269
|
+
* quotes and POSIX-escapes any inner `'` as `'\''`, and SQL string literals are written with `'`:
|
|
270
|
+
* `SELECT 'a' AS y` would reach SQLite carrying literal backslashes. That is exactly why `sql` was
|
|
271
|
+
* exempted from quoting in the first place, and why the exemption then made `>` unusable.
|
|
272
|
+
*/
|
|
273
|
+
export function sqlQuoteStatement(stmt) {
|
|
274
|
+
return `"${stmt.replace(/(["\\])/g, '\\$1')}"`;
|
|
275
|
+
}
|
|
276
|
+
export function buildShellCommand(command, args) {
|
|
277
|
+
if (!REST_OF_LINE_COMMANDS.has(command)) {
|
|
278
|
+
return `${command} ${args.map(shellQuoteArg).join(' ')}`.trim();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* ⚠⚠ THE STATEMENT IS QUOTED NOW, AND SENDING IT BARE MADE `>` UNUSABLE FROM THIS CLI.
|
|
282
|
+
*
|
|
283
|
+
* Measured on staging, both spellings of the same question failed:
|
|
284
|
+
* beehaven sql "SELECT … WHERE created_at > 178…" -> `>` was read as a shell REDIRECT
|
|
285
|
+
* sql "SELECT …" (quotes reaching the handler) -> must begin with SELECT
|
|
286
|
+
* …while the identical statement through `workspace-sql` returned rows. Two shut doors on the
|
|
287
|
+
* one verb that answers what a keyword search cannot.
|
|
288
|
+
*
|
|
289
|
+
* ⚠⚠ IT REQUIRES THE WORKER THAT STRIPS THE WRAPPER — DEPLOY THAT FIRST. Against an older
|
|
290
|
+
* worker every `sql` from this CLI is refused for not beginning with SELECT. That is the
|
|
291
|
+
* standing worker-before-frontend rule, and this is a case where it genuinely bites.
|
|
292
|
+
*
|
|
293
|
+
* ⚠ AN EMPTY REST OF LINE STAYS BARE. `sql` with no statement returns the SCHEMA; `sql ""` is a
|
|
294
|
+
* quoted nothing, which is a different question.
|
|
295
|
+
*/
|
|
296
|
+
const rest = args.join(' ').trim();
|
|
297
|
+
return rest ? `${command} ${sqlQuoteStatement(rest)}` : command;
|
|
298
|
+
}
|
|
299
|
+
export async function shorthand(command, args, opts = {}) {
|
|
300
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
301
|
+
// ⚠ BEFORE the routing decision AND before the command string — see `unexpandHome`.
|
|
302
|
+
const shellArgs = args.map(unexpandHome);
|
|
303
|
+
/**
|
|
304
|
+
* ⚠⚠ ASK `grep` FOR THE ATTRIBUTION IT ALREADY KNOWS HOW TO SEND — see `grepAttributionFlags`.
|
|
305
|
+
* A directory target auto-recurses but the builtin only prints filenames when `-r` was TYPED,
|
|
306
|
+
* so every recursive search came back as bare lines with no way to tell which document each
|
|
307
|
+
* came from. Injected here rather than in `buildShellCommand` because it is a grep decision,
|
|
308
|
+
* not a quoting one, and `wantsNotesShell` below must still see the caller's own operands.
|
|
309
|
+
*/
|
|
310
|
+
const cmdArgs = command === 'grep'
|
|
311
|
+
? [...grepAttributionFlags(shellArgs), ...shellArgs]
|
|
312
|
+
: shellArgs;
|
|
313
|
+
/**
|
|
314
|
+
* ⚠ QUOTED, not joined raw — see `shellQuoteArg`. `wantsNotesShell` is still handed the
|
|
315
|
+
* UNQUOTED args below, because it inspects paths rather than re-parsing a command line.
|
|
316
|
+
*
|
|
317
|
+
* ⚠⚠ EXCEPT WHERE THE ARGUMENT IS THE REST OF THE LINE. `sql` does not take arguments, it takes
|
|
318
|
+
* a STATEMENT: the remote builtin reads everything after the verb verbatim, and quotes inside it
|
|
319
|
+
* are SQL syntax rather than shell syntax. Quoting the whole thing produced
|
|
320
|
+
* `sql 'SELECT 1 AS x'`, which the read-only guard then refused for *not beginning with SELECT* —
|
|
321
|
+
* an error naming the one thing the statement plainly does, about a statement typed correctly.
|
|
322
|
+
*
|
|
323
|
+
* ⚠ IT WAS HIDDEN, NOT NEW. From a connected session the call was refused earlier for developer
|
|
324
|
+
* scope and never reached the guard; on `home` it fails identically, so the bug is older than the
|
|
325
|
+
* routing that was concealing it. Measured both ways.
|
|
326
|
+
*
|
|
327
|
+
* ⚠ QUOTING REMAINS RIGHT FOR EVERYTHING ELSE. `cat "CLR-47 The Beaching.md"` is the case
|
|
328
|
+
* `shellQuoteArg` exists for, and a `grep` pattern containing spaces is genuinely ONE argument.
|
|
329
|
+
* Only a rest-of-line verb is exempt, which is why this is a named set rather than a heuristic.
|
|
330
|
+
*/
|
|
331
|
+
const fullCommand = buildShellCommand(command, cmdArgs);
|
|
332
|
+
// ── The notes shell, when the path is a notes question ──
|
|
333
|
+
if (wantsNotesShell(command, shellArgs)) {
|
|
334
|
+
const viaNotes = await execNotesShell(buildingType, buildingId, fullCommand);
|
|
335
|
+
if (viaNotes.result) {
|
|
336
|
+
// ⚠ NAME THE FILESYSTEM. The two answer the same command with different worlds — titles
|
|
337
|
+
// here, uuids there — and without this the difference reads as a bug rather than a route.
|
|
338
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} ${fullCommand} (notes)\x1b[0m\n`);
|
|
339
|
+
if (viaNotes.result.stdout)
|
|
340
|
+
process.stdout.write(viaNotes.result.stdout);
|
|
341
|
+
if (viaNotes.result.stderr)
|
|
342
|
+
process.stderr.write(viaNotes.result.stderr);
|
|
343
|
+
if (viaNotes.result.exitCode !== 0)
|
|
344
|
+
process.exit(viaNotes.result.exitCode);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* ⚠⚠ CBH2-61 — A FAILED CALL MUST NOT BE ANSWERED BY A DIFFERENT FILESYSTEM. Falling through
|
|
349
|
+
* here is what produced *"RPC shell:exec requires developer scope"* for a dropped socket: the
|
|
350
|
+
* wrong filesystem, a permission the caller does not need, and a fix that cannot help.
|
|
351
|
+
*/
|
|
352
|
+
if (viaNotes.transportError) {
|
|
353
|
+
process.stderr.write(notesShellTransportRefusal(viaNotes.transportError, fullCommand));
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
// else: no `bash` on this DO — fall through to the VFS exactly as before.
|
|
357
|
+
/**
|
|
358
|
+
* ⚠⚠ A NULL FROM `execNotesShell` MEANS EITHER "no notes shell here" OR "no daemon", AND THE
|
|
359
|
+
* SECOND ONE MUST NOT FALL THROUGH.
|
|
360
|
+
*
|
|
361
|
+
* `resolveDefaultBuilding` now answers `home` instead of refusing, which is right — but it
|
|
362
|
+
* turned the daemon-down case into a `getWsUrl` fallback at `127.0.0.1:8787` and the message
|
|
363
|
+
* *"WebSocket error: connect ECONNREFUSED 127.0.0.1"*. That is strictly worse than the refusal
|
|
364
|
+
* it replaced: it names a local dev server the caller never asked for, on a workspace command.
|
|
365
|
+
* Caught immediately after the change by running `ls ~` against a daemon that was still coming
|
|
366
|
+
* up.
|
|
367
|
+
*
|
|
368
|
+
* ⚠ ONLY FOR `home`. A real `<type>/<id>` target may legitimately be served over `--url`
|
|
369
|
+
* without a daemon, which is what `execRemote` is for.
|
|
370
|
+
*/
|
|
371
|
+
if (buildingType === 'home') {
|
|
372
|
+
const { isDaemonRunning } = await import('../daemon-client.js');
|
|
373
|
+
if (!(await isDaemonRunning())) {
|
|
374
|
+
console.error(' Cannot connect to daemon — your workspace is reached through it.');
|
|
375
|
+
console.error('');
|
|
376
|
+
console.error(' beehaven status # is it running, and is the relay up?');
|
|
377
|
+
console.error(' beehaven start --headless # start one without the Mac app');
|
|
378
|
+
console.error('');
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Routing breadcrumb on stderr so the VFS output on stdout stays clean
|
|
384
|
+
// and pipeable. Agents can read this to self-correct if they're on the
|
|
385
|
+
// wrong DO.
|
|
386
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} ${fullCommand}\x1b[0m\n`);
|
|
387
|
+
const result = await execRemote(buildingType, buildingId, fullCommand, opts);
|
|
388
|
+
if (result.stdout)
|
|
389
|
+
process.stdout.write(result.stdout);
|
|
390
|
+
if (result.stderr)
|
|
391
|
+
process.stderr.write(result.stderr);
|
|
392
|
+
if (result.exitCode !== 0) {
|
|
393
|
+
// Give the agent a nav hint on any VFS command failure.
|
|
394
|
+
/**
|
|
395
|
+
* ⚠ THIS USED TO SUGGEST `beehaven ls /` — CLR-1148. A leading `/` outside the four
|
|
396
|
+
* notes roots is a RAW VFS path, which routes to `shell:exec` and needs DEVELOPER
|
|
397
|
+
* scope. So a consumer-scope user following the recovery hint traded one error for a
|
|
398
|
+
* scarier one about permissions. `ls ~` is the notes shell and needs no scope.
|
|
399
|
+
*/
|
|
400
|
+
process.stderr.write('\n\x1b[2m Try:\n');
|
|
401
|
+
process.stderr.write(' beehaven pwd # confirm target\n');
|
|
402
|
+
process.stderr.write(' beehaven ls ~ # the notes workspace (no extra scope)\n');
|
|
403
|
+
process.stderr.write(' beehaven ls / # the raw VFS (needs developer scope)\x1b[0m\n');
|
|
404
|
+
process.exit(result.exitCode);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* beehaven write <path> <content> — overwrite a VFS file.
|
|
409
|
+
*
|
|
410
|
+
* Marshals the content to the DO pseudo-shell as `echo '<content>' > <path>`
|
|
411
|
+
* (single-quoted, so the local shell never sees a `>` redirect to mangle).
|
|
412
|
+
* On a CompositionsMount path this lands in `this.fs.write` → `writeSync` →
|
|
413
|
+
* the canvas blob. JSON content is safe; single quotes can't be escaped by the
|
|
414
|
+
* DO tokenizer, so we reject them and point at `composition-patch`.
|
|
415
|
+
*/
|
|
416
|
+
/**
|
|
417
|
+
* ⚠⚠ THE NOTES DOOR FOR `write`/`edit` — WITHOUT IT THEY DEMAND DEVELOPER SCOPE (fixed 2026-09-04).
|
|
418
|
+
*
|
|
419
|
+
* The read verbs (`ls`, `cat`, `grep`, `tree`, `head`, `tail`, `wc`, `sql`) and the organise verbs
|
|
420
|
+
* (`mkdir`, `cp`, `mv`) were repointed to the notes shell; `write` and `edit` were left on the OLD
|
|
421
|
+
* path, which builds `echo '<content>' > <path>` and runs it through `shell:exec`. Measured against
|
|
422
|
+
* a daemon with no `HIVE_DEV_MODE`: *"RPC 'shell:exec' requires developer scope"* on both, while
|
|
423
|
+
* every sibling verb worked. CLAUDE.md's note that these verbs no longer need dev scope is correct
|
|
424
|
+
* about the reads and silent about these two, so the gap was undocumented as well as unfixed.
|
|
425
|
+
*
|
|
426
|
+
* ⚠ `notes-write` / `notes-edit` ALREADY EXISTED, aliased `write`/`edit`, on the agent allowlist,
|
|
427
|
+
* needing no dev scope. Nothing new is built here — the CLI was calling the wrong thing.
|
|
428
|
+
*
|
|
429
|
+
* ⚠ AND IT REMOVES A REAL LIMITATION, NOT JUST A PERMISSION. The old path REFUSED any content
|
|
430
|
+
* containing a single quote, because it could not escape one into `echo '…'`. An apostrophe is
|
|
431
|
+
* ordinary English, so `write` could not write an ordinary sentence. The RPC takes the content as
|
|
432
|
+
* a JSON field, so there is nothing to escape.
|
|
433
|
+
*
|
|
434
|
+
* ⚠ IT ALSO GAINS THE THINGS THE SHELL PATH SKIPPED — version capture, FTS reindex, preview
|
|
435
|
+
* regeneration and the broadcast to open tabs — because it is the same verb the editor calls.
|
|
436
|
+
*
|
|
437
|
+
* ⚠ NON-NOTES PATHS STILL TAKE THE OLD ROUTE. `/compositions/<id>/blocks/<bid>.json` is a genuine
|
|
438
|
+
* VFS path with no notes equivalent, and that is what the usage line has always advertised.
|
|
439
|
+
*/
|
|
440
|
+
/**
|
|
441
|
+
* ⚠ RETURNS THE SAME THREE-STATE OUTCOME AS `execNotesShell` — CBH2-61 lived here TOO. This ended
|
|
442
|
+
* `catch { return null }` and `write`/`edit` read `null` as "no notes shell", falling through to
|
|
443
|
+
* the `echo … > path` VFS route and its developer-scope refusal. Same wrong cause, same wrong fix,
|
|
444
|
+
* on the two verbs that MUTATE — where "did it half-happen?" is the first question a caller has.
|
|
445
|
+
* The workspace's own reply rides in `result`, so the existing `{error, code, hint}` handling
|
|
446
|
+
* downstream is unchanged.
|
|
447
|
+
*/
|
|
448
|
+
async function notesWriteRpc(action, input) {
|
|
449
|
+
try {
|
|
450
|
+
const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
|
|
451
|
+
// ⚠ See `execNotesShell` — no daemon stays `absent` so the `--url` fallback keeps working.
|
|
452
|
+
if (!(await isDaemonRunning()))
|
|
453
|
+
return { absent: true };
|
|
454
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
455
|
+
let raw;
|
|
456
|
+
if (buildingType === 'home') {
|
|
457
|
+
raw = await daemonRpc(action, input);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const { gatewayEnvelope } = await import('./workspace.js');
|
|
461
|
+
const gw = gatewayEnvelope(buildingType, buildingId, action, input);
|
|
462
|
+
raw = await daemonRpc(gw.gateway, gw.payload);
|
|
463
|
+
}
|
|
464
|
+
const r = raw?.result ?? raw;
|
|
465
|
+
// ⚠ Same probe `execNotesShell` uses — a DO with no notes palette must fall through, not fail.
|
|
466
|
+
if (/unknown (action|message type)/i.test(String(r?.error ?? '')))
|
|
467
|
+
return { absent: true };
|
|
468
|
+
return { result: r };
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
// ⚠ CBH2-61 — was `return null`, i.e. a dropped socket answered as a permission problem.
|
|
472
|
+
return classifyNotesShellFailure(e);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
export async function fsWrite(path, content, opts = {}) {
|
|
476
|
+
if (!path) {
|
|
477
|
+
console.error('Usage: beehaven write <path> <content>');
|
|
478
|
+
console.error(' e.g. beehaven write /compositions/<id>/blocks/<bid>.json \'{"id":"<bid>","type":"text",...}\'');
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
if (wantsNotesShell('write', [path])) {
|
|
482
|
+
const outcome = await notesWriteRpc('notes-write', { file_path: unexpandHome(path), content });
|
|
483
|
+
// ⚠ CBH2-61 — a failed CALL is not an absent shell, and on a mutation the caller must be told
|
|
484
|
+
// plainly that nothing was written rather than sent to fix a permission they already have.
|
|
485
|
+
if (outcome.transportError) {
|
|
486
|
+
process.stderr.write(notesShellTransportRefusal(outcome.transportError, 'write'));
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const r = outcome.result;
|
|
490
|
+
if (r) {
|
|
491
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
492
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} write ${path} (notes)\x1b[0m\n`);
|
|
493
|
+
/**
|
|
494
|
+
* ⚠ THE SAME FORMATTER THE SHELL VERBS USE — it was dropping `hint` here and `hint` AND
|
|
495
|
+
* `code` in `fsEdit`, so two verbs in one family reported the same refusal differently and
|
|
496
|
+
* neither reported all of it. `notes-write` returns the same `{error, code, hint}` triple
|
|
497
|
+
* `mv`/`rm`/`cp` do.
|
|
498
|
+
*/
|
|
499
|
+
if (r.error || r.ok === false) {
|
|
500
|
+
process.stderr.write(formatFailure(r, 'write'));
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* ⚠⚠ `file_path`, NOT `path` — AND NOT THE PATH THAT WAS TYPED. `Write` derives the title
|
|
505
|
+
* from the BODY's first line, so `write ~/Alpha.md 'first line…'` genuinely creates
|
|
506
|
+
* `~/S13-1 first line….md`; the RPC knows this and echoes where the note ACTUALLY landed.
|
|
507
|
+
* Reading a field it does not return fell back to the requested name, so the CLI printed an
|
|
508
|
+
* address that never existed and the next `cat` answered "No such file or directory" —
|
|
509
|
+
* measured on a live workspace during the 2026-09-04 sweep.
|
|
510
|
+
*/
|
|
511
|
+
process.stdout.write(`wrote ${r.file_path ?? r.path ?? path}\n`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (content.includes("'")) {
|
|
516
|
+
console.error("write: content has a single quote, which the DO shell can't escape. Use `beehaven call composition-patch` for that block.");
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
520
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} write ${path}\x1b[0m\n`);
|
|
521
|
+
const result = await execRemote(buildingType, buildingId, `echo '${content}' > ${path}`, opts);
|
|
522
|
+
if (result.stderr)
|
|
523
|
+
process.stderr.write(result.stderr);
|
|
524
|
+
if (result.exitCode !== 0)
|
|
525
|
+
process.exit(result.exitCode);
|
|
526
|
+
process.stdout.write(`wrote ${path}\n`);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* beehaven edit <path> <old> <new> — find/replace inside a VFS file.
|
|
530
|
+
* cat → replace every occurrence of <old> with <new> → write back.
|
|
531
|
+
*/
|
|
532
|
+
export async function fsEdit(path, oldStr, newStr, opts = {}) {
|
|
533
|
+
if (!path || oldStr === undefined || newStr === undefined) {
|
|
534
|
+
console.error('Usage: beehaven edit <path> <old> <new>');
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* ⚠ SEE `fsWrite` — same repoint, same reason. And `notes-edit` carries a property the
|
|
539
|
+
* cat-replace-write below cannot: it matches EXACTLY ONCE OR REFUSES. The old path used
|
|
540
|
+
* `split(old).join(new)`, i.e. a silent replace-all, so an edit meant for one occurrence
|
|
541
|
+
* changed every one of them and reported success.
|
|
542
|
+
*/
|
|
543
|
+
if (wantsNotesShell('edit', [path])) {
|
|
544
|
+
const outcome = await notesWriteRpc('notes-edit', {
|
|
545
|
+
file_path: unexpandHome(path), old_string: oldStr, new_string: newStr,
|
|
546
|
+
});
|
|
547
|
+
// ⚠ CBH2-61 — see `fsWrite`. Falling through here would additionally hand the caller the
|
|
548
|
+
// OLD cat-replace-write path, whose silent replace-all is the very behaviour this repoint
|
|
549
|
+
// exists to remove — so a dropped socket would have quietly changed the edit's semantics.
|
|
550
|
+
if (outcome.transportError) {
|
|
551
|
+
process.stderr.write(notesShellTransportRefusal(outcome.transportError, 'edit'));
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
const r = outcome.result;
|
|
555
|
+
if (r) {
|
|
556
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
557
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} edit ${path} (notes)\x1b[0m\n`);
|
|
558
|
+
// ⚠ See `fsWrite` — one formatter, so the two halves of this verb family agree.
|
|
559
|
+
if (r.error || r.ok === false) {
|
|
560
|
+
process.stderr.write(formatFailure(r, 'edit'));
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
// ⚠ `file_path` first — same reason as `fsWrite` above.
|
|
564
|
+
process.stdout.write(`edited ${r.file_path ?? r.path ?? path}\n`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const { buildingType, buildingId } = await resolveDefaultBuilding();
|
|
569
|
+
process.stderr.write(`\x1b[2m→ ${breadcrumbAddress(buildingType, buildingId)} edit ${path}\x1b[0m\n`);
|
|
570
|
+
const read = await execRemote(buildingType, buildingId, `cat ${path}`, opts);
|
|
571
|
+
if (read.exitCode !== 0) {
|
|
572
|
+
if (read.stderr)
|
|
573
|
+
process.stderr.write(read.stderr);
|
|
574
|
+
process.exit(read.exitCode || 1);
|
|
575
|
+
}
|
|
576
|
+
const current = read.stdout.replace(/\n$/, '');
|
|
577
|
+
if (!current.includes(oldStr)) {
|
|
578
|
+
console.error(`edit: '${oldStr}' not found in ${path}`);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
const next = current.split(oldStr).join(newStr);
|
|
582
|
+
if (next.includes("'")) {
|
|
583
|
+
console.error("edit: result has a single quote the DO shell can't escape. Use `beehaven call composition-patch`.");
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
const result = await execRemote(buildingType, buildingId, `echo '${next}' > ${path}`, opts);
|
|
587
|
+
if (result.stderr)
|
|
588
|
+
process.stderr.write(result.stderr);
|
|
589
|
+
if (result.exitCode !== 0)
|
|
590
|
+
process.exit(result.exitCode);
|
|
591
|
+
process.stdout.write(`edited ${path}\n`);
|
|
592
|
+
}
|
|
593
|
+
// ── Internal ─────────────────────────────────────────
|
|
594
|
+
async function resolveDefaultBuilding() {
|
|
595
|
+
// 1. CLI-side target file (stateless design — source of truth since the
|
|
596
|
+
// stateless-session PR). Survives daemon restarts.
|
|
597
|
+
const target = getTarget();
|
|
598
|
+
if (target?.address?.includes('/')) {
|
|
599
|
+
const slash = target.address.indexOf('/');
|
|
600
|
+
return {
|
|
601
|
+
buildingType: target.address.slice(0, slash),
|
|
602
|
+
buildingId: target.address.slice(slash + 1),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
// 1b. `home` is the user's default workspace alias — no `<type>/<id>` shape
|
|
606
|
+
// because it's the default target the daemon's relay already binds
|
|
607
|
+
// to. Surface it as a special tuple so VFS shorthand (ls, cat,
|
|
608
|
+
// grep, tree) recognise the case in execRemote and dispatch via
|
|
609
|
+
// daemonRpc('shell:exec', …) directly instead of the admin gateway.
|
|
610
|
+
//
|
|
611
|
+
// `cd home` WRITES the canonical address 'workspace' (cli.ts case 'cd'),
|
|
612
|
+
// but this only matched the literal 'home' — so immediately after a
|
|
613
|
+
// successful `beehaven cd home`, `sql`/`ls`/`cat`/`grep`/`tree` all died
|
|
614
|
+
// with "Cannot determine target. You need to pick one:" and told you to
|
|
615
|
+
// run the `cd home` you had just run. Match every spelling the cd verb
|
|
616
|
+
// accepts, and keep them in one list so a future rename can't re-split
|
|
617
|
+
// the writer from the reader.
|
|
618
|
+
if (target?.address && HOME_ADDRESSES.has(target.address)) {
|
|
619
|
+
return { buildingType: 'home', buildingId: 'home' };
|
|
620
|
+
}
|
|
621
|
+
// 2. Legacy daemon session (for CLIs that predate the stateless design
|
|
622
|
+
// and haven't run `cd` since the upgrade). Retained for one release.
|
|
623
|
+
try {
|
|
624
|
+
const conn = await daemonGetConnection();
|
|
625
|
+
if (conn?.buildingType && conn?.buildingId) {
|
|
626
|
+
return { buildingType: conn.buildingType, buildingId: conn.buildingId };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
/* daemon not running or not connected — try fallbacks */
|
|
631
|
+
}
|
|
632
|
+
// 2. beehaven.json in current directory.
|
|
633
|
+
try {
|
|
634
|
+
const config = JSON.parse(readFileSync(join(process.cwd(), 'beehaven.json'), 'utf8'));
|
|
635
|
+
const buildings = config.buildings ?? config.cells ?? {};
|
|
636
|
+
const types = Object.keys(buildings);
|
|
637
|
+
if (types.length > 0) {
|
|
638
|
+
return { buildingType: types[0], buildingId: config.defaultId ?? 'default' };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
/* no beehaven.json — try credentials */
|
|
643
|
+
}
|
|
644
|
+
// 3. Legacy ~/.beehaven/credentials.json (admin `--url/--secret` flow).
|
|
645
|
+
const creds = readCredentials();
|
|
646
|
+
if (creds.building && creds.buildingId) {
|
|
647
|
+
return { buildingType: creds.building, buildingId: creds.buildingId };
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* ⚠⚠ NOTHING RESOLVED ⇒ HOME. THIS USED TO REFUSE, AND TWO SURFACES DISAGREED ABOUT THE SAME
|
|
651
|
+
* STATE.
|
|
652
|
+
*
|
|
653
|
+
* Measured repeatedly on 2026-09-04, one shell, same env, seconds apart:
|
|
654
|
+
*
|
|
655
|
+
* beehaven pwd -> "Target: home (default workspace alias)"
|
|
656
|
+
* beehaven call board-list -> works
|
|
657
|
+
* beehaven ls ~ -> "Cannot determine target. You need to pick one:"
|
|
658
|
+
*
|
|
659
|
+
* `pwd` REPORTS the home default and `call` USES it; only the shell verbs demanded an explicit
|
|
660
|
+
* target. So the CLI told the caller they had a target and then refused to act on it — and the
|
|
661
|
+
* remedy (`beehaven cd home`) is a command that writes the state `pwd` already claimed existed.
|
|
662
|
+
*
|
|
663
|
+
* ⚠ THE SECOND HALF, WHICH IS WHY IT LOOKED INTERMITTENT: the target file is scoped by AGENT
|
|
664
|
+
* NAME when one is bound and by PPID otherwise, so any script that shells out gets a different
|
|
665
|
+
* scope and silently loses the target. Defaulting to home makes that invisible rather than
|
|
666
|
+
* mysterious — and the routing breadcrumb (`→ home/home ls ~ (notes)`) prints the target on
|
|
667
|
+
* every shell command, so an unintended home is visible rather than silent.
|
|
668
|
+
*
|
|
669
|
+
* ⚠ HOME IS ALWAYS REACHABLE — it is the caller's own workspace, not a guess at somebody's DO.
|
|
670
|
+
* A wrong GUESS would be worse than a refusal; this is the same answer the other two verbs give.
|
|
671
|
+
*/
|
|
672
|
+
return { buildingType: 'home', buildingId: 'home' };
|
|
673
|
+
}
|
|
674
|
+
function getWsUrl(buildingType, buildingId, opts) {
|
|
675
|
+
// Explicit --url flag takes priority
|
|
676
|
+
if (opts.url) {
|
|
677
|
+
const base = opts.url.replace(/^http/, 'ws');
|
|
678
|
+
return `${base}/workspace/${buildingType}/${buildingId}/ws`;
|
|
679
|
+
}
|
|
680
|
+
// Check saved credentials (beehaven login --url)
|
|
681
|
+
const creds = readCredentials();
|
|
682
|
+
if (creds.workerUrl) {
|
|
683
|
+
const base = creds.workerUrl.replace(/^http/, 'ws');
|
|
684
|
+
return `${base}/workspace/${buildingType}/${buildingId}/ws`;
|
|
685
|
+
}
|
|
686
|
+
// Fall back to local dev server
|
|
687
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
688
|
+
return `ws://127.0.0.1:${port}/workspace/${buildingType}/${buildingId}/ws`;
|
|
689
|
+
}
|
|
690
|
+
function readCredentials() {
|
|
691
|
+
const home = process.env.HOME ?? homedir();
|
|
692
|
+
const credPath = join(home, '.beehaven', 'credentials.json');
|
|
693
|
+
try {
|
|
694
|
+
const creds = JSON.parse(readFileSync(credPath, 'utf8'));
|
|
695
|
+
return {
|
|
696
|
+
token: creds.token ?? creds.adminSecret ?? '',
|
|
697
|
+
workerUrl: creds.workerUrl ?? '',
|
|
698
|
+
building: creds.building,
|
|
699
|
+
buildingId: creds.buildingId,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
return { token: '', workerUrl: '' };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function getToken(opts) {
|
|
707
|
+
if (opts.token)
|
|
708
|
+
return opts.token;
|
|
709
|
+
return readCredentials().token;
|
|
710
|
+
}
|
|
711
|
+
/** The most useful string available from a thrown value. */
|
|
712
|
+
function errorText(e) {
|
|
713
|
+
if (!e)
|
|
714
|
+
return 'the connection dropped';
|
|
715
|
+
if (typeof e === 'string')
|
|
716
|
+
return e;
|
|
717
|
+
const m = e?.message;
|
|
718
|
+
return typeof m === 'string' && m ? m : String(e);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Absent shell, or failed call? The whole of CBH2-61 turns on this one question.
|
|
722
|
+
*
|
|
723
|
+
* ⚠ ABSENCE IS THE NARROW CASE AND MUST BE PROVEN, NOT ASSUMED. The workspace says so in its own
|
|
724
|
+
* words — `unknown action` / `unknown message type` — and nothing else counts. Defaulting the
|
|
725
|
+
* other way is exactly the old bug: an unrecognised failure became "no notes shell here" and was
|
|
726
|
+
* answered by a filesystem the caller never asked about.
|
|
727
|
+
*
|
|
728
|
+
* PURE, so the decision is testable with no daemon: a reason in, an outcome out.
|
|
729
|
+
*/
|
|
730
|
+
export function classifyNotesShellFailure(reason) {
|
|
731
|
+
const msg = errorText(reason);
|
|
732
|
+
if (/unknown (action|message type)/i.test(msg))
|
|
733
|
+
return { absent: true };
|
|
734
|
+
return { transportError: msg };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* What to print when the call never reached the workspace.
|
|
738
|
+
*
|
|
739
|
+
* ⚠ IT MUST NAME A CONNECTION, NEVER A PERMISSION. The failure this replaces sent readers to
|
|
740
|
+
* `HIVE_DEV_MODE=1 beehaven restart`, which cannot help — and a restart is the user's daemon to
|
|
741
|
+
* cycle, not ours, so the advice is to LOOK first (`status`) and retry.
|
|
742
|
+
*
|
|
743
|
+
* ⚠ IT SAYS NOTHING RAN. A caller who has just been told a mutation failed needs to know whether
|
|
744
|
+
* it half-happened; on this path it did not happen at all.
|
|
745
|
+
*/
|
|
746
|
+
export function notesShellTransportRefusal(transportError, command) {
|
|
747
|
+
const verb = command.trim().split(/\s+/)[0] || 'the command';
|
|
748
|
+
return [
|
|
749
|
+
` Could not reach your workspace — \`${verb}\` did not run, and nothing was changed.`,
|
|
750
|
+
'',
|
|
751
|
+
` ${transportError}`,
|
|
752
|
+
'',
|
|
753
|
+
' This is a connection problem, not a permission one. It is usually momentary —',
|
|
754
|
+
' the same command often succeeds seconds later.',
|
|
755
|
+
'',
|
|
756
|
+
' beehaven status # is the daemon up, and is the relay connected?',
|
|
757
|
+
'',
|
|
758
|
+
].join('\n') + '\n';
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Run a command through the workspace's NOTES shell (`bash`), or report why it could not.
|
|
762
|
+
*
|
|
763
|
+
* ⚠ A `bash` that RAN and failed (`no such file`) is a real answer and comes back as `result`; it
|
|
764
|
+
* must not be retried against a different filesystem, which would turn one honest "not found" into
|
|
765
|
+
* two confusing ones.
|
|
766
|
+
*/
|
|
767
|
+
async function execNotesShell(buildingType, buildingId, command) {
|
|
768
|
+
try {
|
|
769
|
+
const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
|
|
770
|
+
/**
|
|
771
|
+
* ⚠ NO DAEMON IS REPORTED AS `absent`, DELIBERATELY, AND IT IS NOT A TRANSPORT ERROR IN THIS
|
|
772
|
+
* SENSE. The caller below already handles it far better than a generic message could: for
|
|
773
|
+
* `home` it prints the start-the-daemon refusal, and for a real `<type>/<id>` target it falls
|
|
774
|
+
* through to `execRemote`, which can legitimately be served over `--url` with no daemon at
|
|
775
|
+
* all. Classifying it here would BREAK that `--url` path.
|
|
776
|
+
*/
|
|
777
|
+
if (!(await isDaemonRunning()))
|
|
778
|
+
return { absent: true };
|
|
779
|
+
/**
|
|
780
|
+
* ⚠ THIS LOGIN'S cwd RIDES WITH THE COMMAND — see `shell-session.ts` for why the client owns
|
|
781
|
+
* it. Keyed by TARGET as well as login, so `cd` on a team does not move `home`.
|
|
782
|
+
*/
|
|
783
|
+
const { attachShellCwd, absorbShellCwd, shellTargetKey } = await import('../shell-session.js');
|
|
784
|
+
const shellTarget = shellTargetKey(buildingType === 'home' ? 'home' : `${buildingType}/${buildingId}`);
|
|
785
|
+
const payload = attachShellCwd('bash', { command }, shellTarget);
|
|
786
|
+
/**
|
|
787
|
+
* ⚠⚠ THE SHELL FAMILY RAN UNATTRIBUTED — `ls cat grep tree find head tail wc jq rm mv cp
|
|
788
|
+
* mkdir write edit sql search`, SEVENTEEN VERBS, EVERY ONE OF THEM (fixed 2026-09-05).
|
|
789
|
+
*
|
|
790
|
+
* This called `daemonRpc('bash', …)` bare, which is precisely the defect `agentRpc`'s own
|
|
791
|
+
* docstring names for `rpcOnce`: no `agent-invoke`, so no per-agent gate, no `agent_activity`
|
|
792
|
+
* row, and the work filed against the ACCOUNT OWNER rather than the signed-in agent.
|
|
793
|
+
*
|
|
794
|
+
* ⚠ MEASURED AS A BEFORE/AFTER, WHICH IS THE ONLY THING THAT PROVES IT. The identical
|
|
795
|
+
* command — `beehaven ls ~`, same agent, same target, minutes apart — wrote NO row on the
|
|
796
|
+
* old build and one row on this one.
|
|
797
|
+
*
|
|
798
|
+
* ⚠ DO NOT READ THE DOOR OFF `input_summary`. Every historical `bash` row carries an
|
|
799
|
+
* `[mcp:…]` prefix, and that prefix means only that a SESSION ID was present — which the CLI
|
|
800
|
+
* sets too. It is not evidence of the transport, and reading it as such is precisely the
|
|
801
|
+
* mistake `agent_activity.source` exists to make unnecessary.
|
|
802
|
+
*
|
|
803
|
+
* ⚠⚠ AND IT WOULD HAVE POISONED THE VERY MEASUREMENT `source` EXISTS FOR. A door column over
|
|
804
|
+
* an unattributed shell reports zero CLI traffic for the busiest verbs on the surface, and
|
|
805
|
+
* the retirement list built from it deletes them for being unused — on data we generated
|
|
806
|
+
* ourselves. The attribution has to land BEFORE the column is trusted, not after.
|
|
807
|
+
*
|
|
808
|
+
* ⚠ WRAP FIRST, ROUTE SECOND — the ordering `cmdCall` documents at length. `agent-invoke`
|
|
809
|
+
* must travel INSIDE the proxy so it executes on the workspace being addressed; wrapped
|
|
810
|
+
* outside, it runs on the daemon's default DO and `_checkAgentScope` asks the wrong roster.
|
|
811
|
+
*/
|
|
812
|
+
const { activeAgentWrap } = await import('./workspace.js');
|
|
813
|
+
const wrapped = activeAgentWrap('bash', payload, {});
|
|
814
|
+
let raw;
|
|
815
|
+
if (buildingType === 'home') {
|
|
816
|
+
raw = await daemonRpc(wrapped.action, wrapped.input);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const { gatewayEnvelope } = await import('./workspace.js');
|
|
820
|
+
const gw = gatewayEnvelope(buildingType, buildingId, wrapped.action, wrapped.input);
|
|
821
|
+
raw = await daemonRpc(gw.gateway, gw.payload);
|
|
822
|
+
}
|
|
823
|
+
const r = raw?.result ?? raw;
|
|
824
|
+
absorbShellCwd('bash', r, shellTarget);
|
|
825
|
+
// No notes shell on this DO — a generic hive, or one predating the palette.
|
|
826
|
+
const err = String(r?.error ?? '');
|
|
827
|
+
if (/unknown (action|message type)/i.test(err))
|
|
828
|
+
return { absent: true };
|
|
829
|
+
/**
|
|
830
|
+
* ⚠ A SHAPELESS ERROR REPLY IS A FAILED CALL, NOT AN ABSENT SHELL. This used to `return null`
|
|
831
|
+
* too, i.e. fall through to the VFS and inherit the developer-scope refusal — the same
|
|
832
|
+
* CBH2-61 defect arriving through the non-throwing door. The workspace answered SOMETHING and
|
|
833
|
+
* it was not a shell result, so report what it actually said.
|
|
834
|
+
*/
|
|
835
|
+
if (r == null || (typeof r === 'object' && !('ok' in r) && !('stdout' in r) && err)) {
|
|
836
|
+
return classifyNotesShellFailure(err || 'the workspace returned no result');
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* ⚠⚠ THE WHOLE RESULT → TERMINAL MAPPING LIVES IN `formatShellResult`, AND THAT MOVE IS THE
|
|
840
|
+
* FIX, not a tidy-up. Inline here it could only be asserted by grepping this file, and the
|
|
841
|
+
* defect it carried was invisible to source scanning: the mapping read `stdout`, `rows` and
|
|
842
|
+
* `stderr` and dropped `error`, `code`, `hint` and every structured success field, so
|
|
843
|
+
* `mkdir`, `mv`, `cp` and `rm` printed NOTHING — on success and on failure alike. A failed
|
|
844
|
+
* `mv` was byte-identical on screen to a successful one.
|
|
845
|
+
*
|
|
846
|
+
* ⚠ `sql`'s `rows` HANDLING MOVED WITH IT, unchanged — see `formatShellResult`.
|
|
847
|
+
*/
|
|
848
|
+
return { result: formatShellResult(r, command.trim().split(/\s+/)[0] ?? '') };
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
/**
|
|
852
|
+
* ⚠⚠ THE LINE CBH2-61 IS ABOUT. It read `return null` with the comment *"transport trouble —
|
|
853
|
+
* let the VFS path report it in its own words"*. Those words described a different failure:
|
|
854
|
+
* the VFS reports a DEVELOPER-SCOPE refusal, which is not what happened and not what to do
|
|
855
|
+
* about it. The reason travels with the outcome now.
|
|
856
|
+
*/
|
|
857
|
+
return classifyNotesShellFailure(e);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
async function execRemote(buildingType, buildingId, command, opts) {
|
|
861
|
+
// Why the daemon path gave up, so a later fallback failure can report the
|
|
862
|
+
// ORIGINAL cause rather than whatever the fallback happened to hit.
|
|
863
|
+
let daemonError = '';
|
|
864
|
+
// ── Daemon-first path ────────────────────────────────────────────────
|
|
865
|
+
// Route through `/tmp/beehaven-ipc.sock → daemon → relay → user's workspace
|
|
866
|
+
// DO → admin:store-rpc → target DO → shell:exec`. The daemon is already
|
|
867
|
+
// authed with the user's Firebase keychain JWT, so the caller never holds
|
|
868
|
+
// a long-lived bearer.
|
|
869
|
+
//
|
|
870
|
+
// Falls back to direct WebSocket only if the daemon isn't running. The
|
|
871
|
+
// direct path uses ~/.beehaven/credentials.json (legacy bearer pattern).
|
|
872
|
+
try {
|
|
873
|
+
const { isDaemonRunning, daemonRpc } = await import('../daemon-client.js');
|
|
874
|
+
if (await isDaemonRunning()) {
|
|
875
|
+
// Special case: `home` is the user's default workspace alias. The
|
|
876
|
+
// daemon's relay is bound there by default, so we call
|
|
877
|
+
// shell:exec directly instead of going through admin:*-rpc
|
|
878
|
+
// (which would gate on ADMIN_USER_IDS and isn't the right
|
|
879
|
+
// path for a user's own workspace).
|
|
880
|
+
if (buildingType === 'home') {
|
|
881
|
+
const result = await daemonRpc('shell:exec', {
|
|
882
|
+
command,
|
|
883
|
+
_timeout: 60_000,
|
|
884
|
+
});
|
|
885
|
+
const innerHome = result?.result ?? result;
|
|
886
|
+
if (innerHome?.error) {
|
|
887
|
+
return { stdout: '', stderr: innerHome.error + '\n', exitCode: 1 };
|
|
888
|
+
}
|
|
889
|
+
const out = innerHome?.stdout ?? innerHome?.output ?? innerHome ?? '';
|
|
890
|
+
const stdoutHome = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
|
|
891
|
+
return {
|
|
892
|
+
stdout: stdoutHome.endsWith('\n') ? stdoutHome : stdoutHome + '\n',
|
|
893
|
+
stderr: innerHome?.stderr ?? '',
|
|
894
|
+
exitCode: innerHome?.exitCode ?? 0,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
// ⚠ ONE envelope builder — this site had its own copy hardcoding `storeId`, so every
|
|
898
|
+
// `beehaven ls` against a TEAM answered "teamId is required". See `gatewayEnvelope`.
|
|
899
|
+
const { gatewayEnvelope } = await import('./workspace.js');
|
|
900
|
+
const gw = gatewayEnvelope(buildingType, buildingId, 'shell:exec', { command, _timeout: 60_000 });
|
|
901
|
+
const result = await daemonRpc(gw.gateway, gw.payload);
|
|
902
|
+
// Unwrap the {result: ...} envelope from admin:store-rpc
|
|
903
|
+
const inner = result?.result ?? result;
|
|
904
|
+
if (inner?.error) {
|
|
905
|
+
return { stdout: '', stderr: inner.error + '\n', exitCode: 1 };
|
|
906
|
+
}
|
|
907
|
+
const output = inner?.stdout ?? inner?.output ?? inner ?? '';
|
|
908
|
+
const stdout = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
|
909
|
+
return {
|
|
910
|
+
stdout: stdout.endsWith('\n') ? stdout : stdout + '\n',
|
|
911
|
+
stderr: inner?.stderr ?? '',
|
|
912
|
+
exitCode: inner?.exitCode ?? 0,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
// Daemon path failed — fall through to direct WebSocket. Common causes:
|
|
918
|
+
// daemon not authed, ADMIN_USER_IDS doesn't include the user, the
|
|
919
|
+
// target DO doesn't support shell:exec via the gateway.
|
|
920
|
+
if (process.env.BEEHAVEN_DEBUG) {
|
|
921
|
+
console.error(`[shell] daemon path failed, falling back: ${err.message}`);
|
|
922
|
+
}
|
|
923
|
+
// …but some failures the fallback CANNOT fix. A scope or auth denial is
|
|
924
|
+
// about WHO is asking, not WHERE — retrying against a local dev worker
|
|
925
|
+
// just swaps the real message for `ECONNREFUSED 127.0.0.1:8787`, which
|
|
926
|
+
// names a port the user never mentioned and hides the one-line fix.
|
|
927
|
+
// `beehaven sql` on home reported exactly that: the true cause ("requires
|
|
928
|
+
// developer scope") was visible only under BEEHAVEN_DEBUG.
|
|
929
|
+
const reason = String(err?.message ?? '');
|
|
930
|
+
if (/developer scope|requires .* scope|unauthorized|not authenticated|token/i.test(reason)) {
|
|
931
|
+
const hint = /developer scope/i.test(reason)
|
|
932
|
+
? '\n Fix: HIVE_DEV_MODE=1 beehaven restart (or enable Developer Mode in the Hive Mac app)\n'
|
|
933
|
+
: '\n Fix: beehaven login (re-auth; a restart will NOT refresh an expired token)\n';
|
|
934
|
+
return { stdout: '', stderr: `${reason}${hint}`, exitCode: 1 };
|
|
935
|
+
}
|
|
936
|
+
daemonError = reason;
|
|
937
|
+
}
|
|
938
|
+
// ── Legacy direct-WebSocket path (fallback only) ─────────────────────
|
|
939
|
+
return new Promise((resolve, reject) => {
|
|
940
|
+
const token = getToken(opts);
|
|
941
|
+
const wsUrl = getWsUrl(buildingType, buildingId, opts) +
|
|
942
|
+
(token ? `?token=${encodeURIComponent(token)}` : '');
|
|
943
|
+
const ws = new WebSocket(wsUrl, ['beehaven-v1']);
|
|
944
|
+
const requestId = `shell_${Date.now()}`;
|
|
945
|
+
let settled = false;
|
|
946
|
+
const timeout = setTimeout(() => {
|
|
947
|
+
if (!settled) {
|
|
948
|
+
settled = true;
|
|
949
|
+
ws.close();
|
|
950
|
+
reject(new Error('Timeout: shell command did not respond within 30s'));
|
|
951
|
+
}
|
|
952
|
+
}, 30_000);
|
|
953
|
+
ws.on('open', () => {
|
|
954
|
+
ws.send(JSON.stringify({ type: 'shell:exec', command, requestId }));
|
|
955
|
+
});
|
|
956
|
+
ws.on('message', (data) => {
|
|
957
|
+
try {
|
|
958
|
+
const msg = JSON.parse(data.toString());
|
|
959
|
+
if (msg.requestId === requestId || msg.rid === requestId) {
|
|
960
|
+
clearTimeout(timeout);
|
|
961
|
+
settled = true;
|
|
962
|
+
ws.close();
|
|
963
|
+
if (msg.error) {
|
|
964
|
+
resolve({ stdout: '', stderr: msg.error + '\n', exitCode: 1 });
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
const output = msg.data?.stdout ?? msg.data?.output ?? msg.data ?? '';
|
|
968
|
+
const stdout = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
|
969
|
+
resolve({
|
|
970
|
+
stdout: stdout.endsWith('\n') ? stdout : stdout + '\n',
|
|
971
|
+
stderr: msg.data?.stderr ?? '',
|
|
972
|
+
exitCode: msg.data?.exitCode ?? 0,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch { }
|
|
978
|
+
});
|
|
979
|
+
ws.on('error', (err) => {
|
|
980
|
+
if (!settled) {
|
|
981
|
+
settled = true;
|
|
982
|
+
clearTimeout(timeout);
|
|
983
|
+
// The fallback dials a LOCAL dev worker when there are no saved
|
|
984
|
+
// credentials, so its failure is almost always `ECONNREFUSED
|
|
985
|
+
// 127.0.0.1:8787` — a port the user never asked about, standing in
|
|
986
|
+
// for whatever actually went wrong upstream. Lead with the daemon's
|
|
987
|
+
// reason when we have one.
|
|
988
|
+
const detail = daemonError
|
|
989
|
+
? `${daemonError}\n (then the direct fallback failed too: ${err.message})`
|
|
990
|
+
: `WebSocket error: ${err.message}`;
|
|
991
|
+
reject(new Error(detail));
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
ws.on('close', () => {
|
|
995
|
+
if (!settled) {
|
|
996
|
+
settled = true;
|
|
997
|
+
clearTimeout(timeout);
|
|
998
|
+
reject(new Error('Connection closed before response'));
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
function execDebug(buildingType, buildingId, debugType, opts) {
|
|
1004
|
+
return new Promise((resolve, reject) => {
|
|
1005
|
+
const token = getToken(opts);
|
|
1006
|
+
const wsUrl = getWsUrl(buildingType, buildingId, opts) +
|
|
1007
|
+
(token ? `?token=${encodeURIComponent(token)}` : '');
|
|
1008
|
+
const ws = new WebSocket(wsUrl, ['beehaven-v1']);
|
|
1009
|
+
const requestId = `debug_${Date.now()}`;
|
|
1010
|
+
let settled = false;
|
|
1011
|
+
const timeout = setTimeout(() => {
|
|
1012
|
+
if (!settled) {
|
|
1013
|
+
settled = true;
|
|
1014
|
+
ws.close();
|
|
1015
|
+
reject(new Error('Timeout'));
|
|
1016
|
+
}
|
|
1017
|
+
}, 15_000);
|
|
1018
|
+
ws.on('open', () => {
|
|
1019
|
+
ws.send(JSON.stringify({ type: debugType, requestId }));
|
|
1020
|
+
});
|
|
1021
|
+
ws.on('message', (data) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const msg = JSON.parse(data.toString());
|
|
1024
|
+
if (msg.requestId === requestId || msg.rid === requestId) {
|
|
1025
|
+
clearTimeout(timeout);
|
|
1026
|
+
settled = true;
|
|
1027
|
+
ws.close();
|
|
1028
|
+
resolve(msg.data ?? msg);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch { }
|
|
1032
|
+
});
|
|
1033
|
+
ws.on('error', (err) => {
|
|
1034
|
+
if (!settled) {
|
|
1035
|
+
settled = true;
|
|
1036
|
+
clearTimeout(timeout);
|
|
1037
|
+
reject(err);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
ws.on('close', () => {
|
|
1041
|
+
if (!settled) {
|
|
1042
|
+
settled = true;
|
|
1043
|
+
clearTimeout(timeout);
|
|
1044
|
+
reject(new Error('Connection closed'));
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
}
|