@clearlysh/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,1123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* beehaven app — pack / install / list / uninstall apps.
|
|
3
|
+
*
|
|
4
|
+
* Apps are zip archives with an app.json manifest at the root. Once
|
|
5
|
+
* installed they live under ~/.beehaven/apps/<name>@<version>/ and are
|
|
6
|
+
* picked up by the daemon on its next launch (or on a hot-reload, when
|
|
7
|
+
* that lands).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* beehaven app pack <dir> [-o out.hive]
|
|
11
|
+
* beehaven app install <path-or-url>
|
|
12
|
+
* beehaven app list
|
|
13
|
+
* beehaven app uninstall <name>[@<version>]
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'fs';
|
|
16
|
+
import { join, relative, resolve } from 'path';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { execSync, execFileSync, spawnSync } from 'child_process';
|
|
19
|
+
import { parseAppManifest, validateAppManifest } from '../app-manifest.js';
|
|
20
|
+
import { verifyAppSignature, loadSignatureEnvelope, TRUSTED_SIGNERS } from '../app-signing.js';
|
|
21
|
+
import { isPubliclyVisibleApp, isPubliclyVisibleAppAction } from '../public-surface.js';
|
|
22
|
+
const APPS_DIR = join(homedir(), '.beehaven', 'apps');
|
|
23
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Shared helpers
|
|
25
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
26
|
+
function ensureAppsDir() {
|
|
27
|
+
mkdirSync(APPS_DIR, { recursive: true, mode: 0o755 });
|
|
28
|
+
}
|
|
29
|
+
/** Enumerate files under dir, relative paths, skipping common cruft. */
|
|
30
|
+
function walkFiles(root) {
|
|
31
|
+
const results = [];
|
|
32
|
+
const skip = new Set(['node_modules', '.git', '.DS_Store', 'dist.map', '.turbo', '.cache']);
|
|
33
|
+
const walk = (p) => {
|
|
34
|
+
for (const entry of readdirSync(p)) {
|
|
35
|
+
if (skip.has(entry))
|
|
36
|
+
continue;
|
|
37
|
+
const full = join(p, entry);
|
|
38
|
+
const stat = statSync(full);
|
|
39
|
+
if (stat.isDirectory())
|
|
40
|
+
walk(full);
|
|
41
|
+
else if (stat.isFile())
|
|
42
|
+
results.push(relative(root, full));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(root);
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
49
|
+
// beehaven app pack
|
|
50
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
51
|
+
export async function cmdAppPack(args, opts = {}) {
|
|
52
|
+
const dir = resolve(args[0] ?? process.cwd());
|
|
53
|
+
const manifestPath = join(dir, 'app.json');
|
|
54
|
+
if (!existsSync(manifestPath)) {
|
|
55
|
+
console.error(` ✗ No app.json at ${manifestPath}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// Validate before packing — catching errors here is always cheaper than
|
|
59
|
+
// catching them on install.
|
|
60
|
+
const raw = readFileSync(manifestPath, 'utf8');
|
|
61
|
+
const { manifest, error } = parseAppManifest(raw);
|
|
62
|
+
if (error || !manifest) {
|
|
63
|
+
console.error(` ✗ ${error ?? 'invalid manifest'}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// Default output: ./<name>-<version>.hive
|
|
67
|
+
const outPath = resolve(opts.output ?? join(process.cwd(), `${manifest.name}-${manifest.version}.hive`));
|
|
68
|
+
// Build the archive. zip is ubiquitous on macOS/Linux; on Windows we'd
|
|
69
|
+
// reach for an npm zip lib, but the CLI is macOS-first today.
|
|
70
|
+
const files = walkFiles(dir);
|
|
71
|
+
if (!files.includes('app.json')) {
|
|
72
|
+
console.error(' ✗ app.json must be at the archive root');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
// Ensure entries referenced by the manifest actually exist on disk —
|
|
76
|
+
// catches typos in building.entry and frontend.dir before they become
|
|
77
|
+
// runtime errors at install time. Cloud-hosted buildings/frontends
|
|
78
|
+
// are exempt because they don't ship on-disk assets.
|
|
79
|
+
for (const [key, bRaw] of Object.entries(manifest.buildings ?? {})) {
|
|
80
|
+
const b = bRaw;
|
|
81
|
+
const hosted = b.hosted ?? 'local';
|
|
82
|
+
if (hosted !== 'local')
|
|
83
|
+
continue; // cloud buildings → nothing to check
|
|
84
|
+
if (typeof b.entry !== 'string') {
|
|
85
|
+
console.error(` ✗ buildings["${key}"].entry must be a string for hosted="local"`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const p = join(dir, b.entry);
|
|
89
|
+
if (!existsSync(p)) {
|
|
90
|
+
console.error(` ✗ buildings["${key}"].entry not found: ${b.entry}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (manifest.frontend) {
|
|
95
|
+
const fhosted = manifest.frontend.hosted ??
|
|
96
|
+
(manifest.frontend.dir ? 'local' : manifest.frontend.cloudUrl ? 'cloud' : 'local');
|
|
97
|
+
if (fhosted === 'local') {
|
|
98
|
+
if (typeof manifest.frontend.dir !== 'string') {
|
|
99
|
+
console.error(' ✗ frontend.dir must be a string for hosted="local"');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const p = join(dir, manifest.frontend.dir);
|
|
103
|
+
if (!existsSync(p) || !statSync(p).isDirectory()) {
|
|
104
|
+
console.error(` ✗ frontend.dir not found: ${manifest.frontend.dir}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Remove any previous archive at the same path
|
|
110
|
+
try {
|
|
111
|
+
if (existsSync(outPath))
|
|
112
|
+
rmSync(outPath);
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
// Shell to `zip` — reliable on macOS, no native-module hell. The `-@`
|
|
116
|
+
// flag reads file names from stdin, so we pipe the list in directly
|
|
117
|
+
// via spawnSync's `input` option. No shell, no argv-length limits,
|
|
118
|
+
// no injection risk from file names containing metacharacters.
|
|
119
|
+
try {
|
|
120
|
+
const result = spawnSync('zip', ['-q', outPath, '-@'], {
|
|
121
|
+
cwd: dir,
|
|
122
|
+
input: files.join('\n'),
|
|
123
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
124
|
+
});
|
|
125
|
+
if (result.status !== 0) {
|
|
126
|
+
throw new Error(`zip exited with status ${result.status}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error(` ✗ zip failed: ${err?.message ?? err}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
// Optional signing: --key <path-to-ed25519-private-key> --signer <id>
|
|
134
|
+
// Writes a detached <outPath>.sig file that `beehaven app install`
|
|
135
|
+
// verifies against pinned trusted public keys.
|
|
136
|
+
if (opts.signKey) {
|
|
137
|
+
try {
|
|
138
|
+
const signerId = opts.signerId ?? 'clearly-release';
|
|
139
|
+
const { signAppArchive } = await import('../app-signer.js');
|
|
140
|
+
const sigEnvelope = await signAppArchive(outPath, opts.signKey, signerId);
|
|
141
|
+
writeFileSync(outPath + '.sig', JSON.stringify(sigEnvelope, null, 2));
|
|
142
|
+
console.log(` ✓ Signed (${signerId})`);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.error(` ✗ signing failed: ${err?.message ?? err}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const stat = statSync(outPath);
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log(` ✓ Packed ${manifest.name}@${manifest.version}`);
|
|
152
|
+
console.log(` ${files.length} files, ${(stat.size / 1024).toFixed(1)} KB`);
|
|
153
|
+
console.log(` → ${outPath}`);
|
|
154
|
+
if (opts.signKey)
|
|
155
|
+
console.log(` → ${outPath}.sig (signature)`);
|
|
156
|
+
console.log('');
|
|
157
|
+
console.log(` Install locally: beehaven app install ${outPath}`);
|
|
158
|
+
console.log('');
|
|
159
|
+
}
|
|
160
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
161
|
+
// beehaven app install
|
|
162
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
163
|
+
export async function cmdAppInstall(args, opts = {}) {
|
|
164
|
+
const src = args[0];
|
|
165
|
+
if (!src) {
|
|
166
|
+
console.error(' usage: beehaven app install <name|path|url> [--allow-unsigned] [--with-skills|--no-skills]');
|
|
167
|
+
console.error('');
|
|
168
|
+
console.error(' By name (from catalog):');
|
|
169
|
+
console.error(' beehaven app install mwah');
|
|
170
|
+
console.error(' beehaven app install ableton');
|
|
171
|
+
console.error(' beehaven app install memory-cloud');
|
|
172
|
+
console.error('');
|
|
173
|
+
console.error(' By path or URL (archive must be signed unless --allow-unsigned):');
|
|
174
|
+
console.error(' beehaven app install ./my-app.hive');
|
|
175
|
+
console.error(' beehaven app install https://example.com/my-app.hive');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
// If the source looks like a plain name (no path separators, no URL scheme,
|
|
179
|
+
// no .hive extension), route through the daemon which resolves from the
|
|
180
|
+
// catalog and syncs to the user's Clearly account.
|
|
181
|
+
const isName = !src.includes('/') && !src.includes('\\') && !src.startsWith('http') && !src.endsWith('.hive');
|
|
182
|
+
if (isName) {
|
|
183
|
+
const { isDaemonRunning } = await import('../daemon-client.js');
|
|
184
|
+
if (await isDaemonRunning()) {
|
|
185
|
+
const { rpcOnce } = await import('../ipc-client.js');
|
|
186
|
+
try {
|
|
187
|
+
const result = await rpcOnce('daemon:app-install', { name: src }, 300_000);
|
|
188
|
+
console.log(` ✓ Installed ${result.installed}`);
|
|
189
|
+
if (result.buildings?.length)
|
|
190
|
+
console.log(` Buildings: ${result.buildings.join(', ')}`);
|
|
191
|
+
// Skill install runs CLI-side after the daemon writes the app dir.
|
|
192
|
+
// The daemon returned just {installed, buildings} — re-read the
|
|
193
|
+
// manifest from disk to find agentDocs.claudeCode.
|
|
194
|
+
await maybeInstallPairedSkill(src, opts);
|
|
195
|
+
console.log('');
|
|
196
|
+
console.log(' Restart the daemon to pick it up: quit and relaunch the Clearly Dev Mac app.');
|
|
197
|
+
console.log('');
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
console.error(` ✗ ${err.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
console.error(' ✗ Daemon not running. Start it: beehaven start --headless\n (or open the Hive Mac app, which runs one for you.)');
|
|
207
|
+
console.error(' (Install by name requires the daemon for catalog + account sync)');
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
ensureAppsDir();
|
|
212
|
+
// Download if it's a URL. Otherwise resolve to an absolute path.
|
|
213
|
+
//
|
|
214
|
+
// Security: use execFileSync so curl's arguments are passed as argv,
|
|
215
|
+
// not through /bin/sh. A malicious src that contained shell
|
|
216
|
+
// metacharacters would otherwise inject commands — e.g.
|
|
217
|
+
// `"; rm -rf ~ #` baked into a README download link. Also pin curl to
|
|
218
|
+
// its known path so a PATH-poisoning `curl` shim can't run.
|
|
219
|
+
let archivePath;
|
|
220
|
+
let sigPath = null;
|
|
221
|
+
let cleanupArchive = false;
|
|
222
|
+
if (/^https?:\/\//.test(src)) {
|
|
223
|
+
archivePath = join('/tmp', `bh-app-dl-${process.pid}.hive`);
|
|
224
|
+
cleanupArchive = true;
|
|
225
|
+
try {
|
|
226
|
+
execFileSync('curl', ['-fsSL', src, '-o', archivePath], { stdio: 'inherit' });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
console.error(` ✗ failed to download ${src}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
// Best-effort fetch of <src>.sig alongside the archive. If the server
|
|
233
|
+
// doesn't have it we record sigPath=null and let the unsigned-install
|
|
234
|
+
// policy below decide whether to continue.
|
|
235
|
+
const sigUrl = src + '.sig';
|
|
236
|
+
const sigDownload = join('/tmp', `bh-app-dl-${process.pid}.app.sig`);
|
|
237
|
+
const sigResult = spawnSync('curl', ['-fsSL', sigUrl, '-o', sigDownload], {
|
|
238
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
239
|
+
});
|
|
240
|
+
if (sigResult.status === 0) {
|
|
241
|
+
sigPath = sigDownload;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
// Clean up partial file if curl wrote anything
|
|
245
|
+
try {
|
|
246
|
+
if (existsSync(sigDownload))
|
|
247
|
+
rmSync(sigDownload);
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
archivePath = resolve(src);
|
|
254
|
+
if (!existsSync(archivePath)) {
|
|
255
|
+
console.error(` ✗ not found: ${archivePath}`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
// Local install: check for a sibling .sig file
|
|
259
|
+
const localSig = archivePath + '.sig';
|
|
260
|
+
if (existsSync(localSig))
|
|
261
|
+
sigPath = localSig;
|
|
262
|
+
}
|
|
263
|
+
// ── Signature check ───────────────────────────────────────────────
|
|
264
|
+
//
|
|
265
|
+
// Default-deny: no signature → refuse unless user passes --allow-unsigned.
|
|
266
|
+
// The `.sig` bundles a SHA-256 of the archive plus an Ed25519 signature
|
|
267
|
+
// over that digest by a pinned trusted key. A MITM or mirror compromise
|
|
268
|
+
// cannot swap the archive while keeping a valid signature.
|
|
269
|
+
if (!sigPath) {
|
|
270
|
+
// ONLY an explicit --allow-unsigned continues. This used to also
|
|
271
|
+
// auto-continue whenever `TRUSTED_SIGNERS.length === 0` — and since
|
|
272
|
+
// that list ships EMPTY (app-signing.ts pins no production key yet),
|
|
273
|
+
// the condition was true in every published build. So the default
|
|
274
|
+
// path for `beehaven install <url>` was: download unverified bytes,
|
|
275
|
+
// print a warning, install them as a building that runs inside the
|
|
276
|
+
// daemon — which holds the user's Firebase token and keychain access.
|
|
277
|
+
// That is precisely the threat app-signing.ts was written to stop,
|
|
278
|
+
// and its own header claims the empty list makes us "fail closed".
|
|
279
|
+
// It did the opposite. An unpinned build must refuse, not shrug.
|
|
280
|
+
if (opts.allowUnsigned) {
|
|
281
|
+
console.warn(` ⚠ installing unsigned app (user passed --allow-unsigned)`);
|
|
282
|
+
console.warn(` This is safe for local dev but insecure for production downloads.`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
console.error(` ✗ archive has no signature (${archivePath}.sig not found)`);
|
|
286
|
+
if (TRUSTED_SIGNERS.length === 0) {
|
|
287
|
+
// Name the real reason. Otherwise a maintainer who DOES ship a
|
|
288
|
+
// .sig hits "verification failed" and hunts the wrong problem.
|
|
289
|
+
console.error(` This CLI build pins no trusted signing keys, so no`);
|
|
290
|
+
console.error(` signature could be verified even if one were present.`);
|
|
291
|
+
console.error(` Fix properly: add the release key to TRUSTED_SIGNERS`);
|
|
292
|
+
console.error(` in app-signing.ts and sign releases with 'beehaven app pack --key'.`);
|
|
293
|
+
}
|
|
294
|
+
console.error(` To install anyway (DEV USE ONLY — runs unverified code`);
|
|
295
|
+
console.error(` inside your daemon): re-run with --allow-unsigned.`);
|
|
296
|
+
if (cleanupArchive)
|
|
297
|
+
try {
|
|
298
|
+
rmSync(archivePath);
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const envelope = loadSignatureEnvelope(sigPath);
|
|
306
|
+
if (!envelope) {
|
|
307
|
+
console.error(` ✗ signature file is malformed: ${sigPath}`);
|
|
308
|
+
if (cleanupArchive) {
|
|
309
|
+
try {
|
|
310
|
+
rmSync(archivePath);
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
try {
|
|
314
|
+
rmSync(sigPath);
|
|
315
|
+
}
|
|
316
|
+
catch { }
|
|
317
|
+
}
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
const verification = verifyAppSignature(archivePath, envelope);
|
|
321
|
+
if (!verification.ok) {
|
|
322
|
+
console.error(` ✗ signature verification failed: ${verification.reason}`);
|
|
323
|
+
console.error(` archive: ${archivePath}`);
|
|
324
|
+
console.error(` signer: ${envelope.signer}`);
|
|
325
|
+
if (cleanupArchive) {
|
|
326
|
+
try {
|
|
327
|
+
rmSync(archivePath);
|
|
328
|
+
}
|
|
329
|
+
catch { }
|
|
330
|
+
try {
|
|
331
|
+
rmSync(sigPath);
|
|
332
|
+
}
|
|
333
|
+
catch { }
|
|
334
|
+
}
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
console.log(` ✓ signature verified (signer: ${verification.signerId})`);
|
|
338
|
+
// Clean up the sig file once verified; nothing below needs it.
|
|
339
|
+
if (cleanupArchive)
|
|
340
|
+
try {
|
|
341
|
+
rmSync(sigPath);
|
|
342
|
+
}
|
|
343
|
+
catch { }
|
|
344
|
+
}
|
|
345
|
+
// Peek at the manifest inside the archive before committing to install.
|
|
346
|
+
//
|
|
347
|
+
// Security: execFileSync avoids shell interpolation of archivePath. The
|
|
348
|
+
// path is either our /tmp/bh-app-dl-<pid>.app (safe) or user-supplied
|
|
349
|
+
// via resolve(src) — a path containing `; rm -rf ~` would previously
|
|
350
|
+
// have executed. Now each arg is its own argv slot.
|
|
351
|
+
let peekManifest;
|
|
352
|
+
try {
|
|
353
|
+
const out = execFileSync('unzip', ['-p', archivePath, 'app.json'], { encoding: 'utf8' });
|
|
354
|
+
const parsed = parseAppManifest(out);
|
|
355
|
+
if (!parsed.manifest)
|
|
356
|
+
throw new Error(parsed.error ?? 'invalid manifest');
|
|
357
|
+
peekManifest = parsed.manifest;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
console.error(` ✗ archive does not contain a valid app.json: ${err?.message ?? err}`);
|
|
361
|
+
if (cleanupArchive)
|
|
362
|
+
try {
|
|
363
|
+
rmSync(archivePath);
|
|
364
|
+
}
|
|
365
|
+
catch { }
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
// Target install dir: ~/.beehaven/apps/<name>@<version>/
|
|
369
|
+
//
|
|
370
|
+
// The manifest name/version came from the signed archive (or unsigned
|
|
371
|
+
// with explicit opt-in), so the install dir is authoritative. Still,
|
|
372
|
+
// we sanitize against traversal in case a malicious unsigned package
|
|
373
|
+
// declared name="../../etc" — such a path would have already failed
|
|
374
|
+
// manifest validation, but belt-and-suspenders.
|
|
375
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(peekManifest.name) ||
|
|
376
|
+
!/^[a-zA-Z0-9._+-]+$/.test(peekManifest.version)) {
|
|
377
|
+
console.error(` ✗ manifest name/version contain disallowed characters`);
|
|
378
|
+
if (cleanupArchive)
|
|
379
|
+
try {
|
|
380
|
+
rmSync(archivePath);
|
|
381
|
+
}
|
|
382
|
+
catch { }
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
const installDir = join(APPS_DIR, `${peekManifest.name}@${peekManifest.version}`);
|
|
386
|
+
if (existsSync(installDir)) {
|
|
387
|
+
console.log(` (reinstalling over existing ${peekManifest.name}@${peekManifest.version})`);
|
|
388
|
+
try {
|
|
389
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
catch { }
|
|
392
|
+
}
|
|
393
|
+
mkdirSync(installDir, { recursive: true, mode: 0o755 });
|
|
394
|
+
// Extract — execFileSync, same injection-free path handling.
|
|
395
|
+
try {
|
|
396
|
+
execFileSync('unzip', ['-q', archivePath, '-d', installDir], { stdio: 'inherit' });
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
console.error(` ✗ unzip failed: ${err?.message ?? err}`);
|
|
400
|
+
try {
|
|
401
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
catch { }
|
|
404
|
+
if (cleanupArchive)
|
|
405
|
+
try {
|
|
406
|
+
rmSync(archivePath);
|
|
407
|
+
}
|
|
408
|
+
catch { }
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
if (cleanupArchive)
|
|
412
|
+
try {
|
|
413
|
+
rmSync(archivePath);
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
416
|
+
// Final readback — paranoia. The extracted manifest should match what we peeked.
|
|
417
|
+
const installedManifest = readFileSync(join(installDir, 'app.json'), 'utf8');
|
|
418
|
+
const finalValidation = validateAppManifest(JSON.parse(installedManifest));
|
|
419
|
+
if (!finalValidation.valid) {
|
|
420
|
+
console.error(` ✗ extracted app failed validation: ${finalValidation.errors.join('; ')}`);
|
|
421
|
+
try {
|
|
422
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
for (const w of finalValidation.warnings)
|
|
428
|
+
console.warn(` ⚠ ${w}`);
|
|
429
|
+
console.log('');
|
|
430
|
+
console.log(` ✓ Installed ${peekManifest.name}@${peekManifest.version}`);
|
|
431
|
+
console.log(` → ${installDir}`);
|
|
432
|
+
if (peekManifest.frontend) {
|
|
433
|
+
console.log(` Frontend: http://127.0.0.1:9800/apps/${peekManifest.name}/`);
|
|
434
|
+
}
|
|
435
|
+
if (peekManifest.buildings) {
|
|
436
|
+
console.log(` Buildings: ${Object.keys(peekManifest.buildings).join(', ')}`);
|
|
437
|
+
}
|
|
438
|
+
if (peekManifest.recipes) {
|
|
439
|
+
console.log(` Recipes: ${Object.keys(peekManifest.recipes).join(', ')}`);
|
|
440
|
+
}
|
|
441
|
+
// Run setup script if the manifest declares one
|
|
442
|
+
const setupResult = await runAppSetup(installDir, peekManifest);
|
|
443
|
+
if (setupResult === 'skipped') {
|
|
444
|
+
// No setup script — nothing to report
|
|
445
|
+
}
|
|
446
|
+
else if (setupResult === 'ok') {
|
|
447
|
+
console.log(` ✓ Setup complete`);
|
|
448
|
+
}
|
|
449
|
+
// Errors are printed inside runAppSetup
|
|
450
|
+
// Offer to install the paired Claude Code skill if the manifest ships one.
|
|
451
|
+
// Wrapped in try/catch — skill install is opportunistic; a bad SKILL.md
|
|
452
|
+
// or unreadable file shouldn't fail the whole hive install, which has
|
|
453
|
+
// already succeeded at this point.
|
|
454
|
+
try {
|
|
455
|
+
const { installPairedSkill } = await import('./skills.js');
|
|
456
|
+
await installPairedSkill(installDir, peekManifest, {
|
|
457
|
+
withSkills: opts.withSkills,
|
|
458
|
+
noSkills: opts.noSkills,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
console.warn(` ⚠ Skill install skipped: ${err?.message ?? err}`);
|
|
463
|
+
}
|
|
464
|
+
console.log('');
|
|
465
|
+
console.log(' Restart the daemon to pick it up: quit and relaunch the Clearly Dev Mac app.');
|
|
466
|
+
console.log(' (or quit + relaunch the Clearly Mac app)');
|
|
467
|
+
console.log('');
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Catalog-install helper: the daemon writes the app to ~/.beehaven/apps/
|
|
471
|
+
* but the skill prompt + install lives CLI-side. Find the freshly-installed
|
|
472
|
+
* app dir (latest version of `name`), read its manifest, and call
|
|
473
|
+
* installPairedSkill if there's one.
|
|
474
|
+
*/
|
|
475
|
+
async function maybeInstallPairedSkill(name, opts) {
|
|
476
|
+
try {
|
|
477
|
+
if (!existsSync(APPS_DIR))
|
|
478
|
+
return;
|
|
479
|
+
const entries = readdirSync(APPS_DIR).filter((e) => e.startsWith(`${name}@`));
|
|
480
|
+
if (entries.length === 0)
|
|
481
|
+
return;
|
|
482
|
+
// Pick the highest version dir for `name`. Cheap string sort is fine —
|
|
483
|
+
// names are validated to a-zA-Z0-9._- and versions are semver-shaped.
|
|
484
|
+
entries.sort();
|
|
485
|
+
const installDir = join(APPS_DIR, entries[entries.length - 1]);
|
|
486
|
+
const manifestPath = join(installDir, 'app.json');
|
|
487
|
+
if (!existsSync(manifestPath))
|
|
488
|
+
return;
|
|
489
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
490
|
+
if (!manifest?.agentDocs?.claudeCode)
|
|
491
|
+
return;
|
|
492
|
+
const { installPairedSkill } = await import('./skills.js');
|
|
493
|
+
await installPairedSkill(installDir, manifest, opts);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
// Skill install is opportunistic — never fail the whole install
|
|
497
|
+
// because we couldn't drop a SKILL.md.
|
|
498
|
+
console.warn(` ⚠ Skill install skipped: ${err?.message ?? err}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
502
|
+
// Setup script support
|
|
503
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
504
|
+
/**
|
|
505
|
+
* Run an app's setup script if the manifest declares one.
|
|
506
|
+
* The setup script runs with cwd = installDir and gets APP_DIR in env.
|
|
507
|
+
* Returns 'ok', 'skipped', or 'error'.
|
|
508
|
+
*/
|
|
509
|
+
async function runAppSetup(installDir, manifest) {
|
|
510
|
+
const setupPath = manifest.setup;
|
|
511
|
+
if (!setupPath)
|
|
512
|
+
return 'skipped';
|
|
513
|
+
const scriptPath = join(installDir, setupPath);
|
|
514
|
+
if (!existsSync(scriptPath)) {
|
|
515
|
+
console.warn(` ⚠ Setup script not found: ${setupPath}`);
|
|
516
|
+
return 'error';
|
|
517
|
+
}
|
|
518
|
+
console.log(` Running setup...`);
|
|
519
|
+
try {
|
|
520
|
+
// Make executable
|
|
521
|
+
try {
|
|
522
|
+
execSync(`chmod +x "${scriptPath}"`);
|
|
523
|
+
}
|
|
524
|
+
catch { }
|
|
525
|
+
execSync(`"${scriptPath}"`, {
|
|
526
|
+
cwd: installDir,
|
|
527
|
+
stdio: 'inherit',
|
|
528
|
+
env: {
|
|
529
|
+
...process.env,
|
|
530
|
+
APP_DIR: installDir,
|
|
531
|
+
APP_NAME: manifest.name,
|
|
532
|
+
APP_VERSION: manifest.version,
|
|
533
|
+
},
|
|
534
|
+
timeout: 300_000, // 5 min max for model downloads
|
|
535
|
+
});
|
|
536
|
+
return 'ok';
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
console.error(` ✗ Setup failed: ${err?.message ?? err}`);
|
|
540
|
+
console.error(` You can re-run: beehaven app setup ${manifest.name}`);
|
|
541
|
+
return 'error';
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* beehaven app setup <name> — re-run an installed app's setup script.
|
|
546
|
+
*/
|
|
547
|
+
export async function cmdAppSetup(appName) {
|
|
548
|
+
ensureAppsDir();
|
|
549
|
+
const entry = readdirSync(APPS_DIR).find((e) => e.startsWith(`${appName}@`));
|
|
550
|
+
if (!entry) {
|
|
551
|
+
console.error(` ✗ App "${appName}" not installed.`);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
const installDir = join(APPS_DIR, entry);
|
|
555
|
+
const manifestPath = join(installDir, 'app.json');
|
|
556
|
+
if (!existsSync(manifestPath)) {
|
|
557
|
+
console.error(` ✗ No app.json in ${installDir}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
561
|
+
const result = await runAppSetup(installDir, manifest);
|
|
562
|
+
if (result === 'skipped') {
|
|
563
|
+
console.log(` ${appName} has no setup script.`);
|
|
564
|
+
}
|
|
565
|
+
else if (result === 'ok') {
|
|
566
|
+
console.log(` ✓ Setup complete for ${appName}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
570
|
+
// beehaven app list
|
|
571
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
572
|
+
export async function cmdAppList() {
|
|
573
|
+
// Try daemon first — returns installed + available from catalog + cloud
|
|
574
|
+
const { isDaemonRunning } = await import('../daemon-client.js');
|
|
575
|
+
if (await isDaemonRunning()) {
|
|
576
|
+
const { rpcOnce } = await import('../ipc-client.js');
|
|
577
|
+
try {
|
|
578
|
+
const { installed, available } = await rpcOnce('daemon:app-list', {}, 10_000);
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log(' Installed');
|
|
581
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
582
|
+
if (installed.length === 0) {
|
|
583
|
+
console.log(' (none)');
|
|
584
|
+
}
|
|
585
|
+
for (const app of installed) {
|
|
586
|
+
console.log(` ${app.name}@${app.version} [${app.tier}] buildings: ${app.buildings.join(', ') || '—'}`);
|
|
587
|
+
console.log(` ${app.description}`);
|
|
588
|
+
}
|
|
589
|
+
if (available.length > 0) {
|
|
590
|
+
console.log('');
|
|
591
|
+
console.log(' Available');
|
|
592
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
593
|
+
for (const app of available) {
|
|
594
|
+
console.log(` ${app.name}@${app.version} [${app.tier}] ${app.kind}`);
|
|
595
|
+
console.log(` ${app.description}`);
|
|
596
|
+
}
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log(' Install: beehaven app install <name>');
|
|
599
|
+
}
|
|
600
|
+
console.log('');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
/* fall through to local-only list */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Fallback: local-only list
|
|
608
|
+
ensureAppsDir();
|
|
609
|
+
const entries = readdirSync(APPS_DIR).filter((e) => !e.startsWith('.'));
|
|
610
|
+
if (entries.length === 0) {
|
|
611
|
+
console.log('');
|
|
612
|
+
console.log(' No apps installed.');
|
|
613
|
+
console.log(' Install one: beehaven app install <name>');
|
|
614
|
+
console.log('');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
console.log('');
|
|
618
|
+
console.log(' Installed apps');
|
|
619
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
620
|
+
for (const entry of entries) {
|
|
621
|
+
const manifestPath = join(APPS_DIR, entry, 'app.json');
|
|
622
|
+
if (!existsSync(manifestPath))
|
|
623
|
+
continue;
|
|
624
|
+
let m;
|
|
625
|
+
try {
|
|
626
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (!m)
|
|
632
|
+
continue;
|
|
633
|
+
if (!isPubliclyVisibleApp(m))
|
|
634
|
+
continue;
|
|
635
|
+
console.log(` ${m.name}@${m.version} [${m.license?.tier ?? 'free'}] buildings: ${Object.keys(m.buildings ?? {}).join(', ') || '—'}`);
|
|
636
|
+
console.log(` ${m.description ?? '(no description)'}`);
|
|
637
|
+
}
|
|
638
|
+
console.log('');
|
|
639
|
+
}
|
|
640
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
641
|
+
// beehaven app uninstall
|
|
642
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
643
|
+
export async function cmdAppUninstall(args) {
|
|
644
|
+
const nameWithVersion = args[0];
|
|
645
|
+
if (!nameWithVersion) {
|
|
646
|
+
console.error(' usage: beehaven app uninstall <name>');
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
// Strip @version if provided — daemon resolves by name
|
|
650
|
+
const name = nameWithVersion.split('@')[0];
|
|
651
|
+
// Route through daemon so it syncs to the Clearly account.
|
|
652
|
+
// Paired Claude Code skill removal only happens AFTER the uninstall
|
|
653
|
+
// succeeds — otherwise a failed uninstall (daemon error, app not
|
|
654
|
+
// found) would still wipe the user's SKILL.md.
|
|
655
|
+
const { isDaemonRunning } = await import('../daemon-client.js');
|
|
656
|
+
const { removePairedSkill } = await import('./skills.js');
|
|
657
|
+
if (await isDaemonRunning()) {
|
|
658
|
+
const { rpcOnce } = await import('../ipc-client.js');
|
|
659
|
+
try {
|
|
660
|
+
const result = await rpcOnce('daemon:app-uninstall', { name }, 30_000);
|
|
661
|
+
for (const r of result.removed) {
|
|
662
|
+
console.log(` ✓ removed ${r}`);
|
|
663
|
+
}
|
|
664
|
+
// Uninstall succeeded — now safe to remove the paired skill.
|
|
665
|
+
// No-op when the hive didn't ship one.
|
|
666
|
+
removePairedSkill(name);
|
|
667
|
+
console.log('');
|
|
668
|
+
console.log(' Restart the daemon to drop the in-memory building instance.');
|
|
669
|
+
console.log('');
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
console.error(` ✗ ${err.message}`);
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// Fallback: direct filesystem removal (no daemon = no cloud sync)
|
|
678
|
+
ensureAppsDir();
|
|
679
|
+
const entries = readdirSync(APPS_DIR);
|
|
680
|
+
const matches = [];
|
|
681
|
+
for (const e of entries) {
|
|
682
|
+
if (e.startsWith(`${name}@`))
|
|
683
|
+
matches.push(e);
|
|
684
|
+
}
|
|
685
|
+
if (matches.length === 0) {
|
|
686
|
+
console.error(` ✗ not installed: ${name}`);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
let anyRemoved = false;
|
|
690
|
+
for (const m of matches) {
|
|
691
|
+
try {
|
|
692
|
+
rmSync(join(APPS_DIR, m), { recursive: true, force: true });
|
|
693
|
+
console.log(` ✓ removed ${m}`);
|
|
694
|
+
anyRemoved = true;
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
console.error(` ✗ failed to remove ${m}: ${err?.message ?? err}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Same rule applies to the FS fallback — only remove the skill if at
|
|
701
|
+
// least one matching hive directory was actually deleted.
|
|
702
|
+
if (anyRemoved)
|
|
703
|
+
removePairedSkill(name);
|
|
704
|
+
console.log('');
|
|
705
|
+
}
|
|
706
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
707
|
+
// beehaven app actions [building] — query a building's available actions
|
|
708
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
709
|
+
export async function cmdAppActions(args, opts) {
|
|
710
|
+
const building = args[0] ?? opts.building;
|
|
711
|
+
const instanceId = opts.instance ?? 'live';
|
|
712
|
+
const category = opts.category;
|
|
713
|
+
const search = opts.search;
|
|
714
|
+
const json = !!opts.json;
|
|
715
|
+
const { appRpcOnce } = await import('../ipc-client.js');
|
|
716
|
+
// If no building specified, list all buildings and their action counts
|
|
717
|
+
if (!building) {
|
|
718
|
+
await cmdAppListBuildings(opts);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
const result = await appRpcOnce('manifest', {}, { building, instanceId });
|
|
723
|
+
const visibleCategories = Object.fromEntries(Object.entries((result.categories ?? {}))
|
|
724
|
+
.map(([cat, actions]) => [
|
|
725
|
+
cat,
|
|
726
|
+
actions.filter((a) => isPubliclyVisibleAppAction(building, a.name)),
|
|
727
|
+
])
|
|
728
|
+
.filter(([, actions]) => actions.length > 0));
|
|
729
|
+
const visibleTotal = Object.values(visibleCategories).reduce((sum, actions) => sum + actions.length, 0);
|
|
730
|
+
if (json) {
|
|
731
|
+
console.log(JSON.stringify({ ...result, categories: visibleCategories, actionCount: visibleTotal }, null, 2));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const categories = visibleCategories;
|
|
735
|
+
const total = visibleTotal;
|
|
736
|
+
console.log('');
|
|
737
|
+
console.log(` ${building} — ${total} actions`);
|
|
738
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
739
|
+
for (const [cat, actions] of Object.entries(categories)) {
|
|
740
|
+
if (category && cat !== category)
|
|
741
|
+
continue;
|
|
742
|
+
const filtered = search
|
|
743
|
+
? actions.filter((a) => a.name.includes(search) || a.description?.includes(search))
|
|
744
|
+
: actions;
|
|
745
|
+
if (filtered.length === 0)
|
|
746
|
+
continue;
|
|
747
|
+
console.log('');
|
|
748
|
+
console.log(` ${cat}`);
|
|
749
|
+
for (const a of filtered) {
|
|
750
|
+
const paramStr = a.params
|
|
751
|
+
? Object.entries(a.params)
|
|
752
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
753
|
+
.join(', ')
|
|
754
|
+
: '';
|
|
755
|
+
console.log(` ${a.name.padEnd(28)} ${a.description ?? ''}`);
|
|
756
|
+
if (paramStr) {
|
|
757
|
+
console.log(` ${paramStr}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
console.log('');
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
console.error(` ✗ ${err.message}`);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
769
|
+
// beehaven app buildings — list all registered buildings across apps
|
|
770
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
771
|
+
async function cmdAppListBuildings(opts) {
|
|
772
|
+
const json = !!opts.json;
|
|
773
|
+
const { appRpcOnce } = await import('../ipc-client.js');
|
|
774
|
+
ensureAppsDir();
|
|
775
|
+
const entries = readdirSync(APPS_DIR).filter((e) => !e.startsWith('.'));
|
|
776
|
+
// Collect building → app mapping from installed manifests
|
|
777
|
+
const buildings = [];
|
|
778
|
+
for (const entry of entries) {
|
|
779
|
+
const manifestPath = join(APPS_DIR, entry, 'app.json');
|
|
780
|
+
if (!existsSync(manifestPath))
|
|
781
|
+
continue;
|
|
782
|
+
let m;
|
|
783
|
+
try {
|
|
784
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (!m?.buildings)
|
|
790
|
+
continue;
|
|
791
|
+
for (const [bName, bDef] of Object.entries(m.buildings)) {
|
|
792
|
+
const info = {
|
|
793
|
+
building: bName,
|
|
794
|
+
app: m.name,
|
|
795
|
+
version: m.version,
|
|
796
|
+
description: bDef.description ?? m.description ?? '',
|
|
797
|
+
};
|
|
798
|
+
// Try to get action count from the live building
|
|
799
|
+
try {
|
|
800
|
+
const manifest = await appRpcOnce('manifest', {}, { building: bName, timeout: 5_000 });
|
|
801
|
+
info.actionCount = manifest.actionCount ?? manifest.totalActions ?? 0;
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
/* building may not be running */
|
|
805
|
+
}
|
|
806
|
+
buildings.push(info);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (json) {
|
|
810
|
+
console.log(JSON.stringify(buildings, null, 2));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (buildings.length === 0) {
|
|
814
|
+
console.log('');
|
|
815
|
+
console.log(' No buildings registered. Install an app: beehaven app install <path-or-url>');
|
|
816
|
+
console.log('');
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
console.log('');
|
|
820
|
+
console.log(' Registered Buildings');
|
|
821
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
822
|
+
for (const b of buildings) {
|
|
823
|
+
const actions = b.actionCount != null ? `${b.actionCount} actions` : 'offline';
|
|
824
|
+
console.log('');
|
|
825
|
+
console.log(` ${b.building} (${b.app}@${b.version}) [${actions}]`);
|
|
826
|
+
console.log(` ${b.description}`);
|
|
827
|
+
console.log(` → beehaven app actions ${b.building}`);
|
|
828
|
+
}
|
|
829
|
+
console.log('');
|
|
830
|
+
}
|
|
831
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
832
|
+
// beehaven app call <building> <action> [json] — call a building action
|
|
833
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
834
|
+
export async function cmdAppCall(args, opts) {
|
|
835
|
+
let building = args[0];
|
|
836
|
+
let action = args[1];
|
|
837
|
+
let rawInput = args[2];
|
|
838
|
+
let instanceId = opts.instance ?? 'live';
|
|
839
|
+
const timeout = opts.timeout ? parseInt(opts.timeout, 10) : 30_000;
|
|
840
|
+
// Support building/id syntax: `beehaven app call ableton/live getSession`
|
|
841
|
+
if (building?.includes('/')) {
|
|
842
|
+
const slashIdx = building.indexOf('/');
|
|
843
|
+
instanceId = building.slice(slashIdx + 1);
|
|
844
|
+
building = building.slice(0, slashIdx);
|
|
845
|
+
}
|
|
846
|
+
// Disambiguate the 3-arg case `beehaven app call <building> <instance> <action>`
|
|
847
|
+
// from the 3-arg case `beehaven app call <building> <action> <json>`. The
|
|
848
|
+
// signal is what args[2] looks like — JSON payloads start with `{`/`[`/`"`,
|
|
849
|
+
// bare action names don't. The earlier heuristic checked whether args[1]
|
|
850
|
+
// included `-` or `:`, which incorrectly rejected legit instance IDs like
|
|
851
|
+
// `clearly-demo` and made the 3-arg form unusable.
|
|
852
|
+
const looksLikeJson = (s) => /^\s*[\{\[\"]/.test(s);
|
|
853
|
+
if (building &&
|
|
854
|
+
action &&
|
|
855
|
+
args[2] !== undefined &&
|
|
856
|
+
args[3] === undefined &&
|
|
857
|
+
!looksLikeJson(args[2])) {
|
|
858
|
+
// 3-arg form: building instance action (no JSON input)
|
|
859
|
+
instanceId = args[1];
|
|
860
|
+
action = args[2];
|
|
861
|
+
rawInput = undefined;
|
|
862
|
+
}
|
|
863
|
+
else if (building && args[1] && args[2] && args[3]) {
|
|
864
|
+
// 4-arg form: building instance action json
|
|
865
|
+
instanceId = args[1];
|
|
866
|
+
action = args[2];
|
|
867
|
+
rawInput = args[3];
|
|
868
|
+
}
|
|
869
|
+
if (!building || !action) {
|
|
870
|
+
console.error(' Usage: beehaven app call <building>[/<id>] <action> [json-input]');
|
|
871
|
+
console.error(' Example: beehaven app call ableton getSession');
|
|
872
|
+
console.error(' Example: beehaven app call ableton/live createTrack \'{"type":"midi","name":"Strings"}\'');
|
|
873
|
+
process.exit(1);
|
|
874
|
+
}
|
|
875
|
+
if (!isPubliclyVisibleAppAction(building, action)) {
|
|
876
|
+
console.error(` ✗ avatar action "${action}" is temporarily unavailable.`);
|
|
877
|
+
process.exit(1);
|
|
878
|
+
}
|
|
879
|
+
let input = {};
|
|
880
|
+
if (rawInput) {
|
|
881
|
+
try {
|
|
882
|
+
input = JSON.parse(rawInput);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
console.error(` ✗ Invalid JSON: ${rawInput}`);
|
|
886
|
+
process.exit(1);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const { appRpcOnce } = await import('../ipc-client.js');
|
|
890
|
+
const { resolveActiveAgent } = await import('../agent-state.js');
|
|
891
|
+
// When an agent identity is active (--agent or $BEEHAVEN_AGENT), attach
|
|
892
|
+
// its signed token so the daemon tags the call with a CallerContext and
|
|
893
|
+
// routes avatar-directed calls to that agent's own avatar instance.
|
|
894
|
+
const active = resolveActiveAgent(opts);
|
|
895
|
+
if (active) {
|
|
896
|
+
if (active.expired) {
|
|
897
|
+
console.error(`\x1b[2m agent "${active.name}" identity expired — run: beehaven agent login ${active.name}\x1b[0m`);
|
|
898
|
+
}
|
|
899
|
+
console.error(`\x1b[2m→ as ${active.agentId} (avatar ${active.avatarId})\x1b[0m`);
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
const result = await appRpcOnce(action, input, {
|
|
903
|
+
building,
|
|
904
|
+
instanceId,
|
|
905
|
+
timeout,
|
|
906
|
+
// Local app buildings are verified by the daemon's device-local signing secret. A cloud
|
|
907
|
+
// AgentIdentity token is for workspace RPCs and cannot be verified by that local daemon, so
|
|
908
|
+
// agent login stores both and this path deliberately uses the local credential.
|
|
909
|
+
agent: active?.localToken ?? active?.token,
|
|
910
|
+
});
|
|
911
|
+
if (result && typeof result === 'object') {
|
|
912
|
+
console.log(JSON.stringify(result, null, 2));
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
console.log(result);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
console.error(JSON.stringify({ error: err.message }, null, 2));
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
924
|
+
// beehaven app run <recipe> — run a pipeline recipe from an installed app
|
|
925
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
926
|
+
export async function cmdAppRun(args, opts) {
|
|
927
|
+
const recipeName = args[0];
|
|
928
|
+
const instanceId = opts.instance ?? 'live';
|
|
929
|
+
const timeout = opts.timeout ? parseInt(opts.timeout, 10) : 120_000;
|
|
930
|
+
if (!recipeName) {
|
|
931
|
+
// List all available recipes across installed apps
|
|
932
|
+
ensureAppsDir();
|
|
933
|
+
const entries = readdirSync(APPS_DIR).filter((e) => !e.startsWith('.'));
|
|
934
|
+
const recipes = [];
|
|
935
|
+
for (const entry of entries) {
|
|
936
|
+
const manifestPath = join(APPS_DIR, entry, 'app.json');
|
|
937
|
+
if (!existsSync(manifestPath))
|
|
938
|
+
continue;
|
|
939
|
+
let m;
|
|
940
|
+
try {
|
|
941
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (!m?.recipes)
|
|
947
|
+
continue;
|
|
948
|
+
for (const [rName, rPath] of Object.entries(m.recipes)) {
|
|
949
|
+
let title = rName;
|
|
950
|
+
const rFile = typeof rPath === 'string' ? join(APPS_DIR, entry, rPath) : null;
|
|
951
|
+
if (rFile && existsSync(rFile)) {
|
|
952
|
+
try {
|
|
953
|
+
const recipe = JSON.parse(readFileSync(rFile, 'utf8'));
|
|
954
|
+
title = recipe.title ?? rName;
|
|
955
|
+
}
|
|
956
|
+
catch { }
|
|
957
|
+
}
|
|
958
|
+
recipes.push({ app: m.name, recipe: rName, title });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (recipes.length === 0) {
|
|
962
|
+
console.log('');
|
|
963
|
+
console.log(' No recipes found. Install an app with recipes.');
|
|
964
|
+
console.log('');
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
console.log('');
|
|
968
|
+
console.log(' Available Recipes');
|
|
969
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
970
|
+
for (const r of recipes) {
|
|
971
|
+
console.log(` ${r.app}/${r.recipe}`.padEnd(36) + r.title);
|
|
972
|
+
}
|
|
973
|
+
console.log('');
|
|
974
|
+
console.log(' Run: beehaven app run <app>/<recipe>');
|
|
975
|
+
console.log('');
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
// Parse app/recipe format
|
|
979
|
+
let appName;
|
|
980
|
+
let rKey;
|
|
981
|
+
if (recipeName.includes('/')) {
|
|
982
|
+
[appName, rKey] = recipeName.split('/', 2);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
// Search all apps for a matching recipe name
|
|
986
|
+
ensureAppsDir();
|
|
987
|
+
const entries = readdirSync(APPS_DIR).filter((e) => !e.startsWith('.'));
|
|
988
|
+
let found = null;
|
|
989
|
+
for (const entry of entries) {
|
|
990
|
+
const manifestPath = join(APPS_DIR, entry, 'app.json');
|
|
991
|
+
if (!existsSync(manifestPath))
|
|
992
|
+
continue;
|
|
993
|
+
let m;
|
|
994
|
+
try {
|
|
995
|
+
m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (m?.recipes?.[recipeName]) {
|
|
1001
|
+
found = { app: m.name, entry, recipe: recipeName };
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (!found) {
|
|
1006
|
+
console.error(` ✗ Recipe "${recipeName}" not found. Run "beehaven app run" to list recipes.`);
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
1009
|
+
appName = found.app;
|
|
1010
|
+
rKey = found.recipe;
|
|
1011
|
+
}
|
|
1012
|
+
// Find the app directory and load the recipe
|
|
1013
|
+
const appDir = readdirSync(APPS_DIR).find((e) => e.startsWith(`${appName}@`));
|
|
1014
|
+
if (!appDir) {
|
|
1015
|
+
console.error(` ✗ App "${appName}" not installed.`);
|
|
1016
|
+
process.exit(1);
|
|
1017
|
+
}
|
|
1018
|
+
const manifestPath = join(APPS_DIR, appDir, 'app.json');
|
|
1019
|
+
let manifest;
|
|
1020
|
+
try {
|
|
1021
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
console.error(` ✗ Could not read manifest for ${appName}`);
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
}
|
|
1027
|
+
const recipePath = manifest.recipes?.[rKey];
|
|
1028
|
+
if (!recipePath) {
|
|
1029
|
+
console.error(` ✗ Recipe "${rKey}" not found in ${appName}`);
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
const recipeFile = typeof recipePath === 'string' ? join(APPS_DIR, appDir, recipePath) : null;
|
|
1033
|
+
if (!recipeFile || !existsSync(recipeFile)) {
|
|
1034
|
+
console.error(` ✗ Recipe file not found: ${recipePath}`);
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
let recipe;
|
|
1038
|
+
try {
|
|
1039
|
+
recipe = JSON.parse(readFileSync(recipeFile, 'utf8'));
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
console.error(` ✗ Invalid recipe JSON: ${err.message}`);
|
|
1043
|
+
process.exit(1);
|
|
1044
|
+
}
|
|
1045
|
+
const steps = recipe.steps;
|
|
1046
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
1047
|
+
console.error(' ✗ Recipe has no steps.');
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
}
|
|
1050
|
+
console.log('');
|
|
1051
|
+
console.log(` Running: ${recipe.title ?? recipeName}`);
|
|
1052
|
+
console.log(` Steps: ${steps.length}`);
|
|
1053
|
+
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
1054
|
+
const { pipelineOnce } = await import('../ipc-client.js');
|
|
1055
|
+
try {
|
|
1056
|
+
const result = await pipelineOnce(steps, {
|
|
1057
|
+
title: recipe.title ?? recipeName,
|
|
1058
|
+
instanceId,
|
|
1059
|
+
timeout,
|
|
1060
|
+
});
|
|
1061
|
+
if (result.ok) {
|
|
1062
|
+
for (const step of result.steps) {
|
|
1063
|
+
const icon = step.ok ? '✓' : '✗';
|
|
1064
|
+
console.log(` ${icon} ${step.id ?? step.action}`);
|
|
1065
|
+
}
|
|
1066
|
+
console.log('');
|
|
1067
|
+
console.log(` ✓ Done — ${result.steps.length} steps completed.`);
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
console.error(` ✗ Pipeline failed`);
|
|
1071
|
+
}
|
|
1072
|
+
if (opts.json) {
|
|
1073
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1074
|
+
}
|
|
1075
|
+
console.log('');
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
console.error(` ✗ ${err.message}`);
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1083
|
+
// Dispatcher
|
|
1084
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1085
|
+
export async function cmdAppSubcommand(subcommand, args, opts) {
|
|
1086
|
+
switch (subcommand) {
|
|
1087
|
+
case 'pack':
|
|
1088
|
+
await cmdAppPack(args, opts);
|
|
1089
|
+
break;
|
|
1090
|
+
case 'install':
|
|
1091
|
+
await cmdAppInstall(args, {
|
|
1092
|
+
allowUnsigned: !!opts.allowUnsigned,
|
|
1093
|
+
withSkills: !!opts.withSkills,
|
|
1094
|
+
noSkills: !!opts.noSkills,
|
|
1095
|
+
});
|
|
1096
|
+
break;
|
|
1097
|
+
case 'list':
|
|
1098
|
+
case 'ls':
|
|
1099
|
+
await cmdAppList();
|
|
1100
|
+
break;
|
|
1101
|
+
case 'uninstall':
|
|
1102
|
+
case 'remove':
|
|
1103
|
+
case 'rm':
|
|
1104
|
+
await cmdAppUninstall(args);
|
|
1105
|
+
break;
|
|
1106
|
+
case 'actions':
|
|
1107
|
+
await cmdAppActions(args, opts);
|
|
1108
|
+
break;
|
|
1109
|
+
case 'call':
|
|
1110
|
+
await cmdAppCall(args, opts);
|
|
1111
|
+
break;
|
|
1112
|
+
case 'run':
|
|
1113
|
+
await cmdAppRun(args, opts);
|
|
1114
|
+
break;
|
|
1115
|
+
case 'setup':
|
|
1116
|
+
await cmdAppSetup(args[0]);
|
|
1117
|
+
break;
|
|
1118
|
+
default:
|
|
1119
|
+
console.error(` Unknown: beehaven app ${subcommand}`);
|
|
1120
|
+
console.error(' Commands: pack, install, list, uninstall, actions, call, run, setup');
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
}
|