From 6697dc8b38ebb99d8860dd3b21734af4568a9428 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 11:13:04 +0200 Subject: [PATCH 1/2] feat(cli)!: require an explicit app id for android/ios-device delivery --- .gitignore | 6 - CHANGELOG.md | 20 ++ docs/ARCHITECTURE.md | 97 +++++-- docs/PROTOCOL.md | 8 +- packages/appduct/README.md | 18 +- .../__snapshots__/mcp-server.test.ts.snap | 2 +- .../src/__tests__/cli.integration.test.ts | 20 +- .../src/__tests__/daemon.integration.test.ts | 37 --- .../src/__tests__/init.integration.test.ts | 142 +++++++++ .../__tests__/link-open.integration.test.ts | 155 +++++++--- .../__tests__/mcp-server.integration.test.ts | 175 ++++++++++-- .../appduct/src/__tests__/open-target.test.ts | 269 +++++++++++++++--- .../src/__tests__/playground-fixture.test.ts | 28 +- packages/appduct/src/__tests__/scheme.test.ts | 160 +++++++++++ packages/appduct/src/cli/create-cli.ts | 14 +- packages/appduct/src/cli/open-target.ts | 156 ++++++++-- packages/appduct/src/cli/result-types.ts | 5 + packages/appduct/src/cli/routes/init.ts | 14 + packages/appduct/src/cli/routes/link.ts | 12 +- packages/appduct/src/client/bootstrap.ts | 9 +- packages/appduct/src/commands/init.ts | 169 ++++++++++- packages/appduct/src/commands/link.ts | 12 +- packages/appduct/src/commands/mcp.ts | 8 +- packages/appduct/src/daemon/config.ts | 18 -- packages/appduct/src/link.ts | 51 ++-- packages/appduct/src/mcp/connect-tool.ts | 143 +++++++--- packages/appduct/src/mcp/server.ts | 12 +- packages/appduct/src/scheme.ts | 165 ++++++++++- packages/native/android/README.md | 10 +- playground-native/.appduct/config.json | 7 + playground-native/android/README.md | 5 +- playground-native/ios/README.md | 5 +- playground/.appduct/config.json | 6 +- playground/.appduct/key.pem | 28 -- playground/.gitignore | 4 - playground/README.md | 19 +- playground/app.json | 6 +- playground/app/(tabs)/index.tsx | 17 +- playground/scripts/appduct.sh | 13 +- skills/appduct/SKILL.md | 33 ++- skills/appduct/references/setup.md | 9 +- 41 files changed, 1671 insertions(+), 416 deletions(-) create mode 100644 playground-native/.appduct/config.json delete mode 100644 playground/.appduct/key.pem diff --git a/.gitignore b/.gitignore index 8aee6acf..6f9113b0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,12 +40,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -# The playground's daemon writes sockets, logs, PID files, and audit records here. Its key and -# config are deliberately committed fixtures so the example works without local setup. -playground/.appduct/* -!playground/.appduct/key.pem -!playground/.appduct/config.json - # playground-native's Xcode project is generated by `xcodegen` from project.yml -- see # playground-native/ios/README.md. playground-native/ios/*.xcodeproj diff --git a/CHANGELOG.md b/CHANGELOG.md index e57b3f99..8f3b77de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,26 @@ package versions for a release. many positionals, `--limit 0`) crashed the CLI with a stack trace instead of printing a usage error with exit code 64. A numeric flag given without a value (`--limit`, `--limit -1`, `--since`, `--ttl`, `--timeout`) is now a usage error; it used to be read as `1`. +- **Breaking: `--open android` / `target: "android"` now require the installed app's id** — + including an Android device `appduct_connect` auto-detects, which now fails with + `invalid_request` until an app id is configured. + Previously `adb shell am start` was invoked with an implicit intent (no `-p`); when more than + one installed app declared the deep-link scheme, Android showed an "Open with" chooser and + `am start` still reported success, so `appduct_wait_for_session`/the CLI blocked its whole + timeout with nothing explaining why (issue #63). Delivery now names the package explicitly + (`am start ... -p `) and requires an app id rather than falling back — for both + `android` and the experimental `ios-device` target. + - **Migration:** run `appduct init --scheme --android-app-id --ios-app-id ` in + your app root once (writes `appId.android`/`appId.ios` into `.appduct/config.json`), or pass + `--app-id ` on `appduct link` / `appId` on the MCP `appduct_connect` tool / `appId` on + `mintLink`/`appduct/client`'s `link()` per call. `ios-sim` needs none of this — it is a usage + error to pass one there. + - **Removed:** `--bundle-id` (CLI), `bundleId` (MCP `appduct_connect`), `bundleId` + (`mintLink`/`appduct/client`'s `link()`), and `config.json`'s `iosBundleId` — all replaced by + `--app-id`/`appId`/`appId.` above, which now also covers `android`. There is no + deprecation shim: a leftover `iosBundleId` in `config.json` is silently ignored (an unknown + key just warns, and `loadConfig`'s `warn` defaults to a no-op), so the delivery-time error + above is the only signal that it needs replacing. ## 0.10.0 (2026-09-16) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e3cb2f9..bb0123b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,7 +96,6 @@ The daemon refuses to load a key file that is group/world-readable. "policy": { "default": "allow", "destructive": "allow" }, "advertisedIp": null, "scheme": null, - "iosBundleId": null, "restartDaemonOnVersionMismatch": false } ``` @@ -111,8 +110,9 @@ Any other value must be a port number in `1..65535`. `advertisedIp` overrides auto-detection of the address advertised in minted bootstrap payloads. `scheme` is the deep-link URI scheme composed into `appduct link`'s output when `--scheme` is not passed (§10) — set it once here instead of on every invocation. -`iosBundleId` is the same idea for `--open ios-device` (§8): the app `xcrun devicectl` -should launch, overridable per invocation by `--bundle-id` / the `bundleId` MCP argument. +Unlike `scheme`, the app id `--open android`/`--open ios-device` need (issue #63) has no +home in this file: it lives only in a project `.appduct/config.json`'s `appId.` +(§10), never in the state directory's `config.json` — see `resolveAppId` in `scheme.ts`. `eventBufferSize` caps the per-session `events.since` retention buffer (§5). `restartDaemonOnVersionMismatch` makes version-drift restarts unconditional rather than only-when-no-sessions-are-live (§4, "Version drift"). @@ -428,11 +428,29 @@ belong here: an entry only when the field is present and disqualifying — the one deliberate departure from Expo, which tests `pairingState` positively and would therefore find nothing at all if these undocumented keys were ever renamed; here that degrades to a loud launch failure instead. -- The bundle id is the launch argv's only **trailing positional**, so it is shape-validated - (letters, digits, `.`, `-`) at each use site — a value starting with `-` would be read by - `devicectl` as an option. It is *not* validated in `daemon/config.ts`, which keeps the plain - non-empty-string check `scheme` uses: that loader runs on every daemon start, and a typo in a - CLI-side convenience key must not stop the daemon from starting. +- **Both `android` and `ios-device` require the installed app's id** (issue #63), resolved + (`--app-id`/`appId` over MCP, then a project `.appduct/config.json`'s `appId.`) and + validated *before* a link is minted, so a missing or malformed one is a plain usage error rather + than a stranded pending session. Naming it matters most for Android: an implicit `am start -a + android.intent.action.VIEW -d ''` with no `-p` pops an ambiguous "Open with" chooser the + instant more than one installed app declares the same scheme, `am` still exits 0 either way (it + does not reliably fail for an unresolvable intent), and `appduct_wait_for_session` then blocks + its whole timeout with nothing explaining why. `-p ` turns that into a hard, immediate + failure instead — and because `am` can still exit 0 with an unresolvable `-p`, `deliverAndroid` + additionally scans its stdout/stderr for an `Error:` line (or a refused launch's + `Exception occurred while executing` / `Security exception:` line) and throws if it finds one, + naming the package. The value is shape-validated (letters, digits, `.`, `_`, `-`, starting + with a letter or digit) at each use site for two different reasons, not one: on `ios-device` it is the launch argv's + only trailing positional, so a leading `-` would be read by `devicectl` as an option; on + `android` it ends up inside the string `adb shell` reconstructs and re-parses on the *device's + own shell*, so a space or `;` there is command injection on the device — the same reason the URL + itself is single-quoted, above. The pattern is deliberately a superset of both platforms' own id + grammars (Android forbids `-` in an `applicationId` but allows `_`; iOS is the other way + round): it is a safety check on the argv and the device shell, not a spelling check, so a syntactically safe but + wrong id for its platform fails loudly at `am start`/`devicectl` instead. It is *not* validated + in `daemon/config.ts` — there is nothing to validate there any more: an app id has no home in + the state directory's `config.json`, only in a project `.appduct/config.json`'s + `appId.` (§10, `resolveAppId`). - `ios-device` also **refuses to deliver a loopback link**. `daemon/address.ts` falls back to `127.0.0.1` when it finds no routable interface; delivered to a phone, that link points the phone at itself, and the failure is silent — `wait_for_session` simply blocks for its whole @@ -469,9 +487,11 @@ proxies daemon RPC (auto-spawning the daemon like any client): resolved, at which point cancelling it is moot). - Two built-in management tools, `appduct_connect` and `appduct_wait_for_session`, let an agent mint a bootstrap link, deliver it to an emulator/simulator, and wait for the - claim — without shell access. This is what makes the agent path self-service. `target: - "ios-device"` extends that to a paired physical iPhone/iPad (§8), but only when the agent - names it and supplies `bundleId`; the "nothing detected" note says so, so an agent that + claim — without shell access. This is what makes the agent path self-service. Delivering to + `android` (explicit or auto-detected) needs `appId`, resolved the same way as `--app-id` (§8, + §10); it is rejected outright for `target: "ios-sim"`/`"none"`, which need none. `target: + "ios-device"` extends the same path to a paired physical iPhone/iPad (§8), but only when the + agent names it and supplies `appId`; the "nothing detected" note says so, so an agent that finds no simulator knows the option exists rather than defaulting to a QR nobody scans. - Two more built-in tools, `appduct_events` and `appduct_wait_for_event` (issue #6), give an agent a pull surface over `postEvent()`-pushed `app_event`s: `appduct_events` @@ -516,26 +536,51 @@ they cannot drift. First match wins: normalization `app.plugin.js` applies; no walk-up) 6. otherwise an error naming every location above -Only the *client-side* `scheme` is overridable per project. A project `.appduct/config.json` -is read for that key alone and never redirects daemon-side state (`wssPort`, `keyPath`, `policy`, -the audit log): `--state-dir` / `APPDUCT_STATE_DIR` remain the only way to move the state -directory, so a file checked into a repo can never move another developer's private key. +The project `.appduct/config.json` carries a second key alongside `scheme` since issue #63: +`appId`, an object with `ios`/`android` string entries. `scheme.ts`'s `resolveAppId` resolves it +per delivery target (`android` → `appId.android`, `ios-device` → `appId.ios`; `ios-sim` needs +none — see §8), in a shorter order than `scheme`'s, first match wins: + +1. `--app-id` (CLI `link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, + `appduct/client`'s `link()`) — the target is known at the call site, so this is unambiguous +2. the nearest `.appduct/config.json` declaring `appId.`, using the *same* walk-up + `scheme` uses (`findProjectConfigs`) +3. otherwise a usage error, naming `appduct init --android-app-id … --ios-app-id …`, `--app-id`, + and the config path — raised *before* a link is minted, so a missing app id never strands a + pending session + +Unlike `scheme`, there is no environment-variable tier and no filesystem-discovery tier: issue +#63 leaves static discovery of an app id (from `build.gradle`/`app.json`) out of scope, and there +is no `appId` key in the state directory's `config.json` either — an app id is project-scoped +client-side settings only, exactly like `scheme`, just without that file's lower tiers. + +Only the *client-side* `scheme`/`appId` are overridable per project. A project +`.appduct/config.json` is read for those keys alone and never redirects daemon-side state +(`wssPort`, `keyPath`, `policy`, the audit log): `--state-dir` / `APPDUCT_STATE_DIR` remain the +only way to move the state directory, so a file checked into a repo can never move another +developer's private key. `app.config.js` / `app.config.ts` are deliberately **not** evaluated — running arbitrary project code to read one string is a far larger blast radius than this warrants. Dynamic-config projects use `--scheme`, `APPDUCT_SCHEME`, or `appduct init --scheme `. -`appduct init`, run in an app root, writes that project `.appduct/config.json` (scheme only) -and prints the MCP server entry to paste plus the `import "@appduct/react-native/auto"` -reminder. It never generates keys (the daemon auto-generates `key.pem` — §3), and writes the file -`0600` inside a `0700` directory, matching §3's conventions. - -Re-running it is always safe: it keeps the scheme already recorded and only *notes* it when -`app.json` has come to declare a different one — a command documented as safe to re-run must not -start failing because a scheme was renamed. `--scheme ` needs `--force` to replace a -recorded value, `--force` alone re-adopts `app.json`'s, and `--force` merges rather than -truncating. Note the inverse of the rule above: a project `.appduct/` is committed, so -`--state-dir` must never point at one — that directory would then hold `key.pem`. +`appduct init`, run in an app root, writes that project `.appduct/config.json` (`scheme`, and +now `appId.ios`/`appId.android` via `--ios-app-id `/`--android-app-id `) and prints the +MCP server entry to paste plus the `import "@appduct/react-native/auto"` reminder. It never +generates keys (the daemon auto-generates `key.pem` — §3), and writes the file `0600` inside a +`0700` directory, matching §3's conventions. The two app-id flags are independent — there is no +single `--app-id` on `init` — because the platforms' ids usually match but not always, and `init` +never guesses one from a discovered value the way it never guesses `scheme` from an ambiguous +native probe (§10's discussion of `discoverNativeScheme`). + +Re-running it is always safe: it keeps the scheme (and any recorded app id) already recorded and +only *notes* a scheme divergence when `app.json` has come to declare a different one — a command +documented as safe to re-run must not start failing because a scheme was renamed. `--scheme +` needs `--force` to replace a recorded value, `--force` alone re-adopts `app.json`'s; +the same "replacing needs `--force`" rule applies to `--ios-app-id`/`--android-app-id`, which have +no discovery tier to re-adopt on `--force` alone. `--force` merges rather than truncating. Note +the inverse of the rule above: a project `.appduct/` is committed, so `--state-dir` must never +point at one — that directory would then hold `key.pem`. Unlike every other consumer, `appduct mcp` does **not** fail when no scheme resolves: the server is still useful for proxying tools to a session paired some other way, so the failure is diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 344f31db..eb64b5bd 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -57,16 +57,18 @@ endpoint and brackets IPv6 literals: `wss://[fd00::1]:8443` vs. `wss://192.168.1 1. **Emulator/simulator fast path** (`appduct link --open android|ios-sim`, or the MCP `appduct_connect` tool's `target` argument): the daemon mints the link with the advertised address forced to `127.0.0.1`, `adb reverse`/`simctl openurl` delivers it — - no human, fully scriptable. + no human, fully scriptable. `android` additionally names the app explicitly (`adb shell am + start ... -p `, issue #63): without it, more than one installed app declaring the + same scheme pops an "Open with" chooser that `am start` still reports as success. 2. **Physical device on LAN**: printed deep link + QR (`appduct link --qr`). 3. **Physical iOS device, experimental** (`--open ios-device` / `target: "ios-device"`, issue #31): `xcrun devicectl device process launch --device --payload-url - ` hands the link to an installed, dev-signed app on a connected iOS 17+ device + ` hands the link to an installed, dev-signed app on a connected iOS 17+ device (`--relaunch` adds `--terminate-existing`; what a plain launch does to an *already-running* app is unverified on hardware). This is path 2's addressing with path 1's automation: the link keeps the **detected LAN address** — there is no `adb reverse` equivalent on iOS, so `127.0.0.1` would point the phone at itself, and a link that would advertise loopback is - refused rather than delivered. Never auto-detected, and needs the app's bundle id. + refused rather than delivered. Never auto-detected, and needs the app's id (its bundle id). 4. **Remote/production**: the same deep link delivered out-of-band; policy and audit apply identically (§6 below, `docs/ARCHITECTURE.md` §12). diff --git a/packages/appduct/README.md b/packages/appduct/README.md index a4d66c82..cd96ca9b 100644 --- a/packages/appduct/README.md +++ b/packages/appduct/README.md @@ -35,9 +35,9 @@ That's the whole loop. There is no host process to start — `appduct` auto-spaw | Command | Role | | --- | --- | -| `appduct init [--scheme ] [--force]` | set up an app directory: write `.appduct/config.json`, print the MCP snippet | +| `appduct init [--scheme ] [--ios-app-id ] [--android-app-id ] [--force]` | set up an app directory: write `.appduct/config.json`, print the MCP snippet | | `appduct keygen [--out ] [--force]` | generate a daemon private key, print its app pin | -| `appduct link [--ttl ] [--qr] [--open android\|ios-sim\|ios-device] [--device ] [--bundle-id ] [--scheme ]` | mint a pending session and print its deep link | +| `appduct link [--ttl ] [--qr] [--open android\|ios-sim\|ios-device] [--device ] [--app-id ] [--scheme ]` | mint a pending session and print its deep link | | `appduct ls` | list sessions: alias, state, device, tool count | | `appduct tools [selector] [name] [--full] [--filter ] [--limit ] [--offset ]` | list a session's tools (one call signature + description per line), or show one tool's full schema | | `appduct invoke [selector] --input '' [--timeout ]` | call a tool | @@ -97,6 +97,8 @@ It's idempotent and safe to re-run: - `--scheme ` needs `--force` to replace a recorded scheme; `--force` on its own re-adopts `app.json`'s value. - `--force` merges into the existing JSON rather than truncating it. +`--ios-app-id ` and `--android-app-id ` write `appId.ios` and `appId.android` into the same file — the installed app ids that `--open android`, `--open ios-device` and `appduct_connect` need (see [Delivering the link to a device](#delivering-the-link-to-a-device)). They're two flags rather than one because the platforms' ids don't always match, and `init` never guesses one from the other. Like `--scheme`, replacing an id that's already recorded needs `--force`. + A project `.appduct/config.json` holds client-side settings only — it can never redirect the state directory, key, or policy (`--state-dir` / `APPDUCT_STATE_DIR` do that). **Do not point `--state-dir` at a project `.appduct/` directory you commit** — the state dir is where the daemon keeps `key.pem` and its audit log, and a committed one would publish a private key. The two directories share a name and nothing else. `appduct mcp` is the one exception to step 6: it starts even with no scheme, because it's still useful for proxying tools to a session paired another way. Only `appduct_connect` fails, and its error names every location tried. @@ -107,12 +109,14 @@ A project `.appduct/config.json` holds client-side settings only — it can neve | `--open` | Mechanism | Address in the link | | --- | --- | --- | -| `android` | `adb reverse tcp: tcp:`, then `adb shell am start -a android.intent.action.VIEW -d ''` | `127.0.0.1` (the port is forwarded onto the device) | +| `android` | `adb reverse tcp: tcp:`, then `adb shell am start -a android.intent.action.VIEW -d '' -p ` | `127.0.0.1` (the port is forwarded onto the device) | | `ios-sim` | `xcrun simctl openurl ` | `127.0.0.1` (the simulator shares the host's network) | -| `ios-device` **(experimental)** | `xcrun devicectl device process launch --device [--terminate-existing] --payload-url ` | the machine's detected LAN address — there's no `adb reverse` equivalent on iOS | +| `ios-device` **(experimental)** | `xcrun devicectl device process launch --device [--terminate-existing] --payload-url ` | the machine's detected LAN address — there's no `adb reverse` equivalent on iOS | With no `--device`, every target requires exactly one device and errors naming each candidate rather than picking one arbitrarily: `android` counts attached devices (`ANDROID_SERIAL` disambiguates), `ios-sim` counts booted simulators, and `ios-device` counts connected iOS devices only — `devicectl` lists every device the Mac has ever paired, so disconnected phones and non-iOS ones are filtered out first. +**`android` and `ios-device` need the installed app's id** — the Android package name or the iOS bundle id. Pass `--app-id ` (`appId` over MCP), or record it once in `.appduct/config.json` with `appduct init --android-app-id --ios-app-id `. Without one the command fails before minting anything. Naming the app is what keeps Android from showing an "Open with" chooser when more than one installed app declares your scheme — `adb` would report success, and the session would never be claimed. `ios-sim` needs no app id (`simctl openurl` has no equivalent), and passing one with it is an error. The id may contain only letters, digits, `.`, `_` and `-`, and must start with a letter or digit. + #### `--open ios-device` (experimental) This is the only automated path for a wired iPhone or iPad — otherwise you're scanning a QR code by hand. It relies on `xcrun devicectl`'s undocumented `--payload-url` flag, which can't be verified in CI, so treat a failure here as a reason to fall back to the QR flow rather than as a bug in your app. @@ -123,10 +127,10 @@ It needs all of: - The device **paired and trusted** by this Mac, with **Developer Mode** enabled on it (Settings → Privacy & Security → Developer Mode). - A **development-signed build of your app already installed** — `devicectl` launches an installed app, it doesn't install one. - The phone and this machine **on the same network**, reachable at the address the link advertises. `appduct link` prints it on its `Endpoint` line (`--json`: `endpoint.address`); `advertisedIp` in `config.json` overrides detection. If no routable address is found, detection falls back to `127.0.0.1` — which a phone can't reach — so `ios-device` refuses to deliver such a link and tells you to set `advertisedIp`. -- The app's **bundle id**, from `iosBundleId` in `config.json` or `--bundle-id ` on the command line. Without one the command fails with a usage error before minting anything. +- The app's **bundle id** — see [Delivering the link to a device](#delivering-the-link-to-a-device) for how to supply it. ```bash -appduct link --scheme myapp --open ios-device --bundle-id com.example.myapp +appduct link --scheme myapp --open ios-device --app-id com.example.myapp ``` Two things worth knowing before you rely on this: @@ -164,7 +168,7 @@ An MCP server is usually launched with a working directory you don't control, so Once configured, the connected app's tools appear as MCP tools automatically: `tools/list` mirrors the live registry (namespaced `__` when more than one session is active), and `tools/call` proxies straight to the app with progress and errors preserved. -Four built-in tools cover what an agent can't do through the app's own registry. `appduct_connect` mints a link and, by default, delivers it to whichever `android`/`ios-sim` device it detects — pass `target`/`device` to choose, or `target: "none"` to force the human flow — falling back to a QR code, plus instructions to show it, only when there's nothing to deliver to. `appduct_wait_for_session` then waits for that session to be claimed. `target: "ios-device"` reaches a paired physical iPhone or iPad, with `bundleId` (or `iosBundleId` in `config.json`) and the [prerequisites above](#--open-ios-device-experimental) — it's experimental and never auto-detected, so an agent has to ask for it by name. +Four built-in tools cover what an agent can't do through the app's own registry. `appduct_connect` mints a link and, by default, delivers it to whichever `android`/`ios-sim` device it detects — pass `target`/`device` to choose, or `target: "none"` to force the human flow — falling back to a QR code, plus instructions to show it, only when there's nothing to deliver to. Delivering to `android` (chosen or detected) needs `appId`, resolved the same way as `--app-id` (see [Delivering the link to a device](#delivering-the-link-to-a-device)); passing it with `target: "ios-sim"` or `"none"` is an error. `appduct_wait_for_session` then waits for that session to be claimed. `target: "ios-device"` reaches a paired physical iPhone or iPad, with `appId` and the [prerequisites above](#--open-ios-device-experimental) — it's experimental and never auto-detected, so an agent has to ask for it by name. The other two give an agent a pull surface over `postEvent()`-pushed app events: `appduct_events` drains everything retained since a cursor, and `appduct_wait_for_event` blocks for a matching event (checking what's already retained before waiting live), rejecting with `tool_timeout` if none arrives in time. diff --git a/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap b/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap index a96436e0..769bda1e 100644 --- a/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap +++ b/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap @@ -7,7 +7,7 @@ exports[`mcp: tools/list and tools/call > the generated MCP tool list (built-ins "inputSchema": { "additionalProperties": false, "properties": { - "bundleId": { + "appId": { "type": "string", }, "device": { diff --git a/packages/appduct/src/__tests__/cli.integration.test.ts b/packages/appduct/src/__tests__/cli.integration.test.ts index 804d2432..7306ff59 100644 --- a/packages/appduct/src/__tests__/cli.integration.test.ts +++ b/packages/appduct/src/__tests__/cli.integration.test.ts @@ -87,6 +87,8 @@ describe("CLI integration", () => { const initHelp = helpFor("init"); expect(initHelp).toContain("--scheme"); expect(initHelp).toContain("--force"); + expect(initHelp).toContain("--ios-app-id"); + expect(initHelp).toContain("--android-app-id"); const keygenHelp = helpFor("keygen"); expect(keygenHelp).toContain("--out"); @@ -98,22 +100,22 @@ describe("CLI integration", () => { expect(linkHelp).toContain("--scheme"); expect(linkHelp).toContain("--open"); expect(linkHelp).toContain("--device"); - expect(linkHelp).toContain("--bundle-id"); + expect(linkHelp).toContain("--app-id"); expect(linkHelp).toContain("--relaunch"); expect(linkHelp).toContain("ios-device"); - // `--bundle-id` has to survive cac's camelCasing all the way into `handleLinkCommand`, and a + // `--app-id` has to survive cac's camelCasing all the way into `handleLinkCommand`, and a // flag that quietly parsed to `undefined` would look identical to one that was never passed: - // `link --open ios-sim --bundle-id ...` would then mint and deliver instead of erroring. The + // `link --open ios-sim --app-id ...` would then mint and deliver instead of erroring. The // validation runs before any daemon contact, so this needs no state dir beyond an empty one. - const misplacedBundleId = runCliBinary( - ["link", "--open", "ios-sim", "--bundle-id", "com.example.playground", "--json"], - { stateDir: path.join(tmpdir(), "appduct-bundle-id-flag-nonexistent") }, + const misplacedAppId = runCliBinary( + ["link", "--open", "ios-sim", "--app-id", "com.example.playground", "--json"], + { stateDir: path.join(tmpdir(), "appduct-app-id-flag-nonexistent") }, ); - expect(misplacedBundleId.exitCode).not.toBe(0); + expect(misplacedAppId.exitCode).not.toBe(0); // `--json` escapes the quotes in the message, so match on the shape rather than the literal. - expect(`${misplacedBundleId.stdout}${misplacedBundleId.stderr}`).toMatch( - /--bundle-id.{0,4} only applies with .{0,4}--open ios-device/u, + expect(`${misplacedAppId.stdout}${misplacedAppId.stderr}`).toMatch( + /--app-id.{0,4} only applies with .{0,4}--open android.{0,4} or .{0,4}--open ios-device/u, ); const toolsHelp = helpFor("tools"); diff --git a/packages/appduct/src/__tests__/daemon.integration.test.ts b/packages/appduct/src/__tests__/daemon.integration.test.ts index 373e66a9..823979e3 100644 --- a/packages/appduct/src/__tests__/daemon.integration.test.ts +++ b/packages/appduct/src/__tests__/daemon.integration.test.ts @@ -295,43 +295,6 @@ describe("daemon lifecycle", () => { await removeStateDir(stateDir); }); - - test("config.json iosBundleId is a known key, loaded as-is and validated as a non-empty string", async () => { - const stateDir = await makeTempStateDir(); - const paths = getStateDirPaths(stateDir); - const { mkdir } = await import("node:fs/promises"); - await mkdir(stateDir, { recursive: true }); - const { loadConfig } = await import("../daemon/config.js"); - - await writeFile(paths.configPath, JSON.stringify({ iosBundleId: "com.example.playground" })); - - const warnings: string[] = []; - const config = await loadConfig(paths, { warn: (message) => warnings.push(message) }); - - expect(config.iosBundleId).toBe("com.example.playground"); - // A key that warned as unknown would still "work" via the `--bundle-id` flag, hiding a typo'd - // config from the operator for as long as they only ever passed the flag. - expect(warnings).toEqual([]); - - await writeFile(paths.configPath, JSON.stringify({ iosBundleId: "" })); - await expect(loadConfig(paths)).rejects.toThrow(/iosBundleId/u); - - await writeFile(paths.configPath, JSON.stringify({ iosBundleId: 42 })); - await expect(loadConfig(paths)).rejects.toThrow(/iosBundleId/u); - - await writeFile(paths.configPath, JSON.stringify({ iosBundleId: "com.example.my-app2" })); - await expect(loadConfig(paths)).resolves.toMatchObject({ iosBundleId: "com.example.my-app2" }); - - // Deliberately *not* charset-checked here, only where the value is used. This loader runs on - // every daemon start, so a typo in a CLI-side convenience key must not stop the daemon from - // starting — it surfaces as a usage error against the `link`/`connect` call that needed it. - // `wssPort: 0` because this line's config is the one the daemon below actually starts on. - await writeFile(paths.configPath, JSON.stringify({ iosBundleId: "--console", wssPort: 0 })); - await expect(loadConfig(paths)).resolves.toMatchObject({ iosBundleId: "--console" }); - await expect(startTrackedDaemon(stateDir)).resolves.toBeDefined(); - - await removeStateDir(stateDir); - }); }); diff --git a/packages/appduct/src/__tests__/init.integration.test.ts b/packages/appduct/src/__tests__/init.integration.test.ts index 9b871559..01c545ca 100644 --- a/packages/appduct/src/__tests__/init.integration.test.ts +++ b/packages/appduct/src/__tests__/init.integration.test.ts @@ -393,6 +393,122 @@ describe("init command", () => { }); }); +describe("init command (--ios-app-id / --android-app-id, issue #63)", () => { + test("writes both ids into the project config, alongside scheme", async () => { + const root = await makeAppRoot("myapp"); + + const result = await handleInitCommand( + { iosAppId: "com.example.ios", androidAppId: "com.example.android" }, + { cwd: root }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { appId: { ios: "com.example.ios", android: "com.example.android" } }, + }); + expect(await readProjectConfig(root)).toEqual({ + scheme: "myapp", + appId: { ios: "com.example.ios", android: "com.example.android" }, + }); + }); + + test("the two ids are independent: writing only one leaves the other untouched", async () => { + const root = await makeAppRoot("myapp"); + + await handleInitCommand({ androidAppId: "com.example.android" }, { cwd: root }); + const result = await handleInitCommand({ iosAppId: "com.example.ios" }, { cwd: root }); + + expect(result).toMatchObject({ + ok: true, + data: { appId: { ios: "com.example.ios", android: "com.example.android" } }, + }); + expect(await readProjectConfig(root)).toEqual({ + scheme: "myapp", + appId: { ios: "com.example.ios", android: "com.example.android" }, + }); + }); + + test("re-running with the same id is a no-op", async () => { + const root = await makeAppRoot("myapp"); + + await handleInitCommand({ androidAppId: "com.example.android" }, { cwd: root }); + const second = await handleInitCommand({ androidAppId: "com.example.android" }, { cwd: root }); + + expect(second).toMatchObject({ ok: true, data: { changed: false } }); + }); + + test("replacing a recorded id needs --force, exactly like --scheme", async () => { + const root = await makeAppRoot("myapp"); + await handleInitCommand({ androidAppId: "com.example.old" }, { cwd: root }); + + await expect( + handleInitCommand({ androidAppId: "com.example.new" }, { cwd: root }), + ).rejects.toThrow(/--force/u); + + const forced = await handleInitCommand( + { androidAppId: "com.example.new", force: true }, + { cwd: root }, + ); + expect(forced).toMatchObject({ ok: true, data: { appId: { android: "com.example.new" } } }); + expect(await readProjectConfig(root)).toMatchObject({ + appId: { android: "com.example.new" }, + }); + }); + + test("--force on its own (no app id flags) does not touch an already-recorded id", async () => { + const root = await makeAppRoot("myapp"); + await handleInitCommand({ androidAppId: "com.example.android" }, { cwd: root }); + + const result = await handleInitCommand({ force: true }, { cwd: root }); + + expect(result).toMatchObject({ ok: true, data: { appId: { android: "com.example.android" } } }); + }); + + test("no appId flags at all: the config carries only scheme, same as before issue #63", async () => { + const root = await makeAppRoot("myapp"); + + const result = await handleInitCommand({}, { cwd: root }); + + expect(result.ok && result.data.appId).toBeUndefined(); + expect(await readProjectConfig(root)).toEqual({ scheme: "myapp" }); + }); + + test("a malformed already-recorded appId is reported rather than silently overwritten", async () => { + const root = await makeAppRoot("myapp"); + await writeProjectConfigRaw(root, { scheme: "myapp", appId: { android: "" } }); + + await expect(handleInitCommand({}, { cwd: root })).rejects.toThrow(/appId\.android/u); + + // --android-app-id is about to replace exactly the broken value, so it is not fatal here. + const result = await handleInitCommand({ androidAppId: "com.example.android" }, { cwd: root }); + expect(result).toMatchObject({ ok: true, data: { appId: { android: "com.example.android" } } }); + }); + + test("an app id that delivery would refuse is rejected before anything is written", async () => { + for (const [option, bad] of [ + ["androidAppId", "com.x;reboot"], + ["androidAppId", ""], + ["iosAppId", "--console"], + ["iosAppId", "com.$(id)"], + ] as const) { + const root = await makeAppRoot("myapp"); + + await expect(handleInitCommand({ [option]: bad }, { cwd: root })).rejects.toThrow( + /not a valid app id/u, + ); + await expect(readFile(path.join(root, ".appduct", "config.json"), "utf8")).rejects.toThrow(); + } + }); + + test("an Android package name with underscores is accepted", async () => { + const root = await makeAppRoot("myapp"); + + await handleInitCommand({ androidAppId: "com.my_company.app" }, { cwd: root }); + + expect(await readProjectConfig(root)).toMatchObject({ appId: { android: "com.my_company.app" } }); + }); +}); + /** * Issue #48's addition: `init` runs the exact same static-file discovery `resolveScheme`'s last * step does (`scheme.ts`'s `discoverStaticProjectScheme`) — `app.json` first, then the native @@ -655,4 +771,30 @@ describe("appduct init (CLI)", () => { expect(forced.exitCode).toBe(0); expect(await readProjectConfig(root)).toEqual({ scheme: "other" }); }); + + test("--ios-app-id/--android-app-id write appId, and re-running is still safe", async () => { + const root = await makeAppRoot("myapp"); + const stateDir = await makeStateDir(); + + const first = runCliBinary( + ["init", "--ios-app-id", "com.example.ios", "--android-app-id", "com.example.android", "--json"], + { cwd: root, stateDir }, + ); + expect(first.exitCode).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ + ok: true, + data: { appId: { ios: "com.example.ios", android: "com.example.android" } }, + }); + expect(await readProjectConfig(root)).toEqual({ + scheme: "myapp", + appId: { ios: "com.example.ios", android: "com.example.android" }, + }); + + const second = runCliBinary( + ["init", "--ios-app-id", "com.example.ios", "--android-app-id", "com.example.android", "--json"], + { cwd: root, stateDir }, + ); + expect(second.exitCode).toBe(0); + expect(JSON.parse(second.stdout)).toMatchObject({ ok: true, data: { changed: false } }); + }); }); diff --git a/packages/appduct/src/__tests__/link-open.integration.test.ts b/packages/appduct/src/__tests__/link-open.integration.test.ts index c306e19f..d0b70f95 100644 --- a/packages/appduct/src/__tests__/link-open.integration.test.ts +++ b/packages/appduct/src/__tests__/link-open.integration.test.ts @@ -18,10 +18,13 @@ import { afterEach, describe, expect, test } from "vitest"; import { decodeBootstrap } from "@appduct/shared"; import { handleLinkCommand } from "../commands/link.js"; +import { handleLsCommand } from "../commands/ls.js"; import { startDaemon, type RunningDaemon } from "../daemon/daemon.js"; import type { ExecFn } from "../cli/open-target.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; +const APP_ID = "com.example.playground"; + const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; @@ -53,6 +56,20 @@ const startTestDaemon = async ( return { daemon, stateDir, port: daemon.listener.port()! }; }; +/** A project root (distinct from the state dir — `appId` resolution never reads the state dir's + * config.json; see `resolveAppId`) with its own `.appduct/config.json`, so tests can exercise the + * project-config tier of `appId` resolution. Cleaned up alongside every other temp dir. */ +const writeProjectConfig = async (config: Record): Promise => { + const root = await mkdtemp(path.join(tmpdir(), "appduct-link-open-project-")); + stateDirs.push(root); + + const dir = path.join(root, ".appduct"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "config.json"), JSON.stringify(config)); + + return root; +}; + /** Stub `exec` for the `ios-device` path: `devicectl list devices --json-output ` writes its * JSON to the *file* named in argv, so the stub has to produce that side effect. */ const devicectlExec = ( @@ -174,7 +191,7 @@ describe("link --open: CLI wiring", () => { void daemon; }); - test("--open android runs adb reverse before am start, quoting the deep link for the device shell", async () => { + test("--open android runs adb reverse before am start, quoting the deep link for the device shell, -p naming the app", async () => { const { stateDir, port } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; @@ -184,7 +201,7 @@ describe("link --open: CLI wiring", () => { }; const result = await handleLinkCommand( - { open: "android" }, + { open: "android", appId: APP_ID }, { stateDir, exec, env: { ANDROID_SERIAL: "emulator-5554" } }, ); @@ -208,18 +225,80 @@ describe("link --open: CLI wiring", () => { "android.intent.action.VIEW", "-d", `'${result.data.deepLink}'`, + "-p", + APP_ID, ]); }); + + test("no app id anywhere (android): a usage error naming every fix, and no session is minted", async () => { + const { stateDir } = await startTestDaemon(); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "", stderr: "" }; + }; + + await expect( + handleLinkCommand({ open: "android" }, { stateDir, exec, env: { ANDROID_SERIAL: "emulator-5554" } }), + ).rejects.toThrow(/app-id.*appId\.android/su); + + // Raised before `link.create`: nothing ran, and no pending session was left behind to expire + // on its own TTL. + expect(calls).toEqual([]); + + const sessions = await handleLsCommand({ stateDir }); + expect(sessions.ok).toBe(true); + if (sessions.ok) { + expect(sessions.data).toEqual([]); + } + }); + + test("--app-id beats the project config", async () => { + const { stateDir } = await startTestDaemon(); + const cwd = await writeProjectConfig({ appId: { android: "com.example.fromconfig" } }); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "", stderr: "" }; + }; + + await handleLinkCommand( + { open: "android", appId: "com.example.fromflag" }, + { stateDir, cwd, exec, env: { ANDROID_SERIAL: "emulator-5554" } }, + ); + + expect(calls[1]!.args.at(-1)).toBe("com.example.fromflag"); + }); + + test("with no --app-id, appId.android from the project config is used", async () => { + const { stateDir } = await startTestDaemon(); + const cwd = await writeProjectConfig({ appId: { android: "com.example.fromconfig" } }); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "", stderr: "" }; + }; + + await handleLinkCommand( + { open: "android" }, + { stateDir, cwd, exec, env: { ANDROID_SERIAL: "emulator-5554" } }, + ); + + expect(calls[1]!.args.at(-1)).toBe("com.example.fromconfig"); + }); }); describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { test("--open ios-device mints the daemon's advertised address and launches via devicectl", async () => { - const { stateDir } = await startTestDaemon({ iosBundleId: "com.example.playground" }); + const { stateDir } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); - const result = await handleLinkCommand({ open: "ios-device" }, { stateDir, exec }); + const result = await handleLinkCommand({ open: "ios-device", appId: APP_ID }, { stateDir, exec }); expect(result.ok).toBe(true); if (!result.ok) { @@ -250,51 +329,58 @@ describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { "00008030-AAAA", "--payload-url", result.data.deepLink, - "com.example.playground", + APP_ID, ], }); }); - test("--bundle-id overrides config.json's iosBundleId", async () => { - const { stateDir } = await startTestDaemon({ iosBundleId: "com.example.fromconfig" }); + test("--app-id beats the project config", async () => { + const { stateDir } = await startTestDaemon(); + const cwd = await writeProjectConfig({ appId: { ios: "com.example.fromconfig" } }); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); const result = await handleLinkCommand( - { open: "ios-device", bundleId: "com.example.fromflag" }, - { stateDir, exec }, + { open: "ios-device", appId: "com.example.fromflag" }, + { stateDir, cwd, exec }, ); expect(result.ok).toBe(true); expect(calls[1]!.args.at(-1)).toBe("com.example.fromflag"); }); - test("no bundle id anywhere: a usage error, and no link is minted or delivered", async () => { + test("no app id anywhere (ios-device): a usage error naming every fix, and no session is minted", async () => { const { stateDir } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); await expect(handleLinkCommand({ open: "ios-device" }, { stateDir, exec })).rejects.toThrow( - /--bundle-id.*iosBundleId/su, + /app-id.*appId\.ios/su, ); // Raised before `link.create`, so a call that could never deliver does not leave a pending // session behind to expire on its own TTL. expect(calls).toHaveLength(0); + + const sessions = await handleLsCommand({ stateDir }); + expect(sessions.ok).toBe(true); + if (sessions.ok) { + expect(sessions.data).toEqual([]); + } }); - test("--bundle-id without --open ios-device is a usage error", async () => { + test("--app-id only applies with --open android or --open ios-device", async () => { const { stateDir } = await startTestDaemon(); await expect( - handleLinkCommand({ open: "ios-sim", bundleId: "com.example.playground" }, { stateDir }), - ).rejects.toThrow(/"--bundle-id" only applies with "--open ios-device"/u); + handleLinkCommand({ open: "ios-sim", appId: APP_ID }, { stateDir }), + ).rejects.toThrow(/"--app-id" only applies with "--open android" or "--open ios-device"/u); await expect( - handleLinkCommand({ bundleId: "com.example.playground" }, { stateDir }), - ).rejects.toThrow(/"--bundle-id" only applies with "--open ios-device"/u); + handleLinkCommand({ appId: APP_ID }, { stateDir }), + ).rejects.toThrow(/"--app-id" only applies with "--open android" or "--open ios-device"/u); }); test("an unknown --open value names every accepted target", async () => { @@ -306,26 +392,26 @@ describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { }); test("--relaunch is opt-in: absent by default, adds --terminate-existing when passed", async () => { - const { stateDir } = await startTestDaemon({ iosBundleId: "com.example.playground" }); + const { stateDir } = await startTestDaemon(); const plainCalls: Array<{ command: string; args: string[] }> = []; await handleLinkCommand( - { open: "ios-device" }, + { open: "ios-device", appId: APP_ID }, { stateDir, exec: devicectlExec(plainCalls, [{ udid: "00008030-AAAA", name: "My iPhone" }]) }, ); expect(plainCalls[1]!.args).not.toContain("--terminate-existing"); const relaunchCalls: Array<{ command: string; args: string[] }> = []; await handleLinkCommand( - { open: "ios-device", relaunch: true }, + { open: "ios-device", appId: APP_ID, relaunch: true }, { stateDir, exec: devicectlExec(relaunchCalls, [{ udid: "00008030-AAAA", name: "My iPhone" }]) }, ); expect(relaunchCalls[1]!.args).toContain("--terminate-existing"); - // Placed before the URL, so the positional bundle id stays last. + // Placed before the URL, so the positional app id stays last. expect(relaunchCalls[1]!.args.indexOf("--terminate-existing")).toBeLessThan( relaunchCalls[1]!.args.indexOf("--payload-url"), ); - expect(relaunchCalls[1]!.args.at(-1)).toBe("com.example.playground"); + expect(relaunchCalls[1]!.args.at(-1)).toBe(APP_ID); }); test("--relaunch without --open ios-device is a usage error", async () => { @@ -340,17 +426,14 @@ describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { // `detectAdvertisedAddress` falls back to 127.0.0.1 with no routable interface. Delivering // that link points the phone at itself: the app connects to nothing, the session is never // claimed, and `wait_for_session` blocks for its whole timeout with no clue why. - const { stateDir } = await startTestDaemon({ - advertisedIp: "127.0.0.1", - iosBundleId: "com.example.playground", - }); + const { stateDir } = await startTestDaemon({ advertisedIp: "127.0.0.1" }); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); - await expect(handleLinkCommand({ open: "ios-device" }, { stateDir, exec })).rejects.toThrow( - /advertisedIp/u, - ); + await expect( + handleLinkCommand({ open: "ios-device", appId: APP_ID }, { stateDir, exec }), + ).rejects.toThrow(/advertisedIp/u); // Nothing was delivered — the phone never gets an unusable link. expect(calls).toEqual([]); @@ -373,15 +456,15 @@ describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { expect(result.ok).toBe(true); }); - test("a --bundle-id that could be read as a devicectl option is rejected before minting", async () => { + test("a --app-id that could be read as a devicectl option is rejected before minting", async () => { const { stateDir } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); await expect( - handleLinkCommand({ open: "ios-device", bundleId: "--console" }, { stateDir, exec }), - ).rejects.toThrow(/not a valid iOS bundle id/u); + handleLinkCommand({ open: "ios-device", appId: "--console" }, { stateDir, exec }), + ).rejects.toThrow(/not a valid app id/u); expect(calls).toEqual([]); }); @@ -389,7 +472,7 @@ describe("link --open ios-device: the LAN address, not 127.0.0.1", () => { describe("client link({ target: \"ios-device\" })", () => { test("the programmatic client takes the same path as the CLI: LAN address, devicectl launch", async () => { - const { stateDir } = await startTestDaemon({ iosBundleId: "com.example.playground" }); + const { stateDir } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); @@ -397,7 +480,7 @@ describe("client link({ target: \"ios-device\" })", () => { // `appduct/client`'s `link()` is what a test's globalSetup calls; it must not drift from // `appduct link` (both go through `mintLink`, and this is the test that says so). const { link } = await import("../client/bootstrap.js"); - const result = await link({ stateDir, target: "ios-device", exec, autoSpawn: false }); + const result = await link({ stateDir, target: "ios-device", appId: APP_ID, exec, autoSpawn: false }); expect(result.delivered).toBe(true); expect(result.target).toBe("ios-device"); @@ -414,11 +497,11 @@ describe("client link({ target: \"ios-device\" })", () => { "00008030-AAAA", "--payload-url", result.deepLink, - "com.example.playground", + APP_ID, ]); }); - test("the programmatic client surfaces a missing bundle id as an AppductError", async () => { + test("the programmatic client surfaces a missing app id as an AppductError", async () => { const { stateDir } = await startTestDaemon(); const calls: Array<{ command: string; args: string[] }> = []; @@ -428,7 +511,7 @@ describe("client link({ target: \"ios-device\" })", () => { await expect( link({ stateDir, target: "ios-device", exec, autoSpawn: false }), - ).rejects.toThrow(/iosBundleId/u); + ).rejects.toThrow(/app-id.*appId\.ios/su); expect(calls).toEqual([]); }); diff --git a/packages/appduct/src/__tests__/mcp-server.integration.test.ts b/packages/appduct/src/__tests__/mcp-server.integration.test.ts index e266d0ee..f4ad0919 100644 --- a/packages/appduct/src/__tests__/mcp-server.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.integration.test.ts @@ -88,6 +88,20 @@ const startTestDaemon = async (extraConfig: Record = {}): Promi return { daemon, stateDir, port: daemon.listener.port()! }; }; +/** A project root (distinct from the state dir — `appId` resolution never reads the state dir's + * config.json; see `resolveAppId`) carrying its own `.appduct/config.json`, for tests that + * exercise `appduct_connect`'s project-config tier of `appId` resolution. */ +const writeProjectConfig = async (config: Record): Promise => { + const root = await mkdtemp(path.join(tmpdir(), "appduct-mcp-project-")); + stateDirs.push(root); + + const dir = path.join(root, ".appduct"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "config.json"), JSON.stringify(config)); + + return root; +}; + const rpcCall = (socketPath: string, method: string, params?: unknown): Promise => { return new Promise((resolve, reject) => { const socket: Socket = connectUds(socketPath); @@ -236,13 +250,13 @@ const noDevicesExec: ExecFn = async (command) => { const createMcpHandle = async ( stateDir: string, exec: ExecFn = noDevicesExec, - iosBundleId?: string, + cwd?: string, ): Promise => { const handle = await createMcpServer({ stateDir, spawn: failIfCalled, scheme: "appduct", - iosBundleId, + cwd, exec, env: {}, }); @@ -617,7 +631,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { method: "tools/call", params: { name: "appduct_connect", - arguments: { target: "ios-device", bundleId: "com.example.playground" }, + arguments: { target: "ios-device", appId: "com.example.playground" }, }, }, CallToolResultSchema, @@ -698,7 +712,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { method: "tools/call", params: { name: "appduct_connect", - arguments: { target: "ios-device", bundleId: "com.example.playground" }, + arguments: { target: "ios-device", appId: "com.example.playground" }, }, }, CallToolResultSchema, @@ -790,7 +804,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { arguments: { target: "ios-device", device: "00008030-AAAA", - bundleId: "com.example.playground", + appId: "com.example.playground", }, }, }, @@ -811,7 +825,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { return { stdout: "", stderr: "" }; }; - const handle = await createMcpHandle(stateDir, exec, "com.example.playground"); + const handle = await createMcpHandle(stateDir, exec); const client = await connectInMemoryClient(handle); const delivered = await client.request( @@ -819,7 +833,12 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { method: "tools/call", params: { name: "appduct_connect", - arguments: { target: "ios-device", device: "00008030-AAAA", relaunch: true }, + arguments: { + target: "ios-device", + device: "00008030-AAAA", + appId: "com.example.playground", + relaunch: true, + }, }, }, CallToolResultSchema, @@ -840,8 +859,9 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { expect(JSON.stringify(misplaced.content)).toMatch(/relaunch.{0,4} only applies with target/u); }); - test('target "ios-device" falls back to config.json\'s iosBundleId when no bundleId is passed', async () => { + test('target "ios-device" falls back to the project config\'s appId.ios when no appId is passed', async () => { const { stateDir } = await startTestDaemon({ advertisedIp: "203.0.113.9" }); + const cwd = await writeProjectConfig({ appId: { ios: "com.example.fromconfig" } }); const calls: Array<{ command: string; args: string[] }> = []; const exec: ExecFn = async (command, args) => { @@ -849,7 +869,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { return { stdout: "", stderr: "" }; }; - const handle = await createMcpHandle(stateDir, exec, "com.example.fromconfig"); + const handle = await createMcpHandle(stateDir, exec, cwd); const client = await connectInMemoryClient(handle); const result = await client.request( @@ -857,7 +877,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { method: "tools/call", params: { name: "appduct_connect", - // An explicit device skips the listing, so this exercises the bundle-id default alone. + // An explicit device skips the listing, so this exercises the app-id default alone. arguments: { target: "ios-device", device: "00008030-BBBB" }, }, }, @@ -870,7 +890,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { expect(calls[0]!.args.at(-1)).toBe("com.example.fromconfig"); }); - test('target "ios-device" with no bundle id anywhere is an invalid_request, and mints nothing', async () => { + test('target "ios-device" with no app id anywhere is an invalid_request, and mints nothing', async () => { const { stateDir } = await startTestDaemon(); const calls: string[] = []; @@ -891,29 +911,150 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { ); expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toMatch(/iosBundleId/u); + expect(JSON.stringify(result.content)).toMatch(/appId\.ios/u); // Rejected before `link.create`, so no pending session is stranded behind a doomed call. expect(calls).toEqual([]); }); - test('"bundleId" without target "ios-device" is rejected rather than silently ignored', async () => { + test('"appId" is rejected with target "ios-sim" or "none", which need no app id', async () => { const { stateDir } = await startTestDaemon(); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); - const result = await client.request( + const withIosSim = await client.request( + { + method: "tools/call", + params: { + name: "appduct_connect", + arguments: { target: "ios-sim", appId: "com.example.playground" }, + }, + }, + CallToolResultSchema, + ); + + expect(withIosSim.isError).toBe(true); + expect(JSON.stringify(withIosSim.content)).toMatch(/appId.{0,4} only applies with target/u); + + const withNone = await client.request( + { + method: "tools/call", + params: { + name: "appduct_connect", + arguments: { target: "none", appId: "com.example.playground" }, + }, + }, + CallToolResultSchema, + ); + + expect(withNone.isError).toBe(true); + expect(JSON.stringify(withNone.content)).toMatch(/appId.{0,4} only applies with target/u); + }); + + test('target "android" needs an "appId" just like "ios-device" does', async () => { + const { stateDir } = await startTestDaemon(); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + + if (args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n\n", stderr: "" }; + } + + return { stdout: "", stderr: "" }; + }; + + const handle = await createMcpHandle(stateDir, exec); + const client = await connectInMemoryClient(handle); + + const missing = await client.request( + { method: "tools/call", params: { name: "appduct_connect", arguments: { target: "android" } } }, + CallToolResultSchema, + ); + + expect(missing.isError).toBe(true); + expect(JSON.stringify(missing.content)).toMatch(/appId\.android/u); + expect(calls).toEqual([]); + + const delivered = await client.request( { method: "tools/call", params: { name: "appduct_connect", - arguments: { target: "ios-sim", bundleId: "com.example.playground" }, + arguments: { target: "android", appId: "com.example.playground" }, }, }, CallToolResultSchema, ); + expect(delivered.isError).not.toBe(true); + expect((delivered.structuredContent as { delivered?: true }).delivered).toBe(true); + expect(calls.at(-1)!.args.slice(-2)).toEqual(["-p", "com.example.playground"]); + }); + + test("the zero-argument auto-detected android path resolves appId from the project config", async () => { + const { stateDir } = await startTestDaemon(); + const cwd = await writeProjectConfig({ appId: { android: "com.example.fromconfig" } }); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + + if (command === "xcrun") { + return { stdout: JSON.stringify({ devices: {} }), stderr: "" }; + } + + if (args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n\n", stderr: "" }; + } + + return { stdout: "", stderr: "" }; + }; + + const handle = await createMcpHandle(stateDir, exec, cwd); + const client = await connectInMemoryClient(handle); + + const result = await client.request( + { method: "tools/call", params: { name: "appduct_connect", arguments: {} } }, + CallToolResultSchema, + ); + + const data = result.structuredContent as { delivered?: true; autoDetected?: true; target?: string }; + expect(result.isError).not.toBe(true); + expect(data.delivered).toBe(true); + expect(data.autoDetected).toBe(true); + expect(data.target).toBe("android"); + expect(calls.at(-1)!.args.slice(-2)).toEqual(["-p", "com.example.fromconfig"]); + }); + + test("the zero-argument auto-detected android path is a clear invalid_request when appId cannot be resolved", async () => { + const { stateDir } = await startTestDaemon(); + + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + + if (command === "xcrun") { + return { stdout: JSON.stringify({ devices: {} }), stderr: "" }; + } + + if (args[0] === "devices") { + return { stdout: "List of devices attached\nemulator-5554\tdevice\n\n", stderr: "" }; + } + + return { stdout: "", stderr: "" }; + }; + + const handle = await createMcpHandle(stateDir, exec); + const client = await connectInMemoryClient(handle); + + const result = await client.request( + { method: "tools/call", params: { name: "appduct_connect", arguments: {} } }, + CallToolResultSchema, + ); + expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toMatch(/bundleId.{0,4} only applies with target/u); + expect(JSON.stringify(result.content)).toMatch(/appId\.android/u); }); test("an omitted target never picks a physical iPhone, and says so in the QR note", async () => { @@ -942,7 +1083,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { // ...and the note has to tell the agent that the target exists but must be asked for. expect(data.note).toMatch(/never auto-detected/iu); expect(data.note).toMatch(/ios-device/u); - expect(data.note).toMatch(/bundleId/u); + expect(data.note).toMatch(/appId/u); }); test("appduct_wait_for_session resolves once a fake client claims the minted session", async () => { diff --git a/packages/appduct/src/__tests__/open-target.test.ts b/packages/appduct/src/__tests__/open-target.test.ts index 198552bd..d706cc05 100644 --- a/packages/appduct/src/__tests__/open-target.test.ts +++ b/packages/appduct/src/__tests__/open-target.test.ts @@ -14,12 +14,14 @@ import { detectBootedTargets, isLoopbackAddress, isOpenTarget, + isValidAppId, + platformOf, usesLoopbackAddress, type ExecFn, } from "../cli/open-target.js"; const DEEP_LINK = "playground:///?appduct=abc123"; -const BUNDLE_ID = "com.example.playground"; +const APP_ID = "com.example.playground"; const bootedSimJson = JSON.stringify({ devices: { "iOS 17.0": [{ state: "Booted", udid: "AAAA", name: "iPhone 15" }] }, @@ -102,7 +104,7 @@ const devicectlListing = (devices: DevicectlFixtureDevice[]): unknown => ({ const launchArgs = ( udid: string, deepLink: string, - bundleId: string, + appId: string, options: { relaunch?: boolean } = {}, ): string[] => [ "devicectl", @@ -114,7 +116,7 @@ const launchArgs = ( ...(options.relaunch ? ["--terminate-existing"] : []), "--payload-url", deepLink, - bundleId, + appId, ]; /** The directory `runDevicectlJson` creates and must remove; asserted gone after every call. */ @@ -250,7 +252,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }); @@ -260,7 +262,7 @@ describe("deliverToOpenTarget: ios-device", () => { expect(calls[0]!.args[5]).toBe("--json-output"); expect(calls[1]).toEqual({ command: "xcrun", - args: launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID), + args: launchArgs("00008030-AAAA", DEEP_LINK, APP_ID), }); // The deep link is one argv element, unquoted: `execFile` never re-tokenizes and, unlike @@ -276,7 +278,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }); @@ -296,7 +298,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/no devices are available/u); @@ -315,12 +317,12 @@ describe("deliverToOpenTarget: ios-device", () => { deepLink: DEEP_LINK, wssPort: 8443, device: "00008030-BBBB", - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }); expect(calls).toEqual([ - { command: "xcrun", args: launchArgs("00008030-BBBB", DEEP_LINK, BUNDLE_ID) }, + { command: "xcrun", args: launchArgs("00008030-BBBB", DEEP_LINK, APP_ID) }, ]); }); @@ -333,7 +335,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/no connected ios device was found/iu); @@ -356,7 +358,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/My iPhone \(00008030-AAAA\).*Test iPad \(00008030-BBBB\)/su); @@ -364,13 +366,13 @@ describe("deliverToOpenTarget: ios-device", () => { expect(calls).toHaveLength(1); }); - test("missing bundle id: a usage error naming both ways to supply one, before anything is run", async () => { + test("missing app id: a usage error naming both ways to supply one, before anything is run", async () => { const calls: Array<{ command: string; args: string[] }> = []; const exec = devicectlExec(calls, devicectlListing([{ udid: "00008030-AAAA", name: "My iPhone" }])); await expect( deliverToOpenTarget({ target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, exec }), - ).rejects.toThrow(/--bundle-id.*iosBundleId/su); + ).rejects.toThrow(/--app-id.*appId.*appId\./su); // Checked first: spending devicectl's list timeout would only delay the same error. expect(calls).toHaveLength(0); @@ -386,7 +388,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/"xcrun" was not found on PATH/u); @@ -405,7 +407,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/is not installed/u); @@ -422,7 +424,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }); @@ -438,7 +440,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/no connected ios device was found/iu); @@ -458,7 +460,7 @@ describe("deliverToOpenTarget: ios-device", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec, }), ).rejects.toThrow(/no connected ios device was found/iu); @@ -477,7 +479,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, - bundleId: BUNDLE_ID, + appId: APP_ID, exec: devicectlExec(calls, devicectlListing(devices)), }); }; @@ -498,7 +500,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { ); expect(calls).toHaveLength(2); - expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); }); test("unavailable and unpaired devices are ignored, leaving the one that is actually reachable", async () => { @@ -514,7 +516,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { ); expect(calls).toHaveLength(2); - expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); }); test("platform matching is case-insensitive, so an \"ios\" spelling is still deliverable", async () => { @@ -522,7 +524,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { await deliverTo([{ udid: "00008030-AAAA", name: "My iPhone", platform: "ios" }], calls); - expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); }); test("a listing with none of the filter fields keeps the device rather than silently finding nothing", async () => { @@ -532,7 +534,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { // right failure is a loud launch error, not a silent "no device found" on a plugged-in phone. await deliverTo([{ udid: "00008030-AAAA", name: "My iPhone", platform: null }], calls); - expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); }); test("only disconnected/non-iOS entries: reports none found, and says what was ignored", async () => { @@ -601,7 +603,7 @@ describe("deliverToOpenTarget: ios-device listing hygiene", () => { ); expect(calls).toHaveLength(2); - expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[1]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); }); }); @@ -615,7 +617,7 @@ describe("deliverToOpenTarget: ios-device --relaunch", () => { deepLink: DEEP_LINK, wssPort: 8443, device: "00008030-AAAA", - bundleId: BUNDLE_ID, + appId: APP_ID, relaunch, exec: devicectlExec(calls, devicectlListing([])), }); @@ -627,7 +629,7 @@ describe("deliverToOpenTarget: ios-device --relaunch", () => { // A plain `process launch` is what the vendored Expo CLI does, so it is the better-attested // default; terminating a running app is a behaviour nobody should get without asking. - expect(calls[0]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID)); + expect(calls[0]!.args).toEqual(launchArgs("00008030-AAAA", DEEP_LINK, APP_ID)); expect(calls[0]!.args).not.toContain("--terminate-existing"); }); @@ -636,7 +638,7 @@ describe("deliverToOpenTarget: ios-device --relaunch", () => { await deliver(true, calls); expect(calls[0]!.args).toEqual( - launchArgs("00008030-AAAA", DEEP_LINK, BUNDLE_ID, { relaunch: true }), + launchArgs("00008030-AAAA", DEEP_LINK, APP_ID, { relaunch: true }), ); }); @@ -664,33 +666,33 @@ describe("isLoopbackAddress", () => { }); }); -describe("deliverToOpenTarget: ios-device bundle id validation", () => { - const deliverWithBundleId = (bundleId: string, calls: Array<{ command: string; args: string[] }>) => +describe("deliverToOpenTarget: ios-device app id validation", () => { + const deliverWithAppId = (appId: string, calls: Array<{ command: string; args: string[] }>) => deliverToOpenTarget({ target: "ios-device", deepLink: DEEP_LINK, wssPort: 8443, device: "00008030-AAAA", - bundleId, + appId, exec: devicectlExec(calls, devicectlListing([])), }); - test("a bundle id starting with a dash is rejected before it can be read as a devicectl option", async () => { + test("an app id starting with a dash is rejected before it can be read as a devicectl option", async () => { const calls: Array<{ command: string; args: string[] }> = []; - // The bundle id is the launch argv's only trailing positional: `--console` here would not be + // The app id is the launch argv's only trailing positional: `--console` here would not be // "the app to launch", it would change what the command does. - await expect(deliverWithBundleId("--console", calls)).rejects.toThrow(/not a valid iOS bundle id/u); - await expect(deliverWithBundleId("-x", calls)).rejects.toThrow(/not a valid iOS bundle id/u); + await expect(deliverWithAppId("--console", calls)).rejects.toThrow(/not a valid app id/u); + await expect(deliverWithAppId("-x", calls)).rejects.toThrow(/not a valid app id/u); expect(calls).toEqual([]); }); - test("bundle ids containing shell/argv metacharacters or whitespace are rejected", async () => { + test("app ids containing shell/argv metacharacters or whitespace are rejected", async () => { const calls: Array<{ command: string; args: string[] }> = []; for (const bad of ["com.example app", "com.example;rm -rf /", "com.example/../x", "com.$(id)"]) { - await expect(deliverWithBundleId(bad, calls)).rejects.toThrow(/not a valid iOS bundle id/u); + await expect(deliverWithAppId(bad, calls)).rejects.toThrow(/not a valid app id/u); } expect(calls).toEqual([]); @@ -699,14 +701,14 @@ describe("deliverToOpenTarget: ios-device bundle id validation", () => { test("ordinary reverse-DNS bundle ids, including digits and hyphens, are accepted", async () => { for (const good of ["com.example.playground", "com.example.my-app", "com.example.App2"]) { const calls: Array<{ command: string; args: string[] }> = []; - await deliverWithBundleId(good, calls); + await deliverWithAppId(good, calls); expect(calls[0]!.args.at(-1)).toBe(good); } }); }); describe("deliverToOpenTarget: android", () => { - test("happy path with ANDROID_SERIAL set: adb reverse before am start, deep link single-quoted", async () => { + test("happy path with ANDROID_SERIAL set: adb reverse before am start, deep link single-quoted, -p last", async () => { const calls: Array<{ command: string; args: string[] }> = []; const exec: ExecFn = async (command, args) => { calls.push({ command, args }); @@ -717,6 +719,7 @@ describe("deliverToOpenTarget: android", () => { target: "android", deepLink: DEEP_LINK, wssPort: 8443, + appId: APP_ID, exec, env: { ANDROID_SERIAL: "emulator-5554" }, }); @@ -725,7 +728,17 @@ describe("deliverToOpenTarget: android", () => { { command: "adb", args: ["reverse", "tcp:8443", "tcp:8443"] }, { command: "adb", - args: ["shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", `'${DEEP_LINK}'`], + args: [ + "shell", + "am", + "start", + "-a", + "android.intent.action.VIEW", + "-d", + `'${DEEP_LINK}'`, + "-p", + APP_ID, + ], }, ]); }); @@ -742,11 +755,19 @@ describe("deliverToOpenTarget: android", () => { return { stdout: "", stderr: "" }; }; - await deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, exec, env: {} }); + await deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: APP_ID, + exec, + env: {}, + }); expect(calls[0]).toEqual({ command: "adb", args: ["devices"] }); expect(calls[1]!.args).toEqual(["reverse", "tcp:8443", "tcp:8443"]); expect(calls[2]!.args).not.toContain("-s"); + expect(calls[2]!.args.slice(-2)).toEqual(["-p", APP_ID]); }); test("no device attached: a clear error, nothing else runs", async () => { @@ -757,7 +778,7 @@ describe("deliverToOpenTarget: android", () => { }; await expect( - deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, exec, env: {} }), + deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, appId: APP_ID, exec, env: {} }), ).rejects.toThrow(/no android device or emulator is attached/iu); expect(calls).toEqual(["adb devices"]); @@ -775,7 +796,7 @@ describe("deliverToOpenTarget: android", () => { }; await expect( - deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, exec, env: {} }), + deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, appId: APP_ID, exec, env: {} }), ).rejects.toThrow(/emulator-5554.*ZY3239|ZY3239.*emulator-5554/su); }); @@ -791,6 +812,7 @@ describe("deliverToOpenTarget: android", () => { deepLink: DEEP_LINK, wssPort: 8443, device: "emulator-5554", + appId: APP_ID, exec, env: {}, }); @@ -809,6 +831,8 @@ describe("deliverToOpenTarget: android", () => { "android.intent.action.VIEW", "-d", `'${DEEP_LINK}'`, + "-p", + APP_ID, ], }, ]); @@ -820,9 +844,168 @@ describe("deliverToOpenTarget: android", () => { }; await expect( - deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, exec, env: {} }), + deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, appId: APP_ID, exec, env: {} }), ).rejects.toThrow(/"adb" was not found on PATH/u); }); + + test("missing app id: a usage error naming every way to supply one, no adb call at all", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, exec, env: {} }), + ).rejects.toThrow(/--app-id.*appId.*appId\./su); + + expect(calls).toEqual([]); + }); + + test("a malformed app id is rejected before any adb call, including the `adb devices` preflight", async () => { + for (const bad of ["--console", "com.x; rm -rf /", "com.x foo"]) { + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "List of devices attached\nemulator-5554\tdevice\n\n", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ target: "android", deepLink: DEEP_LINK, wssPort: 8443, appId: bad, exec, env: {} }), + ).rejects.toThrow(/not a valid app id/u); + + expect(calls).toEqual([]); + expect(isValidAppId(bad)).toBe(false); + } + }); + + test('"am start" reporting an unresolved intent throws and names the package, even though adb exits 0', async () => { + const exec: ExecFn = async (command, args) => { + if (args.includes("start")) { + return { + stdout: + "Starting: Intent { act=android.intent.action.VIEW dat=playground:/// pkg=com.example.playground }\n" + + "Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW }", + stderr: "", + }; + } + + return { stdout: "", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: APP_ID, + exec, + env: { ANDROID_SERIAL: "emulator-5554" }, + }), + ).rejects.toThrow(new RegExp(`${APP_ID}.*unable to resolve Intent`, "su")); + }); + + test('an "Error:" line on stderr (rather than stdout) is caught the same way', async () => { + const exec: ExecFn = async (command, args) => { + if (args.includes("start")) { + return { stdout: "", stderr: "Error: Activity class does not exist." }; + } + + return { stdout: "", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: APP_ID, + exec, + env: { ANDROID_SERIAL: "emulator-5554" }, + }), + ).rejects.toThrow(/Activity class does not exist/u); + }); + + test("a refused launch (SecurityException) is caught even when adb drops the exit status", async () => { + const exec: ExecFn = async (command, args) => { + if (args.includes("start")) { + return { + stdout: "Starting: Intent { act=android.intent.action.VIEW }\n", + stderr: + "Exception occurred while executing 'start':\n" + + "java.lang.SecurityException: Permission Denial: starting Intent { ... } not exported", + }; + } + + return { stdout: "", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: APP_ID, + exec, + env: { ANDROID_SERIAL: "emulator-5554" }, + }), + ).rejects.toThrow(/Permission Denial/u); + }); + + test('"Warning: ... delivered to currently running top-most instance" is a successful delivery', async () => { + // A `singleTask` activity (React Native's default) that is already running receives the link + // in `onNewIntent`; `am` reports that as a warning, not a failure. + const exec: ExecFn = async (command, args) => { + if (args.includes("start")) { + return { + stdout: + "Starting: Intent { act=android.intent.action.VIEW }\n" + + "Warning: Activity not started, intent has been delivered to currently running top-most instance.\n", + stderr: "", + }; + } + + return { stdout: "", stderr: "" }; + }; + + await expect( + deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: APP_ID, + exec, + env: { ANDROID_SERIAL: "emulator-5554" }, + }), + ).resolves.toBeUndefined(); + }); + + test("an Android package name with underscores is accepted", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const exec: ExecFn = async (command, args) => { + calls.push({ command, args }); + return { stdout: "", stderr: "" }; + }; + + await deliverToOpenTarget({ + target: "android", + deepLink: DEEP_LINK, + wssPort: 8443, + appId: "com.my_company.my_app", + exec, + env: { ANDROID_SERIAL: "emulator-5554" }, + }); + + expect(calls.at(-1)?.args.slice(-2)).toEqual(["-p", "com.my_company.my_app"]); + }); +}); + +describe("platformOf", () => { + test("maps android/ios-device to their appId. key, and ios-sim to nothing", () => { + expect(platformOf("android")).toBe("android"); + expect(platformOf("ios-device")).toBe("ios"); + expect(platformOf("ios-sim")).toBeUndefined(); + }); }); describe("detectBootedTargets", () => { diff --git a/packages/appduct/src/__tests__/playground-fixture.test.ts b/packages/appduct/src/__tests__/playground-fixture.test.ts index 371d2c81..6c603364 100644 --- a/packages/appduct/src/__tests__/playground-fixture.test.ts +++ b/packages/appduct/src/__tests__/playground-fixture.test.ts @@ -3,22 +3,32 @@ import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; -import { getSpkiPinFromPrivateKeyPem } from "../spki-pin.js"; - const playgroundPath = (path: string): string => fileURLToPath(new URL(`../../../../playground/${path}`, import.meta.url)); -describe("playground Appduct fixture", () => { - test("pins the committed host key in its Expo configuration", async () => { - const [keyPem, appConfig] = await Promise.all([ - readFile(playgroundPath(".appduct/key.pem"), "utf8"), +describe("playground Appduct config", () => { + test("records the scheme and app ids app.json declares in its project config", async () => { + const [projectConfig, appConfig] = await Promise.all([ + readFile(playgroundPath(".appduct/config.json"), "utf8"), readFile(playgroundPath("app.json"), "utf8"), ]); - const cliPins = JSON.parse(appConfig).expo.plugins.find( + const { expo } = JSON.parse(appConfig); + + // `link --open android` must work without `--app-id`, and against the app this config builds. + expect(JSON.parse(projectConfig)).toEqual({ + scheme: expo.scheme, + appId: { ios: expo.ios.bundleIdentifier, android: expo.android.package }, + }); + }); + + test("takes the zero-config trust path: no embedded pins", async () => { + const { expo } = JSON.parse(await readFile(playgroundPath("app.json"), "utf8")); + const pluginOptions = expo.plugins.find( (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "@appduct/react-native", - )[1].cliPins; + )[1]; - expect(cliPins).toContain(getSpkiPinFromPrivateKeyPem(keyPem)); + expect(pluginOptions.cliPins).toBeUndefined(); + expect(pluginOptions.trust).toBeUndefined(); }); }); diff --git a/packages/appduct/src/__tests__/scheme.test.ts b/packages/appduct/src/__tests__/scheme.test.ts index cea04aee..46e1fdd1 100644 --- a/packages/appduct/src/__tests__/scheme.test.ts +++ b/packages/appduct/src/__tests__/scheme.test.ts @@ -11,12 +11,15 @@ import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { + describeMissingAppId, describeMissingScheme, discoverExpoScheme, findProjectConfig, findProjectConfigs, isValidScheme, + readProjectConfigAppId, readProjectConfigScheme, + resolveAppId, resolveScheme, resolveSchemeOrThrow, } from "../scheme.js"; @@ -299,6 +302,56 @@ describe("readProjectConfigScheme", () => { }); }); +describe("readProjectConfigAppId", () => { + test("reads the platform-specific value", async () => { + const dir = await makeDir(); + const configPath = await writeProjectConfig(dir, { + appId: { ios: "com.example.ios", android: "com.example.android" }, + }); + + expect(await readProjectConfigAppId(configPath, "ios")).toBe("com.example.ios"); + expect(await readProjectConfigAppId(configPath, "android")).toBe("com.example.android"); + }); + + test("returns undefined for a platform with no entry, without touching the other", async () => { + const dir = await makeDir(); + const configPath = await writeProjectConfig(dir, { appId: { android: "com.example.android" } }); + + expect(await readProjectConfigAppId(configPath, "ios")).toBeUndefined(); + expect(await readProjectConfigAppId(configPath, "android")).toBe("com.example.android"); + }); + + test("a config with scheme but no appId at all resolves as nothing found, for either platform", async () => { + const dir = await makeDir(); + const configPath = await writeProjectConfig(dir, { scheme: "myapp" }); + + expect(await readProjectConfigAppId(configPath, "ios")).toBeUndefined(); + expect(await readProjectConfigAppId(configPath, "android")).toBeUndefined(); + }); + + test.each([ + ["appId is a string, not an object", { appId: "com.example.app" }, ["ios", "android"]], + ["appId is an array", { appId: ["com.example.app"] }, ["ios", "android"]], + ["appId.ios is not a string", { appId: { ios: 7 } }, ["ios"]], + ["appId.android is an empty string", { appId: { android: "" } }, ["android"]], + ] as const)("throws for %s — this is Appduct's own file", async (_label, value, badPlatforms) => { + const dir = await makeDir(); + const configPath = await writeProjectConfig(dir, value); + + for (const platform of badPlatforms) { + await expect(readProjectConfigAppId(configPath, platform)).rejects.toThrow(); + } + }); + + test("throws for an unknown key inside appId, so a typo'd platform name never reads as \"unset\"", async () => { + const dir = await makeDir(); + const configPath = await writeProjectConfig(dir, { appId: { andriod: "com.example.app" } }); + + await expect(readProjectConfigAppId(configPath, "android")).rejects.toThrow(/andriod/u); + await expect(readProjectConfigAppId(configPath, "ios")).rejects.toThrow(/andriod/u); + }); +}); + describe("resolveScheme precedence", () => { /** An app root carrying a scheme at every one of the five sources at once. */ const makeFullyLoadedRoot = async (): Promise<{ cwd: string; stateDir: string }> => { @@ -475,6 +528,113 @@ describe("resolveSchemeOrThrow", () => { }); }); +describe("resolveAppId", () => { + test("the flag wins over the project config", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const dir = await makeDir(); + await writeProjectConfig(dir, { appId: { android: "com.example.fromconfig" } }); + + const resolved = await resolveAppId({ + platform: "android", + flagAppId: "com.example.fromflag", + cwd: dir, + homeDir, + stateDirRoot, + }); + + expect(resolved).toMatchObject({ appId: "com.example.fromflag", source: "flag" }); + }); + + test("falls back to the nearest project config's appId., selected per platform", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const dir = await makeDir(); + await writeProjectConfig(dir, { + appId: { ios: "com.example.ios", android: "com.example.android" }, + }); + + const ios = await resolveAppId({ platform: "ios", cwd: dir, homeDir, stateDirRoot }); + const android = await resolveAppId({ platform: "android", cwd: dir, homeDir, stateDirRoot }); + + expect(ios).toMatchObject({ appId: "com.example.ios", source: "project-config" }); + expect(android).toMatchObject({ appId: "com.example.android", source: "project-config" }); + }); + + test("walks up to a parent project config, same as resolveScheme", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const root = await makeDir(); + const cwd = path.join(root, "packages", "app"); + await mkdir(cwd, { recursive: true }); + await writeProjectConfig(root, { appId: { android: "com.example.fromparent" } }); + + const resolved = await resolveAppId({ platform: "android", cwd, homeDir, stateDirRoot }); + + expect(resolved).toMatchObject({ appId: "com.example.fromparent", source: "project-config" }); + }); + + test("nothing found: appId is undefined, and every location tried is reported", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const dir = await makeDir(); + + const resolved = await resolveAppId({ platform: "ios", cwd: dir, homeDir, stateDirRoot }); + + expect(resolved.appId).toBeUndefined(); + expect(resolved.tried.some((location) => location.includes("--app-id"))).toBe(true); + }); + + test("a config with scheme but no appId behaves as nothing resolved, not an error", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const dir = await makeDir(); + await writeProjectConfig(dir, { scheme: "myapp" }); + + const resolved = await resolveAppId({ platform: "android", cwd: dir, homeDir, stateDirRoot }); + + expect(resolved.appId).toBeUndefined(); + }); + + test("does not apply charset validation — that happens at the use site", async () => { + const { homeDir, stateDirRoot } = await isolatedLookup(); + const dir = await makeDir(); + + // A syntactically wrong-but-safe id is not this function's job to reject. + const resolved = await resolveAppId({ + platform: "android", + flagAppId: "not a valid package id!!", + cwd: dir, + homeDir, + stateDirRoot, + }); + + expect(resolved.appId).toBe("not a valid package id!!"); + }); +}); + +describe("describeMissingAppId", () => { + test("names the platform, numbers the locations, and names every fix", () => { + const message = describeMissingAppId("android", [ + "the --app-id flag", + ".appduct/config.json (searched upwards from /app)", + ]); + + expect(message).toContain("Android"); + expect(message).toContain("1. the --app-id flag"); + expect(message).toContain("2. .appduct/config.json (searched upwards from /app)"); + expect(message).toContain("--app-id"); + expect(message).toContain("appId.android"); + expect(message).toContain("--android-app-id"); + }); + + test("names the ios flag/key for the ios platform", () => { + const message = describeMissingAppId("ios", []); + + expect(message).toContain("appId.ios"); + expect(message).toContain("--ios-app-id"); + }); + + test("omits the list rather than printing an empty one", () => { + expect(describeMissingAppId("android", [])).not.toContain("Looked in"); + }); +}); + describe("describeMissingScheme", () => { test("numbers the locations and names every way to set one", () => { const message = describeMissingScheme(["the --scheme flag", "/tmp/app.json"]); diff --git a/packages/appduct/src/cli/create-cli.ts b/packages/appduct/src/cli/create-cli.ts index a5017d3c..093a98db 100644 --- a/packages/appduct/src/cli/create-cli.ts +++ b/packages/appduct/src/cli/create-cli.ts @@ -25,7 +25,15 @@ export const createCli = () => { "APPDUCT_SCHEME, and no walk-up: init decides what to write here, so it never bakes " + "an ambient value into a committed file.", ) - .option("--force", "Replace the scheme already recorded in the project config."); + .option("--force", "Replace the scheme already recorded in the project config.") + .option( + "--ios-app-id ", + "iOS bundle id to write as \"appId.ios\" (needed to deliver a link with --open ios-device).", + ) + .option( + "--android-app-id ", + "Android package name to write as \"appId.android\" (needed to deliver a link with --open android).", + ); cli .command("keygen", "Generate an Appduct host private key and print its app fingerprint.") @@ -46,8 +54,8 @@ export const createCli = () => { ) .option("--device ", "adb serial, simulator udid or paired-device udid to target when --open is ambiguous.") .option( - "--bundle-id ", - "App bundle id for --open ios-device (default: config.json's \"iosBundleId\").", + "--app-id ", + "Installed app id for --open android/ios-device (default: .appduct/config.json's \"appId.\").", ) .option( "--relaunch", diff --git a/packages/appduct/src/cli/open-target.ts b/packages/appduct/src/cli/open-target.ts index fdcaa3fa..d4a1d3d8 100644 --- a/packages/appduct/src/cli/open-target.ts +++ b/packages/appduct/src/cli/open-target.ts @@ -41,6 +41,24 @@ export const usesLoopbackAddress = (target: OpenTarget): boolean => { return target === "android" || target === "ios-sim"; }; +/** + * The `appId.` config key a target's app id is read from — the single place that maps a + * delivery target to the platform whose app id it needs. `undefined` for `ios-sim`: `simctl + * openurl` has no equivalent of `-p`/a bundle-id argument, so it needs no app id and none should + * ever be required for it. + */ +export const platformOf = (target: OpenTarget): "ios" | "android" | undefined => { + if (target === "android") { + return "android"; + } + + if (target === "ios-device") { + return "ios"; + } + + return undefined; +}; + /** * Whether an advertised address is one that only resolves back to the machine that minted the link. * `daemon/address.ts` falls back to `127.0.0.1` when it cannot find a routable interface, and such @@ -328,28 +346,50 @@ const listPairedIosDevices = async (exec: ExecFn): Promise => const describeIosDevice = (device: PairedIosDevice): string => `${device.name} (${device.udid})`; -/** The single wording naming both ways to supply a bundle id, so the CLI and MCP paths agree. */ -export const MISSING_BUNDLE_ID_MESSAGE = - "Delivering to a physical iPhone needs the app's bundle id: pass \"--bundle-id \" (or " + - '"bundleId" over MCP), or set "iosBundleId" in config.json.'; +/** + * The single wording naming every way to supply an app id, shared by the `android` and + * `ios-device` paths (and by the CLI/MCP/programmatic callers that resolve one before minting a + * link) so the fix cannot drift between them. This is the *last-line-of-defence* message: the + * normal path is `scheme.ts`'s `resolveAppId`/`describeMissingAppId`, which names the exact + * project-config locations it looked in and fires before a link is even minted. This one covers a + * caller that reaches `deliverToOpenTarget` some other way (a test, or a future direct caller) + * with no app id at all. + */ +export const MISSING_APP_ID_MESSAGE = + "Delivering to a device needs the installed app's id (the Android package name, or the iOS " + + 'bundle id): pass "--app-id " (or "appId" over MCP), or record it as "appId." in ' + + '.appduct/config.json — run `appduct init --android-app-id --ios-app-id ` to write it.'; /** - * A bundle id is the **only trailing positional** in the `devicectl device process launch` argv, so - * a value beginning with `-` would be read by `devicectl` as an option rather than as the app to - * launch — `--console`, say, silently changing what the command does. Apple's own grammar for a - * bundle identifier is alphanumerics, `.` and `-`, which never legitimately starts with `-`, so - * enforcing exactly that shape closes the hole at every entry point. + * This pattern guards two unrelated hazards, one per platform, and is deliberately a superset of + * both platforms' own id grammars rather than a spelling check for either: * - * A `--` terminator is deliberately *not* used instead: `devicectl` takes launch arguments after - * the bundle id, its handling of `--` is undocumented, and none of it can be verified without - * hardware — whereas this check is exact and fully testable. + * - `ios-device`: an app id is the **only trailing positional** in the `devicectl device process + * launch` argv, so a value beginning with `-` would be read by `devicectl` as an option rather + * than as the app to launch — `--console`, say, silently changing what the command does. + * - `android`: an app id ends up inside the single string `adb shell` reconstructs and re-parses + * on the *device's own shell* (see the `-d` single-quoting a few lines below and + * ARCHITECTURE.md §8) — a space or `;` there is command injection on the device, not a local + * argv-splitting bug. + * + * Apple's bundle-id grammar and Android's `applicationId` grammar disagree with each other (Android + * forbids `-` but allows `_`, as in `com.my_company.app`; iOS is the other way round), so this + * pattern accepts the union of both rather than picking one platform's spelling rules to enforce on + * the other. That makes it a safety check + * on the local argv and the remote shell, not a validity check on the id itself: a value that is + * syntactically safe but semantically wrong for its platform is not caught here — it fails loudly + * at `am start`/`devicectl` instead, which is an acceptable and much simpler place to find out. + * + * A `--` terminator is deliberately *not* used instead of this check on the `ios-device` side: + * `devicectl` takes launch arguments after the bundle id, its handling of `--` is undocumented, and + * none of it can be verified without hardware — whereas this check is exact and fully testable. */ -const BUNDLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]*$/u; +const APP_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; -export const isValidBundleId = (value: string): boolean => BUNDLE_ID_PATTERN.test(value); +export const isValidAppId = (value: string): boolean => APP_ID_PATTERN.test(value); -export const invalidBundleIdMessage = (value: string): string => - `"${value}" is not a valid iOS bundle id (letters, digits, "." and "-", starting with a letter ` + +export const invalidAppIdMessage = (value: string): string => + `"${value}" is not a valid app id (letters, digits, ".", "_" and "-", starting with a letter ` + "or digit)."; /** Same ambiguity rules as `deliverIosSim`: exactly one paired device, or the caller names one. */ @@ -388,19 +428,19 @@ const deliverIosDevice = async ( exec: ExecFn, deepLink: string, device: string | undefined, - bundleId: string | undefined, + appId: string | undefined, relaunch: boolean, ): Promise => { - // Checked before any enumeration: without a bundle id the launch cannot happen at all, so + // Checked before any enumeration: without an app id the launch cannot happen at all, so // spending a `devicectl list devices` timeout first would only delay the same error. - if (!bundleId) { - throw usageError(MISSING_BUNDLE_ID_MESSAGE); + if (!appId) { + throw usageError(MISSING_APP_ID_MESSAGE); } // Last line of defence: every caller validates too, but this is the one place the value reaches // an argv, so it is the one place that must not be bypassable. - if (!isValidBundleId(bundleId)) { - throw usageError(invalidBundleIdMessage(bundleId)); + if (!isValidAppId(appId)) { + throw usageError(invalidAppIdMessage(appId)); } // An explicit udid is honored without a preflight, mirroring the `ios-sim`/`android` passthrough. @@ -422,7 +462,7 @@ const deliverIosDevice = async ( ...(relaunch ? ["--terminate-existing"] : []), "--payload-url", deepLink, - bundleId, + appId, ]); }; @@ -491,20 +531,47 @@ const withSerial = (args: string[], serial: string | undefined): string[] => { return serial ? ["-s", serial, ...args] : args; }; +/** + * `am` does not reliably exit non-zero for an intent it could not resolve — the whole reason this + * change exists is that an implicit (schemeless-of-package) `am start` can pop an "Open with" + * chooser and still report success. Naming the package with `-p` turns that into a hard resolution + * failure instead of a chooser, but only if the *output* is checked too: an unresolvable `-p` still + * exits 0 and writes `Error: Activity not started, unable to resolve Intent { ... }` to stdout, and + * without this check that reproduces the exact silent hang (`appduct_wait_for_session` blocking its + * whole timeout with nothing explaining why) this change exists to fix. + * + * The other two prefixes cover the launch being *refused* rather than unresolved — typically a + * `SecurityException` for an activity that is not exported. Current `am` reports that as + * "Exception occurred while executing 'start':" and exits non-zero (which `run` already turns into + * an error), but an `adb` without the shell protocol drops the remote exit status, so the output is + * the only reliable signal there too. "Warning: Activity not started, intent has been delivered to + * currently running top-most instance" is deliberately *not* matched: that is a `singleTask` app + * (React Native's default) receiving the link in `onNewIntent`, i.e. a successful delivery. + */ +const AM_START_ERROR_PATTERN = /^(?:Error:|Exception occurred while executing|Security exception:)/mu; + const deliverAndroid = async ( exec: ExecFn, deepLink: string, wssPort: number, device: string | undefined, env: NodeJS.ProcessEnv, + appId: string, ): Promise => { + // Checked before any adb call (mirroring the `ios-device` last-line-of-defence comment above): + // every caller validates too, but this is the one place the value reaches both a local argv and, + // via `adb shell`'s remote reparse, the device's own shell. + if (!isValidAppId(appId)) { + throw usageError(invalidAppIdMessage(appId)); + } + const serial = await resolveAndroidSerial(exec, device, env); // `adb reverse` before `am start`: the port forward must exist before the app tries to connect, // which can happen the instant the deep link is handled. await run(exec, "adb", withSerial(["reverse", `tcp:${wssPort}`, `tcp:${wssPort}`], serial)); - await run( + const { stdout, stderr } = await run( exec, "adb", withSerial( @@ -519,10 +586,23 @@ const deliverAndroid = async ( // device's own shell; single-quoting the URL here (not just for local execFile, which never // re-tokenizes argv) protects against that second, remote parse (ARCHITECTURE.md §8). `'${deepLink}'`, + // Names the app explicitly: without this, more than one installed app declaring the same + // scheme makes Android show an "Open with" chooser, which `am start` still reports as a + // successful launch — see `AM_START_ERROR_PATTERN` below for the other half of the fix. + "-p", + appId, ], serial, ), ); + + if (AM_START_ERROR_PATTERN.test(stdout) || AM_START_ERROR_PATTERN.test(stderr)) { + throw usageError( + `"adb shell am start" could not launch "${appId}": ${(stdout + stderr).trim() || "(no output)"} ` + + `— the app is likely not installed on this device, or its installed build does not declare ` + + "a matching deep-link scheme.", + ); + } }; // --- detection --- @@ -614,8 +694,11 @@ export type OpenTargetOptions = { /** An adb device serial (`target: "android"`), a simulator udid (`target: "ios-sim"`) or a * paired-device udid (`target: "ios-device"`). */ device?: string; - /** The installed app's bundle id. Required by (and only used for) `target: "ios-device"`. */ - bundleId?: string; + /** The installed app's id: the Android package name for `target: "android"`, the iOS bundle id + * for `target: "ios-device"`. Required by both; unused (and never required) for `target: + * "ios-sim"` — see {@link platformOf}. Resolution (flag, then project config) happens before + * this is called; see `scheme.ts`'s `resolveAppId`. */ + appId?: string; /** `target: "ios-device"` only: terminate a running instance first (`--terminate-existing`) * instead of launching over it. Off by default; see {@link deliverToOpenTarget}. */ relaunch?: boolean; @@ -626,7 +709,7 @@ export type OpenTargetOptions = { /** Delivers `deepLink` to a booted Android device/emulator, an iOS simulator, or (experimentally) * a paired physical iOS device. Throws a clear, actionable error (missing tool, no booted device, - * ambiguous device, missing bundle id) rather than a raw one. */ + * ambiguous device, missing app id) rather than a raw one. */ export const deliverToOpenTarget = async (options: OpenTargetOptions): Promise => { const exec = options.exec ?? defaultExec; @@ -640,11 +723,26 @@ export const deliverToOpenTarget = async (options: OpenTargetOptions): Promise` as it stands in the project config after this run — from `--ios-app-id`/ + * `--android-app-id` on this invocation, or already recorded from an earlier one. Omitted + * entirely (rather than `{}`) when neither platform has one, so `--json` output doesn't grow an + * empty object issue #63 didn't ask for. */ + appId?: { ios?: string; android?: string }; /** Present when the recorded scheme and the value discovery would currently find (`app.json` or * a native probe) disagree. The recorded one still wins (a re-run must not start failing because * the underlying project file was edited); this says so and names the `--force` invocation that diff --git a/packages/appduct/src/cli/routes/init.ts b/packages/appduct/src/cli/routes/init.ts index 5dc72c74..a0fda32e 100644 --- a/packages/appduct/src/cli/routes/init.ts +++ b/packages/appduct/src/cli/routes/init.ts @@ -16,6 +16,20 @@ export const route: Route = async (context) => { { scheme: typeof options.scheme === "string" ? options.scheme : undefined, force: Boolean(options.force), + // cac camelCases `--ios-app-id`/`--android-app-id`; the dashed spellings are kept as a + // fallback so a parser change can't silently drop the flag. + iosAppId: + typeof options.iosAppId === "string" + ? options.iosAppId + : typeof options["ios-app-id"] === "string" + ? options["ios-app-id"] + : undefined, + androidAppId: + typeof options.androidAppId === "string" + ? options.androidAppId + : typeof options["android-app-id"] === "string" + ? options["android-app-id"] + : undefined, }, // `init` never reads the state dir, but it must know which directory it is so it can // refuse to write a "safe to commit" project config into the daemon's own state. diff --git a/packages/appduct/src/cli/routes/link.ts b/packages/appduct/src/cli/routes/link.ts index b58ad80b..f4834b9c 100644 --- a/packages/appduct/src/cli/routes/link.ts +++ b/packages/appduct/src/cli/routes/link.ts @@ -20,13 +20,13 @@ export const route: Route = async (context) => { scheme: typeof options.scheme === "string" ? options.scheme : undefined, open: typeof options.open === "string" ? options.open : undefined, device: typeof options.device === "string" ? options.device : undefined, - // cac camelCases `--bundle-id`; the dashed spelling is kept as a fallback so a + // cac camelCases `--app-id`; the dashed spelling is kept as a fallback so a // parser change can't silently drop the flag. - bundleId: - typeof options.bundleId === "string" - ? options.bundleId - : typeof options["bundle-id"] === "string" - ? options["bundle-id"] + appId: + typeof options.appId === "string" + ? options.appId + : typeof options["app-id"] === "string" + ? options["app-id"] : undefined, // Left `undefined` when absent rather than coerced to `false`, so that // "--relaunch only applies with --open ios-device" fires on the flag actually being diff --git a/packages/appduct/src/client/bootstrap.ts b/packages/appduct/src/client/bootstrap.ts index 4c4e6d73..726c6a93 100644 --- a/packages/appduct/src/client/bootstrap.ts +++ b/packages/appduct/src/client/bootstrap.ts @@ -27,9 +27,10 @@ export type LinkOptions = { target?: OpenTarget; /** `--device` equivalent: an adb serial, a simulator udid, or a paired-device udid. */ device?: string; - /** `--bundle-id` equivalent; only valid with `target: "ios-device"`, where it overrides - * `config.json`'s `iosBundleId`. */ - bundleId?: string; + /** `--app-id` equivalent: the installed app's id, required (via this, or the project + * `.appduct/config.json`'s `appId.`) with `target: "android"` or `target: + * "ios-device"`. */ + appId?: string; /** `--relaunch` equivalent; only valid with `target: "ios-device"`. */ relaunch?: boolean; /** Highest-precedence scheme source, ahead of `APPDUCT_SCHEME`, a project @@ -58,7 +59,7 @@ export const link = async (options: LinkOptions = {}): Promise => { ttlSeconds: options.ttlSeconds, target: options.target, device: options.device, - bundleId: options.bundleId, + appId: options.appId, relaunch: options.relaunch, scheme: options.scheme, cwd: options.cwd, diff --git a/packages/appduct/src/commands/init.ts b/packages/appduct/src/commands/init.ts index d99876a2..6c0e010a 100644 --- a/packages/appduct/src/commands/init.ts +++ b/packages/appduct/src/commands/init.ts @@ -2,9 +2,17 @@ * `appduct init` (issue #29): the one command that takes an app root from "package installed" * to "an agent can connect". * - * It writes a *project-level* `.appduct/config.json` holding the deep-link scheme, and returns - * the two things that are not discoverable from the filesystem: the MCP server entry to paste into - * an agent's config, and the `import "@appduct/react-native/auto"` reminder the app needs. + * It writes a *project-level* `.appduct/config.json` holding the deep-link scheme and, since issue + * #63, the installed app's id per platform (`appId.ios`/`appId.android` — needed to deliver a + * link with `--open ios-device`/`--open android` without an "Open with" chooser silently eating + * it), and returns the two things that are not discoverable from the filesystem: the MCP server + * entry to paste into an agent's config, and the `import "@appduct/react-native/auto"` reminder + * the app needs. + * + * Unlike `scheme`, an app id has no discovery tier: `--ios-app-id`/`--android-app-id` (or an + * already-recorded value) is the whole story, and the two are independent flags rather than one + * `--app-id` — the platforms' ids usually match but not always, and `init` never guesses one from + * the other (see `discoverNativeScheme`'s own refusal to guess when two probes disagree). * * What it deliberately does **not** do: * @@ -38,6 +46,7 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; +import { invalidAppIdMessage, isValidAppId } from "../cli/open-target.js"; import type { CliResult, InitCommandData } from "../cli/result-types.js"; import { usageError } from "../errors.js"; import { @@ -53,6 +62,12 @@ import { export type InitCommandOptions = { scheme?: string; force?: boolean; + /** Writes `appId.ios`. Optional and independent of `androidAppId` — the two ids usually match + * but not always, so `init` never guesses one from the other (issue #63; see + * `discoverNativeScheme`'s own refusal-to-guess for the same reasoning applied to a scheme). */ + iosAppId?: string; + /** Writes `appId.android`. See {@link iosAppId}. */ + androidAppId?: string; }; export type InitCommandContext = { @@ -151,6 +166,57 @@ const readExistingScheme = ( return scheme; }; +/** + * The `appId.` already recorded in the project config. Same "fatal unless this run is + * about to overwrite it anyway" stance as {@link readExistingScheme}, scoped per platform: a + * malformed `appId.android` must not block a run that only touches `--ios-app-id`, and vice versa + * — `replaceable` is `true` exactly when this run's own flag (or `--force`) is already about to + * replace *this* platform's value. + */ +const readExistingAppId = ( + existing: Record | undefined, + path: string, + platform: "ios" | "android", + flagName: "--ios-app-id" | "--android-app-id", + replaceable: boolean, +): string | undefined => { + const appId = existing?.appId; + + if (appId === undefined) { + return undefined; + } + + if (typeof appId !== "object" || appId === null || Array.isArray(appId)) { + if (replaceable) { + return undefined; + } + + throw usageError( + `${path} records an invalid "appId" (must be an object with "ios" and/or "android" keys). ` + + `Fix it, or re-run with \`${flagName} --force\` to replace it.`, + ); + } + + const value = (appId as Record)[platform]; + + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string" || value.length === 0) { + if (replaceable) { + return undefined; + } + + throw usageError( + `${path} records an invalid "appId.${platform}" (must be a non-empty string). Fix it, or ` + + `re-run with \`${flagName} --force\` to replace it.`, + ); + } + + return value; +}; + export const handleInitCommand = async ( options: InitCommandOptions, context: InitCommandContext = {}, @@ -181,6 +247,18 @@ export const handleInitCommand = async ( ); } + // The same shape check delivery applies (`cli/open-target.ts`), run here too so a value that + // could never be delivered is refused at the flag rather than written into a file the user is + // told is safe to commit, only to fail on every later `link --open`. + for (const [flag, value] of [ + ["--ios-app-id", options.iosAppId], + ["--android-app-id", options.androidAppId], + ] as const) { + if (value !== undefined && !isValidAppId(value)) { + throw usageError(`${invalidAppIdMessage(value)} (from ${flag})`); + } + } + const existing = await readExistingConfig(configPath); // A `--scheme` or a `--force` is about to overwrite whatever is recorded, so a garbage value // there is not worth failing over — and failing would make this error's own remedy unusable. @@ -189,6 +267,57 @@ export const handleInitCommand = async ( configPath, options.force === true || options.scheme !== undefined, ); + // Same "fatal unless this run overwrites it anyway" stance, scoped per platform — see + // `readExistingAppId`'s doc comment. + const existingIosAppId = readExistingAppId( + existing, + configPath, + "ios", + "--ios-app-id", + options.force === true || options.iosAppId !== undefined, + ); + const existingAndroidAppId = readExistingAppId( + existing, + configPath, + "android", + "--android-app-id", + options.force === true || options.androidAppId !== undefined, + ); + + if ( + options.iosAppId !== undefined && + existingIosAppId !== undefined && + existingIosAppId !== options.iosAppId && + !options.force + ) { + throw usageError( + `${configPath} already records "appId.ios" as "${existingIosAppId}", but --ios-app-id asked ` + + `for "${options.iosAppId}". Re-run with --force to replace it (every other key in the file ` + + "is preserved), or drop --ios-app-id to keep what is recorded.", + ); + } + + if ( + options.androidAppId !== undefined && + existingAndroidAppId !== undefined && + existingAndroidAppId !== options.androidAppId && + !options.force + ) { + throw usageError( + `${configPath} already records "appId.android" as "${existingAndroidAppId}", but ` + + `--android-app-id asked for "${options.androidAppId}". Re-run with --force to replace it ` + + "(every other key in the file is preserved), or drop --android-app-id to keep what is " + + "recorded.", + ); + } + + // No discovery tier for an app id (issue #63 leaves static discovery — build.gradle, app.json — + // out of scope, unlike `scheme`): a platform's id is either what was just given, or whatever was + // already recorded. `--force` alone adopts nothing new here because there is nothing to adopt. + const iosAppId = options.iosAppId ?? existingIosAppId; + const androidAppId = options.androidAppId ?? existingAndroidAppId; + const appIdChanged = iosAppId !== existingIosAppId || androidAppId !== existingAndroidAppId; + // Same discovery `resolveScheme`'s own last step runs (app.json, then the native Android/iOS // probes) — never APPDUCT_SCHEME, never a walk-up, per this file's doc comment. `discovered` // throws on its own for a malformed value or two native probes disagreeing, exactly as it would @@ -268,11 +397,18 @@ export const handleInitCommand = async ( "adopt that value instead." : undefined; - const alreadyCorrect = existingScheme === scheme; + const alreadyCorrect = existingScheme === scheme && !appIdChanged; if (!alreadyCorrect) { - // Merge rather than replace: `--force` changes the scheme, it does not reset the file. - const next = { ...(existing ?? {}), scheme }; + // Merge rather than replace: `--force` changes the scheme/appId, it does not reset the file. + const next: Record = { ...(existing ?? {}), scheme }; + + if (iosAppId !== undefined || androidAppId !== undefined) { + next.appId = { + ...(iosAppId === undefined ? {} : { ios: iosAppId }), + ...(androidAppId === undefined ? {} : { android: androidAppId }), + }; + } await mkdir(dirname(configPath), { recursive: true, mode: 0o700 }); await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, { @@ -301,6 +437,14 @@ export const handleInitCommand = async ( ...(origin === undefined ? {} : { origin }), created: existing === undefined, changed: !alreadyCorrect, + ...(iosAppId === undefined && androidAppId === undefined + ? {} + : { + appId: { + ...(iosAppId === undefined ? {} : { ios: iosAppId }), + ...(androidAppId === undefined ? {} : { android: androidAppId }), + }, + }), ...(note === undefined ? {} : { note }), mcpServerEntry: { command: "appduct", @@ -314,9 +458,16 @@ export const handleInitCommand = async ( "work too.", "With the app running, pair a device: `appduct link --open ios-sim` (or `--open android`).", ...(origin === undefined ? [] : [`Scheme "${scheme}" was read from ${origin}.`]), - `This file is safe to commit — it holds only "scheme". Do not point --state-dir at this ` + - "directory: the state dir holds the daemon's private key and audit log, which must " + - "never be committed.", + ...(iosAppId === undefined && androidAppId === undefined + ? [ + "Delivering to a physical iPhone (`--open ios-device`) or an Android device/emulator " + + "(`--open android`) needs the installed app's id: run `appduct init --ios-app-id " + + " --android-app-id `, or pass --app-id on `appduct link`.", + ] + : []), + `This file is safe to commit — it holds only "scheme" and "appId". Do not point --state-dir ` + + "at this directory: the state dir holds the daemon's private key and audit log, which " + + "must never be committed.", ], }, }; diff --git a/packages/appduct/src/commands/link.ts b/packages/appduct/src/commands/link.ts index ec54faa7..31e1d041 100644 --- a/packages/appduct/src/commands/link.ts +++ b/packages/appduct/src/commands/link.ts @@ -15,7 +15,7 @@ export type LinkCommandOptions = { scheme?: string; open?: string; device?: string; - bundleId?: string; + appId?: string; relaunch?: boolean; }; @@ -54,10 +54,10 @@ export const handleLinkCommand = async ( throw usageError('"--device" only applies with "--open".'); } - // A bundle id is only ever consumed by the `devicectl` launch; accepting it silently elsewhere - // would let `--open ios-sim --bundle-id ...` look like it did something it did not. - if (options.bundleId !== undefined && openTarget !== "ios-device") { - throw usageError('"--bundle-id" only applies with "--open ios-device".'); + // An app id is only ever consumed by `am start -p`/the `devicectl` launch; accepting it silently + // elsewhere would let `--open ios-sim --app-id ...` look like it did something it did not. + if (options.appId !== undefined && openTarget !== "android" && openTarget !== "ios-device") { + throw usageError('"--app-id" only applies with "--open android" or "--open ios-device".'); } if (options.relaunch !== undefined && openTarget !== "ios-device") { @@ -72,7 +72,7 @@ export const handleLinkCommand = async ( cwd: context.cwd, target: openTarget, device: options.device, - bundleId: options.bundleId, + appId: options.appId, relaunch: options.relaunch, exec: context.exec, env: context.env, diff --git a/packages/appduct/src/commands/mcp.ts b/packages/appduct/src/commands/mcp.ts index b6ffaf66..34ca0bd4 100644 --- a/packages/appduct/src/commands/mcp.ts +++ b/packages/appduct/src/commands/mcp.ts @@ -30,10 +30,9 @@ export type McpCommandContext = { checkVersion?: VersionCheckOptions; /** `--scheme`, the highest-precedence source in `scheme.ts`'s order. */ scheme?: string; - /** Where scheme discovery starts; defaults to `process.cwd()`. */ + /** Where scheme discovery starts, and where `appduct_connect` resolves an `appId` from on each + * call (`ConnectToolDeps.cwd`); defaults to `process.cwd()`. */ cwd?: string; - /** Overrides `config.json`'s `iosBundleId`; test seam, same shape as `scheme`. */ - iosBundleId?: string; exec?: ExecFn; /** Environment for `adb`/`simctl`, not for `APPDUCT_SCHEME` (see `schemeEnv`). */ env?: NodeJS.ProcessEnv; @@ -107,7 +106,8 @@ export const handleMcpCommand = async (context: McpCommandContext): Promise([ @@ -92,7 +84,6 @@ const KNOWN_TOP_LEVEL_KEYS = new Set([ "policy", "advertisedIp", "scheme", - "iosBundleId", "restartDaemonOnVersionMismatch", ]); @@ -230,15 +221,6 @@ export const loadConfig = async ( config.scheme = requireNonEmptyString(parsed.scheme, "scheme"); } - // Only a non-empty string here, like `scheme` — deliberately *not* the bundle-id charset check. - // This loader runs on every daemon start, and a typo in a CLI-side convenience key must not stop - // the daemon from starting. The charset is enforced where the value is actually used (`mintLink`, - // `handleConnectTool`, and `deliverToOpenTarget` itself), which is where it can be reported as a - // usage error against the command that needed it. - if (parsed.iosBundleId !== undefined) { - config.iosBundleId = requireNonEmptyString(parsed.iosBundleId, "iosBundleId"); - } - if (parsed.graceSeconds !== undefined) { config.graceSeconds = requirePositiveInteger(parsed.graceSeconds, "graceSeconds"); } diff --git a/packages/appduct/src/link.ts b/packages/appduct/src/link.ts index 17969c06..10d66d9d 100644 --- a/packages/appduct/src/link.ts +++ b/packages/appduct/src/link.ts @@ -21,12 +21,12 @@ import { RPC_METHODS, type AgentEndpoint, type LinkCreateResult } from "@appduct import { deliverToOpenTarget, isOpenTarget, - isValidBundleId, - invalidBundleIdMessage, + isValidAppId, + invalidAppIdMessage, isLoopbackAddress, loopbackAddressMessage, + platformOf, usesLoopbackAddress, - MISSING_BUNDLE_ID_MESSAGE, OPEN_TARGETS, type ExecFn, type OpenTarget, @@ -35,7 +35,7 @@ import { loadConfig } from "./daemon/config.js"; import { getStateDirPaths } from "./daemon/state-dir.js"; import { usageError } from "./errors.js"; import { callDaemon, type SpawnFn } from "./rpc/client.js"; -import { resolveSchemeOrThrow } from "./scheme.js"; +import { describeMissingAppId, resolveAppId, resolveSchemeOrThrow } from "./scheme.js"; /** The emulator/simulator fast path forces `127.0.0.1`: the daemon's wss listener already binds * all interfaces, and both delivery mechanisms (adb reverse, the iOS simulator's shared host @@ -67,8 +67,9 @@ export type MintLinkOptions = { /** An adb device serial (`target: "android"`), a simulator udid (`target: "ios-sim"`) or a * paired-device udid (`target: "ios-device"`). Only meaningful alongside a target. */ device?: string; - /** Overrides `config.json`'s `iosBundleId`. Only meaningful with `target: "ios-device"`. */ - bundleId?: string; + /** The installed app's id (Android package name / iOS bundle id) — highest-precedence source in + * `resolveAppId`'s order. Only meaningful with `target: "android"` or `target: "ios-device"`. */ + appId?: string; /** `target: "ios-device"` only: terminate a running instance before launching * (`--terminate-existing`). Off by default. */ relaunch?: boolean; @@ -114,8 +115,12 @@ export const mintLink = async (options: MintLinkOptions): Promise( @@ -171,7 +190,7 @@ export const mintLink = async (options: MintLinkOptions): Promise\" in the " + + "nearest .appduct/config.json (see \"appduct init\"); it is rejected outright with target " + + "\"ios-sim\" or \"none\", which need no app id. Pass \"relaunch\": true with ios-device if the " + + "app is already running and the delivery does not take. Only when no device is detected (or " + + "target is \"none\") does this return a QR code for a human to scan, along with an " + + "\"instructions\" field saying what to do with it. Follow up with appduct_wait_for_session to " + + "know when the device has connected.", inputSchema: { type: "object", properties: { target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET] }, device: { type: "string" }, - bundleId: { type: "string" }, + appId: { type: "string" }, relaunch: { type: "boolean" }, ttlSeconds: { type: "number", exclusiveMinimum: 0 }, }, @@ -203,9 +208,16 @@ export type ConnectToolDeps = { /** Every location `scheme.ts` consulted, named in the failure below so an agent can tell its * human exactly where to put a scheme instead of guessing at one global file. */ schemeTried?: string[]; - /** `config.json`'s `iosBundleId`, the default for the experimental `ios-device` target when the - * call did not pass `bundleId`. Same source order as `scheme`. */ - iosBundleId?: string; + /** Where the project `.appduct/config.json` walk-up starts when resolving an `appId` for + * `target: "android"`/`"ios-device"` (`scheme.ts`'s `resolveAppId`) — unlike `scheme`, this is + * resolved fresh on every call rather than once at startup, since which platform's `appId.` + * is needed depends on the call's (or auto-detection's) target. Defaults to `process.cwd()`. */ + cwd?: string; + /** The state directory in use, so the `appId` walk-up can never mistake it for a project config + * — the same exclusion `resolveScheme` is given at server startup (`commands/mcp.ts`). Without + * it, an operator who points `--state-dir` at a directory on the walk-up path would have that + * *global* file read at the project tier, inverting the documented precedence. */ + stateDirRoot?: string; exec?: ExecFn; env?: NodeJS.ProcessEnv; }; @@ -232,13 +244,50 @@ export type ConnectToolResult = { type ResolvedDelivery = { target: OpenTarget; device?: string; - /** Resolved bundle id for `target: "ios-device"` (call argument, else `config.json`). */ - bundleId?: string; + /** Resolved app id for `target: "android"`/`"ios-device"` (call argument, else project config). + * Left undefined when `platformOf(target)` is undefined (`ios-sim`, which needs none) OR when + * resolution found nothing — the caller distinguishes those two with `platformOf` itself. */ + appId?: string; /** Human-readable device description for notes and errors. */ label: string; autoDetected: boolean; }; +type DeliveryResolution = { + delivery?: ResolvedDelivery; + note?: string; + /** Every location `resolveAppId` consulted, set whenever `delivery`'s target needs one — named + * in {@link describeMissingAppId} when nothing resolved. */ + appIdTried?: string[]; +}; + +/** + * Attaches a resolved `appId` to a delivery candidate whose target needs one — `android` (explicit + * or auto-detected) and `ios-device` (always explicit; see {@link resolveDelivery}'s doc comment). + * `ios-sim` passes through untouched: {@link platformOf} returns `undefined` for it, and it is the + * one target `--app-id`/`appId` must never be required for. + */ +const withResolvedAppId = async ( + base: ResolvedDelivery, + flagAppId: string | undefined, + deps: ConnectToolDeps, +): Promise => { + const platform = platformOf(base.target); + + if (platform === undefined) { + return { delivery: base }; + } + + const resolved = await resolveAppId({ + platform, + flagAppId, + cwd: deps.cwd, + stateDirRoot: deps.stateDirRoot, + }); + + return { delivery: { ...base, appId: resolved.appId }, appIdTried: resolved.tried }; +}; + /** * Decides where (if anywhere) this link is going, *before* it is minted: the `127.0.0.1` address * override baked into a link depends on whether it is being delivered locally, and that cannot be @@ -248,21 +297,17 @@ type ResolvedDelivery = { const resolveDelivery = async ( requested: ConnectTarget | undefined, device: string | undefined, - bundleId: string | undefined, + appId: string | undefined, deps: ConnectToolDeps, -): Promise<{ delivery?: ResolvedDelivery; note?: string }> => { +): Promise => { if (requested !== undefined && requested !== NO_TARGET) { // `ios-device` is reachable only through this branch: `detectBootedTargets` never returns it, // so a paired iPhone can never be picked up by the auto-detection path below. - return { - delivery: { - target: requested, - device, - ...(requested === "ios-device" ? { bundleId: bundleId ?? deps.iosBundleId } : {}), - label: device ?? requested, - autoDetected: false, - }, - }; + return withResolvedAppId( + { target: requested, device, label: device ?? requested, autoDetected: false }, + appId, + deps, + ); } if (requested === NO_TARGET) { @@ -274,7 +319,10 @@ const resolveDelivery = async ( const detection = await detectBootedTargets({ exec: deps.exec, env: deps.env }); if (detection.kind === "single") { - return { delivery: { ...detection.detected, autoDetected: true } }; + // The auto-detected target may still be `android`, which needs an `appId` exactly as an + // explicit `target: "android"` would — nobody asked for a specific device, but the app being + // launched on it is not optional. + return withResolvedAppId({ ...detection.detected, autoDetected: true }, appId, deps); } if (detection.kind === "ambiguous") { @@ -291,7 +339,7 @@ const resolveDelivery = async ( note: 'No "target" was given and no booted iOS simulator or attached Android device was detected, ' + "so the link could not be delivered automatically. A physical iPhone/iPad is never " + - 'auto-detected: to deliver to one, re-call with target "ios-device" plus "bundleId" (needs ' + + 'auto-detected: to deliver to one, re-call with target "ios-device" plus "appId" (needs ' + "iOS 17+, Xcode 15+ and a paired, trusted device with Developer Mode on) — otherwise use " + "the QR flow below.", }; @@ -305,7 +353,7 @@ export const handleConnectTool = async ( const requestedTarget = asOptionalConnectTarget(args.target); const device = asOptionalNonEmptyString(args.device, "device"); - const bundleId = asOptionalNonEmptyString(args.bundleId, "bundleId"); + const appId = asOptionalNonEmptyString(args.appId, "appId"); const relaunch = asOptionalBoolean(args.relaunch, "relaunch"); const ttlSeconds = asOptionalPositiveNumber(args.ttlSeconds, "ttlSeconds"); @@ -317,10 +365,15 @@ export const handleConnectTool = async ( ); } - if (bundleId !== undefined && requestedTarget !== "ios-device") { + // Valid with an explicit "android"/"ios-device" target, *and* with no target at all — the + // auto-detection path below may still land on "android", which needs one just as much as an + // explicit target does. Rejected for "ios-sim" (no equivalent of `-p`/a bundle id exists to pass + // it to) and "none" (no delivery is being attempted, so there is nothing for it to name). + if (appId !== undefined && (requestedTarget === "ios-sim" || requestedTarget === NO_TARGET)) { throw new McpBuiltinToolError( "invalid_request", - '"bundleId" only applies with target "ios-device".', + '"appId" only applies with target "android", target "ios-device", or no target at all ' + + "(auto-detection).", ); } @@ -339,16 +392,22 @@ export const handleConnectTool = async ( // not stay narrowed across one). const scheme = deps.scheme; - const { delivery, note } = await resolveDelivery(requestedTarget, device, bundleId, deps); + const { delivery, note, appIdTried } = await resolveDelivery(requestedTarget, device, appId, deps); // Rejected before minting, so a call that cannot possibly deliver does not strand a pending - // session behind it. - if (delivery?.target === "ios-device" && !delivery.bundleId) { - throw new McpBuiltinToolError("invalid_request", MISSING_BUNDLE_ID_MESSAGE); + // session behind it. `platformOf` is `undefined` for `ios-sim`, the one delivery target that + // never needs (and never gets) a resolved `appId`. + const deliveryPlatform = delivery ? platformOf(delivery.target) : undefined; + + if (deliveryPlatform !== undefined && delivery?.appId === undefined) { + throw new McpBuiltinToolError( + "invalid_request", + describeMissingAppId(deliveryPlatform, appIdTried ?? []), + ); } - if (delivery?.bundleId !== undefined && !isValidBundleId(delivery.bundleId)) { - throw new McpBuiltinToolError("invalid_request", invalidBundleIdMessage(delivery.bundleId)); + if (delivery?.appId !== undefined && !isValidAppId(delivery.appId)) { + throw new McpBuiltinToolError("invalid_request", invalidAppIdMessage(delivery.appId)); } // A link's advertised address is fixed at mint time: `127.0.0.1` only when it is being handed to @@ -397,7 +456,7 @@ export const handleConnectTool = async ( await deliverToOpenTarget({ target: delivery.target, device: delivery.device, - bundleId: delivery.bundleId, + appId: delivery.appId, relaunch, deepLink, wssPort: result.endpoint.port, diff --git a/packages/appduct/src/mcp/server.ts b/packages/appduct/src/mcp/server.ts index fb4b061d..c7e96c29 100644 --- a/packages/appduct/src/mcp/server.ts +++ b/packages/appduct/src/mcp/server.ts @@ -370,9 +370,12 @@ export type CreateMcpServerOptions = { /** Every location consulted while resolving {@link scheme}, so `appduct_connect` can name * them when it has to fail. Only meaningful when `scheme` is undefined. */ schemeTried?: string[]; - /** `config.json`'s `iosBundleId`; the default bundle id for `appduct_connect`'s experimental - * `ios-device` target (issue #31). */ - iosBundleId?: string; + /** Where `appduct_connect` resolves an `appId` from (its project `.appduct/config.json` + * walk-up); see `ConnectToolDeps.cwd`. Defaults to `process.cwd()`. */ + cwd?: string; + /** The state directory in use, excluded from that walk-up exactly as it is from `scheme`'s. + * See `ConnectToolDeps.stateDirRoot`. */ + stateDirRoot?: string; exec?: ExecFn; env?: NodeJS.ProcessEnv; /** Overrides `ELICITATION_TIMEOUT_MS` (ARCHITECTURE.md §12 / issue #10) — test-only seam so the @@ -641,7 +644,8 @@ export const createMcpServer = async (options: CreateMcpServerOptions): Promise< call: stream.call, scheme: options.scheme, schemeTried: options.schemeTried, - iosBundleId: options.iosBundleId, + cwd: options.cwd, + stateDirRoot: options.stateDirRoot, exec: options.exec, env: options.env, }), diff --git a/packages/appduct/src/scheme.ts b/packages/appduct/src/scheme.ts index 8c18c0ab..74a909fa 100644 --- a/packages/appduct/src/scheme.ts +++ b/packages/appduct/src/scheme.ts @@ -33,7 +33,9 @@ * project file (`native-scheme.ts`'s doc comment has the detail). Dynamic-config projects use * `--scheme`, `APPDUCT_SCHEME`, or a project `.appduct/config.json` instead. * - * A project `.appduct/config.json` carries client-side keys only (`scheme` today). It never + * A project `.appduct/config.json` carries client-side keys only — `scheme` and, since issue #63, + * `appId.` (see {@link resolveAppId} below, which mirrors this module's shape but with a + * shorter order: no environment-variable tier and no filesystem-discovery tier). It never * redirects the state directory — `--state-dir` / `APPDUCT_STATE_DIR` remain the only way to do * that — so a project file can never move the daemon's key, socket or audit log. */ @@ -332,6 +334,76 @@ export const readProjectConfigScheme = async (path: string): Promise = new Set(["ios", "android"]); + +/** + * Reads `appId.` out of a project `.appduct/config.json`. Same stance as + * {@link readProjectConfigScheme}: this is Appduct's own file, so anything wrong with the `appId` + * block is a hard error rather than a silent fall-through, including an unknown key inside it — a + * typo'd `"andriod"` must read as "this file is broken", not as "no Android app id was recorded", + * which would otherwise look exactly like a project that simply hasn't set one up yet. + * + * Deliberately does *not* apply `cli/open-target.ts`'s `isValidAppId` charset check here: that + * check guards an argv/remote-shell hazard at the point a value is actually used, the same + * arrangement the old `iosBundleId` config key had. A structurally well-formed but wrong-for-its- + * platform id is caught there instead, with the command that needed it named in the error. + */ +export const readProjectConfigAppId = async ( + path: string, + platform: AppIdPlatform, +): Promise => { + const file = await readJsonFile(path); + + if (!file) { + return undefined; + } + + if (!isPlainObject(file.raw)) { + throw usageError(`Invalid Appduct project config at ${path}: must be a JSON object.`); + } + + const appId = file.raw.appId; + + if (appId === undefined) { + return undefined; + } + + if (!isPlainObject(appId)) { + throw usageError( + `Invalid Appduct project config at ${path}: "appId" must be an object with "ios" and/or ` + + '"android" keys.', + ); + } + + for (const key of Object.keys(appId)) { + if (!APP_ID_PLATFORMS.has(key)) { + throw usageError( + `Invalid Appduct project config at ${path}: unknown key "appId.${key}" (expected "ios" ` + + 'and/or "android").', + ); + } + } + + const value = appId[platform]; + + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string" || value.length === 0) { + throw usageError( + `Invalid Appduct project config at ${path}: "appId.${platform}" must be a non-empty string.`, + ); + } + + return value; +}; + export type ResolveSchemeOptions = { /** `--scheme` (or a programmatic `scheme` option) — highest precedence. */ flagScheme?: string; @@ -469,3 +541,94 @@ export const resolveSchemeOrThrow = async (options: ResolveSchemeOptions = {}): return resolved.scheme; }; + +/** Which step of {@link resolveAppId}'s order produced the app id. */ +export type AppIdSource = "flag" | "project-config"; + +export type ResolvedAppId = { + /** Undefined when no source produced one; `tried` then explains where we looked. */ + appId?: string; + source?: AppIdSource; + /** Human-readable descriptions of every location consulted, in order, for error messages. */ + tried: string[]; +}; + +export type ResolveAppIdOptions = { + /** Which `appId.` key to read from a project config; see `cli/open-target.ts`'s + * `platformOf`. */ + platform: AppIdPlatform; + /** `--app-id` (CLI `link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, the + * `appduct/client` `link()`) — highest precedence, because the target is known at the call + * site. */ + flagAppId?: string; + /** Where the project walk-up starts. Defaults to `process.cwd()`. */ + cwd?: string; + /** The state directory root, so the walk-up never mistakes it for a project config. */ + stateDirRoot?: string; + /** Overrides the home directory whose `.appduct` the walk-up skips (tests point this at a + * temp tree rather than the real `$HOME`). */ + homeDir?: string; +}; + +/** + * Resolves an app id against the order the contract fixes: an explicit value first, then the + * nearest project `.appduct/config.json` declaring `appId.` (the same walk-up + * {@link resolveScheme} uses, via {@link findProjectConfigs}). Unlike {@link resolveScheme} there + * is no environment-variable tier and no filesystem-discovery tier (issue #63 leaves static + * discovery of an app id from `build.gradle`/`app.json` out of scope) — an app id that cannot be + * found either way is a plain usage error, not something worth guessing at. + */ +export const resolveAppId = async (options: ResolveAppIdOptions): Promise => { + const cwd = resolve(options.cwd ?? process.cwd()); + const tried: string[] = []; + + tried.push("the --app-id flag"); + + if (options.flagAppId !== undefined && options.flagAppId.length > 0) { + return { appId: options.flagAppId, source: "flag", tried }; + } + + const projectConfigPaths = findProjectConfigs(cwd, { + stateDirRoot: options.stateDirRoot, + homeDir: options.homeDir, + }); + + tried.push( + projectConfigPaths.length === 0 + ? `${PROJECT_CONFIG_RELATIVE_PATH} (searched upwards from ${cwd})` + : projectConfigPaths.join(", "), + ); + + for (const projectConfigPath of projectConfigPaths) { + const projectAppId = await readProjectConfigAppId(projectConfigPath, options.platform); + + if (projectAppId !== undefined) { + return { appId: projectAppId, source: "project-config", tried }; + } + } + + return { tried }; +}; + +/** + * The shared "no app id anywhere" message (issue #63). Every caller renders the same body — the + * CLI's `--app-id` guard, `mintLink`, and `appduct_connect` — so the three fixes it names (the + * flag, the MCP argument, and the project config `appduct init` writes) cannot drift between them. + */ +export const describeMissingAppId = (platform: AppIdPlatform, tried: string[]): string => { + const locations = + tried.length === 0 + ? "" + : `Looked in, in order:\n${tried + .map((location, index) => ` ${index + 1}. ${location}`) + .join("\n")}\n`; + + const platformLabel = platform === "android" ? "Android" : "a physical iPhone/iPad"; + const initFlag = platform === "android" ? "--android-app-id" : "--ios-app-id"; + + return ( + `Delivering to ${platformLabel} needs the installed app's id, and none was found. ${locations}` + + `Set one with \`appduct init ${initFlag} \`, --app-id , or "appId.${platform}" in ` + + `${PROJECT_CONFIG_RELATIVE_PATH}.` + ); +}; diff --git a/packages/native/android/README.md b/packages/native/android/README.md index 39c42351..c9c99d59 100644 --- a/packages/native/android/README.md +++ b/packages/native/android/README.md @@ -224,10 +224,12 @@ appduct`, or invoke the workspace build directly if the two might disagree. `manifestPlaceholders["appductScheme"]` in your app's `build.gradle` — see [The `appductScheme` placeholder](#the-appductscheme-placeholder) above. -**`appduct link --open android` reports "unable to resolve Intent".** The scheme -`--scheme`/`appduct link` used doesn't match `manifestPlaceholders["appductScheme"]` on the -installed build, or the app was built before that placeholder was set (rebuild and reinstall — -placeholders are baked in at build time, not read at runtime). +**`appduct link --open android` reports "unable to resolve Intent".** Either the app id +(`--app-id`, or `appId.android` in `.appduct/config.json`) isn't the installed build's +`applicationId` — check for an `applicationIdSuffix` on the variant you installed — or the scheme +`appduct link` used doesn't match `manifestPlaceholders["appductScheme"]` on the installed build. +If the app was built before that placeholder was set, rebuild and reinstall — placeholders are +baked in at build time, not read at runtime. **A bootstrap link reaches the trampoline but the app never claims the session, failing with an EPERM-style connect error.** Your app's manifest is missing diff --git a/playground-native/.appduct/config.json b/playground-native/.appduct/config.json new file mode 100644 index 00000000..40f6435a --- /dev/null +++ b/playground-native/.appduct/config.json @@ -0,0 +1,7 @@ +{ + "scheme": "appduct-native", + "appId": { + "ios": "com.callstack.appduct.playgroundnative", + "android": "com.callstack.appduct.playground" + } +} diff --git a/playground-native/android/README.md b/playground-native/android/README.md index 37fb9936..8eb067f9 100644 --- a/playground-native/android/README.md +++ b/playground-native/android/README.md @@ -65,10 +65,11 @@ adb shell am start -n com.callstack.appduct.playground/.MainActivity Then drive it from the `appduct` CLI, pointed at a daemon whose state directory this build's scheme can reach (see `packages/native/android/README.md`'s live-check section for the full -sequence): +sequence). Run it from inside `playground-native/`: the scheme and the app id are recorded in +`playground-native/.appduct/config.json`. ```bash -appduct link --open android --scheme appduct-native +appduct link --open android appduct tools appduct invoke sum --input '{"a":2,"b":3}' appduct events diff --git a/playground-native/ios/README.md b/playground-native/ios/README.md index dc7bfbf0..56f0912a 100644 --- a/playground-native/ios/README.md +++ b/playground-native/ios/README.md @@ -29,8 +29,9 @@ xcrun simctl install booted \ ~/Library/Developer/Xcode/DerivedData/AppductPlayground-*/Build/Products/Debug-iphonesimulator/AppductPlayground.app xcrun simctl launch booted com.callstack.appduct.playgroundnative -# 3. Point the CLI at it -- the app registers the `appduct-native` URL scheme. -appduct link --scheme appduct-native --open ios-sim +# 3. Point the CLI at it. playground-native/.appduct/config.json records the app's +# `appduct-native` URL scheme, so no --scheme is needed. +appduct link --open ios-sim appduct tools appduct invoke sum --input '{"a":2,"b":3}' ``` diff --git a/playground/.appduct/config.json b/playground/.appduct/config.json index 83fffd13..16e204a0 100644 --- a/playground/.appduct/config.json +++ b/playground/.appduct/config.json @@ -1,3 +1,7 @@ { - "scheme": "playground" + "scheme": "playground", + "appId": { + "ios": "com.aitwar.playground", + "android": "com.aitwar.playground" + } } diff --git a/playground/.appduct/key.pem b/playground/.appduct/key.pem deleted file mode 100644 index 215491b4..00000000 --- a/playground/.appduct/key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC1DzL5IUy7xN3V -hGBA/sbE+73fAW/Bb09yrPwJXM7T+8f3kBkeSwu1JzLR2CPljgmitMztiDz2Tzno -1R7JGoP7Ln37rexvozj/ZdeO0VW33wbkfXdCFm1GEZbWuVbWkY93whYYtvSgQmbL -D4icIZbOnnttw4Ej9AP4i7R3Q9JB90QHUgd6V4c1I7X0MCSlZdbmdI9SsPbTu2a7 -G4WcJBeBe/j+DnRQ1MXOvzPwJRHPCwZ48RxSETSc2EMM5gQ3NEfWL2+VAeHejVrw -frVWMC1SWMRi2SMMKC0+R8qVjXoeHEBDru51sF4kvuM3Er4o9np1swd6UaPhWcSr -uwO928q9AgMBAAECggEAMFKNJSZ5B0gkeH7q/Y3B2QLjCuikPfkKOC3o2krVvIff -N7hSq94VktSSYKUR94swESe15dNNieNdLkHrBHs6LEidQRWLG8RxSuCQZkKCYR96 -BHNj59R6d4/sNQSkI4IXyzuWkCQMJ9Vw/9L2f80LJbvwbzwVdFYE1Unb8VS9gfMa -qolYvYkR3EuAVq3PSEatuHyzBe3INfMIsQdLWifaq4TpF2xwt1YAlZbGrwf+g5Bk -XKR1dwtHmi9nLANOlLHK7cDHIsUz+nxAeZlRrtx9KdBRKOKgmOUZSoEJRk0BMNfc -iLf1cTXgieu3R8pye8LPHeqRqdr3RYHBSqqB02e4VQKBgQD/rhcxiUB7QhNoc/1I -l+qCfhfPPjNM7Z2MDjcYgFYyunzGVCO7UPYAL9hoYSISZzSK5xPJ065IKPT8/LLy -nD86JGE95sHOZ3sb7+OMG26Lj82CCzpBGC9ZVaFoN/puSgHmExZelsT9gS4khmAf -2lBScHyFAMLT1LQ2c0IjB+MZqwKBgQC1STP/ISSjSAkbbZRSad2JuXvq8KKh5ZnM -RcQ8YOI2C86rmNBrZvTUaL4hz951P0/U6HROgVx9KumA4TIq0sJcDFlA1ex+MjR+ -wmfbfptX7lfBTrXar5T6Qtyv+c+HrnGItaGAlpVKqJE11dVgBw9Ci4SzrNkiCK4P -4+hiDSLVNwKBgQDNb2IvaSqoXvUdLmVK2NWSuCIFYOLzYtD94cYfJ68a34C2EpEL -RwgCKm9lnvYzdz1ehkD+H9mfHVmLlLdr/GNJIkYH6Mu8PqVKUzuwLbJq7MahQMgz -2WziCM3B4YiU6V4RMTD+G4J4dj1PF7FQpqd3Ne63J19juTp7raxsXT/6NQKBgQCV -zS7NVQrcbiswYgVdcKOjKgWu5zfMhljnhoMYzYo/1NPr0jPvfD01xHPQpt+RjUaf -jZKGSLkvqSUY3X3CCzSWMZITVHK++SeN04FUzDL7nzUexLbbRZnajyOjus71tH2W -1VfmVQh6j/6mONPALTU77OQ0QNGQIRIMiWy+kVJGUwKBgQCBcKASsJYgTHKeqdr0 -/uTslPzl+DTHI3mUDmYCEoq204+1wS0eQwvvvo7utqwg0BOsBjf/TkeSz2GKVb8Y -qGKjKPUf/ijAi2ZHSHHETCHN44D7ZsDuUqvEYHIx+1+IliMjkT0A6lCM/I24hOgB -e3lPxHDC7WXdNfShZnfMdaiKjg== ------END PRIVATE KEY----- diff --git a/playground/.gitignore b/playground/.gitignore index 54bacdf8..1f890a16 100644 --- a/playground/.gitignore +++ b/playground/.gitignore @@ -40,10 +40,6 @@ yarn-error.* app-example -# Intentionally public Appduct playground fixture; every other PEM remains ignored. -!.appduct/key.pem -!.appduct/config.json - # generated native folders /ios /android diff --git a/playground/README.md b/playground/README.md index 4c901139..e985ec1e 100644 --- a/playground/README.md +++ b/playground/README.md @@ -6,13 +6,15 @@ The playground is an Expo **development build** that demonstrates Appduct's v2 model: an always-on **daemon** on your machine, an app that claims a **pinned `wss://`** session from a -bootstrap deep link, and a thin **CLI/MCP** surface driving tools registered in JS—no extra debug +bootstrap deep link that carries the daemon's key pin, and a thin **CLI/MCP** surface driving tools registered in JS—no extra debug screens in the app, same ideas as in **production** builds. ## Why it's here -- **End-to-end check** that SPKI pins in `app.json` match the daemon's key material while tools - run from the **CLI** (or an MCP client), not in-app menus. +- **Zero-config path**: no keys or pins to set up. The app trusts the key pin carried by the + bootstrap link (`trust: "link"`, the default without `cliPins`), and uses the same shared daemon + in `~/.appduct` as any other app, while tools run from the **CLI** (or an MCP client), not + in-app menus. - **Safe local defaults**: `allowPrivateLanOnly` stays enabled while iterating—same knob as production, not a statement that Appduct only works offline or on one subnet. - **Resume smoke test**: the app uses `@appduct/react-native/auto`, so a Metro reload suspends @@ -26,13 +28,12 @@ screens in the app, same ideas as in **production** builds. Everything below runs from the monorepo root unless noted. Use a **development build**, not Expo Go—this app ships native pinning code. -### 1. Use the committed playground host identity +### 1. Nothing to configure -The playground ships an intentionally non-secret TLS host-key fixture at -`.appduct/key.pem`; its matching SPKI pin is already in `app.json`. The launcher below selects -that isolated state directory and corrects the key mode after checkout, so no key generation or -configuration changes are needed. Do not use this identity for another app or any production -environment. +The deep-link scheme and the app's id on each platform are recorded in `.appduct/config.json`, +so no `--scheme` or `--app-id` flags are needed. The `playground:appduct` script below runs this +repository's own CLI build from the playground directory; the daemon it talks to is the shared +one in `~/.appduct`. ### 2. Build and run the dev client diff --git a/playground/app.json b/playground/app.json index 0bffa222..c4366b93 100644 --- a/playground/app.json +++ b/playground/app.json @@ -35,11 +35,7 @@ [ "@appduct/react-native", { - "cliPins": [ - "sha256/jMvNOyMBXvRaZlyrje70/867kKkSA/qTlu30xCrTk4s=" - ], - "allowPrivateLanOnly": true, - "trust": "pin" + "allowPrivateLanOnly": true } ], "./plugins/with-app-display-name", diff --git a/playground/app/(tabs)/index.tsx b/playground/app/(tabs)/index.tsx index 6346f887..9fd3e4ff 100644 --- a/playground/app/(tabs)/index.tsx +++ b/playground/app/(tabs)/index.tsx @@ -12,13 +12,10 @@ import { ThemedView } from "@/components/themed-view"; import { Layout, Radius } from "@/constants/theme"; import { useThemeColor } from "@/hooks/use-theme-color"; -// The playground is deliberately *not* the zero-config path: app.json pins `cliPins` with -// `trust: "pin"` to the fixture key checked in at playground/.appduct/key.pem, so only the -// launcher below (which points --state-dir at that directory) serves a key this build will trust. -// A bare `appduct link` would mint a link from the global daemon and fail the TLS pin. -// -// In a normal app there is no keygen and no pin to paste: the daemon generates its own key, and -// `appduct link` reads the scheme straight out of app.json's `expo.scheme`. +// The playground takes the zero-config path: no `cliPins`, so the app trusts the key pin carried by +// the bootstrap link, and the daemon is the shared one in ~/.appduct. `playground:appduct` only +// runs this repository's CLI build from the playground directory, where .appduct/config.json +// records the scheme and the app ids `--open` needs. const CONNECT_COMMANDS = [ "pnpm exec expo run:ios # or: pnpm exec expo run:android", "pnpm run playground:appduct -- link --open ios-sim # or: --open android / --qr", @@ -168,9 +165,9 @@ export default function ToolsScreen() { {" '{\"a\":1,\"b\":2}'"}. - The launcher exists because this app pins its fixture key (app.json's cliPins with - trust: "pin"), so it only trusts the daemon in playground/.appduct. Your own - app needs none of this: no keygen, no pins, and the scheme is read from app.json. + In your own app, run appduct link from the app root: the scheme and app ids come from + .appduct/config.json (appduct init writes it), and there is no keygen and no pin to + paste. diff --git a/playground/scripts/appduct.sh b/playground/scripts/appduct.sh index d78b25a8..333f67bd 100755 --- a/playground/scripts/appduct.sh +++ b/playground/scripts/appduct.sh @@ -1,21 +1,22 @@ #!/usr/bin/env sh set -eu +# Runs this repository's own CLI build from the playground's app root, so the CLI finds +# .appduct/config.json (scheme and app ids) by its usual walk-up. The daemon is the shared one in +# ~/.appduct (or APPDUCT_STATE_DIR), exactly as for any other app. + script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) playground_dir=$(dirname "$script_dir") repository_dir=$(dirname "$playground_dir") -fixture_key="$playground_dir/.appduct/key.pem" if [ "${1-}" = "--" ]; then shift fi -# Git does not preserve 0600, while the daemon refuses group- or world-readable host keys. -chmod 600 "$fixture_key" - if [ ! -f "$repository_dir/packages/appduct/dist/bin.js" ]; then pnpm --dir "$repository_dir" --filter appduct build fi -exec node "$repository_dir/packages/appduct/bin.js" \ - --state-dir "$playground_dir/.appduct" "$@" +cd "$playground_dir" + +exec node "$repository_dir/packages/appduct/bin.js" "$@" diff --git a/skills/appduct/SKILL.md b/skills/appduct/SKILL.md index 3152566e..1fccbd39 100644 --- a/skills/appduct/SKILL.md +++ b/skills/appduct/SKILL.md @@ -82,17 +82,27 @@ From `link`'s JSON output, use: For a simulator/emulator you control directly, skip the deep link entirely: ```bash -appduct link --open ios-sim # or: --open android +appduct link --open ios-sim # no app id needed +appduct link --open android --app-id com.example.myapp # app id required ``` +**`--open android` needs `--app-id `** (the installed app's package name) — without it +the command fails with a usage error before minting anything. This is not optional the way it +looks: without naming the package, more than one installed app declaring the same scheme pops +an ambiguous "Open with" chooser on the device, `adb` still reports success either way, and the +next step (`appduct_wait_for_session`/polling) then blocks its whole timeout with nothing +explaining why. Skip `--app-id` if `appduct init --android-app-id ` already recorded one in +this project's `.appduct/config.json` — it is picked up automatically. `--open ios-sim` is the +one target that needs no app id at all. + With more than one simulator booted (or several devices attached) this errors and lists them rather than picking one — re-run with `--device `. A paired **physical iPhone/iPad** has an experimental path of its own, never auto-detected -and always opt-in: +and always opt-in — it needs `--app-id` too (the iOS bundle id, same flag as Android's): ```bash -appduct link --scheme myapp --open ios-device --bundle-id com.example.myapp +appduct link --scheme myapp --open ios-device --app-id com.example.myapp ``` It goes through `xcrun devicectl`, so it needs iOS 17+, Xcode 15+, the device paired, @@ -121,11 +131,20 @@ like any other MCP tool. Pass `target: "android"` / `"ios-sim"` (plus `device` — an adb serial or simulator udid) only to override that choice, e.g. when several devices are up and the result said so. +**Android delivery needs an `appId` too** — explicit `target: "android"` or the zero-argument +auto-detected path, either one. Pass it, or record `appId.android` once via `appduct init +--android-app-id ` in the project's `.appduct/config.json`; otherwise the call fails with a +clear `invalid_request` before anything is minted, rather than delivering to an ambiguous +"Open with" chooser that would leave `appduct_wait_for_session` hanging with no explanation. +`target: "ios-sim"` and `target: "none"` are the only ones that never need an `appId` — passing +one there is itself rejected. + `target: "ios-device"` reaches a paired **physical** iPhone/iPad and additionally needs -`bundleId` (or `iosBundleId` in `config.json`); add `relaunch: true` if the app is already -running and the link does not take. It is experimental and is never picked automatically — a -paired iPhone may be someone's personal phone — so ask for it by name only when the user has -said that is where the app is running, and expect the same prerequisites as the CLI flag above. +`appId` (the iOS bundle id, same argument Android uses); add `relaunch: true` if the app is +already running and the link does not take. It is experimental and is never picked +automatically — a paired iPhone may be someone's personal phone — so ask for it by name only +when the user has said that is where the app is running, and expect the same prerequisites as +the CLI flag above. **If the result has a `qr` field instead of `delivered: true`, nothing was delivered and a human has to act.** Do not call `appduct_wait_for_session` yet — it produces no output diff --git a/skills/appduct/references/setup.md b/skills/appduct/references/setup.md index acc415ff..2f31b08a 100644 --- a/skills/appduct/references/setup.md +++ b/skills/appduct/references/setup.md @@ -57,7 +57,10 @@ is for builds that leave your machine — do not do it as part of a first-time s `.appduct/config.json` and prints the MCP server entry to paste into an agent's config. Re-running it is always safe: it keeps the scheme already recorded, and only notes it if `app.json` has since come to declare a different one. Use - `appduct init --force` to adopt the new `app.json` value. + `appduct init --force` to adopt the new `app.json` value. Add + `--android-app-id --ios-app-id ` (`expo.android.package` and + `expo.ios.bundleIdentifier`) so `--open android` and `appduct_connect` can deliver links + without an `--app-id` on every call. 5. Run prebuild or rebuild the native project so the native config is applied. 6. Use a development build. Expo Go is not enough — this library ships native code. @@ -72,7 +75,9 @@ If the project uses a dynamic `app.config.js` / `app.config.ts`, discovery does 3. Configure URL schemes / intent filters so bootstrap links (`{scheme}:///?appduct=…`) open your app. 4. Run `appduct init --scheme ` in the project root — there is no `app.json` - `expo.scheme` to discover, so name the scheme you configured in step 3. + `expo.scheme` to discover, so name the scheme you configured in step 3. Add + `--android-app-id --ios-app-id ` so device delivery works + without an `--app-id` on every call. 5. Add the optional private-LAN-only setting only if the project wants that restriction. 6. Rebuild the native app after the configuration changes. From 3322a158de2f34e78d07b6dc6a65a708cd79d30b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 11:16:00 +0200 Subject: [PATCH 2/2] test(appduct): restore the temp-dir imports the app-id walk-up tests need --- packages/appduct/src/__tests__/link-open.integration.test.ts | 4 +++- packages/appduct/src/__tests__/mcp-server.integration.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/appduct/src/__tests__/link-open.integration.test.ts b/packages/appduct/src/__tests__/link-open.integration.test.ts index d0b70f95..189fec1f 100644 --- a/packages/appduct/src/__tests__/link-open.integration.test.ts +++ b/packages/appduct/src/__tests__/link-open.integration.test.ts @@ -11,7 +11,9 @@ * *not* get the override — a physical iPhone reaches the daemon only over the LAN (issue #31). */ -import { writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; diff --git a/packages/appduct/src/__tests__/mcp-server.integration.test.ts b/packages/appduct/src/__tests__/mcp-server.integration.test.ts index f4ad0919..5d72c67c 100644 --- a/packages/appduct/src/__tests__/mcp-server.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.integration.test.ts @@ -13,8 +13,9 @@ * moved to `mcp-server.test.ts`, which runs them against an in-memory daemon. */ -import { writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { connect as connectUds, type Socket } from "node:net"; +import { tmpdir } from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream";