From cdfd6c395eef0831f2a1fd22925ce064fe543c34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 23:38:11 -0700 Subject: [PATCH 1/8] fix(mobile): ensure a compatible native client before verification --- .agents/skills/ios-debugger-agent/SKILL.md | 2 + .agents/skills/test-t3-mobile/SKILL.md | 32 +-- AGENTS.md | 2 + apps/mobile/README.md | 17 +- scripts/mobile-native-client.test.ts | 84 ++++++ scripts/mobile-native-client.ts | 281 +++++++++++++++++++++ 6 files changed, 402 insertions(+), 16 deletions(-) create mode 100644 scripts/mobile-native-client.test.ts create mode 100644 scripts/mobile-native-client.ts diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 7afa579383d9..8d204ff201bc 100644 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -31,6 +31,8 @@ Avoid generic Mac window automation for switching among Simulator windows. Expli ## Choose build or launch +For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. + - Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. - Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. - Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 3fcf94334fd6..afebcb7aa7d5 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -15,24 +15,28 @@ Inspect the host and the affected code before launching processes: - On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. - On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. -- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification. +- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. -## Choose the lightest valid launch path +## Ensure a compatible native client -- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. -- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. -- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. -- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. +Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. -The development identity on both platforms is: +Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: -- App: `T3 Code Dev` -- Bundle/package identifier: `com.t3tools.t3code.dev` -- URL scheme: `t3code-dev` +```bash +node scripts/mobile-native-client.ts ensure ios +node scripts/mobile-native-client.ts ensure android +``` + +`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue. + +For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually. + +A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. -Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source. +The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” ## Start one disposable T3 environment @@ -98,10 +102,9 @@ Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session - Simulator ID: the selected UDID - Bundle ID: `com.t3tools.t3code.dev` -Check the installed client with: +After `ensure` succeeds, open the Metro URL: ```bash -xcrun simctl get_app_container com.t3tools.t3code.dev app xcrun simctl openurl ``` @@ -109,10 +112,9 @@ Accept the iOS confirmation prompt and dismiss the developer menu when it obscur ### Android launch -Select one running emulator serial from `adb devices` and check the installed client: +Use the emulator serial already checked by `ensure`: ```bash -adb -s shell pm path com.t3tools.t3code.dev adb -s reverse tcp: tcp: adb -s shell am start -W \ -a android.intent.action.VIEW \ diff --git a/AGENTS.md b/AGENTS.md index ccf1fdc1d85c..38df1e94fa8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. + ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a9a8177c4ece..20f98c6c9e34 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -22,7 +22,22 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See ## Development -Start Metro for the dev client: +For simulator/emulator development, select and boot a device, then ensure its native client matches +this checkout before starting Metro: + +```bash +node ../../scripts/mobile-native-client.ts ensure ios +# Or: node ../../scripts/mobile-native-client.ts ensure android +vp run dev:client +``` + +The helper compares a local Expo fingerprint and the installed binary with its last successful +build record. It builds and installs missing, stale, or unverified clients and reuses matching ones. +Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is +needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required. +An externally installed client is unverified until the helper builds it once. + +Start Metro for an already verified dev client: ```bash vp run dev:client diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts new file mode 100644 index 000000000000..df5d38541333 --- /dev/null +++ b/scripts/mobile-native-client.test.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests exercise local filesystem build records. +import { assert, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { + clientStatus, + ensureClient, + hashBundle, + type NativeClientRecord, +} from "./mobile-native-client.ts"; + +it("requires a build for absent, unrecorded, replaced, and stale clients", () => { + const record = { fingerprint: "native-a", binary: "binary-a" }; + assert.equal(clientStatus("native-a", null, record), "missing"); + assert.equal(clientStatus("native-a", "binary-a", null), "unknown"); + assert.equal(clientStatus("native-a", "binary-b", record), "unknown"); + assert.equal(clientStatus("native-b", "binary-a", record), "stale"); + assert.equal(clientStatus("native-a", "binary-a", record), "compatible"); +}); + +it("builds an unknown client once, then reuses it across JavaScript changes", async () => { + let record: NativeClientRecord | null = null; + let builds = 0; + const operations = { + fingerprint: async () => "native-a", + installedBinary: async () => "binary-a", + readRecord: async () => record, + build: async () => { + builds++; + }, + saveRecord: async (value: NativeClientRecord) => { + record = value; + }, + }; + assert.equal((await ensureClient(operations)).rebuilt, true); + assert.equal((await ensureClient(operations)).rebuilt, false); + assert.equal(builds, 1); + assert.deepEqual(record, { fingerprint: "native-a", binary: "binary-a" }); +}); + +it("never records failed builds, missing installations, or native inputs changed during a build", async () => { + for (const failure of ["build", "missing", "changed"] as const) { + let built = false; + let recorded = false; + await expect( + ensureClient({ + fingerprint: async () => (built && failure === "changed" ? "native-b" : "native-a"), + installedBinary: async () => null, + readRecord: async () => null, + build: async () => { + if (failure === "build") throw new Error("Compiler failed"); + built = true; + }, + saveRecord: async () => { + recorded = true; + }, + }), + ).rejects.toThrow(/Compiler failed|not installed|inputs changed/); + assert.equal(recorded, false); + } +}); + +it("detects native library and resource replacement independent of the install directory", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "native-client-test-")); + try { + const first = NodePath.join(root, "first.app"); + const second = NodePath.join(root, "second.app"); + for (const dir of [first, second]) { + await NodeFSP.mkdir(NodePath.join(dir, "Frameworks"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, "Frameworks/native.dylib"), "native-a"); + await NodeFSP.writeFile(NodePath.join(dir, "Info.plist"), "config-a"); + } + const baseline = await hashBundle(first); + assert.equal(await hashBundle(second), baseline); + await NodeFSP.writeFile(NodePath.join(second, "Frameworks/native.dylib"), "native-b"); + assert.notEqual(await hashBundle(second), baseline); + await NodeFSP.writeFile(NodePath.join(second, "Frameworks/native.dylib"), "native-a"); + await NodeFSP.writeFile(NodePath.join(second, "Info.plist"), "config-b"); + assert.notEqual(await hashBundle(second), baseline); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts new file mode 100644 index 000000000000..a776764f5d63 --- /dev/null +++ b/scripts/mobile-native-client.ts @@ -0,0 +1,281 @@ +// @effect-diagnostics nodeBuiltinImport:off - Local native build tooling runs outside the server runtime. +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +export type NativePlatform = "ios" | "android"; +export interface NativeClientRecord { + fingerprint: string; + binary: string; +} +export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale"; + +export function clientStatus( + fingerprint: string, + binary: string | null, + record: NativeClientRecord | null, +): NativeClientStatus { + if (binary === null) return "missing"; + if (!record || record.binary !== binary) return "unknown"; + return record.fingerprint === fingerprint ? "compatible" : "stale"; +} + +/** Record only a successful installation built from unchanged native inputs. */ +export async function ensureClient(operations: { + fingerprint: () => Promise; + installedBinary: () => Promise; + readRecord: () => Promise; + build: () => Promise; + saveRecord: (record: NativeClientRecord) => Promise; +}) { + const fingerprint = await operations.fingerprint(); + const status = clientStatus( + fingerprint, + await operations.installedBinary(), + await operations.readRecord(), + ); + if (status === "compatible") return { status, rebuilt: false, fingerprint }; + await operations.build(); + if ((await operations.fingerprint()) !== fingerprint) { + throw new Error( + "Native inputs changed during the build. Run ensure again; this build was not recorded.", + ); + } + const binary = await operations.installedBinary(); + if (binary === null) + throw new Error("Build finished but the development client is not installed."); + await operations.saveRecord({ fingerprint, binary }); + return { status: "compatible" as const, rebuilt: true, fingerprint }; +} + +/** Hash bundle contents, including native libraries and resources, not its install path or mtime. */ +export async function hashBundle(root: string): Promise { + const hash = NodeCrypto.createHash("sha256"); + async function visit(relative: string) { + const entries = await NodeFSP.readdir(NodePath.join(root, relative), { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name, "en")); + for (const entry of entries) { + const name = NodePath.join(relative, entry.name); + const absolute = NodePath.join(root, name); + hash.update(JSON.stringify([name, entry.isDirectory(), entry.isSymbolicLink()])); + if (entry.isDirectory()) await visit(name); + else if (entry.isSymbolicLink()) hash.update(await NodeFSP.readlink(absolute)); + else { + const fileHash = NodeCrypto.createHash("sha256"); + for await (const chunk of NodeFS.createReadStream(absolute)) fileHash.update(chunk); + hash.update(fileHash.digest()); + } + } + } + await visit(""); + return hash.digest("hex"); +} + +const repoRoot = NodeURL.fileURLToPath(new URL("../", import.meta.url)); +const mobileRoot = NodePath.join(repoRoot, "apps/mobile"); +const bundleId = "com.t3tools.t3code.dev"; + +function command(program: string, args: string[], inherit = false): string { + const result = NodeChildProcess.spawnSync(program, args, { + cwd: mobileRoot, + env: { + ...process.env, + APP_VARIANT: "development", + MOBILE_VERSION_POLICY: "appVersion", + T3CODE_IOS_PERSONAL_TEAM: "0", + CI: "1", + EXPO_NO_GIT_STATUS: "1", + }, + encoding: "utf8", + stdio: inherit ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"], + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${program} ${args[0]} failed (${result.status}): ${result.stderr ?? "see build output"}`, + ); + } + return result.stdout?.trim() ?? ""; +} + +async function fingerprint(platform: NativePlatform) { + const output = command(process.execPath, [ + "--eval", + `require('expo/fingerprint').createFingerprintAsync(process.cwd(), { platforms: [process.argv[1]], silent: true }).then(fp => console.log('T3_NATIVE_FINGERPRINT=' + fp.hash)).catch(e => { console.error(e); process.exitCode = 1; });`, + platform, + ]); + const hash = output + .split("\n") + .find((line) => line.startsWith("T3_NATIVE_FINGERPRINT=")) + ?.split("=")[1]; + if (!hash || !/^[a-f0-9]{40,64}$/.test(hash)) + throw new Error("Expo did not return a native fingerprint."); + return hash; +} + +function validateDevice(platform: NativePlatform, device: string) { + if (platform === "ios") { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone host build CLI, outside the Effect runtime. + if (NodeOS.platform() !== "darwin") + throw new Error("Run iOS check/ensure on the Mac that hosts the simulator."); + const listing: { devices: Record } = JSON.parse( + command("xcrun", ["simctl", "list", "devices", "available", "--json"]), + ); + const simulator = Object.values(listing.devices) + .flat() + .find((entry) => entry.udid === device); + if (!simulator) throw new Error(`No available iOS simulator with UDID ${device}.`); + if (simulator.state !== "Booted") + throw new Error(`Boot the selected simulator first: xcrun simctl boot ${device}`); + } else { + if (command("adb", ["-s", device, "get-state"]) !== "device") + throw new Error("Android device is not connected."); + if (command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"]) !== "1") { + throw new Error("Select an Android emulator, not a physical device."); + } + } +} + +async function installedBinary(platform: NativePlatform, device: string) { + if (platform === "ios") { + // Listing apps distinguishes an absent app from a failed simctl command. + const apps = command("xcrun", ["simctl", "listapps", device]); + if (!apps.includes(`"${bundleId}"`)) return null; + return hashBundle(command("xcrun", ["simctl", "get_app_container", device, bundleId, "app"])); + } + const installed = command("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); + if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null; + const packages = command("adb", ["-s", device, "shell", "pm", "path", bundleId]); + const apks = packages + .split("\n") + .filter((line) => line.startsWith("package:")) + .map((line) => line.slice(8).trim()) + .sort(); + if (apks.length === 0) return null; + const hashes = apks.map((apk) => { + if (!/^\/[\w/+=.-]+\.apk$/.test(apk)) throw new Error("Unexpected installed APK path."); + const hash = command("adb", ["-s", device, "shell", "sha256sum", apk]).split(/\s/)[0]; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("Could not hash installed APK."); + return hash; + }); + return NodeCrypto.createHash("sha256").update(hashes.sort().join("\n")).digest("hex"); +} + +async function main() { + const [mode, platform, device, ...extra] = process.argv.slice(2); + if ( + (mode !== "check" && mode !== "ensure") || + (platform !== "ios" && platform !== "android") || + !device || + extra.length + ) { + throw new Error( + "Usage: node scripts/mobile-native-client.ts ", + ); + } + validateDevice(platform, device); + const recordPath = NodePath.join( + NodeOS.homedir(), + ".cache/t3code/native-clients", + platform, + `${NodeCrypto.createHash("sha256").update(device).digest("hex")}.json`, + ); + const operations = { + fingerprint: () => fingerprint(platform), + installedBinary: () => installedBinary(platform, device), + readRecord: async (): Promise => { + try { + const record: unknown = JSON.parse(await NodeFSP.readFile(recordPath, "utf8")); + return record !== null && + typeof record === "object" && + "fingerprint" in record && + "binary" in record && + typeof record.fingerprint === "string" && + typeof record.binary === "string" + ? { fingerprint: record.fingerprint, binary: record.binary } + : null; + } catch (error) { + if (error instanceof SyntaxError || (error as NodeJS.ErrnoException).code === "ENOENT") + return null; + throw error; + } + }, + build: async () => { + console.error( + "Native client is missing, stale, or unverified. Building and installing a development client...", + ); + const tracked = NodeChildProcess.execFileSync( + "git", + ["ls-files", `apps/mobile/${platform}`], + { + cwd: repoRoot, + encoding: "utf8", + }, + ); + if (tracked.trim()) + throw new Error( + "Native directory contains tracked files; clean prebuild would overwrite them.", + ); + command( + "vp", + ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"], + true, + ); + command( + "vp", + [ + "exec", + "expo", + `run:${platform}`, + "--device", + device, + "--no-bundler", + ...(platform === "ios" + ? ["--configuration", "Debug", "--scheme", "T3CodeDev"] + : ["--variant", "debug"]), + ], + true, + ); + }, + saveRecord: async (record: NativeClientRecord) => { + await NodeFSP.mkdir(NodePath.dirname(recordPath), { recursive: true }); + await NodeFSP.writeFile(recordPath, JSON.stringify(record) + "\n"); + }, + }; + if (mode === "ensure") { + console.log(JSON.stringify(await ensureClient(operations))); + } else { + const current = await operations.fingerprint(); + const status = clientStatus( + current, + await operations.installedBinary(), + await operations.readRecord(), + ); + console.log( + JSON.stringify({ + status, + fingerprint: current, + next: + status === "compatible" + ? "Start Metro with vp run dev:client" + : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`, + }), + ); + process.exitCode = status === "compatible" ? 0 : 2; + } +} + +if ( + process.argv[1] && + NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} From 162c6294b3b24ef800829391599e36a256be259c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 23:40:42 -0700 Subject: [PATCH 2/8] fix(mobile): write native checker output directly --- scripts/mobile-native-client.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index a776764f5d63..84e8bd556c0d 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -206,8 +206,8 @@ async function main() { } }, build: async () => { - console.error( - "Native client is missing, stale, or unverified. Building and installing a development client...", + process.stderr.write( + "Native client is missing, stale, or unverified. Building and installing a development client...\n", ); const tracked = NodeChildProcess.execFileSync( "git", @@ -248,7 +248,7 @@ async function main() { }, }; if (mode === "ensure") { - console.log(JSON.stringify(await ensureClient(operations))); + process.stdout.write(JSON.stringify(await ensureClient(operations)) + "\n"); } else { const current = await operations.fingerprint(); const status = clientStatus( @@ -256,7 +256,7 @@ async function main() { await operations.installedBinary(), await operations.readRecord(), ); - console.log( + process.stdout.write( JSON.stringify({ status, fingerprint: current, @@ -264,7 +264,7 @@ async function main() { status === "compatible" ? "Start Metro with vp run dev:client" : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`, - }), + }) + "\n", ); process.exitCode = status === "compatible" ? 0 : 2; } @@ -275,7 +275,7 @@ if ( NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) ) { main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); + process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n"); process.exitCode = 1; }); } From 5deebeb38e9fed7fdbd919ab0b796fd6e600eadf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 23:59:46 -0700 Subject: [PATCH 3/8] fix(mobile): recognize Android randomized APK paths --- scripts/mobile-native-client.test.ts | 16 ++++++++++++++++ scripts/mobile-native-client.ts | 14 +++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index df5d38541333..b16e3e116c51 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -7,6 +7,7 @@ import { clientStatus, ensureClient, hashBundle, + installedBinary, type NativeClientRecord, } from "./mobile-native-client.ts"; @@ -82,3 +83,18 @@ it("detects native library and resource replacement independent of the install d await NodeFSP.rm(root, { recursive: true, force: true }); } }); + +it("recognizes Android APK installs with randomized tilde paths and rejects failed hash reads", async () => { + let hashOutput = "a".repeat(64) + " /data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; + const run = (_program: string, args: string[]) => { + if (args.includes("list")) return "package:com.t3tools.t3code.dev"; + if (args.includes("path")) + return "package:/data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; + return hashOutput; + }; + const binary = await installedBinary("android", "emulator-5554", run); + assert.match(binary!, /^[a-f0-9]{64}$/); + hashOutput = "sha256sum: read error"; + await expect(installedBinary("android", "emulator-5554", run)).rejects.toThrow("Could not hash"); + assert.equal(await installedBinary("android", "emulator-5554", () => ""), null); +}); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 84e8bd556c0d..b8e7aad23a6b 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -141,16 +141,16 @@ function validateDevice(platform: NativePlatform, device: string) { } } -async function installedBinary(platform: NativePlatform, device: string) { +export async function installedBinary(platform: NativePlatform, device: string, run = command) { if (platform === "ios") { // Listing apps distinguishes an absent app from a failed simctl command. - const apps = command("xcrun", ["simctl", "listapps", device]); + const apps = run("xcrun", ["simctl", "listapps", device]); if (!apps.includes(`"${bundleId}"`)) return null; - return hashBundle(command("xcrun", ["simctl", "get_app_container", device, bundleId, "app"])); + return hashBundle(run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"])); } - const installed = command("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); + const installed = run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null; - const packages = command("adb", ["-s", device, "shell", "pm", "path", bundleId]); + const packages = run("adb", ["-s", device, "shell", "pm", "path", bundleId]); const apks = packages .split("\n") .filter((line) => line.startsWith("package:")) @@ -158,8 +158,8 @@ async function installedBinary(platform: NativePlatform, device: string) { .sort(); if (apks.length === 0) return null; const hashes = apks.map((apk) => { - if (!/^\/[\w/+=.-]+\.apk$/.test(apk)) throw new Error("Unexpected installed APK path."); - const hash = command("adb", ["-s", device, "shell", "sha256sum", apk]).split(/\s/)[0]; + if (!/^\/[\w/+=.~-]+\.apk$/.test(apk)) throw new Error("Unexpected installed APK path."); + const hash = run("adb", ["-s", device, "shell", "sha256sum", apk]).split(/\s/)[0]; if (!hash || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("Could not hash installed APK."); return hash; }); From 0014633ef7a257096dc419646bdd11e33a23adf2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:14:09 -0700 Subject: [PATCH 4/8] refactor(mobile): use Effect for native client tooling --- scripts/mobile-native-client.test.ts | 196 ++++++---- scripts/mobile-native-client.ts | 556 ++++++++++++++++----------- 2 files changed, 456 insertions(+), 296 deletions(-) diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index b16e3e116c51..b8fa8fd3bc25 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -1,9 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off - Tests exercise local filesystem build records. -import { assert, expect, it } from "@effect/vitest"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { + NativeClientError, clientStatus, ensureClient, hashBundle, @@ -20,81 +21,116 @@ it("requires a build for absent, unrecorded, replaced, and stale clients", () => assert.equal(clientStatus("native-a", "binary-a", record), "compatible"); }); -it("builds an unknown client once, then reuses it across JavaScript changes", async () => { - let record: NativeClientRecord | null = null; - let builds = 0; - const operations = { - fingerprint: async () => "native-a", - installedBinary: async () => "binary-a", - readRecord: async () => record, - build: async () => { - builds++; - }, - saveRecord: async (value: NativeClientRecord) => { - record = value; - }, - }; - assert.equal((await ensureClient(operations)).rebuilt, true); - assert.equal((await ensureClient(operations)).rebuilt, false); - assert.equal(builds, 1); - assert.deepEqual(record, { fingerprint: "native-a", binary: "binary-a" }); -}); - -it("never records failed builds, missing installations, or native inputs changed during a build", async () => { - for (const failure of ["build", "missing", "changed"] as const) { - let built = false; - let recorded = false; - await expect( - ensureClient({ - fingerprint: async () => (built && failure === "changed" ? "native-b" : "native-a"), - installedBinary: async () => null, - readRecord: async () => null, - build: async () => { - if (failure === "build") throw new Error("Compiler failed"); - built = true; - }, - saveRecord: async () => { - recorded = true; - }, +it.effect("builds an unknown client once, then reuses it across JavaScript changes", () => + Effect.gen(function* () { + let record: NativeClientRecord | null = null; + let builds = 0; + const operations = { + fingerprint: Effect.succeed("native-a"), + installedBinary: Effect.succeed("binary-a"), + readRecord: Effect.sync(() => record), + build: Effect.sync(() => { + builds++; }), - ).rejects.toThrow(/Compiler failed|not installed|inputs changed/); - assert.equal(recorded, false); - } -}); + saveRecord: (value: NativeClientRecord) => + Effect.sync(() => { + record = value; + }), + }; + assert.equal((yield* ensureClient(operations)).rebuilt, true); + assert.equal((yield* ensureClient(operations)).rebuilt, false); + assert.equal(builds, 1); + assert.deepEqual(record, { fingerprint: "native-a", binary: "binary-a" }); + }), +); -it("detects native library and resource replacement independent of the install directory", async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "native-client-test-")); - try { - const first = NodePath.join(root, "first.app"); - const second = NodePath.join(root, "second.app"); - for (const dir of [first, second]) { - await NodeFSP.mkdir(NodePath.join(dir, "Frameworks"), { recursive: true }); - await NodeFSP.writeFile(NodePath.join(dir, "Frameworks/native.dylib"), "native-a"); - await NodeFSP.writeFile(NodePath.join(dir, "Info.plist"), "config-a"); - } - const baseline = await hashBundle(first); - assert.equal(await hashBundle(second), baseline); - await NodeFSP.writeFile(NodePath.join(second, "Frameworks/native.dylib"), "native-b"); - assert.notEqual(await hashBundle(second), baseline); - await NodeFSP.writeFile(NodePath.join(second, "Frameworks/native.dylib"), "native-a"); - await NodeFSP.writeFile(NodePath.join(second, "Info.plist"), "config-b"); - assert.notEqual(await hashBundle(second), baseline); - } finally { - await NodeFSP.rm(root, { recursive: true, force: true }); - } -}); +it.effect( + "never records failed builds, missing installations, or native inputs changed during a build", + () => + Effect.gen(function* () { + for (const failure of ["build", "missing", "changed"] as const) { + let built = false; + let recorded = false; + const result = yield* ensureClient({ + fingerprint: Effect.sync(() => + built && failure === "changed" ? "native-b" : "native-a", + ), + installedBinary: Effect.succeed(null), + readRecord: Effect.succeed(null), + build: Effect.gen(function* () { + if (failure === "build") + return yield* new NativeClientError({ message: "Compiler failed" }); + built = true; + }), + saveRecord: () => + Effect.sync(() => { + recorded = true; + }), + }).pipe(Effect.flip); + assert.match(result.message, /Compiler failed|not installed|inputs changed/); + assert.equal(recorded, false); + } + }), +); -it("recognizes Android APK installs with randomized tilde paths and rejects failed hash reads", async () => { - let hashOutput = "a".repeat(64) + " /data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; - const run = (_program: string, args: string[]) => { - if (args.includes("list")) return "package:com.t3tools.t3code.dev"; - if (args.includes("path")) - return "package:/data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; - return hashOutput; - }; - const binary = await installedBinary("android", "emulator-5554", run); - assert.match(binary!, /^[a-f0-9]{64}$/); - hashOutput = "sha256sum: read error"; - await expect(installedBinary("android", "emulator-5554", run)).rejects.toThrow("Could not hash"); - assert.equal(await installedBinary("android", "emulator-5554", () => ""), null); -}); +it.effect( + "detects native library and resource replacement independent of the install directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "native-client-test-" }); + const first = path.join(root, "first.app"); + const second = path.join(root, "second.app"); + for (const dir of [first, second]) { + yield* fs.makeDirectory(path.join(dir, "Frameworks"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "Frameworks/native.dylib"), + "native-a".repeat(20000), + ); + yield* fs.writeFileString(path.join(dir, "Info.plist"), "config-a"); + yield* fs.symlink("Info.plist", path.join(dir, "config-link")); + } + const baseline = yield* hashBundle(first); + assert.equal(yield* hashBundle(second), baseline); + yield* fs.writeFileString(path.join(second, "Frameworks/native.dylib"), "native-b"); + assert.notEqual(yield* hashBundle(second), baseline); + yield* fs.writeFileString( + path.join(second, "Frameworks/native.dylib"), + "native-a".repeat(20000), + ); + yield* fs.writeFileString(path.join(second, "Info.plist"), "config-b"); + assert.notEqual(yield* hashBundle(second), baseline); + yield* fs.writeFileString(path.join(second, "Info.plist"), "config-a"); + assert.equal(yield* hashBundle(second), baseline); + yield* fs.remove(path.join(second, "config-link")); + yield* fs.symlink("Frameworks/native.dylib", path.join(second, "config-link")); + assert.notEqual(yield* hashBundle(second), baseline); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "recognizes Android APK installs with randomized tilde paths and rejects failed hash reads", + () => + Effect.gen(function* () { + let hashOutput = + "a".repeat(64) + " /data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; + const run = (_program: string, args: string[]) => { + if (args.includes("list")) return Effect.succeed("package:com.t3tools.t3code.dev"); + if (args.includes("path")) + return Effect.succeed( + "package:/data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk", + ); + return Effect.succeed(hashOutput); + }; + const binary = yield* installedBinary("android", "emulator-5554", run); + assert.match(binary!, /^[a-f0-9]{64}$/); + hashOutput = "sha256sum: read error"; + const error = yield* installedBinary("android", "emulator-5554", run).pipe(Effect.flip); + assert.match(error.message, /Could not hash/); + assert.equal( + yield* installedBinary("android", "emulator-5554", () => Effect.succeed("")), + null, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index b8e7aad23a6b..d2468f76d334 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -1,18 +1,42 @@ -// @effect-diagnostics nodeBuiltinImport:off - Local native build tooling runs outside the server runtime. -import * as NodeCrypto from "node:crypto"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Argument, Command } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export type NativePlatform = "ios" | "android"; -export interface NativeClientRecord { - fingerprint: string; - binary: string; -} +const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String }); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord)); +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord)); +const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") })); +const decodeSimulators = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + devices: Schema.Record( + Schema.String, + Schema.Array(Schema.Struct({ udid: Schema.String, state: Schema.String })), + ), + }), + ), +); +export type NativeClientRecord = typeof NativeClientRecord.Type; export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale"; +export class NativeClientError extends Schema.TaggedError()( + "NativeClientError", + { message: Schema.String }, +) {} export function clientStatus( fingerprint: string, @@ -25,86 +49,140 @@ export function clientStatus( } /** Record only a successful installation built from unchanged native inputs. */ -export async function ensureClient(operations: { - fingerprint: () => Promise; - installedBinary: () => Promise; - readRecord: () => Promise; - build: () => Promise; - saveRecord: (record: NativeClientRecord) => Promise; +export const ensureClient = Effect.fn("ensureClient")(function* (operations: { + fingerprint: Effect.Effect; + installedBinary: Effect.Effect; + readRecord: Effect.Effect; + build: Effect.Effect; + saveRecord: (record: NativeClientRecord) => Effect.Effect; }) { - const fingerprint = await operations.fingerprint(); + const fingerprint = yield* operations.fingerprint; const status = clientStatus( fingerprint, - await operations.installedBinary(), - await operations.readRecord(), + yield* operations.installedBinary, + yield* operations.readRecord, ); if (status === "compatible") return { status, rebuilt: false, fingerprint }; - await operations.build(); - if ((await operations.fingerprint()) !== fingerprint) { - throw new Error( - "Native inputs changed during the build. Run ensure again; this build was not recorded.", - ); + yield* operations.build; + if ((yield* operations.fingerprint) !== fingerprint) { + return yield* new NativeClientError({ + message: + "Native inputs changed during the build. Run ensure again; this build was not recorded.", + }); } - const binary = await operations.installedBinary(); + const binary = yield* operations.installedBinary; if (binary === null) - throw new Error("Build finished but the development client is not installed."); - await operations.saveRecord({ fingerprint, binary }); + return yield* new NativeClientError({ + message: "Build finished but the development client is not installed.", + }); + yield* operations.saveRecord({ fingerprint, binary }); return { status: "compatible" as const, rebuilt: true, fingerprint }; -} +}); -/** Hash bundle contents, including native libraries and resources, not its install path or mtime. */ -export async function hashBundle(root: string): Promise { - const hash = NodeCrypto.createHash("sha256"); - async function visit(relative: string) { - const entries = await NodeFSP.readdir(NodePath.join(root, relative), { withFileTypes: true }); - entries.sort((a, b) => a.name.localeCompare(b.name, "en")); - for (const entry of entries) { - const name = NodePath.join(relative, entry.name); - const absolute = NodePath.join(root, name); - hash.update(JSON.stringify([name, entry.isDirectory(), entry.isSymbolicLink()])); - if (entry.isDirectory()) await visit(name); - else if (entry.isSymbolicLink()) hash.update(await NodeFSP.readlink(absolute)); - else { - const fileHash = NodeCrypto.createHash("sha256"); - for await (const chunk of NodeFS.createReadStream(absolute)) fileHash.update(chunk); - hash.update(fileHash.digest()); +const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) { + const crypto = yield* Crypto.Crypto; + const bytes = yield* crypto.digest( + "SHA-256", + typeof value === "string" ? new TextEncoder().encode(value) : value, + ); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +/** Hash fixed-size chunks to bound memory, including resources and symlink targets. */ +export const hashBundle = Effect.fn("hashBundle")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries: string[] = []; + const visit = ( + relative: string, + ): Effect.Effect => + Effect.gen(function* () { + const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) => + a.localeCompare(b, "en"), + ); + for (const name of names) { + const key = path.join(relative, name); + const absolute = path.join(root, key); + // FileSystem.stat follows links; readLink distinguishes them without following a cycle. + const link = yield* fs.readLink(absolute).pipe( + Effect.catchIf( + (error) => isNotLink(error.reason.cause), + () => Effect.succeed(null), + ), + ); + if (link !== null) { + entries.push(`link:${key}:${link}`); + continue; + } + const info = yield* fs.stat(absolute); + if (info.type === "Directory") { + entries.push(`directory:${key}`); + yield* visit(key); + } else { + const chunks = yield* fs.stream(absolute, { chunkSize: FileSystem.Size(65536) }).pipe( + Stream.mapEffect((chunk) => digest(chunk)), + Stream.runCollect, + ); + entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`); + } } - } - } - await visit(""); - return hash.digest("hex"); -} + }); + yield* visit(""); + return yield* digest(entries.map((entry) => `${entry.length}:${entry}`).join("")); +}); +type FileSystemError = import("effect/PlatformError").PlatformError; -const repoRoot = NodeURL.fileURLToPath(new URL("../", import.meta.url)); -const mobileRoot = NodePath.join(repoRoot, "apps/mobile"); const bundleId = "com.t3tools.t3code.dev"; +const roots = Effect.gen(function* () { + const path = yield* Path.Path; + const repo = yield* path.fromFileUrl(new URL("../", import.meta.url)); + return { repo, mobile: path.join(repo, "apps/mobile") }; +}); +const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (a, b) => a + b, + ), + ); +const command = Effect.fn("nativeClient.command")(function* ( + program: string, + args: string[], + inherit = false, + cwd?: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + const child = yield* spawner.spawn( + ChildProcess.make(program, args, { + cwd: cwd ?? (yield* roots).mobile, + env: { + ...environment, + APP_VARIANT: "development", + MOBILE_VERSION_POLICY: "appVersion", + T3CODE_IOS_PERSONAL_TEAM: "0", + CI: "1", + EXPO_NO_GIT_STATUS: "1", + }, + stdin: "ignore", + stdout: inherit ? "inherit" : "pipe", + stderr: inherit ? "inherit" : "pipe", + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + if (code !== 0) + return yield* new NativeClientError({ + message: `${program} ${args[0]} failed (${code}): ${stderr || "see build output"}`, + }); + return stdout.trim(); +}, Effect.scoped); -function command(program: string, args: string[], inherit = false): string { - const result = NodeChildProcess.spawnSync(program, args, { - cwd: mobileRoot, - env: { - ...process.env, - APP_VARIANT: "development", - MOBILE_VERSION_POLICY: "appVersion", - T3CODE_IOS_PERSONAL_TEAM: "0", - CI: "1", - EXPO_NO_GIT_STATUS: "1", - }, - encoding: "utf8", - stdio: inherit ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"], - maxBuffer: 32 * 1024 * 1024, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error( - `${program} ${args[0]} failed (${result.status}): ${result.stderr ?? "see build output"}`, - ); - } - return result.stdout?.trim() ?? ""; -} - -async function fingerprint(platform: NativePlatform) { - const output = command(process.execPath, [ +const fingerprint = Effect.fn("nativeClient.fingerprint")(function* (platform: NativePlatform) { + const output = yield* command(yield* HostProcessExecutablePath, [ "--eval", `require('expo/fingerprint').createFingerprintAsync(process.cwd(), { platforms: [process.argv[1]], silent: true }).then(fp => console.log('T3_NATIVE_FINGERPRINT=' + fp.hash)).catch(e => { console.error(e); process.exitCode = 1; });`, platform, @@ -114,168 +192,214 @@ async function fingerprint(platform: NativePlatform) { .find((line) => line.startsWith("T3_NATIVE_FINGERPRINT=")) ?.split("=")[1]; if (!hash || !/^[a-f0-9]{40,64}$/.test(hash)) - throw new Error("Expo did not return a native fingerprint."); + return yield* new NativeClientError({ message: "Expo did not return a native fingerprint." }); return hash; -} +}); -function validateDevice(platform: NativePlatform, device: string) { +const validateDevice = Effect.fn("nativeClient.validateDevice")(function* ( + platform: NativePlatform, + device: string, +) { if (platform === "ios") { - // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone host build CLI, outside the Effect runtime. - if (NodeOS.platform() !== "darwin") - throw new Error("Run iOS check/ensure on the Mac that hosts the simulator."); - const listing: { devices: Record } = JSON.parse( - command("xcrun", ["simctl", "list", "devices", "available", "--json"]), + if ((yield* HostProcessPlatform) !== "darwin") + return yield* new NativeClientError({ + message: "Run iOS check/ensure on the Mac that hosts the simulator.", + }); + const listing = yield* decodeSimulators( + yield* command("xcrun", ["simctl", "list", "devices", "available", "--json"]), ); const simulator = Object.values(listing.devices) .flat() .find((entry) => entry.udid === device); - if (!simulator) throw new Error(`No available iOS simulator with UDID ${device}.`); + if (!simulator) + return yield* new NativeClientError({ + message: `No available iOS simulator with UDID ${device}.`, + }); if (simulator.state !== "Booted") - throw new Error(`Boot the selected simulator first: xcrun simctl boot ${device}`); + return yield* new NativeClientError({ + message: `Boot the selected simulator first: xcrun simctl boot ${device}`, + }); } else { - if (command("adb", ["-s", device, "get-state"]) !== "device") - throw new Error("Android device is not connected."); - if (command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"]) !== "1") { - throw new Error("Select an Android emulator, not a physical device."); - } + if ((yield* command("adb", ["-s", device, "get-state"])) !== "device") + return yield* new NativeClientError({ message: "Android device is not connected." }); + if ((yield* command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"])) !== "1") + return yield* new NativeClientError({ + message: "Select an Android emulator, not a physical device.", + }); } -} +}); -export async function installedBinary(platform: NativePlatform, device: string, run = command) { +export const installedBinary = Effect.fn("installedBinary")(function* ( + platform: NativePlatform, + device: string, + run: typeof command = command, +) { if (platform === "ios") { - // Listing apps distinguishes an absent app from a failed simctl command. - const apps = run("xcrun", ["simctl", "listapps", device]); + const apps = yield* run("xcrun", ["simctl", "listapps", device]); if (!apps.includes(`"${bundleId}"`)) return null; - return hashBundle(run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"])); + return yield* hashBundle( + yield* run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"]), + ); } - const installed = run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); + const installed = yield* run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null; - const packages = run("adb", ["-s", device, "shell", "pm", "path", bundleId]); + const packages = yield* run("adb", ["-s", device, "shell", "pm", "path", bundleId]); const apks = packages .split("\n") .filter((line) => line.startsWith("package:")) .map((line) => line.slice(8).trim()) .sort(); if (apks.length === 0) return null; - const hashes = apks.map((apk) => { - if (!/^\/[\w/+=.~-]+\.apk$/.test(apk)) throw new Error("Unexpected installed APK path."); - const hash = run("adb", ["-s", device, "shell", "sha256sum", apk]).split(/\s/)[0]; - if (!hash || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("Could not hash installed APK."); - return hash; - }); - return NodeCrypto.createHash("sha256").update(hashes.sort().join("\n")).digest("hex"); -} + const hashes = yield* Effect.forEach(apks, (apk) => + Effect.gen(function* () { + if (!/^\/[\w/+=.~-]+\.apk$/.test(apk)) + return yield* new NativeClientError({ message: "Unexpected installed APK path." }); + const hash = (yield* run("adb", ["-s", device, "shell", "sha256sum", apk])).split(/\s/)[0]; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) + return yield* new NativeClientError({ message: "Could not hash installed APK." }); + return hash; + }), + ); + return yield* digest(hashes.sort().join("\n")); +}); -async function main() { - const [mode, platform, device, ...extra] = process.argv.slice(2); - if ( - (mode !== "check" && mode !== "ensure") || - (platform !== "ios" && platform !== "android") || - !device || - extra.length - ) { - throw new Error( - "Usage: node scripts/mobile-native-client.ts ", +const main = Command.make( + "mobile-native-client", + { + mode: Argument.choice("mode", ["check", "ensure"]), + platform: Argument.choice("platform", ["ios", "android"]), + device: Argument.string("device"), + }, + Effect.fn("nativeClient.main")(function* ({ mode, platform, device }) { + yield* validateDevice(platform, device); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const home = environment.HOME ?? environment.USERPROFILE; + if (!home) + return yield* new NativeClientError({ + message: "HOME or USERPROFILE must be set to store native client records.", + }); + const recordPath = path.join( + home, + ".cache/t3code/native-clients", + platform, + `${yield* digest(device)}.json`, ); - } - validateDevice(platform, device); - const recordPath = NodePath.join( - NodeOS.homedir(), - ".cache/t3code/native-clients", - platform, - `${NodeCrypto.createHash("sha256").update(device).digest("hex")}.json`, - ); - const operations = { - fingerprint: () => fingerprint(platform), - installedBinary: () => installedBinary(platform, device), - readRecord: async (): Promise => { - try { - const record: unknown = JSON.parse(await NodeFSP.readFile(recordPath, "utf8")); - return record !== null && - typeof record === "object" && - "fingerprint" in record && - "binary" in record && - typeof record.fingerprint === "string" && - typeof record.binary === "string" - ? { fingerprint: record.fingerprint, binary: record.binary } - : null; - } catch (error) { - if (error instanceof SyntaxError || (error as NodeJS.ErrnoException).code === "ENOENT") - return null; - throw error; - } - }, - build: async () => { - process.stderr.write( - "Native client is missing, stale, or unverified. Building and installing a development client...\n", - ); - const tracked = NodeChildProcess.execFileSync( - "git", - ["ls-files", `apps/mobile/${platform}`], - { - cwd: repoRoot, - encoding: "utf8", - }, - ); - if (tracked.trim()) - throw new Error( - "Native directory contains tracked files; clean prebuild would overwrite them.", + const operations = { + fingerprint: fingerprint(platform), + installedBinary: installedBinary(platform, device), + readRecord: fs.readFileString(recordPath).pipe( + Effect.flatMap(decodeRecord), + Effect.catchTag("SchemaError", () => Effect.succeed(null)), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ), + build: Effect.gen(function* () { + yield* Console.error( + "Native client is missing, stale, or unverified. Building and installing a development client...", ); - command( - "vp", - ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"], - true, + const tracked = yield* command( + "git", + ["ls-files", `apps/mobile/${platform}`], + false, + (yield* roots).repo, + ); + if (tracked) + return yield* new NativeClientError({ + message: + "Native directory contains tracked files; clean prebuild would overwrite them.", + }); + yield* command( + "vp", + ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"], + true, + ); + if (platform === "ios") { + const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" }); + const { mobile } = yield* roots; + yield* command("pod", ["install"], true, path.join(mobile, "ios")); + // Target this simulator only, without Expo's desktop activation or log streaming. + yield* command( + "xcrun", + [ + "xcodebuild", + "-workspace", + path.join(mobile, "ios/T3CodeDev.xcworkspace"), + "-scheme", + "T3CodeDev", + "-configuration", + "Debug", + "-destination", + `id=${device}`, + "-derivedDataPath", + output, + "build", + ], + true, + ); + yield* command( + "xcrun", + [ + "simctl", + "install", + device, + path.join(output, "Build/Products/Debug-iphonesimulator/T3CodeDev.app"), + ], + true, + ); + } else { + yield* command( + "vp", + [ + "exec", + "expo", + "run:android", + "--device", + device, + "--no-bundler", + "--variant", + "debug", + ], + true, + ); + } + }).pipe(Effect.scoped), + saveRecord: Effect.fn(function* (record: NativeClientRecord) { + yield* fs.makeDirectory(path.dirname(recordPath), { recursive: true }); + yield* fs.writeFileString(recordPath, yield* encodeRecord(record)); + }), + }; + if (mode === "ensure") { + yield* Console.log(yield* encodeOutput(yield* ensureClient(operations))); + } else { + const current = yield* operations.fingerprint; + const status = clientStatus( + current, + yield* operations.installedBinary, + yield* operations.readRecord, ); - command( - "vp", - [ - "exec", - "expo", - `run:${platform}`, - "--device", - device, - "--no-bundler", - ...(platform === "ios" - ? ["--configuration", "Debug", "--scheme", "T3CodeDev"] - : ["--variant", "debug"]), - ], - true, + yield* Console.log( + yield* encodeOutput({ + status, + fingerprint: current, + next: + status === "compatible" + ? "Start Metro with vp run dev:client" + : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`, + }), ); - }, - saveRecord: async (record: NativeClientRecord) => { - await NodeFSP.mkdir(NodePath.dirname(recordPath), { recursive: true }); - await NodeFSP.writeFile(recordPath, JSON.stringify(record) + "\n"); - }, - }; - if (mode === "ensure") { - process.stdout.write(JSON.stringify(await ensureClient(operations)) + "\n"); - } else { - const current = await operations.fingerprint(); - const status = clientStatus( - current, - await operations.installedBinary(), - await operations.readRecord(), - ); - process.stdout.write( - JSON.stringify({ - status, - fingerprint: current, - next: - status === "compatible" - ? "Start Metro with vp run dev:client" - : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`, - }) + "\n", - ); - process.exitCode = status === "compatible" ? 0 : 2; - } -} + process.exitCode = status === "compatible" ? 0 : 2; + } + }), +); -if ( - process.argv[1] && - NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) -) { - main().catch((error: unknown) => { - process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n"); - process.exitCode = 1; - }); +if (import.meta.main) { + Command.run(main, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); } From 65b85a8f0cb64c93bf79343472a698e7d0153c16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:27:11 -0700 Subject: [PATCH 5/8] fix(mobile): resolve SDK tools and recheck native inputs --- scripts/mobile-native-client.test.ts | 38 ++++++++++++++++++++++++ scripts/mobile-native-client.ts | 43 ++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index b8fa8fd3bc25..a74cb8710edf 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -1,9 +1,11 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { + resolveAdb, NativeClientError, clientStatus, ensureClient, @@ -134,3 +136,39 @@ it.effect( ); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("rejects native edits while reading a previously compatible binary", () => + Effect.gen(function* () { + let fingerprint = "native-a"; + const error = yield* ensureClient({ + fingerprint: Effect.sync(() => fingerprint), + installedBinary: Effect.sync(() => { + fingerprint = "native-b"; + return "binary-a"; + }), + readRecord: Effect.succeed({ fingerprint: "native-a", binary: "binary-a" }), + build: Effect.die("A changed checkout must be rechecked before building"), + saveRecord: () => Effect.die("Must not record changed inputs"), + }).pipe(Effect.flip); + assert.match(error.message, /inputs changed/); + }), +); + +it.effect("finds adb in the Android SDK when PATH does not include platform-tools", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdk = yield* fs.makeTempDirectoryScoped({ prefix: "native-client-sdk-" }); + yield* fs.makeDirectory(path.join(sdk, "platform-tools")); + const executable = (yield* HostProcessPlatform) === "win32" ? "adb.exe" : "adb"; + const adb = path.join(sdk, "platform-tools", executable); + yield* fs.writeFileString(adb, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(adb, 0o755); + assert.equal( + yield* resolveAdb.pipe( + Effect.provideService(HostProcessEnvironment, { PATH: "", ANDROID_HOME: sdk }), + ), + adb, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index d2468f76d334..6b2c832801bf 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -5,6 +5,7 @@ import { HostProcessExecutablePath, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -48,7 +49,7 @@ export function clientStatus( return record.fingerprint === fingerprint ? "compatible" : "stale"; } -/** Record only a successful installation built from unchanged native inputs. */ +/** Keep native sources stable during ensure, as with a normal build; endpoint checks reject detected edits. */ export const ensureClient = Effect.fn("ensureClient")(function* (operations: { fingerprint: Effect.Effect; installedBinary: Effect.Effect; @@ -62,19 +63,25 @@ export const ensureClient = Effect.fn("ensureClient")(function* (o yield* operations.installedBinary, yield* operations.readRecord, ); - if (status === "compatible") return { status, rebuilt: false, fingerprint }; - yield* operations.build; - if ((yield* operations.fingerprint) !== fingerprint) { - return yield* new NativeClientError({ - message: - "Native inputs changed during the build. Run ensure again; this build was not recorded.", - }); + const verifyInputs = Effect.gen(function* () { + if ((yield* operations.fingerprint) !== fingerprint) { + return yield* new NativeClientError({ + message: + "Native inputs changed during verification. Run ensure again; this build was not recorded.", + }); + } + }); + if (status === "compatible") { + yield* verifyInputs; + return { status, rebuilt: false, fingerprint }; } + yield* operations.build; const binary = yield* operations.installedBinary; if (binary === null) return yield* new NativeClientError({ message: "Build finished but the development client is not installed.", }); + yield* verifyInputs; yield* operations.saveRecord({ fingerprint, binary }); return { status: "compatible" as const, rebuilt: true, fingerprint }; }); @@ -146,6 +153,22 @@ const collect = (stream: Stream.Stream) => (a, b) => a + b, ), ); +export const resolveAdb = Effect.gen(function* () { + if (yield* isCommandAvailable("adb")) return "adb"; + const environment = yield* HostProcessEnvironment; + const path = yield* Path.Path; + const executable = (yield* HostProcessPlatform) === "win32" ? "adb.exe" : "adb"; + for (const sdk of [environment.ANDROID_SDK_ROOT, environment.ANDROID_HOME]) { + if (!sdk) continue; + const candidate = path.join(sdk, "platform-tools", executable); + if (yield* isCommandAvailable(candidate)) return candidate; + } + return yield* new NativeClientError({ + message: + "adb was not found on PATH or in ANDROID_SDK_ROOT/ANDROID_HOME. Install Android SDK platform-tools.", + }); +}); + const command = Effect.fn("nativeClient.command")(function* ( program: string, args: string[], @@ -154,8 +177,10 @@ const command = Effect.fn("nativeClient.command")(function* ( ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = yield* HostProcessEnvironment; + const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args); const child = yield* spawner.spawn( - ChildProcess.make(program, args, { + ChildProcess.make(spawn.command, spawn.args, { + shell: spawn.shell, cwd: cwd ?? (yield* roots).mobile, env: { ...environment, From 04024135d1bd8ec970dade861e4170069bf20d5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:32:50 -0700 Subject: [PATCH 6/8] fix(mobile): verify native inputs before starting a build --- scripts/mobile-native-client.test.ts | 28 +++++++++++++++------------- scripts/mobile-native-client.ts | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index a74cb8710edf..093a05a36bcf 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -137,20 +137,22 @@ it.effect( }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("rejects native edits while reading a previously compatible binary", () => +it.effect("rejects native edits during status reads before reuse or building", () => Effect.gen(function* () { - let fingerprint = "native-a"; - const error = yield* ensureClient({ - fingerprint: Effect.sync(() => fingerprint), - installedBinary: Effect.sync(() => { - fingerprint = "native-b"; - return "binary-a"; - }), - readRecord: Effect.succeed({ fingerprint: "native-a", binary: "binary-a" }), - build: Effect.die("A changed checkout must be rechecked before building"), - saveRecord: () => Effect.die("Must not record changed inputs"), - }).pipe(Effect.flip); - assert.match(error.message, /inputs changed/); + for (const binary of ["binary-a", null]) { + let fingerprint = "native-a"; + const error = yield* ensureClient({ + fingerprint: Effect.sync(() => fingerprint), + installedBinary: Effect.sync(() => { + fingerprint = "native-b"; + return binary; + }), + readRecord: Effect.succeed({ fingerprint: "native-a", binary: "binary-a" }), + build: Effect.die("A changed checkout must be rechecked before building"), + saveRecord: () => Effect.die("Must not record changed inputs"), + }).pipe(Effect.flip); + assert.match(error.message, /inputs changed/); + } }), ); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 6b2c832801bf..c93a644bc493 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -71,8 +71,8 @@ export const ensureClient = Effect.fn("ensureClient")(function* (o }); } }); + yield* verifyInputs; if (status === "compatible") { - yield* verifyInputs; return { status, rebuilt: false, fingerprint }; } yield* operations.build; From d7831f92fcbc76309419dc83631c27202cb5dc93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:39:01 -0700 Subject: [PATCH 7/8] Delete mobile-native-client.test.ts --- scripts/mobile-native-client.test.ts | 176 --------------------------- 1 file changed, 176 deletions(-) delete mode 100644 scripts/mobile-native-client.test.ts diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts deleted file mode 100644 index 093a05a36bcf..000000000000 --- a/scripts/mobile-native-client.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { - resolveAdb, - NativeClientError, - clientStatus, - ensureClient, - hashBundle, - installedBinary, - type NativeClientRecord, -} from "./mobile-native-client.ts"; - -it("requires a build for absent, unrecorded, replaced, and stale clients", () => { - const record = { fingerprint: "native-a", binary: "binary-a" }; - assert.equal(clientStatus("native-a", null, record), "missing"); - assert.equal(clientStatus("native-a", "binary-a", null), "unknown"); - assert.equal(clientStatus("native-a", "binary-b", record), "unknown"); - assert.equal(clientStatus("native-b", "binary-a", record), "stale"); - assert.equal(clientStatus("native-a", "binary-a", record), "compatible"); -}); - -it.effect("builds an unknown client once, then reuses it across JavaScript changes", () => - Effect.gen(function* () { - let record: NativeClientRecord | null = null; - let builds = 0; - const operations = { - fingerprint: Effect.succeed("native-a"), - installedBinary: Effect.succeed("binary-a"), - readRecord: Effect.sync(() => record), - build: Effect.sync(() => { - builds++; - }), - saveRecord: (value: NativeClientRecord) => - Effect.sync(() => { - record = value; - }), - }; - assert.equal((yield* ensureClient(operations)).rebuilt, true); - assert.equal((yield* ensureClient(operations)).rebuilt, false); - assert.equal(builds, 1); - assert.deepEqual(record, { fingerprint: "native-a", binary: "binary-a" }); - }), -); - -it.effect( - "never records failed builds, missing installations, or native inputs changed during a build", - () => - Effect.gen(function* () { - for (const failure of ["build", "missing", "changed"] as const) { - let built = false; - let recorded = false; - const result = yield* ensureClient({ - fingerprint: Effect.sync(() => - built && failure === "changed" ? "native-b" : "native-a", - ), - installedBinary: Effect.succeed(null), - readRecord: Effect.succeed(null), - build: Effect.gen(function* () { - if (failure === "build") - return yield* new NativeClientError({ message: "Compiler failed" }); - built = true; - }), - saveRecord: () => - Effect.sync(() => { - recorded = true; - }), - }).pipe(Effect.flip); - assert.match(result.message, /Compiler failed|not installed|inputs changed/); - assert.equal(recorded, false); - } - }), -); - -it.effect( - "detects native library and resource replacement independent of the install directory", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "native-client-test-" }); - const first = path.join(root, "first.app"); - const second = path.join(root, "second.app"); - for (const dir of [first, second]) { - yield* fs.makeDirectory(path.join(dir, "Frameworks"), { recursive: true }); - yield* fs.writeFileString( - path.join(dir, "Frameworks/native.dylib"), - "native-a".repeat(20000), - ); - yield* fs.writeFileString(path.join(dir, "Info.plist"), "config-a"); - yield* fs.symlink("Info.plist", path.join(dir, "config-link")); - } - const baseline = yield* hashBundle(first); - assert.equal(yield* hashBundle(second), baseline); - yield* fs.writeFileString(path.join(second, "Frameworks/native.dylib"), "native-b"); - assert.notEqual(yield* hashBundle(second), baseline); - yield* fs.writeFileString( - path.join(second, "Frameworks/native.dylib"), - "native-a".repeat(20000), - ); - yield* fs.writeFileString(path.join(second, "Info.plist"), "config-b"); - assert.notEqual(yield* hashBundle(second), baseline); - yield* fs.writeFileString(path.join(second, "Info.plist"), "config-a"); - assert.equal(yield* hashBundle(second), baseline); - yield* fs.remove(path.join(second, "config-link")); - yield* fs.symlink("Frameworks/native.dylib", path.join(second, "config-link")); - assert.notEqual(yield* hashBundle(second), baseline); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect( - "recognizes Android APK installs with randomized tilde paths and rejects failed hash reads", - () => - Effect.gen(function* () { - let hashOutput = - "a".repeat(64) + " /data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk"; - const run = (_program: string, args: string[]) => { - if (args.includes("list")) return Effect.succeed("package:com.t3tools.t3code.dev"); - if (args.includes("path")) - return Effect.succeed( - "package:/data/app/~~random==/com.t3tools.t3code.dev-abc==/base.apk", - ); - return Effect.succeed(hashOutput); - }; - const binary = yield* installedBinary("android", "emulator-5554", run); - assert.match(binary!, /^[a-f0-9]{64}$/); - hashOutput = "sha256sum: read error"; - const error = yield* installedBinary("android", "emulator-5554", run).pipe(Effect.flip); - assert.match(error.message, /Could not hash/); - assert.equal( - yield* installedBinary("android", "emulator-5554", () => Effect.succeed("")), - null, - ); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect("rejects native edits during status reads before reuse or building", () => - Effect.gen(function* () { - for (const binary of ["binary-a", null]) { - let fingerprint = "native-a"; - const error = yield* ensureClient({ - fingerprint: Effect.sync(() => fingerprint), - installedBinary: Effect.sync(() => { - fingerprint = "native-b"; - return binary; - }), - readRecord: Effect.succeed({ fingerprint: "native-a", binary: "binary-a" }), - build: Effect.die("A changed checkout must be rechecked before building"), - saveRecord: () => Effect.die("Must not record changed inputs"), - }).pipe(Effect.flip); - assert.match(error.message, /inputs changed/); - } - }), -); - -it.effect("finds adb in the Android SDK when PATH does not include platform-tools", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sdk = yield* fs.makeTempDirectoryScoped({ prefix: "native-client-sdk-" }); - yield* fs.makeDirectory(path.join(sdk, "platform-tools")); - const executable = (yield* HostProcessPlatform) === "win32" ? "adb.exe" : "adb"; - const adb = path.join(sdk, "platform-tools", executable); - yield* fs.writeFileString(adb, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(adb, 0o755); - assert.equal( - yield* resolveAdb.pipe( - Effect.provideService(HostProcessEnvironment, { PATH: "", ANDROID_HOME: sdk }), - ), - adb, - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); From d758fc6f7326233545ed7033d24e0f380b0dec42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:43:22 -0700 Subject: [PATCH 8/8] fix(scripts): register native client CLI with Knip --- knip.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index e53792d8a299..3365862842b8 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -13,8 +13,8 @@ "vitest": { "entry": [".github/**/*.test.cjs"] }, }, "scripts": { - // Knip loads its preprocessor through a CLI option, not a source import. - "entry": ["knip-schemas.ts"], + // Knip loads its preprocessor through a CLI option; native verification runs directly. + "entry": ["knip-schemas.ts", "mobile-native-client.ts"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services.