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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. 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
+ }