@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
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keychain — OS-native credential storage for beehaven daemon.
|
|
3
|
+
*
|
|
4
|
+
* macOS: Uses `security` CLI (Keychain Access)
|
|
5
|
+
* Linux: Uses `secret-tool` CLI (libsecret / GNOME Keyring)
|
|
6
|
+
* Fallback: null — caller must handle (we never silently fall back to plaintext)
|
|
7
|
+
*
|
|
8
|
+
* Security model:
|
|
9
|
+
* - All secrets are passed to child processes via stdin or argv arrays,
|
|
10
|
+
* never via shell-interpolated strings. This prevents command injection
|
|
11
|
+
* when a secret contains shell metacharacters ($, `, ", etc.).
|
|
12
|
+
* - Each secret is stored under a distinct `account` within the `beehaven`
|
|
13
|
+
* service, so one keychain can hold multiple independent credentials
|
|
14
|
+
* (ipc-auth token, Firebase idToken/refreshToken, BYOK keys, etc.).
|
|
15
|
+
* - Reads return null on not-found — callers must handle missing secrets.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
18
|
+
import { platform } from 'os';
|
|
19
|
+
const SERVICE = 'beehaven';
|
|
20
|
+
/** The keychain service every account below is stored under. Exported so a caller clearing
|
|
21
|
+
* credentials cannot type it out a second time and drift. */
|
|
22
|
+
export const KEYCHAIN_SERVICE = SERVICE;
|
|
23
|
+
/**
|
|
24
|
+
* The accounts that belong to a SESSION, and must therefore be deleted on logout.
|
|
25
|
+
*
|
|
26
|
+
* ⚠⚠ THIS EXISTS BECAUSE THE LIST WAS A LITERAL IN ONE CALLER AND WAS ALREADY WRONG.
|
|
27
|
+
* `setup-relay.ts` cleared `daemon-token`, `firebase-refresh` and `ipc-auth` — and
|
|
28
|
+
* `agent-identity-secret`, written by `agent-identity-daemon.ts`, was added later and never made
|
|
29
|
+
* it into that list. Logging out left it in the keychain indefinitely, and nothing said so.
|
|
30
|
+
*
|
|
31
|
+
* ⚠ SESSION CREDENTIALS ONLY. `byok-*` and `vertex-service-account` are the USER'S OWN keys —
|
|
32
|
+
* they survive a logout deliberately, because signing out of an account is not the same as
|
|
33
|
+
* handing back the API keys you pay for. Removing those belongs to uninstall, a different verb.
|
|
34
|
+
*/
|
|
35
|
+
export const SESSION_KEYCHAIN_ACCOUNTS = [
|
|
36
|
+
'daemon-token',
|
|
37
|
+
'firebase-refresh',
|
|
38
|
+
'ipc-auth',
|
|
39
|
+
'agent-identity-secret',
|
|
40
|
+
];
|
|
41
|
+
/** Default account name for the legacy daemon token (Firebase ID token). */
|
|
42
|
+
const LEGACY_DAEMON_ACCOUNT = 'daemon-token';
|
|
43
|
+
/**
|
|
44
|
+
* ⚠⚠ EVERY KEYCHAIN CALL IS TIME-BOUNDED, BECAUSE ONE CAN OPEN A MODAL SYSTEM WINDOW.
|
|
45
|
+
*
|
|
46
|
+
* `security` in a process with no Aqua security session — which a DETACHED daemon is — cannot
|
|
47
|
+
* resolve a default keychain and macOS answers with a blocking dialog ("A keychain cannot be found
|
|
48
|
+
* to store …"). `spawnSync` then waits on a window the daemon cannot see or dismiss. Measured at
|
|
49
|
+
* 153 SECONDS on one call before it returned.
|
|
50
|
+
*
|
|
51
|
+
* A timeout cannot stop the dialog appearing, but it stops it holding the process: the call gives
|
|
52
|
+
* up, the caller treats it as unavailable, and the file store — which is primary everywhere this
|
|
53
|
+
* module is used for ephemeral secrets — carries on.
|
|
54
|
+
*/
|
|
55
|
+
const KEYCHAIN_TIMEOUT_MS = 5_000;
|
|
56
|
+
export class Keychain {
|
|
57
|
+
_available = null;
|
|
58
|
+
/** Check if OS keychain is available */
|
|
59
|
+
get available() {
|
|
60
|
+
if (this._available !== null)
|
|
61
|
+
return this._available;
|
|
62
|
+
/**
|
|
63
|
+
* ⚠ AN EXPLICIT OFF SWITCH, because the keychain is the one dependency a sandboxed HOME cannot
|
|
64
|
+
* redirect: `security` always talks to the REAL login keychain. A test that mints tokens under
|
|
65
|
+
* a temp HOME still reaches it, and on this machine that blocked for 153 SECONDS on first use.
|
|
66
|
+
* The file store is primary; the mirror is best-effort, so skipping it changes no behaviour.
|
|
67
|
+
*/
|
|
68
|
+
if (process.env.BEEHAVEN_NO_KEYCHAIN === '1') {
|
|
69
|
+
this._available = false;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
if (platform() === 'darwin') {
|
|
74
|
+
// macOS: probe `security` without a shell
|
|
75
|
+
execFileSync('/usr/bin/security', ['help'], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
76
|
+
this._available = true;
|
|
77
|
+
}
|
|
78
|
+
else if (platform() === 'linux') {
|
|
79
|
+
// Linux: probe `secret-tool` without a shell
|
|
80
|
+
const r = spawnSync('secret-tool', ['--version'], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
81
|
+
this._available = r.status === 0;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
this._available = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
this._available = false;
|
|
89
|
+
}
|
|
90
|
+
return this._available;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Store a secret under the given account.
|
|
94
|
+
*
|
|
95
|
+
* Account is a free-form identifier for the secret's namespace. Callers
|
|
96
|
+
* should use stable, descriptive names like:
|
|
97
|
+
* - 'daemon-token' — Firebase ID token (legacy)
|
|
98
|
+
* - 'firebase-refresh' — Firebase refresh token
|
|
99
|
+
* - 'ipc-auth' — per-launch IPC auth secret
|
|
100
|
+
* - 'byok-anthropic' — user-supplied Anthropic API key
|
|
101
|
+
* - 'byok-openrouter' — user-supplied OpenRouter API key
|
|
102
|
+
* - 'vertex-service-account' — Vertex service account JSON
|
|
103
|
+
*
|
|
104
|
+
* The secret is never interpolated into a shell string — it goes in via
|
|
105
|
+
* stdin (macOS `security -w -` and Linux `secret-tool store`).
|
|
106
|
+
*/
|
|
107
|
+
set(account, secret) {
|
|
108
|
+
if (!this.available)
|
|
109
|
+
return false;
|
|
110
|
+
if (!isSafeAccount(account))
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
if (platform() === 'darwin') {
|
|
114
|
+
// Delete existing entry first so we don't accumulate duplicates.
|
|
115
|
+
// spawnSync with argv does NOT invoke a shell, so there's no
|
|
116
|
+
// metacharacter injection risk even if account contained bad
|
|
117
|
+
// chars (which isSafeAccount rules out anyway).
|
|
118
|
+
spawnSync('/usr/bin/security', ['delete-generic-password', '-s', SERVICE, '-a', account], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
119
|
+
// Pass the secret as an argv element. This is the standard way to
|
|
120
|
+
// script `security add-generic-password` — the `-w` flag expects
|
|
121
|
+
// the password as its argument, not from stdin. Because spawnSync
|
|
122
|
+
// forks `security` directly (no /bin/sh between us), the secret
|
|
123
|
+
// never hits a shell and can't be split on whitespace or metachars.
|
|
124
|
+
//
|
|
125
|
+
// Trade-off: the secret is briefly visible to `ps auxww` for the
|
|
126
|
+
// same user during the sub-second lifetime of the subprocess.
|
|
127
|
+
// Since the same user already has filesystem access to read tokens
|
|
128
|
+
// from `~/.beehaven/`, this doesn't expand the threat surface in
|
|
129
|
+
// practice — and Security.framework is the only serious
|
|
130
|
+
// alternative, which would require a native helper binary.
|
|
131
|
+
const r = spawnSync('/usr/bin/security', ['add-generic-password', '-s', SERVICE, '-a', account, '-w', secret, '-U'], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
132
|
+
return r.status === 0;
|
|
133
|
+
}
|
|
134
|
+
if (platform() === 'linux') {
|
|
135
|
+
// secret-tool reads the password from stdin by design — this is the
|
|
136
|
+
// recommended scripting pattern, and it keeps the secret off argv
|
|
137
|
+
// (so `ps` never sees it even briefly).
|
|
138
|
+
const r = spawnSync('secret-tool', ['store', '--label=Beehaven', 'service', SERVICE, 'account', account], { input: secret, stdio: ['pipe', 'ignore', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS });
|
|
139
|
+
return r.status === 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.warn(`[Keychain] Failed to store ${account}:`, err.message);
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
/** Retrieve a secret. Returns null if not found or keychain unavailable. */
|
|
148
|
+
get(account) {
|
|
149
|
+
if (!this.available)
|
|
150
|
+
return null;
|
|
151
|
+
if (!isSafeAccount(account))
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
if (platform() === 'darwin') {
|
|
155
|
+
const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS });
|
|
156
|
+
if (r.status !== 0)
|
|
157
|
+
return null;
|
|
158
|
+
return r.stdout.trim() || null;
|
|
159
|
+
}
|
|
160
|
+
if (platform() === 'linux') {
|
|
161
|
+
const r = spawnSync('secret-tool', ['lookup', 'service', SERVICE, 'account', account], {
|
|
162
|
+
encoding: 'utf-8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS,
|
|
164
|
+
});
|
|
165
|
+
if (r.status !== 0)
|
|
166
|
+
return null;
|
|
167
|
+
return r.stdout.trim() || null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Not found
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Remove a secret. Returns true if removed (or nothing to remove). */
|
|
176
|
+
delete(account) {
|
|
177
|
+
if (!this.available)
|
|
178
|
+
return false;
|
|
179
|
+
if (!isSafeAccount(account))
|
|
180
|
+
return false;
|
|
181
|
+
try {
|
|
182
|
+
if (platform() === 'darwin') {
|
|
183
|
+
spawnSync('/usr/bin/security', ['delete-generic-password', '-s', SERVICE, '-a', account], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (platform() === 'linux') {
|
|
187
|
+
spawnSync('secret-tool', ['clear', 'service', SERVICE, 'account', account], { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* not found — fine */
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
// ── Legacy compat (single daemon token) ───────────────────────────────
|
|
197
|
+
//
|
|
198
|
+
// The existing daemon.ts calls `keychain.setToken()/getToken()/deleteToken()`
|
|
199
|
+
// for the Firebase ID token. Keep those working by delegating to the named
|
|
200
|
+
// API, but new code should use set()/get()/delete() with an explicit account.
|
|
201
|
+
/** @deprecated Use set('daemon-token', …) */
|
|
202
|
+
setToken(token) {
|
|
203
|
+
return this.set(LEGACY_DAEMON_ACCOUNT, token);
|
|
204
|
+
}
|
|
205
|
+
/** @deprecated Use get('daemon-token') */
|
|
206
|
+
getToken() {
|
|
207
|
+
return this.get(LEGACY_DAEMON_ACCOUNT);
|
|
208
|
+
}
|
|
209
|
+
/** @deprecated Use delete('daemon-token') */
|
|
210
|
+
deleteToken() {
|
|
211
|
+
return this.delete(LEGACY_DAEMON_ACCOUNT);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Account names are passed to `security` and `secret-tool` as argv, so shell
|
|
216
|
+
* injection isn't possible — but we still constrain them to alphanumerics +
|
|
217
|
+
* a small set of separators to prevent callers from accidentally stepping on
|
|
218
|
+
* reserved keychain attribute names or writing un-auditable account keys.
|
|
219
|
+
*/
|
|
220
|
+
function isSafeAccount(account) {
|
|
221
|
+
return /^[a-zA-Z0-9._-]{1,64}$/.test(account);
|
|
222
|
+
}
|
|
223
|
+
/** Singleton for the daemon process */
|
|
224
|
+
export const keychain = new Keychain();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight admin HTTP client — direct bearer auth, daemon-optional.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the worker URL + admin secret from (in order): explicit opts,
|
|
5
|
+
* env vars (BEEHAVEN_WORKER_URL / BEEHAVEN_ADMIN_SECRET), then
|
|
6
|
+
* ~/.beehaven/credentials.json if present. No daemon dependency, no relay —
|
|
7
|
+
* third-party SDK adopters can use this against their own worker.
|
|
8
|
+
*
|
|
9
|
+
* Different layer from cli.ts's inline `adminCall` which prefers the
|
|
10
|
+
* daemon-fetch path. This is the unauthenticated-by-default sibling for
|
|
11
|
+
* commands that don't need Clearly's auth integration.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
export function resolveAdminConfig(opts = {}) {
|
|
17
|
+
let url = opts.url ?? process.env.BEEHAVEN_WORKER_URL;
|
|
18
|
+
let secret = opts.secret ?? process.env.BEEHAVEN_ADMIN_SECRET;
|
|
19
|
+
if (!url || !secret) {
|
|
20
|
+
const credsPath = join(homedir(), '.beehaven', 'credentials.json');
|
|
21
|
+
if (existsSync(credsPath)) {
|
|
22
|
+
try {
|
|
23
|
+
const creds = JSON.parse(readFileSync(credsPath, 'utf-8'));
|
|
24
|
+
url = url ?? creds.workerUrl;
|
|
25
|
+
secret = secret ?? creds.adminSecret;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* ignore */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!url)
|
|
33
|
+
return {
|
|
34
|
+
error: 'No worker URL. Pass --url, set BEEHAVEN_WORKER_URL, or save to ~/.beehaven/credentials.json.',
|
|
35
|
+
};
|
|
36
|
+
if (!secret)
|
|
37
|
+
return {
|
|
38
|
+
error: 'No admin secret. Pass --secret, set BEEHAVEN_ADMIN_SECRET, or save to ~/.beehaven/credentials.json.',
|
|
39
|
+
};
|
|
40
|
+
return { url: url.replace(/\/$/, ''), secret };
|
|
41
|
+
}
|
|
42
|
+
export function getAdminClient(opts = {}) {
|
|
43
|
+
const cfg = resolveAdminConfig(opts);
|
|
44
|
+
if ('error' in cfg)
|
|
45
|
+
return { error: cfg.error };
|
|
46
|
+
const call = async (path, init = {}) => {
|
|
47
|
+
const res = await fetch(`${cfg.url}${path}`, {
|
|
48
|
+
method: init.method ?? 'GET',
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${cfg.secret}`,
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
'X-Beehaven-Source': 'cli',
|
|
53
|
+
},
|
|
54
|
+
body: init.body !== undefined
|
|
55
|
+
? typeof init.body === 'string'
|
|
56
|
+
? init.body
|
|
57
|
+
: JSON.stringify(init.body)
|
|
58
|
+
: undefined,
|
|
59
|
+
});
|
|
60
|
+
let body = null;
|
|
61
|
+
try {
|
|
62
|
+
body = await res.json();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
return { status: res.status, ok: res.ok, body };
|
|
68
|
+
};
|
|
69
|
+
return { workerUrl: cfg.url, call };
|
|
70
|
+
}
|
|
71
|
+
export function doActionPath(type, id, action) {
|
|
72
|
+
return `/admin/do/${encodeURIComponent(type)}/${encodeURIComponent(id)}/${encodeURIComponent(action)}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-build-parser — CLI-side metadata extraction from .ipa / .apk.
|
|
3
|
+
*
|
|
4
|
+
* Called by `beehaven teleport build.ipa --composition <id>` to
|
|
5
|
+
* auto-populate bundle id / version / display name without forcing
|
|
6
|
+
* the developer to type them as CLI flags. Any failure falls back
|
|
7
|
+
* to defaults; the developer can still override via --bundle-id etc.
|
|
8
|
+
*
|
|
9
|
+
* Coverage today:
|
|
10
|
+
* - .ipa → Info.plist (both binary plist and XML plist). bplist-parser
|
|
11
|
+
* handles binary; XML uses a regex extractor (good enough
|
|
12
|
+
* for the keys we need; a full plist parser is overkill).
|
|
13
|
+
* - .ipa → embedded.mobileprovision UDID extraction (CMS-wrapped plist)
|
|
14
|
+
* is NOT implemented here. Use --udids manually until task
|
|
15
|
+
* #17 ships (UDID auto-collection via mobileconfig).
|
|
16
|
+
* - .apk → AndroidManifest.xml uses the binary AXML format which
|
|
17
|
+
* needs a dedicated parser. Not implemented in this iter.
|
|
18
|
+
* Use --bundle-id / --app-version manually.
|
|
19
|
+
*
|
|
20
|
+
* All parse failures resolve to `{ ok: false, ... }` so the caller
|
|
21
|
+
* decides whether to fall back to filename-based defaults.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { unzipSync, strFromU8 } from 'fflate';
|
|
25
|
+
import bplist from 'bplist-parser';
|
|
26
|
+
/**
|
|
27
|
+
* Top-level entry: dispatch on filename extension.
|
|
28
|
+
*/
|
|
29
|
+
export function parseMobileBinary(filePath) {
|
|
30
|
+
const lower = filePath.toLowerCase();
|
|
31
|
+
if (lower.endsWith('.ipa'))
|
|
32
|
+
return parseIPA(filePath);
|
|
33
|
+
if (lower.endsWith('.apk'))
|
|
34
|
+
return parseAPK(filePath);
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: 'unsupported-extension',
|
|
38
|
+
detail: `Expected .ipa or .apk, got ${filePath}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse an iOS .ipa (ZIP) → Payload/<App>.app/Info.plist.
|
|
43
|
+
* Reads the whole file into memory (typically <200MB; fflate is
|
|
44
|
+
* synchronous and the CLI doesn't need streaming).
|
|
45
|
+
*/
|
|
46
|
+
function parseIPA(filePath) {
|
|
47
|
+
let buf;
|
|
48
|
+
try {
|
|
49
|
+
buf = readFileSync(filePath);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
return { ok: false, reason: 'read-failed', detail: e?.message };
|
|
53
|
+
}
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = unzipSync(new Uint8Array(buf));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return { ok: false, reason: 'unzip-failed', detail: e?.message };
|
|
60
|
+
}
|
|
61
|
+
// Locate Info.plist. IPAs always have exactly one `Payload/<X>.app/Info.plist`
|
|
62
|
+
// (multiple .app entries would be an enterprise wrapper, which we don't
|
|
63
|
+
// support — fall back to the first match).
|
|
64
|
+
const infoPlistKey = Object.keys(entries).find((k) => /^Payload\/[^/]+\.app\/Info\.plist$/i.test(k));
|
|
65
|
+
if (!infoPlistKey) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: 'no-info-plist',
|
|
69
|
+
detail: 'IPA does not contain Payload/<App>.app/Info.plist',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const plistBytes = entries[infoPlistKey];
|
|
73
|
+
let plist;
|
|
74
|
+
try {
|
|
75
|
+
plist = decodePlist(plistBytes);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return { ok: false, reason: 'plist-decode-failed', detail: e?.message?.slice(0, 240) };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
platform: 'ios',
|
|
83
|
+
bundleId: stringOrUndefined(plist?.CFBundleIdentifier),
|
|
84
|
+
version: stringOrUndefined(plist?.CFBundleShortVersionString),
|
|
85
|
+
buildNumber: stringOrUndefined(plist?.CFBundleVersion),
|
|
86
|
+
displayName: stringOrUndefined(plist?.CFBundleDisplayName ?? plist?.CFBundleName ?? plist?.CFBundleExecutable),
|
|
87
|
+
minOsVersion: stringOrUndefined(plist?.MinimumOSVersion),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse an .apk. AndroidManifest.xml is binary AXML — not parseable
|
|
92
|
+
* without a dedicated decoder. Stubbed for now.
|
|
93
|
+
*/
|
|
94
|
+
function parseAPK(_filePath) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: 'apk-parser-not-implemented',
|
|
98
|
+
detail: 'AndroidManifest.xml binary AXML parsing is a future iteration. Use --bundle-id / --app-version / --display-name / --build flags manually for now.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Decode an Info.plist that may be either binary plist (bplist00...)
|
|
103
|
+
* or XML plist (<?xml ...>). bplist-parser is synchronous despite the
|
|
104
|
+
* Promise API; we just resolve immediately.
|
|
105
|
+
*/
|
|
106
|
+
function decodePlist(bytes) {
|
|
107
|
+
// Binary plists start with the magic "bplist" header.
|
|
108
|
+
if (bytes.length >= 6 &&
|
|
109
|
+
bytes[0] === 0x62 && // 'b'
|
|
110
|
+
bytes[1] === 0x70 && // 'p'
|
|
111
|
+
bytes[2] === 0x6c && // 'l'
|
|
112
|
+
bytes[3] === 0x69 && // 'i'
|
|
113
|
+
bytes[4] === 0x73 && // 's'
|
|
114
|
+
bytes[5] === 0x74 // 't'
|
|
115
|
+
) {
|
|
116
|
+
const parsed = bplist.parseBuffer(Buffer.from(bytes));
|
|
117
|
+
return parsed?.[0] ?? {};
|
|
118
|
+
}
|
|
119
|
+
// XML plist — regex-extract the keys we care about. Not a real
|
|
120
|
+
// parser, but Info.plist XML is a flat <dict> of <key>NAME</key>
|
|
121
|
+
// <string>VALUE</string> pairs and the regex catches every case
|
|
122
|
+
// I've seen in the wild.
|
|
123
|
+
const xml = strFromU8(bytes);
|
|
124
|
+
return parseXmlPlistShallow(xml);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Shallow regex extractor for XML plists. Pulls every top-level
|
|
128
|
+
* <key>X</key><string|integer>Y</string|integer> pair. Sufficient
|
|
129
|
+
* for Info.plist (no nested dicts that we care about).
|
|
130
|
+
*/
|
|
131
|
+
function parseXmlPlistShallow(xml) {
|
|
132
|
+
const out = {};
|
|
133
|
+
// Match <key>NAME</key> followed by <string>VALUE</string> OR <integer>123</integer>.
|
|
134
|
+
// Whitespace tolerant. Doesn't handle CDATA — Info.plist doesn't use it.
|
|
135
|
+
const re = /<key>([^<]+)<\/key>\s*<(string|integer)>([^<]*)<\/(?:string|integer)>/g;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = re.exec(xml)) !== null) {
|
|
138
|
+
const [, key, , value] = m;
|
|
139
|
+
out[decodeXmlEntities(key)] = decodeXmlEntities(value);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function decodeXmlEntities(s) {
|
|
144
|
+
return s
|
|
145
|
+
.replace(/&/g, '&')
|
|
146
|
+
.replace(/</g, '<')
|
|
147
|
+
.replace(/>/g, '>')
|
|
148
|
+
.replace(/"/g, '"')
|
|
149
|
+
.replace(/'/g, "'");
|
|
150
|
+
}
|
|
151
|
+
function stringOrUndefined(v) {
|
|
152
|
+
if (v == null)
|
|
153
|
+
return undefined;
|
|
154
|
+
const s = String(v).trim();
|
|
155
|
+
return s.length ? s : undefined;
|
|
156
|
+
}
|