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,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(/&amp;/g, '&')
146
+ .replace(/&lt;/g, '<')
147
+ .replace(/&gt;/g, '>')
148
+ .replace(/&quot;/g, '"')
149
+ .replace(/&apos;/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
+ }