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,660 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// LocalAppManager — Hosts Beehaven Buildings locally inside the daemon.
|
|
3
|
+
//
|
|
4
|
+
// The daemon is a hybrid node: it relays events to Clearly cloud AND hosts
|
|
5
|
+
// local Building instances via LocalHive. Cloud DOs can route requests to
|
|
6
|
+
// local Buildings through the relay WebSocket, and local clients can connect
|
|
7
|
+
// directly via the local WebSocket server.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// const manager = new LocalAppManager({ dataDir: '~/.beehaven/apps' });
|
|
11
|
+
// await manager.loadBuildings('~/my-project/src/buildings');
|
|
12
|
+
// await manager.start(9876);
|
|
13
|
+
// ============================================================================
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
15
|
+
import { join, resolve, basename, extname } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import { pathToFileURL } from 'url';
|
|
19
|
+
// ── Manager ──────────────────────────────────────────────────────────────────
|
|
20
|
+
export class LocalAppManager extends EventEmitter {
|
|
21
|
+
config;
|
|
22
|
+
localServer = null; // LocalHive instance (lazy-loaded)
|
|
23
|
+
_running = false;
|
|
24
|
+
_port = null;
|
|
25
|
+
_buildings = {};
|
|
26
|
+
_dataDir;
|
|
27
|
+
_installedApps = new Map();
|
|
28
|
+
constructor(config = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.config = config;
|
|
31
|
+
this._dataDir = config.dataDir ?? join(homedir(), '.beehaven', 'apps');
|
|
32
|
+
}
|
|
33
|
+
/** Get current status */
|
|
34
|
+
status() {
|
|
35
|
+
return {
|
|
36
|
+
running: this._running,
|
|
37
|
+
port: this._port,
|
|
38
|
+
buildingTypes: Object.keys(this._buildings),
|
|
39
|
+
activeInstances: this.localServer ? Array.from(this.localServer.active.keys()) : [],
|
|
40
|
+
dataDir: this._dataDir,
|
|
41
|
+
apps: this.getAgentCatalog(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compact catalog describing every installed app — the shape Omni /
|
|
46
|
+
* cloud agents consume to understand what's available on this machine.
|
|
47
|
+
* Strips secrets and large blobs; includes recipes by id so an agent
|
|
48
|
+
* can reference them ("run nitepunk-piano:build") without having to
|
|
49
|
+
* read the whole manifest.
|
|
50
|
+
*
|
|
51
|
+
* `frontend.url` and `cloudBuildings[].cloudUrl` pass through template
|
|
52
|
+
* substitution here (see resolveTemplateVars) so the same manifest
|
|
53
|
+
* targets dev / staging / prod without per-env .app rebuilds, and so
|
|
54
|
+
* multi-instance apps (store-per-user) resolve the current user's ids.
|
|
55
|
+
*/
|
|
56
|
+
getAgentCatalog() {
|
|
57
|
+
const vars = this.templateVars();
|
|
58
|
+
const entries = [];
|
|
59
|
+
for (const h of this._installedApps.values()) {
|
|
60
|
+
const resolvedFrontendUrl = h.frontendUrl
|
|
61
|
+
? resolveTemplateVars(h.frontendUrl, vars)
|
|
62
|
+
: undefined;
|
|
63
|
+
entries.push({
|
|
64
|
+
name: h.name,
|
|
65
|
+
version: h.version,
|
|
66
|
+
description: h.manifest.description ?? '',
|
|
67
|
+
vendor: h.manifest.vendor?.name ?? '',
|
|
68
|
+
tier: h.manifest.license?.tier ?? 'free',
|
|
69
|
+
buildings: h.buildings,
|
|
70
|
+
cloudBuildings: h.cloudBuildings.map((c) => c.name),
|
|
71
|
+
browserBuildings: h.browserBuildings.map((b) => ({
|
|
72
|
+
name: b.name,
|
|
73
|
+
url: b.url,
|
|
74
|
+
fallbackFor: b.fallbackFor,
|
|
75
|
+
handles: b.handles,
|
|
76
|
+
})),
|
|
77
|
+
recipes: Object.keys(h.recipes),
|
|
78
|
+
depends: h.depends,
|
|
79
|
+
frontend: resolvedFrontendUrl
|
|
80
|
+
? {
|
|
81
|
+
title: h.manifest.frontend?.title ?? h.name,
|
|
82
|
+
icon: h.manifest.frontend?.icon ?? '',
|
|
83
|
+
url: resolvedFrontendUrl,
|
|
84
|
+
hosted: h.frontendHosted ?? 'local',
|
|
85
|
+
window: h.manifest.frontend?.window ?? null,
|
|
86
|
+
}
|
|
87
|
+
: null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Snapshot the template variables substitution uses. Read from the
|
|
94
|
+
* daemon's env + config every catalog request so an in-flight sign-in
|
|
95
|
+
* (which writes new values to config) reflects immediately without
|
|
96
|
+
* daemon restart.
|
|
97
|
+
*/
|
|
98
|
+
templateVars() {
|
|
99
|
+
// `webBase` — dev Mac app sets this via env; defaults to production.
|
|
100
|
+
const webBase = process.env.BEEHAVEN_WEB_BASE ?? process.env.CLEARLY_WEB_BASE ?? 'https://clearly.sh';
|
|
101
|
+
// User identity — the daemon stores these in config on sign-in
|
|
102
|
+
// install(), so they're available without decoding the JWT here.
|
|
103
|
+
const cfg = readDaemonConfigSafe();
|
|
104
|
+
return {
|
|
105
|
+
webBase,
|
|
106
|
+
userId: cfg.user?.uid ?? cfg.user?.userId ?? '',
|
|
107
|
+
email: cfg.user?.email ?? '',
|
|
108
|
+
storeId: cfg.activeStoreId ?? cfg.user?.activeStoreId ?? '',
|
|
109
|
+
teamId: cfg.activeTeamId ?? '',
|
|
110
|
+
tier: cfg.tier ?? 'local',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Register a building class manually.
|
|
115
|
+
* Used by daemon integrations that import building classes directly.
|
|
116
|
+
*/
|
|
117
|
+
registerBuilding(name, BuildingClass) {
|
|
118
|
+
this._buildings[name] = BuildingClass;
|
|
119
|
+
this.emit('building-registered', name);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Load building classes from a directory.
|
|
123
|
+
* Looks for .ts/.js files that export a class extending Workspace.
|
|
124
|
+
* Uses dynamic import, so the files must be importable at runtime.
|
|
125
|
+
*/
|
|
126
|
+
async loadBuildings(dir) {
|
|
127
|
+
const absDir = resolve(dir);
|
|
128
|
+
if (!existsSync(absDir)) {
|
|
129
|
+
this.emit('error', new Error(`Buildings directory not found: ${absDir}`));
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const loaded = [];
|
|
133
|
+
const files = readdirSync(absDir).filter((f) => (f.endsWith('.ts') || f.endsWith('.js')) &&
|
|
134
|
+
!f.endsWith('.test.ts') &&
|
|
135
|
+
!f.endsWith('.test.js') &&
|
|
136
|
+
!f.endsWith('.d.ts'));
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
try {
|
|
139
|
+
const filePath = join(absDir, file);
|
|
140
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
141
|
+
// Find exported classes (look for default export or named exports)
|
|
142
|
+
const candidates = mod.default ? { [basename(file, extname(file))]: mod.default } : mod;
|
|
143
|
+
for (const [name, value] of Object.entries(candidates)) {
|
|
144
|
+
if (typeof value === 'function' && value.prototype?.fetch) {
|
|
145
|
+
// Looks like a Building class (has fetch method on prototype)
|
|
146
|
+
const buildingName = name.charAt(0).toLowerCase() + name.slice(1);
|
|
147
|
+
this._buildings[buildingName] = value;
|
|
148
|
+
loaded.push(buildingName);
|
|
149
|
+
this.emit('building-registered', buildingName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
this.emit('error', new Error(`Failed to load ${file}: ${err.message}`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return loaded;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Load buildings from a beehaven project config (beehaven.config.ts/json).
|
|
161
|
+
*/
|
|
162
|
+
async loadFromProject(projectDir) {
|
|
163
|
+
const absDir = resolve(projectDir);
|
|
164
|
+
// Try beehaven.config.json first
|
|
165
|
+
const jsonConfig = join(absDir, 'beehaven.config.json');
|
|
166
|
+
if (existsSync(jsonConfig)) {
|
|
167
|
+
try {
|
|
168
|
+
const config = JSON.parse(readFileSync(jsonConfig, 'utf8'));
|
|
169
|
+
if (config.buildings && typeof config.buildings === 'object') {
|
|
170
|
+
const buildingsDir = join(absDir, config.buildingsDir ?? 'src/buildings');
|
|
171
|
+
return this.loadBuildings(buildingsDir);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* fall through */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Try src/buildings/ convention
|
|
179
|
+
const conventionDir = join(absDir, 'src', 'buildings');
|
|
180
|
+
if (existsSync(conventionDir)) {
|
|
181
|
+
return this.loadBuildings(conventionDir);
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Scan ~/.beehaven/apps/ for installed .app packages and register
|
|
187
|
+
* them in two passes:
|
|
188
|
+
*
|
|
189
|
+
* Pass 1 — parse every manifest, build the dep graph.
|
|
190
|
+
* Pass 2 — topo-sort by depends, load each app in order. An app
|
|
191
|
+
* whose dependency is missing or whose dependency load
|
|
192
|
+
* failed is marked with an error string and skipped
|
|
193
|
+
* (rather than failing the whole scan).
|
|
194
|
+
*
|
|
195
|
+
* Building-name conflicts (two apps trying to register the same
|
|
196
|
+
* building name, e.g. an installed `ableton` vs. a dev-linked `ableton`)
|
|
197
|
+
* resolve to the FIRST-REGISTERED winner. The later one is skipped with
|
|
198
|
+
* a visible warning so the dev loop (linkedProject first, installed
|
|
199
|
+
* apps second) is predictable: your source edits always win.
|
|
200
|
+
*/
|
|
201
|
+
async loadInstalledApps(hivesRoot) {
|
|
202
|
+
// Scan ~/.beehaven/apps/ (canonical) and ~/.beehaven/hives/ (legacy).
|
|
203
|
+
// Accept app.json or hive.json as manifest.
|
|
204
|
+
const roots = hivesRoot
|
|
205
|
+
? (Array.isArray(hivesRoot) ? hivesRoot.map((root) => resolve(root)) : [resolve(hivesRoot)])
|
|
206
|
+
: [join(homedir(), '.beehaven', 'apps'), join(homedir(), '.beehaven', 'hives')];
|
|
207
|
+
let entries = [];
|
|
208
|
+
const seen = new Set(); // dedupe by directory name
|
|
209
|
+
for (const root of roots) {
|
|
210
|
+
if (!existsSync(root))
|
|
211
|
+
continue;
|
|
212
|
+
try {
|
|
213
|
+
// A bundled first-party hive is often handed in as its app directory
|
|
214
|
+
// (Resources/beehaven/hives/avatar), while user-installed roots are
|
|
215
|
+
// containers of app directories. Accept both shapes so the bundle can
|
|
216
|
+
// be loaded without copying it into ~/.beehaven/apps.
|
|
217
|
+
const directManifest = existsSync(join(root, 'app.json')) || existsSync(join(root, 'hive.json'));
|
|
218
|
+
if (directManifest) {
|
|
219
|
+
const parent = resolve(root, '..');
|
|
220
|
+
const entry = basename(root);
|
|
221
|
+
if (!seen.has(entry)) {
|
|
222
|
+
seen.add(entry);
|
|
223
|
+
entries.push({ dir: parent, entry });
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const entry of readdirSync(root)) {
|
|
228
|
+
if (!seen.has(entry)) {
|
|
229
|
+
seen.add(entry);
|
|
230
|
+
entries.push({ dir: root, entry });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
}
|
|
236
|
+
if (entries.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const pending = [];
|
|
239
|
+
for (const { dir: root, entry } of entries) {
|
|
240
|
+
if (entry.startsWith('.'))
|
|
241
|
+
continue;
|
|
242
|
+
const appDir = join(root, entry);
|
|
243
|
+
// Accept app.json (canonical) or hive.json (legacy)
|
|
244
|
+
const appManifest = join(appDir, 'app.json');
|
|
245
|
+
const hiveManifest = join(appDir, 'hive.json');
|
|
246
|
+
const manifestPath = existsSync(appManifest)
|
|
247
|
+
? appManifest
|
|
248
|
+
: existsSync(hiveManifest)
|
|
249
|
+
? hiveManifest
|
|
250
|
+
: null;
|
|
251
|
+
if (!manifestPath)
|
|
252
|
+
continue;
|
|
253
|
+
let manifest;
|
|
254
|
+
try {
|
|
255
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
this.emit('error', new Error(`Skipping app at ${appDir} — bad manifest: ${err.message}`));
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Auth gate. Apps that declare `requiresAuth: true` are
|
|
262
|
+
// skipped entirely when the daemon is signed out — building
|
|
263
|
+
// classes don't load, IPC routes don't register, and the
|
|
264
|
+
// tray-coupled BrowserWindow never spawns. The whole hive
|
|
265
|
+
// disappears from the running process until the user signs
|
|
266
|
+
// back in (which restarts the daemon and re-runs this load).
|
|
267
|
+
if (manifest.requiresAuth === true) {
|
|
268
|
+
const isAuthed = this.config.isAuthenticated?.() ?? true;
|
|
269
|
+
if (!isAuthed) {
|
|
270
|
+
this.emit('info', `Skipping app "${manifest.name}" — requiresAuth:true and daemon is signed out.`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let frontendAbs;
|
|
275
|
+
if (manifest.frontend?.dir) {
|
|
276
|
+
const candidate = join(appDir, manifest.frontend.dir);
|
|
277
|
+
if (existsSync(candidate))
|
|
278
|
+
frontendAbs = candidate;
|
|
279
|
+
}
|
|
280
|
+
const recipes = {};
|
|
281
|
+
if (manifest.recipes && typeof manifest.recipes === 'object') {
|
|
282
|
+
for (const [rid, val] of Object.entries(manifest.recipes)) {
|
|
283
|
+
if (typeof val === 'string') {
|
|
284
|
+
const rpath = join(appDir, val);
|
|
285
|
+
if (existsSync(rpath)) {
|
|
286
|
+
try {
|
|
287
|
+
recipes[rid] = JSON.parse(readFileSync(rpath, 'utf8'));
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
this.emit('error', new Error(`${manifest.name}: recipe "${rid}" parse failed: ${err.message}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
recipes[rid] = val;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const provides = manifest.buildings && typeof manifest.buildings === 'object'
|
|
300
|
+
? Object.keys(manifest.buildings)
|
|
301
|
+
: [];
|
|
302
|
+
pending.push({
|
|
303
|
+
appDir,
|
|
304
|
+
manifest,
|
|
305
|
+
frontendAbs,
|
|
306
|
+
recipes,
|
|
307
|
+
provides,
|
|
308
|
+
depends: manifest.depends && typeof manifest.depends === 'object' ? manifest.depends : {},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// ── Pass 2: topo-sort + load ─────────────────────────────────────
|
|
312
|
+
// Deps can reference a BUILDING name (the common case — `ableton`)
|
|
313
|
+
// or an APP name. We resolve against the set of provides *and* app
|
|
314
|
+
// names so both work. Recipes-only apps that have no provides but
|
|
315
|
+
// depend on building X still resolve as long as X is available.
|
|
316
|
+
const alreadyHaveBuilding = (name) => name in this._buildings;
|
|
317
|
+
const providedByPending = {};
|
|
318
|
+
for (const p of pending) {
|
|
319
|
+
for (const b of p.provides)
|
|
320
|
+
providedByPending[b] = p;
|
|
321
|
+
providedByPending[p.manifest.name] = p; // allow depends-by-app-name
|
|
322
|
+
}
|
|
323
|
+
// Iterative load: repeatedly pick a pending app whose deps are all
|
|
324
|
+
// currently satisfied. Halts when the set stops shrinking.
|
|
325
|
+
const ordered = [];
|
|
326
|
+
const remaining = [...pending];
|
|
327
|
+
const depSatisfied = (dep) => alreadyHaveBuilding(dep) ||
|
|
328
|
+
ordered.some((o) => o.provides.includes(dep) || o.manifest.name === dep);
|
|
329
|
+
let progressed = true;
|
|
330
|
+
while (remaining.length > 0 && progressed) {
|
|
331
|
+
progressed = false;
|
|
332
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
333
|
+
const p = remaining[i];
|
|
334
|
+
const deps = Object.keys(p.depends);
|
|
335
|
+
if (deps.every(depSatisfied)) {
|
|
336
|
+
ordered.push(p);
|
|
337
|
+
remaining.splice(i, 1);
|
|
338
|
+
progressed = true;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Anything still in `remaining` is stuck on an unmet dep.
|
|
344
|
+
for (const p of remaining) {
|
|
345
|
+
const unmet = Object.keys(p.depends).filter((d) => !depSatisfied(d));
|
|
346
|
+
this.emit('error', new Error(`${p.manifest.name}@${p.manifest.version}: unmet dependencies [${unmet.join(', ')}] — app skipped`));
|
|
347
|
+
}
|
|
348
|
+
// ── Pass 3: actually import + register ──────────────────────────
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const p of ordered) {
|
|
351
|
+
const { manifest, appDir, frontendAbs, recipes } = p;
|
|
352
|
+
const registeredBuildings = [];
|
|
353
|
+
const cloudBuildings = [];
|
|
354
|
+
const browserBuildings = [];
|
|
355
|
+
if (manifest.buildings && typeof manifest.buildings === 'object') {
|
|
356
|
+
for (const [buildingName, bRaw] of Object.entries(manifest.buildings)) {
|
|
357
|
+
const b = bRaw;
|
|
358
|
+
// `runtime` is canonical; `hosted` is the deprecated alias kept
|
|
359
|
+
// for backward compat with shipped hives. When both are set,
|
|
360
|
+
// `runtime` wins (matches validator behavior).
|
|
361
|
+
const runtime = b.runtime ?? b.hosted ?? 'local';
|
|
362
|
+
// ── Cloud runtime ────────────────────────────────────────
|
|
363
|
+
// Not instantiated locally — the daemon routes app-rpc
|
|
364
|
+
// messages to the declared cloudUrl via the relay. The cloud
|
|
365
|
+
// half (worker auto-registering hive cloud buildings as DO
|
|
366
|
+
// siblings) is Sprint A4.
|
|
367
|
+
if (runtime === 'cloud') {
|
|
368
|
+
if (typeof b.cloudUrl !== 'string') {
|
|
369
|
+
this.emit('error', new Error(`${manifest.name}: cloud building "${buildingName}" missing cloudUrl`));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
cloudBuildings.push({ name: buildingName, cloudUrl: b.cloudUrl });
|
|
373
|
+
this.emit('info', `${manifest.name}: registered cloud building "${buildingName}" → ${b.cloudUrl}`);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// ── Browser runtime ──────────────────────────────────────
|
|
377
|
+
// Daemon doesn't instantiate — the file is served as a static
|
|
378
|
+
// JS module under the dashboard's /apps/<name>/buildings/<key>.js
|
|
379
|
+
// path. Frontend `hive.call()` fetches + dispatches when no
|
|
380
|
+
// local equivalent is installed (per `fallbackFor` declaration).
|
|
381
|
+
// Sprint A3 wires the browser-side runtime.
|
|
382
|
+
if (runtime === 'browser') {
|
|
383
|
+
if (typeof b.entry !== 'string') {
|
|
384
|
+
this.emit('error', new Error(`${manifest.name}: browser building "${buildingName}" missing entry`));
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const entryAbs = join(appDir, b.entry);
|
|
388
|
+
if (!existsSync(entryAbs)) {
|
|
389
|
+
this.emit('error', new Error(`${manifest.name}: browser building "${buildingName}" entry not found: ${b.entry}`));
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const url = `http://127.0.0.1:9800/apps/${manifest.name}/buildings/${buildingName}.js`;
|
|
393
|
+
browserBuildings.push({
|
|
394
|
+
name: buildingName,
|
|
395
|
+
entry: b.entry,
|
|
396
|
+
url,
|
|
397
|
+
fallbackFor: typeof b.fallbackFor === 'string' ? b.fallbackFor : undefined,
|
|
398
|
+
handles: Array.isArray(b.handles) ? b.handles : undefined,
|
|
399
|
+
});
|
|
400
|
+
this.emit('info', `${manifest.name}: registered browser building "${buildingName}" → ${url}${b.fallbackFor ? ` (fallback for "${b.fallbackFor}")` : ''}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
// ── Local runtime (default) ──────────────────────────────
|
|
404
|
+
// Conflict detection: if the same name is already registered
|
|
405
|
+
// (e.g. from a linked dev project), skip this app's copy and
|
|
406
|
+
// warn. First-registered wins.
|
|
407
|
+
if (buildingName in this._buildings) {
|
|
408
|
+
this.emit('info', `${manifest.name}@${manifest.version}: building "${buildingName}" skipped — already registered (first-registered wins)`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (typeof b.entry !== 'string') {
|
|
412
|
+
this.emit('error', new Error(`${manifest.name}: local building "${buildingName}" missing entry`));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const entryPath = join(appDir, b.entry);
|
|
416
|
+
if (!existsSync(entryPath)) {
|
|
417
|
+
this.emit('error', new Error(`${manifest.name}: building "${buildingName}" entry not found: ${b.entry}`));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const mod = await import(pathToFileURL(entryPath).href);
|
|
422
|
+
const className = b.className;
|
|
423
|
+
const BuildingClass = className
|
|
424
|
+
? mod[className]
|
|
425
|
+
: (mod.default ?? Object.values(mod)[0]);
|
|
426
|
+
if (typeof BuildingClass !== 'function') {
|
|
427
|
+
this.emit('error', new Error(`${manifest.name}: building "${buildingName}" has no loadable class export`));
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
this._buildings[buildingName] = BuildingClass;
|
|
431
|
+
registeredBuildings.push(buildingName);
|
|
432
|
+
this.emit('building-registered', buildingName);
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
this.emit('error', new Error(`${manifest.name}: failed to load building "${buildingName}": ${err.message}`));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Compute the frontend URL. Local frontends get the dashboard path;
|
|
440
|
+
// cloud frontends get their manifest cloudUrl. Hives with no frontend
|
|
441
|
+
// stay undefined — they're purely action surfaces, no UI.
|
|
442
|
+
let frontendUrl;
|
|
443
|
+
let frontendHosted;
|
|
444
|
+
if (manifest.frontend) {
|
|
445
|
+
const declared = manifest.frontend.hosted ??
|
|
446
|
+
(manifest.frontend.dir ? 'local' : manifest.frontend.cloudUrl ? 'cloud' : 'local');
|
|
447
|
+
frontendHosted = declared;
|
|
448
|
+
if (declared === 'local' && frontendAbs) {
|
|
449
|
+
frontendUrl = `http://127.0.0.1:9800/apps/${manifest.name}/`;
|
|
450
|
+
}
|
|
451
|
+
else if (declared === 'cloud' && typeof manifest.frontend.cloudUrl === 'string') {
|
|
452
|
+
frontendUrl = manifest.frontend.cloudUrl;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const installed = {
|
|
456
|
+
name: manifest.name,
|
|
457
|
+
version: manifest.version,
|
|
458
|
+
appDir,
|
|
459
|
+
manifest,
|
|
460
|
+
frontendDir: frontendAbs,
|
|
461
|
+
frontendUrl,
|
|
462
|
+
frontendHosted,
|
|
463
|
+
buildings: registeredBuildings,
|
|
464
|
+
cloudBuildings,
|
|
465
|
+
browserBuildings,
|
|
466
|
+
recipes,
|
|
467
|
+
depends: p.depends,
|
|
468
|
+
};
|
|
469
|
+
out.push(installed);
|
|
470
|
+
this._installedApps.set(manifest.name, installed);
|
|
471
|
+
this.emit('info', `Loaded app ${manifest.name}@${manifest.version}${frontendUrl ? ' (' + frontendHosted + ' frontend)' : ''}${Object.keys(p.depends).length > 0 ? ' (deps: ' + Object.keys(p.depends).join(', ') + ')' : ''}`);
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
/** Return metadata for an installed app (or all). */
|
|
476
|
+
getInstalledApp(name) {
|
|
477
|
+
return this._installedApps.get(name);
|
|
478
|
+
}
|
|
479
|
+
listInstalledApps() {
|
|
480
|
+
return Array.from(this._installedApps.values());
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Start the local app server.
|
|
484
|
+
* Dynamically imports @clearly-sh/beehaven-core to avoid hard dependency at module load time.
|
|
485
|
+
*/
|
|
486
|
+
async start(port) {
|
|
487
|
+
if (this._running)
|
|
488
|
+
return;
|
|
489
|
+
if (Object.keys(this._buildings).length === 0) {
|
|
490
|
+
this.emit('info', 'No buildings registered — local app server not started.');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
// Dynamic import — @clearly-sh/beehaven-core is a peer dependency
|
|
495
|
+
// @ts-ignore — optional peer dependency, resolved at runtime
|
|
496
|
+
const { LocalHive } = await import('@clearly-sh/beehaven-core/server/local');
|
|
497
|
+
mkdirSync(this._dataDir, { recursive: true });
|
|
498
|
+
this.localServer = new LocalHive({
|
|
499
|
+
buildings: this._buildings,
|
|
500
|
+
dataDir: this._dataDir,
|
|
501
|
+
env: this.config.env ?? {},
|
|
502
|
+
auth: this.config.auth,
|
|
503
|
+
});
|
|
504
|
+
// Try to set up better-sqlite3 database factory.
|
|
505
|
+
// If it's not bundled, fall through — LocalHive has its own in-memory
|
|
506
|
+
// fallback, and some buildings (e.g. Ableton) only need SQLite for
|
|
507
|
+
// non-critical history/snapshots. Returning early here used to skip
|
|
508
|
+
// the actual `listen()` call, silently breaking every local app.
|
|
509
|
+
try {
|
|
510
|
+
// @ts-ignore — optional dependency
|
|
511
|
+
const Database = (await import('better-sqlite3')).default;
|
|
512
|
+
this.localServer.setDatabaseFactory((path) => new Database(path));
|
|
513
|
+
this.emit('info', 'better-sqlite3 loaded — building state will persist to disk.');
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
// better-sqlite3 not available — set up a no-op database factory.
|
|
517
|
+
// Buildings that need SQLite will get null and must handle it.
|
|
518
|
+
// Buildings like Avatar that don't use SQLite will work fine.
|
|
519
|
+
// Surface the actual reason so packaging issues (missing native
|
|
520
|
+
// binding, ABI mismatch, missing peer dep, wrong arch) are
|
|
521
|
+
// diagnosable instead of opaquely "not bundled".
|
|
522
|
+
const e = err;
|
|
523
|
+
this.localServer.setDatabaseFactory(() => null);
|
|
524
|
+
this.emit('info', `better-sqlite3 not bundled — buildings run without persistent SQLite. (${e?.code || 'no-code'}: ${e?.message?.slice(0, 200) || 'no-message'})`);
|
|
525
|
+
}
|
|
526
|
+
// Pre-create the per-building SQLite data directories with 0o755 so
|
|
527
|
+
// they have the execute bit. LocalHive.createDatabase uses plain
|
|
528
|
+
// mkdirSync with no explicit mode, which picks up whatever umask is
|
|
529
|
+
// currently active — and the ipc server sets a restrictive umask
|
|
530
|
+
// when binding the Unix socket. If a building is first touched
|
|
531
|
+
// during that window the dir gets 0o600 and SQLite can't enter it
|
|
532
|
+
// (errors out as "unable to open database file"). Doing the mkdir
|
|
533
|
+
// here, ourselves, with a known-good mode, eliminates that race.
|
|
534
|
+
try {
|
|
535
|
+
mkdirSync(this._dataDir, { recursive: true, mode: 0o755 });
|
|
536
|
+
chmodSync(this._dataDir, 0o755);
|
|
537
|
+
for (const type of Object.keys(this._buildings)) {
|
|
538
|
+
const dir = join(this._dataDir, type);
|
|
539
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
540
|
+
// chmodSync bypasses umask, so even if something else on the
|
|
541
|
+
// daemon is holding a restrictive umask, the building data
|
|
542
|
+
// dir ends up with the execute bit SQLite needs.
|
|
543
|
+
chmodSync(dir, 0o755);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
this.emit('error', new Error(`Failed to create building data dirs: ${err?.message ?? err}`));
|
|
548
|
+
}
|
|
549
|
+
const actualPort = port ?? this.config.port ?? 9876;
|
|
550
|
+
const host = this.config.host ?? '127.0.0.1';
|
|
551
|
+
await this.localServer.listen(actualPort, { host });
|
|
552
|
+
this._running = true;
|
|
553
|
+
this._port = actualPort;
|
|
554
|
+
this.emit('started', { port: actualPort, host, buildings: Object.keys(this._buildings) });
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
this.emit('error', new Error(`Failed to start local app: ${err.message}`));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Stop the local app server. */
|
|
561
|
+
async stop() {
|
|
562
|
+
if (!this._running || !this.localServer)
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
await this.localServer.close();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* best effort */
|
|
569
|
+
}
|
|
570
|
+
this._running = false;
|
|
571
|
+
this._port = null;
|
|
572
|
+
this.localServer = null;
|
|
573
|
+
this.emit('stopped');
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Route a fetch request to a local building.
|
|
577
|
+
* Used by the relay to forward cloud requests to local apps.
|
|
578
|
+
*/
|
|
579
|
+
async routeFetch(type, id, request) {
|
|
580
|
+
if (!this.localServer) {
|
|
581
|
+
return new Response('Local app not running', { status: 503 });
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
return await this.localServer.fetch(type, id, request);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
return new Response(err.message, { status: 500 });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Get a local building instance.
|
|
592
|
+
* Used for direct programmatic access from daemon code.
|
|
593
|
+
*
|
|
594
|
+
* Errors from LocalHive.building() (unknown type, createDatabase
|
|
595
|
+
* failure, constructor throw) are surfaced via the error event stream
|
|
596
|
+
* so they don't silently become "building not found" — that made
|
|
597
|
+
* debugging instantiation failures much harder than it had to be.
|
|
598
|
+
*/
|
|
599
|
+
getBuilding(type, id) {
|
|
600
|
+
if (!this.localServer)
|
|
601
|
+
return null;
|
|
602
|
+
try {
|
|
603
|
+
return this.localServer.building(type, id);
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
this.emit('error', new Error(`getBuilding("${type}","${id}") failed: ${err?.message ?? err}`));
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Check if a building type is registered. */
|
|
611
|
+
hasBuilding(type) {
|
|
612
|
+
return type in this._buildings;
|
|
613
|
+
}
|
|
614
|
+
/** Get the data directory. */
|
|
615
|
+
get dataDir() {
|
|
616
|
+
return this._dataDir;
|
|
617
|
+
}
|
|
618
|
+
/** Check if the app server is running. */
|
|
619
|
+
get running() {
|
|
620
|
+
return this._running;
|
|
621
|
+
}
|
|
622
|
+
/** Get the port. */
|
|
623
|
+
get port() {
|
|
624
|
+
return this._port;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
628
|
+
// Helpers — template substitution for app URLs
|
|
629
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
630
|
+
/**
|
|
631
|
+
* Substitute {foo} / {foo.bar} placeholders in a URL (or any string).
|
|
632
|
+
* Unknown placeholders are left untouched so apps don't silently break
|
|
633
|
+
* when the daemon runs before a user has signed in, before a store is
|
|
634
|
+
* picked, etc. The frontend typically handles the "not-yet-resolved"
|
|
635
|
+
* case itself (redirect to login, show store picker, etc).
|
|
636
|
+
*/
|
|
637
|
+
function resolveTemplateVars(input, vars) {
|
|
638
|
+
return input.replace(/\{([a-zA-Z_][\w.]*)\}/g, (match, key) => {
|
|
639
|
+
if (key in vars && vars[key])
|
|
640
|
+
return vars[key];
|
|
641
|
+
return match;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Read the daemon config without throwing. Used by templateVars() to pull
|
|
646
|
+
* `user.uid`, `user.email`, `activeStoreId`, etc. for template resolution.
|
|
647
|
+
* Reads on every call — the file is tiny and the user expects sign-in to
|
|
648
|
+
* reflect without a daemon restart.
|
|
649
|
+
*/
|
|
650
|
+
function readDaemonConfigSafe() {
|
|
651
|
+
try {
|
|
652
|
+
const configPath = join(homedir(), '.beehaven', 'config.json');
|
|
653
|
+
if (!existsSync(configPath))
|
|
654
|
+
return {};
|
|
655
|
+
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return {};
|
|
659
|
+
}
|
|
660
|
+
}
|