dsh-mobilecode 0.1.0

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.
@@ -0,0 +1,964 @@
1
+ /**
2
+ * dsh-mobilecode — device-build helpers.
3
+ *
4
+ * Faithful plain-JS port of `mobilecode/packages/core/src/device-build.ts`
5
+ * (hsandhu/mobilecode), with the Effect/Schema types stripped and cross-spawn
6
+ * replaced by a self-contained spawn helper: cross-spawn is not resolvable
7
+ * from the profile node_modules tree this plugin loads in, so `launch()`
8
+ * re-implements its two essential behaviors — PATHEXT lookup and spawning
9
+ * .cmd/.bat through cmd.exe on Windows.
10
+ *
11
+ * All functions here are async/sync pure helpers; the engine that owns
12
+ * long-running children lives in device-preview.js.
13
+ */
14
+
15
+ import { spawn, spawnSync } from "node:child_process"
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
17
+ import { fileURLToPath } from "node:url"
18
+ import net from "node:net"
19
+ import os from "node:os"
20
+ import path from "node:path"
21
+
22
+ /** Resolve a bare command name to an executable file, the way a shell would. */
23
+ export function resolveExecutable(command) {
24
+ if (!command || command.includes("/") || command.includes("\\")) return command
25
+ const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
26
+ // Prefer PATHEXT matches: npm ships an extensionless `npx` shim next to npx.cmd,
27
+ // and the bare file is not directly spawnable.
28
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
29
+ if (!dir) continue
30
+ for (const ext of exts) {
31
+ const file = path.join(dir, command + ext)
32
+ if (existsSync(file)) return file
33
+ }
34
+ }
35
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
36
+ if (!dir) continue
37
+ const candidate = path.join(dir, command)
38
+ if (existsSync(candidate)) return candidate
39
+ }
40
+ return command
41
+ }
42
+
43
+ /** Quote one argument for cmd.exe: wrap in quotes, escape embedded quotes. */
44
+ function cmdArg(arg) {
45
+ return `"${String(arg).replace(/"/g, '\\"')}"`
46
+ }
47
+
48
+ /**
49
+ * Spawn `command` with cross-spawn semantics: on Windows, `npx`, `pod`, and
50
+ * friends are .cmd/.bat shims that must run under cmd.exe; on POSIX this is
51
+ * a plain `spawn`.
52
+ */
53
+ export function launch(command, args, options) {
54
+ if (process.platform !== "win32") return spawn(command, args, options)
55
+ const resolved = resolveExecutable(command)
56
+ if (/\.(cmd|bat)$/i.test(resolved)) {
57
+ // cmd.exe /c strips the outer pair of quotes only; the inner ones survive,
58
+ // so double-wrap the whole command line ("...") with each token quoted.
59
+ const line = [cmdArg(resolved), ...args.map(cmdArg)].join(" ")
60
+ return spawn("cmd.exe", ["/d", "/s", "/c", `"${line}"`], {
61
+ ...options,
62
+ windowsVerbatimArguments: true,
63
+ shell: false,
64
+ })
65
+ }
66
+ return spawn(resolved, args, options)
67
+ }
68
+
69
+ /** Spawn a command and stream combined output line by line. */
70
+ export function exec(command, args, options, onLine) {
71
+ const child = launch(command, args, {
72
+ cwd: options?.cwd,
73
+ env: { ...process.env, ...options?.env },
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ windowsHide: true,
76
+ })
77
+ for (const stream of [child.stdout, child.stderr]) {
78
+ if (!stream || !onLine) continue
79
+ let rest = ""
80
+ stream.setEncoding("utf8")
81
+ stream.on("data", (chunk) => {
82
+ const parts = (rest + chunk).split(/\r?\n/)
83
+ rest = parts.pop() ?? ""
84
+ for (const part of parts) {
85
+ const text = part.trimEnd()
86
+ if (text) onLine(text)
87
+ }
88
+ })
89
+ stream.on("end", () => {
90
+ if (rest.trim()) onLine(rest.trim())
91
+ })
92
+ }
93
+ const exit = new Promise((resolve) => {
94
+ child.once("error", () => resolve(-1))
95
+ child.once("close", (code) => resolve(code ?? -1))
96
+ })
97
+ return { child, exit }
98
+ }
99
+
100
+ /** Run a command purely for its stdout, e.g. a `-json` query. Empty string on failure. */
101
+ export async function capture(command, args, options = {}) {
102
+ const out = []
103
+ const running = launch(command, args, {
104
+ cwd: options.cwd,
105
+ env: options.env ? { ...process.env, ...options.env } : undefined,
106
+ stdio: ["ignore", "pipe", "ignore"],
107
+ windowsHide: true,
108
+ })
109
+ running.stdout?.setEncoding("utf8")
110
+ running.stdout?.on("data", (chunk) => out.push(chunk))
111
+ const code = await new Promise((resolve) => {
112
+ running.once("error", () => resolve(-1))
113
+ running.once("close", (value) => resolve(value ?? -1))
114
+ })
115
+ return code === 0 ? out.join("") : ""
116
+ }
117
+
118
+ /** Direct child pids of `pid`. POSIX only; returns nothing when pgrep is unavailable. */
119
+ export function spawnPgrep(pid) {
120
+ const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" })
121
+ return (result.stdout ?? "")
122
+ .split("\n")
123
+ .map(Number)
124
+ .filter((value) => Number.isInteger(value) && value > 0)
125
+ }
126
+
127
+ // ── project discovery ─────────────────────────────────────────────────────────
128
+
129
+ const SKIP = new Set([
130
+ "node_modules",
131
+ ".git",
132
+ "build",
133
+ "dist",
134
+ "out",
135
+ "target",
136
+ "Pods",
137
+ "DerivedData",
138
+ ".gradle",
139
+ ".build",
140
+ "vendor",
141
+ "Carthage",
142
+ ])
143
+ const MAX_DEPTH = 2
144
+ const EXPO_CONFIGS = ["app.config.js", "app.config.ts", "app.config.mjs", "app.config.cjs"]
145
+ const GRADLE_MARKERS = ["settings.gradle", "settings.gradle.kts", "gradlew", "build.gradle", "build.gradle.kts"]
146
+
147
+ /**
148
+ * Find the native project root for each platform at or below `directory`.
149
+ * Bounded walk: agents and templates routinely nest the app one or two levels
150
+ * down, so a listing of just the session directory misses them.
151
+ */
152
+ export function findProjects(directory) {
153
+ const found = new Map()
154
+ const walk = (current, depth) => {
155
+ const entries = read(current)
156
+ if (entries.length === 0) return
157
+ const names = new Set(entries.map((entry) => entry.name))
158
+ const framework = detectFramework(current, names)
159
+ const expo = framework === "expo"
160
+ const here = (platform, dir, needsPrebuild = false) => ({
161
+ platform,
162
+ directory: dir,
163
+ root: current,
164
+ framework,
165
+ needsPrebuild,
166
+ })
167
+
168
+ if (!found.has("ios") && process.platform === "darwin") {
169
+ const native = entries.some((e) => e.name.endsWith(".xcodeproj") || e.name.endsWith(".xcworkspace"))
170
+ if (native || names.has("Podfile")) found.set("ios", here("ios", current))
171
+ else if (names.has("ios") && hasXcodeProject(path.join(current, "ios")))
172
+ found.set("ios", here("ios", path.join(current, "ios")))
173
+ else if (expo) found.set("ios", here("ios", current, true))
174
+ }
175
+
176
+ if (!found.has("android")) {
177
+ if (GRADLE_MARKERS.some((marker) => names.has(marker))) found.set("android", here("android", current))
178
+ else if (names.has("android") && hasGradleProject(path.join(current, "android")))
179
+ found.set("android", here("android", path.join(current, "android")))
180
+ else if (expo) found.set("android", here("android", current, true))
181
+ }
182
+
183
+ if (depth >= MAX_DEPTH) return
184
+ for (const entry of entries) {
185
+ if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith(".")) continue
186
+ if (entry.name.endsWith(".xcodeproj") || entry.name.endsWith(".xcworkspace")) continue
187
+ walk(path.join(current, entry.name), depth + 1)
188
+ }
189
+ }
190
+ walk(directory, 0)
191
+ // A real native project always wins over an Expo app still needing prebuild.
192
+ return [...found.values()].sort((a, b) => Number(a.needsPrebuild) - Number(b.needsPrebuild))
193
+ }
194
+
195
+ function read(directory) {
196
+ try {
197
+ return readdirSync(directory, { withFileTypes: true })
198
+ } catch {
199
+ return []
200
+ }
201
+ }
202
+
203
+ function hasXcodeProject(directory) {
204
+ return read(directory).some((entry) => entry.name.endsWith(".xcodeproj") || entry.name.endsWith(".xcworkspace"))
205
+ }
206
+
207
+ function hasGradleProject(directory) {
208
+ const names = new Set(read(directory).map((entry) => entry.name))
209
+ return GRADLE_MARKERS.some((marker) => names.has(marker))
210
+ }
211
+
212
+ function isExpoApp(file) {
213
+ try {
214
+ const parsed = JSON.parse(readFileSync(file, "utf8"))
215
+ return typeof parsed === "object" && parsed !== null && "expo" in parsed
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ /** Expo when configured as one, React Native when package.json depends on it, else native. */
222
+ export function detectFramework(directory, names) {
223
+ const expo = names.has("app.json")
224
+ ? isExpoApp(path.join(directory, "app.json"))
225
+ : EXPO_CONFIGS.some((name) => names.has(name))
226
+ const pkg = names.has("package.json") ? readPackage(directory) : undefined
227
+ if (expo || pkg?.dependencies?.["expo"]) return "expo"
228
+ if (pkg?.dependencies?.["react-native"]) return "react-native"
229
+ return "native"
230
+ }
231
+
232
+ function readPackage(directory) {
233
+ return parseJson(readText(path.join(directory, "package.json")))
234
+ }
235
+
236
+ function readText(file) {
237
+ try {
238
+ return readFileSync(file, "utf8")
239
+ } catch {
240
+ return ""
241
+ }
242
+ }
243
+
244
+ // ── long-running children ─────────────────────────────────────────────────────
245
+
246
+ // Runs the command, and when this process's stdin pipe closes (which happens even when the
247
+ // parent is SIGKILLed, as Electron does to its utility processes) tears the command's whole
248
+ // tree down. Without this every app restart leaves a preview server and Metro behind.
249
+ const GUARD = `
250
+ exec 3<&0
251
+ killtree() { for c in $(pgrep -P "$1" 2>/dev/null); do killtree "$c" "$2"; done; kill "-$2" "$1" 2>/dev/null; }
252
+ "$@" &
253
+ child=$!
254
+ # serve-avd stalls its graceful shutdown while a stream is connected, so escalate after a moment.
255
+ ( cat <&3 >/dev/null; killtree "$child" TERM; sleep 2; killtree "$child" KILL ) &
256
+ watcher=$!
257
+ wait "$child"
258
+ code=$?
259
+ for c in $(pgrep -P "$watcher" 2>/dev/null); do kill "$c" 2>/dev/null; done
260
+ kill "$watcher" 2>/dev/null
261
+ exit "$code"
262
+ `
263
+
264
+ /** Wrap a long-running command so it dies with us. Spawn the result with stdin as a pipe. */
265
+ export function guarded(command, args) {
266
+ if (process.platform === "win32") return { command, args }
267
+ return { command: "/bin/sh", args: ["-c", GUARD, "guard", command, ...args] }
268
+ }
269
+
270
+ // ── prerequisites ─────────────────────────────────────────────────────────────
271
+
272
+ const METRO_PORT = 8081
273
+
274
+ /**
275
+ * Whether `command` is on `searchPath`. Pass the login shell's PATH: the desktop app's own PATH
276
+ * is the bare system one, without Homebrew, so `pod` and friends look missing from it.
277
+ */
278
+ export function commandExists(command, searchPath = process.env["PATH"]) {
279
+ if (whichIn(searchPath, command)) return true
280
+ const probe = process.platform === "win32" ? "where" : "which"
281
+ return (
282
+ spawnSync(probe, [command], {
283
+ stdio: "ignore",
284
+ windowsHide: true,
285
+ env: { ...process.env, PATH: searchPath ?? process.env["PATH"] ?? "" },
286
+ }).status === 0
287
+ )
288
+ }
289
+
290
+ /**
291
+ * The tools a run needs before any build starts. Returns a one-line problem with the fix, so a
292
+ * missing SDK fails in a second with something actionable instead of minutes into a build.
293
+ */
294
+ export function preflight(project, env) {
295
+ const searchPath = pathEnv(env)
296
+ if (project.framework !== "native" && !existsSync(path.join(project.root, "node_modules")))
297
+ return `Dependencies are not installed. Run \`npm install\` in ${project.root} and try again.`
298
+ if (project.platform === "ios") {
299
+ if (!commandExists("xcodebuild", searchPath))
300
+ return "Xcode was not found. Install Xcode from the App Store, then run `xcode-select --install`."
301
+ // Only a Podfile nobody has installed needs the tool; an installed one builds without it.
302
+ const pods = project.needsPrebuild || !podsInstalled(project.directory)
303
+ if (pods && !commandExists("pod", searchPath))
304
+ return "CocoaPods is not installed. Run `brew install cocoapods` (or `sudo gem install cocoapods`) and try again."
305
+ return undefined
306
+ }
307
+ if (!androidSdk())
308
+ return "Android SDK was not found. Install Android Studio, or set ANDROID_HOME to your SDK directory."
309
+ if (!commandExists("java", searchPath))
310
+ return "Java was not found. Install a JDK 17, for example `brew install --cask zulu@17`."
311
+ if (!project.needsPrebuild && !gradleWrapper(project.directory)) return "No Gradle wrapper found in this project."
312
+ return undefined
313
+ }
314
+
315
+ /** Gradle and the React Native plugin read the SDK location from the environment when there is no local.properties. */
316
+ export function androidEnv() {
317
+ const sdk = androidSdk()
318
+ if (!sdk || process.env["ANDROID_HOME"]) return {}
319
+ return { ANDROID_HOME: sdk }
320
+ }
321
+
322
+ // ── Expo ──────────────────────────────────────────────────────────────────────
323
+
324
+ /**
325
+ * `expo prebuild` prompts for the bundle identifier and package name when app.json has none, and
326
+ * refuses to continue without a TTY. Fill in the same defaults the prompt would suggest so a fresh
327
+ * `create-expo-app` project runs without the user editing config first.
328
+ */
329
+ export function ensureExpoAppIds(root, platform) {
330
+ const file = path.join(root, "app.json")
331
+ const text = readText(file)
332
+ if (!text) return undefined
333
+ const parsed = parseJson(text)
334
+ const expo = parsed?.expo
335
+ if (!expo) return undefined
336
+ const key = platform === "ios" ? "ios" : "android"
337
+ const field = platform === "ios" ? "bundleIdentifier" : "package"
338
+ const section = typeof expo[key] === "object" && expo[key] !== null ? expo[key] : {}
339
+ if (typeof section[field] === "string") return undefined
340
+ const slug = typeof expo["slug"] === "string" ? expo["slug"] : typeof expo["name"] === "string" ? expo["name"] : "app"
341
+ const cleaned = slug.replace(/[^A-Za-z0-9]+/g, "").toLowerCase() || "app"
342
+ section[field] = `com.anonymous.${cleaned}`
343
+ expo[key] = section
344
+ const indent = /^\s*\{\r?\n(\s+)"/.exec(text)?.[1] ?? " "
345
+ writeFileSync(file, JSON.stringify(parsed, null, indent) + "\n")
346
+ return `Set expo.${key}.${field} to ${String(section[field])} in app.json`
347
+ }
348
+
349
+ /** Native project directory produced by `expo prebuild` for one platform. */
350
+ export function prebuiltDirectory(root, platform) {
351
+ const directory = path.join(root, platform)
352
+ const ready = platform === "ios" ? hasXcodeProject(directory) : hasGradleProject(directory)
353
+ return ready ? directory : undefined
354
+ }
355
+
356
+ /** True when there is no Podfile, or it has been installed at least once. */
357
+ export function podsInstalled(directory) {
358
+ if (!existsSync(path.join(directory, "Podfile"))) return true
359
+ return existsSync(path.join(directory, "Pods", "Manifest.lock"))
360
+ }
361
+
362
+ // ── ports ─────────────────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * First port at or above `start` that nothing on loopback is listening on. Preview servers from
366
+ * other tools, or orphaned from an earlier opencode process, routinely hold the defaults.
367
+ */
368
+ export async function freePort(start, span = 50) {
369
+ for (let port = start; port < start + span; port += 1) {
370
+ if (await available(port)) return port
371
+ }
372
+ return undefined
373
+ }
374
+
375
+ function available(port) {
376
+ return new Promise((resolve) => {
377
+ const probe = net.createServer()
378
+ probe.unref()
379
+ probe.once("error", () => resolve(false))
380
+ probe.listen({ port, host: "127.0.0.1", exclusive: true }, () => probe.close(() => resolve(true)))
381
+ })
382
+ }
383
+
384
+ // ── Node ──────────────────────────────────────────────────────────────────────
385
+
386
+ export function parseVersion(text) {
387
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(text)
388
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined
389
+ }
390
+
391
+ function compare(a, b) {
392
+ for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1
393
+ return 0
394
+ }
395
+
396
+ const formatVersion = (version) => `v${version.join(".")}`
397
+
398
+ /**
399
+ * Enough of node-semver for `engines.node`: comparators, caret and tilde, x-ranges, and `||`.
400
+ * Anything it cannot read is treated as satisfied rather than blocking a build.
401
+ */
402
+ export function satisfies(version, range) {
403
+ return range.split("||").some((alternative) =>
404
+ alternative
405
+ .trim()
406
+ .replace(/(>=|<=|>|<|=|\^|~)\s+/g, "$1")
407
+ .split(/\s+/)
408
+ .filter(Boolean)
409
+ .every((part) => comparator(version, part)),
410
+ )
411
+ }
412
+
413
+ function comparator(version, part) {
414
+ const match = /^(>=|<=|>|<|=|\^|~)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(part)
415
+ if (!match) return true
416
+ const op = match[1] ?? ""
417
+ const major = Number(match[2])
418
+ const minor = match[3] === undefined || match[3] === "x" || match[3] === "*" ? undefined : Number(match[3])
419
+ const patch = match[4] === undefined || match[4] === "x" || match[4] === "*" ? undefined : Number(match[4])
420
+ const low = [major, minor ?? 0, patch ?? 0]
421
+ // First version above everything the partial version covers: 20 → 21.0.0, 20.19 → 20.20.0.
422
+ const next = minor === undefined ? [major + 1, 0, 0] : patch === undefined ? [major, minor + 1, 0] : low
423
+ switch (op) {
424
+ case ">=":
425
+ return compare(version, low) >= 0
426
+ case ">":
427
+ return patch === undefined ? compare(version, next) >= 0 : compare(version, low) > 0
428
+ case "<":
429
+ return compare(version, low) < 0
430
+ case "<=":
431
+ return patch === undefined ? compare(version, next) < 0 : compare(version, low) <= 0
432
+ case "^": {
433
+ const upper =
434
+ major > 0 ? [major + 1, 0, 0] : (minor ?? 0) > 0 ? [0, (minor ?? 0) + 1, 0] : [0, minor ?? 0, (patch ?? 0) + 1]
435
+ return compare(version, low) >= 0 && compare(version, upper) < 0
436
+ }
437
+ case "~":
438
+ return (
439
+ compare(version, low) >= 0 &&
440
+ compare(version, minor === undefined ? [major + 1, 0, 0] : [major, minor + 1, 0]) < 0
441
+ )
442
+ default:
443
+ return patch === undefined
444
+ ? compare(version, low) >= 0 && compare(version, next) < 0
445
+ : compare(version, low) === 0
446
+ }
447
+ }
448
+
449
+ const NODE_MANIFESTS = [
450
+ "package.json",
451
+ "node_modules/react-native/package.json",
452
+ "node_modules/expo/package.json",
453
+ "node_modules/@expo/cli/package.json",
454
+ ]
455
+
456
+ /** Every `engines.node` range the project and its mobile toolchain declare. All must hold. */
457
+ export function nodeRequirement(root) {
458
+ return NODE_MANIFESTS.flatMap((file) => {
459
+ const range = parseJson(readText(path.join(root, file)))?.engines?.node
460
+ return typeof range === "string" && range.trim() ? [range.trim()] : []
461
+ })
462
+ }
463
+
464
+ /**
465
+ * Read PATH from an env object regardless of key casing. `process.env` is a
466
+ * case-insensitive proxy on Windows, but spreading it (`{ ...process.env }`)
467
+ * yields a plain object whose PATH key keeps the real casing (`Path`), so
468
+ * `env["PATH"]` silently reads undefined. Fall back to the proxy when the
469
+ * object has no PATH-like key or env is missing entirely.
470
+ */
471
+ export function pathEnv(env) {
472
+ if (env) {
473
+ for (const key of Object.keys(env)) {
474
+ if (key.toLowerCase() === "path") return env[key]
475
+ }
476
+ }
477
+ return process.env["PATH"]
478
+ }
479
+
480
+ function whichIn(searchPath, command) {
481
+ const name = process.platform === "win32" ? `${command}.exe` : command
482
+ for (const dir of (searchPath ?? "").split(path.delimiter)) {
483
+ if (!dir) continue
484
+ const candidate = path.join(dir, name)
485
+ if (existsSync(candidate)) return candidate
486
+ }
487
+ return undefined
488
+ }
489
+
490
+ /** Bin directories of Node installs from the usual version managers and Homebrew, if present. */
491
+ export function nodeInstalls() {
492
+ const home = os.homedir()
493
+ const roots = [
494
+ { dir: process.env["NVM_DIR"] ?? path.join(home, ".nvm"), sub: ["versions", "node"], bin: "bin" },
495
+ {
496
+ dir: process.env["FNM_DIR"] ?? path.join(home, ".local", "share", "fnm"),
497
+ sub: ["node-versions"],
498
+ bin: "installation/bin",
499
+ },
500
+ { dir: path.join(home, "Library", "Application Support", "fnm"), sub: ["node-versions"], bin: "installation/bin" },
501
+ { dir: path.join(home, ".volta", "tools", "image", "node"), sub: [], bin: "bin" },
502
+ { dir: process.env["ASDF_DATA_DIR"] ?? path.join(home, ".asdf"), sub: ["installs", "nodejs"], bin: "bin" },
503
+ ]
504
+ const found = []
505
+ for (const root of roots) {
506
+ const parent = path.join(root.dir, ...root.sub)
507
+ for (const entry of read(parent)) {
508
+ if (!entry.isDirectory()) continue
509
+ const bin = path.join(parent, entry.name, root.bin)
510
+ if (existsSync(path.join(bin, "node"))) found.push(bin)
511
+ }
512
+ }
513
+ for (const prefix of ["/opt/homebrew/opt", "/usr/local/opt"]) {
514
+ for (const entry of read(prefix)) {
515
+ if (!/^node(@\d+)?$/.test(entry.name)) continue
516
+ const bin = path.join(prefix, entry.name, "bin")
517
+ if (existsSync(path.join(bin, "node"))) found.push(bin)
518
+ }
519
+ }
520
+ return found
521
+ }
522
+
523
+ /**
524
+ * A Node that satisfies every range: the one already on PATH when it does, otherwise the newest
525
+ * install a version manager or Homebrew has. `bin` is the directory to put first on PATH.
526
+ */
527
+ export async function resolveNode(ranges, env) {
528
+ const current = whichIn(pathEnv(env), "node")
529
+ const currentVersion = current ? parseVersion(await capture(current, ["--version"])) : undefined
530
+ const ok = (version) => ranges.every((range) => satisfies(version, range))
531
+ if (currentVersion && ok(currentVersion)) return { version: currentVersion }
532
+
533
+ const candidates = []
534
+ for (const bin of nodeInstalls()) {
535
+ const version =
536
+ parseVersion(path.basename(path.dirname(bin.replace(/\/installation\/bin$/, "")))) ??
537
+ parseVersion(await capture(path.join(bin, "node"), ["--version"]))
538
+ if (version && ok(version)) candidates.push({ bin, version })
539
+ }
540
+ candidates.sort((a, b) => compare(b.version, a.version))
541
+ const best = candidates[0]
542
+ const needs = ranges.join(" and ")
543
+ const have = currentVersion ? `Node ${formatVersion(currentVersion)} on PATH` : "no Node on PATH"
544
+ if (best)
545
+ return {
546
+ bin: best.bin,
547
+ version: best.version,
548
+ note: `Using Node ${formatVersion(best.version)} from ${best.bin} (${have} does not satisfy ${needs})`,
549
+ }
550
+ return {
551
+ problem: `This project needs Node ${needs}, but there is ${have} and no newer Node installed. Install one (for example \`nvm install 22\`) and try again.`,
552
+ }
553
+ }
554
+
555
+ // ── Metro ─────────────────────────────────────────────────────────────────────
556
+
557
+ export function metroPort() {
558
+ const value = Number(process.env["RCT_METRO_PORT"])
559
+ return Number.isInteger(value) && value > 0 ? value : METRO_PORT
560
+ }
561
+
562
+ export function metroUrl(port = metroPort()) {
563
+ return `http://localhost:${port}`
564
+ }
565
+
566
+ /** Metro (React Native CLI and Expo alike) answers /status with `packager-status:running` once ready. */
567
+ export async function metroRunning(port = metroPort()) {
568
+ try {
569
+ const response = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1500) })
570
+ if (!response.ok) return false
571
+ return (await response.text()).includes("packager-status:running")
572
+ } catch {
573
+ return false
574
+ }
575
+ }
576
+
577
+ /** Process listening on a loopback port and its working directory, via lsof (POSIX only). */
578
+ export async function portOwner(port) {
579
+ if (process.platform === "win32") return undefined
580
+ const pid = Number(
581
+ (await capture("lsof", ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"]))
582
+ .split("\n")
583
+ .map((line) => line.trim())
584
+ .find((line) => /^\d+$/.test(line)),
585
+ )
586
+ if (!Number.isInteger(pid) || pid <= 0) return undefined
587
+ const cwd = parseLsofCwd(await capture("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]))
588
+ return { pid, cwd }
589
+ }
590
+
591
+ /** `lsof -Fn` prints one field per line: `p<pid>`, `fcwd`, `n<path>`. */
592
+ export function parseLsofCwd(output) {
593
+ return output
594
+ .split("\n")
595
+ .find((line) => line.startsWith("n/"))
596
+ ?.slice(1)
597
+ }
598
+
599
+ export function bundlerCommand(framework, port = metroPort()) {
600
+ if (framework === "expo") return { command: "npx", args: ["expo", "start", "--dev-client", "--port", String(port)] }
601
+ return { command: "npx", args: ["react-native", "start", "--port", String(port)] }
602
+ }
603
+
604
+ // ── iOS ───────────────────────────────────────────────────────────────────────
605
+
606
+ /** Resolve the scheme, built product and bundle id for an Xcode project. */
607
+ export async function iosTarget(directory) {
608
+ const entries = read(directory)
609
+ const workspace = entries.find((entry) => entry.name.endsWith(".xcworkspace"))
610
+ const project = entries.find((entry) => entry.name.endsWith(".xcodeproj"))
611
+ const container = workspace
612
+ ? ["-workspace", path.join(directory, workspace.name)]
613
+ : project
614
+ ? ["-project", path.join(directory, project.name)]
615
+ : undefined
616
+ if (!container) return "No Xcode project or workspace found."
617
+
618
+ const listed = await capture("xcodebuild", [...container, "-list", "-json"], { cwd: directory })
619
+ const schemes = parseJson(listed)
620
+ const scheme = pickScheme(
621
+ schemes?.project?.schemes ?? schemes?.workspace?.schemes ?? [],
622
+ (workspace ?? project).name,
623
+ )
624
+ if (!scheme) return "No shared scheme found in the Xcode project."
625
+
626
+ const settingsOutput = await capture(
627
+ "xcodebuild",
628
+ [
629
+ ...container,
630
+ "-scheme",
631
+ scheme,
632
+ "-sdk",
633
+ "iphonesimulator",
634
+ "-configuration",
635
+ "Debug",
636
+ "-showBuildSettings",
637
+ "-json",
638
+ ],
639
+ { cwd: directory },
640
+ )
641
+ const settings = parseJson(settingsOutput)?.[0]?.buildSettings
642
+ const bundleID = settings?.["PRODUCT_BUNDLE_IDENTIFIER"]
643
+ const products = settings?.["BUILT_PRODUCTS_DIR"]
644
+ const product = settings?.["FULL_PRODUCT_NAME"]
645
+ if (!bundleID || !products || !product) return "Could not read the Xcode build settings for this scheme."
646
+ return { container, scheme, bundleID, app: path.join(products, product) }
647
+ }
648
+
649
+ // CocoaPods workspaces list Pods-* schemes next to the app: prefer the one named like the container.
650
+ function pickScheme(schemes, container) {
651
+ const name = container.replace(/\.(xcworkspace|xcodeproj)$/, "")
652
+ return (
653
+ schemes.find((scheme) => scheme === name) ??
654
+ schemes.find((scheme) => !scheme.startsWith("Pods") && !scheme.includes("Tests")) ??
655
+ schemes[0]
656
+ )
657
+ }
658
+
659
+ export function iosBuildArgs(target, udid) {
660
+ return [
661
+ ...target.container,
662
+ "-scheme",
663
+ target.scheme,
664
+ "-configuration",
665
+ "Debug",
666
+ "-destination",
667
+ `platform=iOS Simulator,id=${udid}`,
668
+ "-sdk",
669
+ "iphonesimulator",
670
+ "build",
671
+ ]
672
+ }
673
+
674
+ /** UDID of the booted simulator, if any. */
675
+ export async function bootedSimulator() {
676
+ const output = await capture("xcrun", ["simctl", "list", "devices", "booted", "-j"])
677
+ const parsed = parseJson(output)
678
+ for (const devices of Object.values(parsed?.devices ?? {})) {
679
+ const booted = devices.find((device) => device.state === "Booted")
680
+ if (booted) return booted.udid
681
+ }
682
+ return undefined
683
+ }
684
+
685
+ // ── Android ───────────────────────────────────────────────────────────────────
686
+
687
+ export function androidSdk() {
688
+ const home = os.homedir()
689
+ const candidates = [
690
+ process.env["ANDROID_HOME"],
691
+ process.env["ANDROID_SDK_ROOT"],
692
+ // Windows installs (Android Studio default, and the common C:\Android\Sdk).
693
+ path.join(home, "AppData", "Local", "Android", "Sdk"),
694
+ path.join(process.env["LOCALAPPDATA"] ?? path.join(home, "AppData", "Local"), "Android", "Sdk"),
695
+ "C:\\Android\\Sdk",
696
+ // macOS installs.
697
+ path.join(home, "Library", "Android", "sdk"),
698
+ path.join(home, "Android", "Sdk"),
699
+ ]
700
+ return candidates.find((candidate) => !!candidate && existsSync(candidate))
701
+ }
702
+
703
+ export function adb() {
704
+ const sdk = androidSdk()
705
+ const bundled = sdk && path.join(sdk, "platform-tools", "adb")
706
+ const exe = bundled && existsSync(bundled) ? bundled : bundled && existsSync(`${bundled}.exe`) ? `${bundled}.exe` : undefined
707
+ return exe ?? "adb"
708
+ }
709
+
710
+ /** SDK emulator binary (emulator.exe on Windows), or undefined. */
711
+ export function emulatorBinary() {
712
+ const sdk = androidSdk()
713
+ if (!sdk) return undefined
714
+ const candidate = path.join(sdk, "emulator", process.platform === "win32" ? "emulator.exe" : "emulator")
715
+ return existsSync(candidate) ? candidate : undefined
716
+ }
717
+
718
+ /** Names of configured AVDs (`emulator -list-avds`), empty on any failure. */
719
+ export async function androidAvds() {
720
+ const emulator = emulatorBinary()
721
+ if (!emulator) return []
722
+ const output = await capture(emulator, ["-list-avds"])
723
+ return output
724
+ .split(/\r?\n/)
725
+ .map((line) => line.trim())
726
+ .filter((line) => line && !/^INFO|^WARN/i.test(line))
727
+ }
728
+
729
+ /** True once the serial has finished booting (sys.boot_completed == 1). */
730
+ export async function androidBooted(serial) {
731
+ const output = await capture(adb(), ["-s", serial, "shell", "getprop", "sys.boot_completed"])
732
+ return output.trim() === "1"
733
+ }
734
+
735
+ function aapt2() {
736
+ const sdk = androidSdk()
737
+ if (!sdk) return undefined
738
+ const root = path.join(sdk, "build-tools")
739
+ const versions = read(root)
740
+ .filter((entry) => entry.isDirectory())
741
+ .map((entry) => entry.name)
742
+ .sort()
743
+ .reverse()
744
+ for (const version of versions) {
745
+ const candidate = path.join(root, version, process.platform === "win32" ? "aapt2.exe" : "aapt2")
746
+ if (existsSync(candidate)) return candidate
747
+ }
748
+ return undefined
749
+ }
750
+
751
+ /** Serial of the first attached device, if any. */
752
+ export async function androidDevice() {
753
+ const output = await capture(adb(), ["devices"])
754
+ return output
755
+ .split("\n")
756
+ .slice(1)
757
+ .map((line) => line.split("\t"))
758
+ .find((parts) => parts[1]?.trim() === "device")?.[0]
759
+ }
760
+
761
+ /** Primary CPU ABI of a device, e.g. `arm64-v8a`. */
762
+ export async function androidAbi(serial) {
763
+ const output = await capture(adb(), ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"])
764
+ const abi = output.trim()
765
+ return /^[a-z0-9_-]+$/i.test(abi) ? abi : undefined
766
+ }
767
+
768
+ /** Free space on the device's data partition in megabytes, when `df` reports it. */
769
+ export async function androidFreeMb(serial) {
770
+ return parseFreeMb(await capture(adb(), ["-s", serial, "shell", "df", "-k", "/data"]))
771
+ }
772
+
773
+ /** Second line of `df -k`: Filesystem 1K-blocks Used Available Use% Mounted. */
774
+ export function parseFreeMb(output) {
775
+ const row = output.trim().split("\n")[1]?.trim().split(/\s+/)
776
+ const available = Number(row?.[3])
777
+ return Number.isFinite(available) && available >= 0 ? Math.round(available / 1024) : undefined
778
+ }
779
+
780
+ /** The reason adb gives for a failed install, e.g. `INSTALL_FAILED_INSUFFICIENT_STORAGE`. */
781
+ export function installFailure(log) {
782
+ for (const line of [...log].reverse()) {
783
+ const match = /Failure \[([^\]]+)\]/.exec(line)
784
+ if (match) return match[1]
785
+ }
786
+ return undefined
787
+ }
788
+
789
+ /**
790
+ * React Native's Gradle plugin builds every ABI listed in `reactNativeArchitectures`. A debug
791
+ * build for one known device only needs its own, which cuts build time and the APK by about 3x.
792
+ */
793
+ export function reactNativeArchitectureArgs(abi) {
794
+ return abi ? [`-PreactNativeArchitectures=${abi}`] : []
795
+ }
796
+
797
+ export function gradleWrapper(directory) {
798
+ const wrapper = path.join(directory, process.platform === "win32" ? "gradlew.bat" : "gradlew")
799
+ return existsSync(wrapper) ? wrapper : undefined
800
+ }
801
+
802
+ /** Application module name from settings.gradle, defaulting to `app`. */
803
+ export function androidModule(directory) {
804
+ for (const name of ["settings.gradle", "settings.gradle.kts"]) {
805
+ const file = path.join(directory, name)
806
+ if (!existsSync(file)) continue
807
+ const includes = [...readFileSync(file, "utf8").matchAll(/include\s*\(?\s*["']:?([A-Za-z0-9_\-.]+)["']/g)].map(
808
+ (match) => match[1],
809
+ )
810
+ if (includes.includes("app")) return "app"
811
+ const first = includes[0]
812
+ if (first) return first
813
+ }
814
+ return "app"
815
+ }
816
+
817
+ export function androidApk(directory, module) {
818
+ const outputs = path.join(directory, module, "build", "outputs", "apk", "debug")
819
+ const apk = read(outputs)
820
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".apk"))
821
+ .sort((a, b) => a.name.length - b.name.length)[0]
822
+ return apk ? path.join(outputs, apk.name) : undefined
823
+ }
824
+
825
+ /** Package name and launch activity, read from the built APK when possible. */
826
+ export async function androidApp(apk, directory, module) {
827
+ const tool = aapt2()
828
+ if (tool) {
829
+ const badging = await capture(tool, ["dump", "badging", apk])
830
+ const id = /package: name='([^']+)'/.exec(badging)?.[1]
831
+ const activity = /launchable-activity: name='([^']+)'/.exec(badging)?.[1]
832
+ if (id) return { id, activity }
833
+ }
834
+ // Fallback for SDKs without build-tools: read the Gradle config directly.
835
+ for (const name of ["build.gradle", "build.gradle.kts"]) {
836
+ const file = path.join(directory, module, name)
837
+ if (!existsSync(file)) continue
838
+ const source = readFileSync(file, "utf8")
839
+ const id = /applicationId\s*=?\s*["']([^"']+)["']/.exec(source)?.[1]
840
+ if (!id) continue
841
+ const suffix = /applicationIdSuffix\s*=?\s*["']([^"']+)["']/.exec(source)?.[1] ?? ""
842
+ return { id: id + suffix }
843
+ }
844
+ return undefined
845
+ }
846
+
847
+ function parseJson(value) {
848
+ if (!value.trim()) return undefined
849
+ try {
850
+ return JSON.parse(value)
851
+ } catch {
852
+ return undefined
853
+ }
854
+ }
855
+
856
+ // ── agent observability (screen / logs / ocr) ────────────────────────────────
857
+
858
+ /** Serial of the first attached device, or undefined. Same as androidDevice() but exported for tools. */
859
+
860
+ /** Attached devices as [{serial, state}], state filtering optional. */
861
+ export async function devices(serial) {
862
+ const output = await capture(adb(), ["devices"])
863
+ const rows = output
864
+ .split("\n")
865
+ .slice(1)
866
+ .map((line) => line.split("\t"))
867
+ .filter((parts) => parts[0] && parts[0] !== "List")
868
+ .map((parts) => ({ serial: parts[0], state: (parts[1] ?? "").trim() || "unknown" }))
869
+ return serial ? rows.filter((row) => row.serial === serial) : rows
870
+ }
871
+
872
+ /** Local path of a fresh screenshot of the serial. undefined on failure. */
873
+ export async function screenCapture(serial, outDir) {
874
+ const remote = "/sdcard/dsh-mobilecode-shot.png"
875
+ await exec(adb(), ["-s", serial, "shell", "screencap", "-p", remote]).exit
876
+ const name = `screen-${serial}-${Date.now()}.png`
877
+ const local = path.join(outDir ?? os.tmpdir(), name)
878
+ const pulled = await new Promise((resolve) => {
879
+ const child = launch(adb(), ["-s", serial, "pull", remote, local], {
880
+ stdio: ["ignore", "ignore", "ignore"],
881
+ windowsHide: true,
882
+ })
883
+ child.once("error", () => resolve(false))
884
+ child.once("close", (code) => resolve(code === 0))
885
+ })
886
+ return pulled && existsSync(local) ? local : undefined
887
+ }
888
+
889
+ /**
890
+ * The current view hierarchy as a compact list: [{text, resourceId, bounds}],
891
+ * skipping empty containers. Uses uiautomator dump (works on any app that
892
+ * exposes accessibility — the same data the logcat plugin's ui_dump reads).
893
+ */
894
+ export async function uiDump(serial) {
895
+ const remote = "/sdcard/dsh-mobilecode-ui.xml"
896
+ await exec(adb(), ["-s", serial, "shell", "uiautomator", "dump", remote]).exit
897
+ const xml = await capture(adb(), ["-s", serial, "shell", "cat", remote])
898
+ const items = []
899
+ const node = /<node[^>]*>/g
900
+ for (const match of xml.match(node) ?? []) {
901
+ const attr = (name) => new RegExp(`${name}="([^"]*)"`).exec(match)?.[1] ?? ""
902
+ const text = attr("text").replace(/&quot;/g, '"').replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
903
+ const desc = attr("content-desc").replace(/&quot;/g, '"').replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
904
+ const label = text || desc
905
+ if (!label) continue
906
+ const resourceId = attr("resource-id")
907
+ const bounds = attr("bounds")
908
+ const item = { text: label }
909
+ if (resourceId && resourceId !== "") item.resourceId = resourceId
910
+ const box = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds)
911
+ if (box) item.bounds = [Number(box[1]), Number(box[2]), Number(box[3]), Number(box[4])]
912
+ items.push(item)
913
+ }
914
+ return items
915
+ }
916
+
917
+ /** Foreground activity, e.g. "com.foo/.MainActivity", or undefined. */
918
+ export async function foregroundActivity(serial) {
919
+ const output = await capture(adb(), ["-s", serial, "shell", "dumpsys", "activity", "activities"])
920
+ const line = output.split("\n").find((item) => /topResumedActivity|mResumedActivity/.test(item))
921
+ const match = /ActivityRecord\{[^}]*\s([^\s}]+)\}/.exec(line ?? "")
922
+ return match?.[1] ?? undefined
923
+ }
924
+
925
+ /** Kernel log (dmesg). Requires adb root — works on emulators, usually not on real devices. */
926
+ export async function dmesg(serial) {
927
+ return capture(adb(), ["-s", serial, "shell", "dmesg"])
928
+ }
929
+
930
+ /** logcat snapshot, filtered by buffer/level/package-like substring. */
931
+ export async function logcat(serial, { buffer = "main", lines = 200, filter } = {}) {
932
+ const args = ["-s", serial, "logcat", "-d", "-t", String(lines)]
933
+ if (buffer && buffer !== "all") args.push("-b", buffer)
934
+ let output = await capture(adb(), args)
935
+ if (filter) output = output.split("\n").filter((line) => line.toLowerCase().includes(filter.toLowerCase())).join("\n")
936
+ return output
937
+ }
938
+
939
+ /** Path to the PaddleOCR venv python, or undefined. Env override wins, then the shared ~/.dsh location. */
940
+ export function ocrPython() {
941
+ const override = process.env["DSH_MOBILECODE_OCR_PY"]
942
+ if (override && existsSync(override)) return override
943
+ const home = path.join(os.homedir(), ".dsh", "mobilecode", "ocr-venv", "Scripts", "python.exe")
944
+ return existsSync(home) ? home : undefined
945
+ }
946
+
947
+ /** OCR one image with PaddleOCR: [{text, confidence, box:[x1,y1,x2,y2]}]. Empty on failure. */
948
+ export async function ocrImage(pngPath, lang = "ch") {
949
+ const python = ocrPython()
950
+ if (!python) return []
951
+ const script = fileURLToPath(new URL("../scripts/ocr.py", import.meta.url))
952
+ if (!existsSync(script)) return []
953
+ const output = await capture(python, [script, pngPath, lang])
954
+ // PaddleOCR may interleave progress bars with the JSON on stdout; take the last JSON line.
955
+ const lines = output.split(/\r?\n/).reverse()
956
+ for (const line of lines) {
957
+ const trimmed = line.trim()
958
+ if (!trimmed.startsWith("{")) continue
959
+ const parsed = parseJson(trimmed)
960
+ if (Array.isArray(parsed?.items)) return parsed.items
961
+ break
962
+ }
963
+ return []
964
+ }