rn-iso 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -36
  3. package/bin/cli.js +4 -7
  4. package/package.json +28 -2
  5. package/skill/SKILL.md +41 -43
  6. package/src/commands/android.js +120 -14
  7. package/src/commands/ios.js +95 -33
  8. package/src/commands/release.js +19 -25
  9. package/src/commands/reserve.js +141 -144
  10. package/src/commands/status.js +1 -15
  11. package/src/commands/stop.js +62 -30
  12. package/src/commands/unreserve.js +23 -43
  13. package/src/config.js +14 -91
  14. package/src/labels.js +25 -0
  15. package/src/project.js +25 -7
  16. package/src/sim/android.js +31 -18
  17. package/src/sim/ios.js +7 -1
  18. package/.claude/settings.local.json +0 -7
  19. package/CLAUDE.md +0 -178
  20. package/docs/plans/2026-04-25-rn-iso-implementation.md +0 -2653
  21. package/docs/specs/2026-04-25-rn-iso-design.md +0 -282
  22. package/src/commands/logs.js +0 -28
  23. package/src/commands/prune.js +0 -57
  24. package/src/commands/shutdown.js +0 -41
  25. package/test/config.test.js +0 -208
  26. package/test/exec.test.js +0 -26
  27. package/test/fixtures/sample-bare-project/android/app/build.gradle +0 -6
  28. package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +0 -10
  29. package/test/fixtures/sample-bare-project/package.json +0 -4
  30. package/test/fixtures/sample-expo-project/app.json +0 -6
  31. package/test/fixtures/sample-expo-project/package.json +0 -4
  32. package/test/fixtures/sample-expo-project/src/.keep +0 -0
  33. package/test/metro.test.js +0 -34
  34. package/test/ports.test.js +0 -76
  35. package/test/project.test.js +0 -109
  36. package/test/runner.test.js +0 -209
  37. package/test/sim-android.test.js +0 -140
  38. package/test/sim-ios.test.js +0 -168
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -6,77 +6,82 @@ Per-project Metro server and dedicated simulator/emulator for React Native / Exp
6
6
 
7
7
  State lives in `~/.rn-iso/config.json`, keyed by absolute project path. Worktrees count as separate projects. There is no shared mutex — each project is pinned to its own port and its own sim.
8
8
 
9
- ## Install
10
-
11
- ```bash
12
- npm install -g rn-iso
13
- ```
9
+ ## Quick start
14
10
 
15
- To install the agent skill (so AI coding agents know how to drive the CLI):
11
+ Run via `npx` from any RN/Expo project directory no install needed:
16
12
 
17
13
  ```bash
18
- npx skills add janicduplessis/rn-iso
14
+ npx rn-iso ios # ensure sim, allocate port, build/install
15
+ npx rn-iso device # print the assigned UDID
19
16
  ```
20
17
 
21
- ## Quick start
22
-
23
- In any RN/Expo project directory:
18
+ In a different worktree of the same app:
24
19
 
25
20
  ```bash
26
- rn-iso ios # ensure sim, allocate port, build/install
27
- rn-iso device # print the assigned UDID
21
+ npx rn-iso ios # gets a different sim and Metro port
28
22
  ```
29
23
 
30
- In a different worktree of the same app:
24
+ Both run side by side. For non-interactive / agent use, pass `--auto` to skip the picker (this is also implied automatically when stdin isn't a TTY):
31
25
 
32
26
  ```bash
33
- rn-iso ios # gets a different sim and Metro port
27
+ npx rn-iso ios --auto
34
28
  ```
35
29
 
36
- Both run side by side. For non-interactive / agent use, pass `--auto` to skip the picker:
30
+ For AI coding agents, install the skill so the agent knows how to drive the CLI:
37
31
 
38
32
  ```bash
39
- rn-iso ios --auto
33
+ npx skills add janicduplessis/rn-iso
40
34
  ```
41
35
 
42
36
  ## Commands
43
37
 
38
+ All commands below take the same `npx rn-iso` prefix.
39
+
44
40
  | Command | Purpose |
45
41
  |---|---|
46
- | `rn-iso ios [--auto] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Ensure iOS sim + Metro + build/install |
47
- | `rn-iso android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Same for Android |
48
- | `rn-iso start` | Start Metro detached, no platform action |
49
- | `rn-iso device [--platform ios\|android] [--json]` | Print the assigned device target |
50
- | `rn-iso status` | Show all projects' state and reservations |
51
- | `rn-iso reserve [<platform> <id>] [--label <name>] [--list]` | Mark an external sim/emulator as in-use so rn-iso skips it |
52
- | `rn-iso unreserve [<id\|label>] [--all]` | Release a reservation |
53
- | `rn-iso release [<project\|label\|udid>] [--platform <p>]` | Unbind a project assignment or reservation |
54
- | `rn-iso shutdown [--platform <p>]` | Release and shut down sims for current project |
55
- | `rn-iso prune [--shutdown]` | GC dead entries machine-wide |
56
- | `rn-iso logs` | Tail Metro log for current project |
57
- | `rn-iso stop` | Kill Metro for current project |
42
+ | `ios [--auto] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Ensure iOS sim + Metro + build/install |
43
+ | `android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Same for Android |
44
+ | `start` | Start Metro detached, no platform action |
45
+ | `stop [<port>\|<shortcut>\|<path>]` | Kill Metro. No arg = current project; pass a port (e.g. 8083), a project shortcut (label or unique basename), or an absolute path. |
46
+ | `device [--platform ios\|android] [--json]` | Print the assigned device target |
47
+ | `status` | Show all projects' state |
48
+ | `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
49
+ | `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
50
+ | `release [<shortcut>\|<path>] [--platform <p>] [--shutdown]` | Free a project's assignment; `--shutdown` also stops the sim |
58
51
 
59
52
  ## How it works
60
53
 
61
54
  - **Config** at `~/.rn-iso/config.json`, keyed by absolute project path. Symlinked worktrees collapse via `realpath`.
62
55
  - **Port allocation:** assigns 8082, 8083, 8084 etc., reclaiming dead ports on the way.
63
- - **Simulator pool:** prefers the project's existing assignment; otherwise picks any unclaimed booted sim, then any unclaimed shutdown sim (booting it). Does not auto-create new sims — pass `--device-type "iPhone 17 Pro" [--runtime 26.2]` to opt in.
56
+ - **Simulator / AVD pool:** prefers the project's existing assignment; otherwise picks an unclaimed device running ones first, shutdown ones next (booting them). On iOS, does not auto-create new sims — pass `--device-type "iPhone 17 Pro" [--runtime 26.2]` to opt in. The interactive picker (iOS or Android) also lets you take over a device claimed by another project after a confirm prompt.
64
57
  - **Build via your project's `ios` / `android` script** when present. Falls back to `npx expo run:ios` / `npx react-native run-ios --udid <UDID>` when no script exists. Override with `--script <name>` or skip with `--no-script`. Package manager is detected from your lockfile (walks up for monorepos); override with `--pm <npm|yarn|pnpm|bun>`.
65
- - **Metro is started by the build CLI** on the assigned port, not by rn-iso. `rn-iso start` is the standalone "I just want Metro" path. `rn-iso stop` finds Metro by port via `lsof`, so it works regardless of who started it.
58
+ - **Metro is started by the build CLI** on the assigned port, not by rn-iso. `npx rn-iso start` is the standalone "I just want Metro" path. `npx rn-iso stop` finds Metro by port via `lsof`, so it works regardless of who started it.
66
59
 
67
60
  If you need a single shared sim with a mutex instead of one-per-project, see [`react-native-worktree`](https://github.com/aleqsio/react-native-worktree).
68
61
 
69
- ## Reservations
62
+ ## Reserving a manually-started sim
63
+
64
+ If you booted a simulator yourself (Xcode, Simulator.app, `xcrun simctl boot`, or a manual `expo run:ios`) and want rn-iso to know that sim belongs to the current project — so other rn-iso projects skip it:
65
+
66
+ ```bash
67
+ npx rn-iso reserve --label agent-1 # picks from booted iOS sims
68
+ npx rn-iso reserve android # picks from running emulators
69
+ npx rn-iso unreserve # drop the lock without shutting the sim down
70
+ ```
71
+
72
+ Reserve binds the sim to the current project the same way `ios` / `android` would, but without running a build. If the sim is already held by another project, the picker prompts you to take it over.
73
+
74
+ ## Project shortcuts (--label)
70
75
 
71
- If you boot a sim outside rn-iso (Xcode, manual `simctl boot`, another tool), reserve it so rn-iso's allocator skips it:
76
+ Each registered project has a "shortcut" you can pass to `stop` / `release` instead of the full path. The first time you run `ios`, `android`, or `reserve` interactively you'll be prompted for one (the directory basename is the default — hit enter to accept). Override any time with `--label <name>`:
72
77
 
73
78
  ```bash
74
- rn-iso reserve ios <UDID> --label agent-1
75
- rn-iso reserve # interactive multi-select picker
76
- rn-iso unreserve agent-1 # release by label, UDID, or serial
79
+ npx rn-iso ios --label agent-1
80
+ npx rn-iso stop agent-1 # later, from anywhere
81
+ npx rn-iso release agent-1 --shutdown
77
82
  ```
78
83
 
79
- Reserved sims appear greyed-out as `[reserved]` in the picker.
84
+ Under `--auto` (or any non-TTY invocation) the prompt is skipped — the project's basename serves as its shortcut by default. Shortcut collisions (two projects sharing the same basename, or two labels colliding) error out and list the candidates so you can disambiguate with the absolute path.
80
85
 
81
86
  ## Requirements
82
87
 
package/bin/cli.js CHANGED
@@ -1,34 +1,31 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { Command } from 'commander';
3
4
  import deviceCommand from '../src/commands/device.js';
4
5
  import iosCommand from '../src/commands/ios.js';
5
6
  import androidCommand from '../src/commands/android.js';
6
7
  import startCommand from '../src/commands/start.js';
7
8
  import stopCommand from '../src/commands/stop.js';
8
- import logsCommand from '../src/commands/logs.js';
9
9
  import statusCommand from '../src/commands/status.js';
10
10
  import releaseCommand from '../src/commands/release.js';
11
- import shutdownCommand from '../src/commands/shutdown.js';
12
- import pruneCommand from '../src/commands/prune.js';
13
11
  import reserveCommand from '../src/commands/reserve.js';
14
12
  import unreserveCommand from '../src/commands/unreserve.js';
15
13
 
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
15
+
16
16
  const program = new Command();
17
17
  program
18
18
  .name('rn-iso')
19
19
  .description('Isolated React Native dev environments per project/worktree')
20
- .version('0.1.0');
20
+ .version(pkg.version);
21
21
 
22
22
  deviceCommand(program);
23
23
  iosCommand(program);
24
24
  androidCommand(program);
25
25
  startCommand(program);
26
26
  stopCommand(program);
27
- logsCommand(program);
28
27
  statusCommand(program);
29
28
  releaseCommand(program);
30
- shutdownCommand(program);
31
- pruneCommand(program);
32
29
  reserveCommand(program);
33
30
  unreserveCommand(program);
34
31
 
package/package.json CHANGED
@@ -1,11 +1,18 @@
1
1
  {
2
2
  "name": "rn-iso",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Isolated React Native dev environments per project/worktree",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "rn-iso": "bin/cli.js"
8
8
  },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "skill",
13
+ "LICENSE",
14
+ "README.md"
15
+ ],
9
16
  "scripts": {
10
17
  "test": "node --test test/*.test.js"
11
18
  },
@@ -16,5 +23,24 @@
16
23
  },
17
24
  "engines": {
18
25
  "node": ">=20"
19
- }
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/janicduplessis/rn-iso.git"
30
+ },
31
+ "homepage": "https://github.com/janicduplessis/rn-iso#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/janicduplessis/rn-iso/issues"
34
+ },
35
+ "keywords": [
36
+ "react-native",
37
+ "expo",
38
+ "metro",
39
+ "simulator",
40
+ "emulator",
41
+ "worktree",
42
+ "agent"
43
+ ],
44
+ "license": "MIT",
45
+ "author": "Janic Duplessis"
20
46
  }
package/skill/SKILL.md CHANGED
@@ -8,16 +8,18 @@ user_invocable: true
8
8
 
9
9
  You are an AI agent working on a React Native / Expo project, possibly alongside other agents working on different projects or worktrees. Each project owns its own dedicated simulator and Metro server. There is no locking — your sim is yours.
10
10
 
11
+ Invoke the CLI via `npx`: `npx rn-iso <command>`. Don't `npm install -g`; `npx` resolves the latest published version.
12
+
11
13
  ## Core workflow
12
14
 
13
15
  From the project root (or any subdirectory):
14
16
 
15
- 1. **Ensure the platform is ready** — `rn-iso ios --auto` (or `rn-iso android`). This:
17
+ 1. **Ensure the platform is ready** — `npx rn-iso ios --auto` (or `npx rn-iso android`). This:
16
18
  - Allocates a Metro port for the project (or reuses the assigned one)
17
19
  - Picks a dedicated unclaimed sim (booting it if shutdown). With `--auto`, picks the first candidate without prompting.
18
20
  - Builds and installs the app via the project's `ios` / `android` script if present, else `expo run:ios` / `react-native run-ios`. The build CLI starts Metro itself on the assigned port; rn-iso doesn't spawn a separate Metro. Detects the package manager from the lockfile (walks up for monorepos).
19
21
 
20
- 2. **Get the device target** — `rn-iso device --platform ios --json`:
22
+ 2. **Get the device target** — `npx rn-iso device --platform ios --json`:
21
23
  ```json
22
24
  {"platform":"ios","udid":"ABC-...","metroPort":8083}
23
25
  ```
@@ -27,85 +29,81 @@ From the project root (or any subdirectory):
27
29
 
28
30
  ## CRITICAL rules
29
31
 
30
- - **Always pass `--auto` for non-interactive use.** Without it, `rn-iso ios` will prompt with an arrow-key picker if multiple unclaimed sims exist. Agents must use `--auto` to skip.
31
- - **Always use `rn-iso device` to discover your target.** Never assume `booted` is your simanother project's simulator might be booted too.
32
+ - **Pass `--auto` for non-interactive use** of `ios` or `android`. Without it, the command will prompt with an arrow-key picker if multiple unclaimed sims/AVDs exist. `--auto` is also implied automatically when stdin isn't a TTY (e.g., when an agent pipes the command), so under most agent harnesses you don't have to remember the flag — but passing it explicitly is harmless and clearer.
33
+ - **`--auto` will NOT take over a claimed sim/AVD.** If every device is claimed by other rn-iso projects, `--auto` errors. To take one over, run the command interactively (no `--auto`, with a real TTY) and confirm at the prompt only do this if the user explicitly asks.
34
+ - **Always use `npx rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
32
35
  - **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
33
36
  - `xcrun simctl io <UDID> screenshot out.png`
34
37
  - `adb -s emulator-5556 shell input tap 100 200`
35
- - **Don't call `release` or `shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
36
- - **Don't manually start Metro on a different port.** `rn-iso start` (or `rn-iso ios/android`) already handles port assignment.
38
+ - **Don't call `release` or `release --shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
39
+ - **Don't manually start Metro on a different port.** `npx rn-iso start` (or `npx rn-iso ios/android`) already handles port assignment.
37
40
  - **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown). If none are available, it errors. To create a new one explicitly, pass `--device-type "iPhone 17 Pro" [--runtime 26.2]`.
38
41
 
39
42
  ## Typical agent workflow
40
43
 
41
44
  ```bash
42
45
  # Once per session -- ensure the project's sim and Metro are up.
43
- rn-iso ios --auto
46
+ npx rn-iso ios --auto
44
47
 
45
48
  # Get the target.
46
- UDID=$(rn-iso device --platform ios)
49
+ UDID=$(npx rn-iso device --platform ios)
47
50
 
48
51
  # Use the target for UI interactions (delegate to agent-device or your tool).
49
52
  xcrun simctl io "$UDID" screenshot /tmp/screen.png
50
53
 
51
54
  # When you change app code, Metro hot-reloads automatically. No restart needed.
52
- # Only re-run `rn-iso ios` after native code changes or new native modules.
55
+ # Only re-run `npx rn-iso ios` after native code changes or new native modules.
53
56
  ```
54
57
 
55
- ## Reserving sims used by external processes
58
+ ## Locking a manually-started sim
56
59
 
57
- If you boot a sim outside of rn-iso (Xcode, manual `simctl boot`, another agent that doesn't use rn-iso), tell rn-iso to skip it during allocation:
60
+ If the user has already booted a sim and started the app themselves (Xcode, Simulator.app, `xcrun simctl boot`, manual `expo run:ios`), and asks you to "lock" or "claim" that sim for the current project, use `reserve`:
58
61
 
59
62
  ```bash
60
- # Direct form -- if you know the UDID:
61
- rn-iso reserve ios <UDID> --label "agent-1"
62
- rn-iso reserve android emulator-5554 --label "agent-2"
63
-
64
- # Interactive form -- pick from currently booted sims / running emulators:
65
- rn-iso reserve # both platforms, multi-select picker
66
- rn-iso reserve ios # iOS only
67
- rn-iso reserve --list # show current reservations
68
-
69
- # Release when done -- by UDID/serial OR by the label:
70
- rn-iso unreserve <UDID>
71
- rn-iso unreserve agent-1 # by label
72
- rn-iso release agent-1 # `release` accepts the label too
73
- rn-iso unreserve --all
63
+ npx rn-iso reserve # picks from booted iOS sims (current project)
64
+ npx rn-iso reserve android # picks from running emulators
65
+ npx rn-iso unreserve # drop the project's lock (without shutting down)
74
66
  ```
75
67
 
76
- Reserved sims show grayed out as `[reserved]` in `rn-iso ios` pickers and won't be picked by allocation.
68
+ Reserve binds the sim to the current project the same way `ios` does, but skips the build/install step. After reserving, `npx rn-iso device` will return that sim's UDID. Other rn-iso projects will see it as claimed.
77
69
 
78
70
  ## When things go wrong
79
71
 
80
- - **"No rn-iso assignment for project"** — run `rn-iso ios` (or android) first.
81
- - **"No unclaimed iOS simulator available"** — every existing sim is claimed by another project or reserved. Options: open a sim in Simulator.app, run `rn-iso unreserve --all` if you have stale reservations, free another project (`rn-iso release`), or pass `--device-type "iPhone 17 Pro"` to create a new one.
72
+ - **"No rn-iso assignment for project"** — run `npx rn-iso ios` (or android) first.
73
+ - **"All iOS simulators are claimed by other rn-iso projects"** (under `--auto`) — every existing sim is held by another project. Options: free another project (`npx rn-iso release` from there), pass `--device-type "iPhone 17 Pro"` to create a new sim, or re-run without `--auto` (in a real TTY) and ask the user before confirming the take-over prompt.
74
+ - **"All Android AVDs are claimed by other rn-iso projects"** — same situation on Android. Free another project or re-run interactively to take one over.
82
75
  - **Wrong sim got the app** — older `@expo/cli` (< 54.0.24) had a bug where the launch ignored `--device`. Bump expo to 54.0.34+ if on SDK 54.
83
- - **Metro port collision** — `rn-iso ios` reclaims dead ports automatically. If you see "port busy by non-Metro process," another tool is using that port; close it.
84
- - **Sim was deleted** — `rn-iso ios` detects the stale assignment and re-allocates. If not, run `rn-iso prune` then `rn-iso ios`.
76
+ - **Metro port collision** — `npx rn-iso ios` reclaims dead ports automatically. If you see "port busy by non-Metro process," another tool is using that port; close it.
77
+ - **Sim was deleted** — `npx rn-iso ios` detects the stale assignment and re-allocates.
85
78
  - **Detection picked the wrong CLI** (e.g. project has `expo` in deps but uses `react-native run-ios`) — rn-iso prefers your `ios` / `android` script and detects the CLI from its body. Override with `--script <name>` or skip with `--no-script` to force the direct CLI fallback. Override package manager with `--pm <npm|yarn|pnpm|bun>`.
86
79
 
87
80
  ## Other useful commands
88
81
 
89
- - `rn-iso status` — show all projects, their assignments, and Metro state. Reservations appear in their own section.
90
- - `rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful if you want logs (`rn-iso logs`) or to keep Metro alive across builds.
91
- - `rn-iso logs` — tail the Metro log file (only available if Metro was started via `rn-iso start`; the build CLI's Metro doesn't write to our log).
92
- - `rn-iso stop` — kill the project's Metro. Finds the process by port, so it works whether Metro was started by `rn-iso start` or by the build CLI.
93
- - `rn-iso prune` — GC dead entries machine-wide; safe to run periodically.
94
- - `rn-iso release [target]` — free a project assignment OR a reservation. `[target]` is an absolute project path, the `--label` you set when reserving, or a UDID/serial. Defaults to the current project. Examples:
95
- - `rn-iso release` -- current project
96
- - `rn-iso release /Users/x/Developer/myapp` -- specific project by path
97
- - `rn-iso release agent-1` -- reservation by label (works across iOS/Android)
98
- - `rn-iso unreserve <id|label>` — same as `rn-iso release` for reservations specifically. Accepts UDID, emulator serial, OR the `--label` from when you reserved. Pass `--platform ios|android` to restrict.
82
+ - `npx rn-iso status` — show all projects, their assignments, and Metro state.
83
+ - `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
84
+ - `npx rn-iso stop [<port>|<shortcut>|<path>]` — kill Metro. No arg = current project. Passing a port (e.g. `8083`) kills whatever is on it; a project shortcut (label or unique basename) or absolute path targets that project. Finds the process by port, so it works whether Metro was started by `npx rn-iso start` or by the build CLI.
85
+ - `npx rn-iso release [<shortcut>|<path>] [--platform <p>] [--shutdown]` — free a project's sim assignment. Defaults to the current project. `--shutdown` also stops the sim/emulator.
86
+
87
+ ### Project shortcuts (--label)
88
+
89
+ Every project has a "shortcut" you can pass to `stop` / `release` instead of the full path. The first interactive run of `ios` / `android` / `reserve` prompts for one (default: directory basename); under `--auto` / non-TTY the prompt is skipped and the basename is used implicitly. To set or override explicitly, pass `--label <name>`:
90
+
91
+ ```bash
92
+ npx rn-iso ios --auto --label agent-1
93
+ npx rn-iso stop agent-1
94
+ ```
99
95
 
100
96
  ## Sort order in the picker
101
97
 
102
- When the `ios` picker fires, sims are sorted by:
103
- 1. Family (iPhone before iPad before others — set by user preference if you usually use iPhones)
98
+ When the iOS picker fires, sims are sorted by:
99
+ 1. Family (iPhone before iPad before others)
104
100
  2. State (booted before shutdown within family)
105
101
  3. Usage count (most-used floats up; tracked per UDID across all projects)
106
102
  4. Name (alphabetical, stable tiebreak)
107
103
 
108
- Claimed and reserved sims are listed but greyed out and skipped by the cursor.
104
+ When the Android picker fires, AVDs are sorted by running state (running emulators first), then alphabetically.
105
+
106
+ Sims/AVDs claimed by other rn-iso projects show in yellow with a `[claimed by ...]` tag. They're selectable but require a confirm prompt before being taken over.
109
107
 
110
108
  ## Differences from `react-native-worktree`
111
109
 
@@ -1,16 +1,27 @@
1
1
  // src/commands/android.js
2
2
  import chalk from 'chalk';
3
+ import prompts from 'prompts';
3
4
  import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
4
- import { getProject, upsertProject, setMetro, setDevice, allClaimedDevices } from '../config.js';
5
+ import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices } from '../config.js';
5
6
  import { allocatePort, isMetroRunning } from '../ports.js';
6
- import { selectAndroidDevice, bootAndroidEmulator, waitForBoot, adbReverse, listAdbDevices } from '../sim/android.js';
7
+ import {
8
+ selectAndroidDevice,
9
+ sortAndroidCandidates,
10
+ bootAndroidEmulator,
11
+ waitForBoot,
12
+ adbReverse,
13
+ nextConsolePort,
14
+ } from '../sim/android.js';
7
15
  import { buildAndroidCommand, detectPackageManager } from '../runner.js';
8
16
  import { getExecutor } from '../exec.js';
17
+ import { resolveLabel } from '../labels.js';
9
18
 
10
19
  export default function androidCommand(program) {
11
20
  program
12
21
  .command('android')
13
22
  .description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed')
23
+ .option('--auto', 'Non-interactive: pick the first unclaimed AVD without prompting (also implied when stdin is not a TTY)')
24
+ .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
14
25
  .option('--script <name>', 'package.json script to invoke for build/install (default: android)', 'android')
15
26
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
16
27
  .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: detected from lockfile)')
@@ -26,7 +37,14 @@ export default function androidCommand(program) {
26
37
  const androidPackage = detectAndroidPackage(root);
27
38
  const isExpo = detectIsExpo(root);
28
39
 
29
- upsertProject(root, { bundleId, androidPackage, isExpo });
40
+ const existing = getProject(root);
41
+ const label = await resolveLabel({ root, existingProject: existing, optsLabel: opts.label });
42
+ upsertProject(root, {
43
+ bundleId,
44
+ androidPackage,
45
+ isExpo,
46
+ ...(label ? { label } : {}),
47
+ });
30
48
  let proj = getProject(root);
31
49
 
32
50
  if (!proj.metroPort) {
@@ -41,6 +59,7 @@ export default function androidCommand(program) {
41
59
  (metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
42
60
  ));
43
61
 
62
+ const auto = isAuto(opts);
44
63
  const claimed = allClaimedDevices();
45
64
  const myAvd = proj.platforms?.android?.avdName || null;
46
65
  const myPort = proj.platforms?.android?.consolePort || null;
@@ -54,19 +73,59 @@ export default function androidCommand(program) {
54
73
  claimedConsolePorts: claimedPorts,
55
74
  });
56
75
 
57
- if (selection.kind === 'noAvd') {
76
+ let avdName, consolePort, isRunning;
77
+ if (selection.kind === 'reuse') {
78
+ ({ avdName, consolePort, isRunning } = selection);
79
+ if (isRunning) {
80
+ console.log(chalk.dim(`Reusing running emulator emulator-${consolePort} (${avdName})`));
81
+ } else {
82
+ console.log(chalk.dim(`Booting assigned AVD ${avdName} on port ${consolePort}...`));
83
+ }
84
+ } else if (selection.kind === 'allocate') {
85
+ const picked = (selection.candidates.length === 1 || auto)
86
+ ? { c: selection.candidates[0], prevClaim: null }
87
+ : await pickAvd({
88
+ candidates: selection.candidates,
89
+ androidClaims: claimed.androidClaims,
90
+ });
91
+ await releasePriorClaim(picked.prevClaim);
92
+ ({ avdName, isRunning, consolePort } = picked.c);
93
+ if (!isRunning) {
94
+ consolePort = nextConsolePort(claimedPorts);
95
+ }
96
+ console.log(isRunning
97
+ ? chalk.green(`Picked ${avdName} (running on emulator-${consolePort})`)
98
+ : chalk.dim(`Booting ${avdName} on port ${consolePort}...`));
99
+ } else if (selection.kind === 'allClaimed') {
100
+ if (auto) {
101
+ console.error(chalk.red('All Android AVDs are claimed by other rn-iso projects.'));
102
+ console.error(chalk.dim('Re-run without --auto to confirm taking one over, or create a new AVD via Android Studio.'));
103
+ process.exit(1);
104
+ }
105
+ const picked = await pickAvd({
106
+ candidates: selection.candidates,
107
+ androidClaims: claimed.androidClaims,
108
+ allClaimed: true,
109
+ });
110
+ await releasePriorClaim(picked.prevClaim);
111
+ ({ avdName, isRunning, consolePort } = picked.c);
112
+ if (!isRunning) {
113
+ // Prior owner's port is freed by releasePriorClaim, but compute fresh.
114
+ const fresh = allClaimedDevices().androidConsolePorts.filter(p => p !== myPort);
115
+ consolePort = nextConsolePort(fresh);
116
+ }
117
+ console.log(isRunning
118
+ ? chalk.green(`Took over ${avdName} (running on emulator-${consolePort})`)
119
+ : chalk.dim(`Booting ${avdName} on port ${consolePort}...`));
120
+ } else {
58
121
  console.error(chalk.red(
59
- 'No AVDs available (or all are claimed by other projects). ' +
60
- 'Create one via Android Studio (Tools -> Device Manager).'
122
+ 'No AVDs available. Create one via Android Studio (Tools -> Device Manager).'
61
123
  ));
62
124
  process.exit(1);
63
125
  }
64
126
 
65
- const { avdName, consolePort, isRunning } = selection;
66
127
  const serial = `emulator-${consolePort}`;
67
-
68
128
  if (!isRunning) {
69
- console.log(chalk.dim(`Booting emulator ${avdName} on port ${consolePort}...`));
70
129
  bootAndroidEmulator(avdName, consolePort);
71
130
  console.log(chalk.dim('Waiting for boot to complete (this can take 10-30s)...'));
72
131
  const ok = await waitForBoot(serial, 120000);
@@ -74,15 +133,10 @@ export default function androidCommand(program) {
74
133
  console.error(chalk.red(`Emulator ${serial} did not finish booting within 2 minutes.`));
75
134
  process.exit(1);
76
135
  }
77
- } else {
78
- console.log(chalk.dim(`Reusing running emulator ${serial}`));
79
136
  }
80
137
 
81
138
  setDevice(root, 'android', { avdName, consolePort });
82
139
 
83
- // Metro is started by the build CLI; we don't spawn our own. For
84
- // Metro-only (no build/install), use `rn-iso start`.
85
-
86
140
  adbReverse(serial, proj.metroPort);
87
141
  console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
88
142
 
@@ -110,3 +164,55 @@ export default function androidCommand(program) {
110
164
  console.log(chalk.green(`\nAndroid ready on ${serial}, Metro port ${proj.metroPort}`));
111
165
  });
112
166
  }
167
+
168
+ async function pickAvd({ candidates, androidClaims = {}, allClaimed = false }) {
169
+ const sorted = sortAndroidCandidates(candidates);
170
+ const nameWidth = Math.max(...sorted.map(c => c.avdName.length), 18);
171
+ const choices = sorted.map(c => {
172
+ const claim = androidClaims[c.consolePort];
173
+ const claimTag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
174
+ const runTag = c.isRunning ? chalk.green(` [running on emulator-${c.consolePort}]`) : '';
175
+ return {
176
+ title: `${c.avdName.padEnd(nameWidth)}${runTag}${claimTag}`,
177
+ value: { c, claim: claim || null },
178
+ };
179
+ });
180
+ const message = allClaimed
181
+ ? 'All AVDs are claimed. Pick one to take over:'
182
+ : 'Pick an AVD (claimed AVDs will prompt to confirm):';
183
+ const answer = await prompts({
184
+ type: 'select',
185
+ name: 'pick',
186
+ message,
187
+ choices,
188
+ });
189
+ if (!answer.pick) {
190
+ console.error(chalk.red('Cancelled.'));
191
+ process.exit(1);
192
+ }
193
+ const { c, claim } = answer.pick;
194
+ if (claim) {
195
+ const ok = await prompts({
196
+ type: 'confirm',
197
+ name: 'ok',
198
+ message: `${c.avdName} is currently held by project "${claim.label}". Take it over?`,
199
+ initial: false,
200
+ });
201
+ if (!ok.ok) {
202
+ console.error(chalk.red('Cancelled.'));
203
+ process.exit(1);
204
+ }
205
+ return { c, prevClaim: claim };
206
+ }
207
+ return { c, prevClaim: null };
208
+ }
209
+
210
+ async function releasePriorClaim(prevClaim) {
211
+ if (!prevClaim?.path) return;
212
+ clearDevice(prevClaim.path, 'android');
213
+ console.log(chalk.dim(`Released prior assignment from "${prevClaim.label}"`));
214
+ }
215
+
216
+ function isAuto(opts) {
217
+ return opts.auto || !process.stdin.isTTY;
218
+ }