rn-iso 0.4.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,10 +46,12 @@ All commands below take the same `npx rn-iso` prefix.
46
46
 
47
47
  | Command | Purpose |
48
48
  |---|---|
49
- | `ios [--auto] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Ensure iOS sim + Metro + build/install. Extras after `--` are forwarded to the build command. |
49
+ | `ios [--auto] [--managed-metro] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Ensure iOS sim + Metro + build/install. Extras after `--` are forwarded to the build command. |
50
50
  | `android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Same for Android. |
51
51
  | `start` | Start Metro detached, no platform action |
52
52
  | `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. |
53
+ | `logs [<port>\|<shortcut>\|<path>] [-n <lines>] [--follow]` | Print the managed Metro log (bundle progress, resolution errors, client logs). |
54
+ | `prune` | Remove entries for deleted project directories, freeing their devices and ports. |
53
55
  | `device [--platform ios\|android] [--json]` | Print the assigned device target |
54
56
  | `status` | Show all projects' state |
55
57
  | `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
@@ -64,7 +66,7 @@ All commands below take the same `npx rn-iso` prefix.
64
66
  - **Port allocation:** assigns 8082, 8083, 8084 etc., reclaiming dead ports on the way.
65
67
  - **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.
66
68
  - **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>`.
67
- - **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.
69
+ - **Metro is started by the build CLI by default** (interactive bundler UX preserved). Pass `--managed-metro` to have rn-iso start it instead — detached, PID-tracked, output captured in a per-project log file under `~/.rn-iso/logs/`; the build CLI then gets `--no-packager` / `--no-bundler` so it never spawns a second Metro. Managed Metro survives the shell that ran the build, which is why coding agents (finite shells) should always pass the flag. `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.
68
70
 
69
71
  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).
70
72
 
package/bin/cli.js CHANGED
@@ -6,6 +6,8 @@ import iosCommand from '../src/commands/ios.js';
6
6
  import androidCommand from '../src/commands/android.js';
7
7
  import startCommand from '../src/commands/start.js';
8
8
  import stopCommand from '../src/commands/stop.js';
9
+ import logsCommand from '../src/commands/logs.js';
10
+ import pruneCommand from '../src/commands/prune.js';
9
11
  import statusCommand from '../src/commands/status.js';
10
12
  import releaseCommand from '../src/commands/release.js';
11
13
  import reserveCommand from '../src/commands/reserve.js';
@@ -26,6 +28,8 @@ iosCommand(program);
26
28
  androidCommand(program);
27
29
  startCommand(program);
28
30
  stopCommand(program);
31
+ logsCommand(program);
32
+ pruneCommand(program);
29
33
  statusCommand(program);
30
34
  releaseCommand(program);
31
35
  reserveCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-iso",
3
- "version": "0.4.6",
3
+ "version": "0.6.0",
4
4
  "description": "Isolated React Native dev environments per project/worktree",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -14,23 +14,25 @@ Invoke the CLI via `npx`: `npx rn-iso <command>`. Don't `npm install -g`; `npx`
14
14
 
15
15
  From the project root (or any subdirectory):
16
16
 
17
- 1. **Ensure the platform is ready** — `npx rn-iso ios --auto` (or `npx rn-iso android`). This:
17
+ 1. **Ensure the platform is ready** — `npx rn-iso ios --auto --managed-metro` (or `npx rn-iso android --auto --managed-metro`). This:
18
18
  - Allocates a Metro port for the project (or reuses the assigned one)
19
+ - With `--managed-metro`: **starts Metro detached, logging to a per-project file.** Metro survives the shell that ran the command — you do NOT need to keep the command running or restart Metro after a build. The build CLI is passed `--no-packager` / `--no-bundler` so it never spawns a competing Metro.
19
20
  - Picks a dedicated unclaimed sim (booting it if shutdown). With `--auto`, picks the first candidate without prompting.
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).
21
+ - Builds and installs the app via the project's `ios` / `android` script if present, else `expo run:ios` / `react-native run-ios`. Detects the package manager from the lockfile (walks up for monorepos).
21
22
 
22
23
  2. **Get the device target** — `npx rn-iso device --platform ios --json`:
23
24
  ```json
24
- {"platform":"ios","udid":"ABC-...","metroPort":8083}
25
+ {"platform":"ios","udid":"ABC-...","metroPort":8083,"metroPid":12345,"metroHealthy":true,"metroLog":"~/.rn-iso/logs/<hash>.log"}
25
26
  ```
26
- Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` (or the hardware serial for a physical device) to use with `adb -s`. The Android JSON payload also includes `kind: "emulator" | "physical"`.
27
+ `metroHealthy` is a live ping of Metro's /status endpoint — if it's `false` after a build, something is wrong (see "When things go wrong"). `metroLog` is the managed Metro log file (also via `rn-iso logs`). Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` (or the hardware serial for a physical device) to use with `adb -s`. The Android JSON payload also includes `kind: "emulator" | "physical"`.
27
28
 
28
29
  3. **Interact with the device** — pass the UDID/serial to your UI tools. Never call `simctl <verb>` without `<UDID>` — `booted` could be the wrong sim.
29
30
 
30
31
  ## CRITICAL rules
31
32
 
33
+ - **ALWAYS pass `--managed-metro`** to `ios` / `android`. Without it, the build CLI starts Metro as a child of YOUR shell — when your shell command exits, Metro dies with it and the app is left showing a blank screen. The flag is off by default because humans want the interactive bundler; agents never do.
32
34
  - **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
- - **Forward extra flags to the build CLI with `--`.** `npx rn-iso ios -- --variant=release` (or `android -- --mode=diaRelease`) appends those flags to the underlying `react-native run-*` / `expo run:*` invocation. Useful for release-mode builds, custom terminals, etc. Last-wins semantics, so extras can override defaults rn-iso set earlier in the command. `start` accepts the same `--` extras and forwards them to `expo start` / `react-native start` e.g. `npx rn-iso start -- --reset-cache`. If Metro is already running, extras are not applied (run `rn-iso stop` first and re-run).
35
+ - **Forward extra flags to the build CLI with `--`.** `npx rn-iso ios -- --variant=release` (or `android -- --mode=diaRelease`) appends those flags to the underlying `react-native run-*` / `expo run:*` invocation. Useful for release-mode builds, custom terminals, etc. Last-wins semantics, so extras can override defaults rn-iso set earlier in the command. `start` accepts the same `--` extras and forwards them to `expo start` / `react-native start`. For a cache-cleared restart use the first-class flag `npx rn-iso start --reset-cache` (the bare `--` form does not survive `npx`, which swallows the separator). If Metro is already running, extras are not applied (run `rn-iso stop` first and re-run).
34
36
  - **`--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.
35
37
  - **Always use `npx rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
36
38
  - **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
@@ -44,7 +46,8 @@ From the project root (or any subdirectory):
44
46
 
45
47
  ```bash
46
48
  # Once per session -- ensure the project's sim and Metro are up.
47
- npx rn-iso ios --auto
49
+ # --managed-metro keeps Metro alive after this command exits (see CRITICAL rules).
50
+ npx rn-iso ios --auto --managed-metro
48
51
 
49
52
  # Get the target.
50
53
  UDID=$(npx rn-iso device --platform ios)
@@ -54,6 +57,9 @@ xcrun simctl io "$UDID" screenshot /tmp/screen.png
54
57
 
55
58
  # When you change app code, Metro hot-reloads automatically. No restart needed.
56
59
  # Only re-run `npx rn-iso ios` after native code changes or new native modules.
60
+
61
+ # Something looks wrong (blank screen, red box)? Read the Metro log first.
62
+ npx rn-iso logs -n 50
57
63
  ```
58
64
 
59
65
  ## Locking a manually-started sim
@@ -71,9 +77,10 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
71
77
  ## When things go wrong
72
78
 
73
79
  - **"No rn-iso assignment for project"** — run `npx rn-iso ios` (or android) first.
74
- - **"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.
80
+ - **"All iOS simulators are claimed by other rn-iso projects"** (under `--auto`) — every existing sim is held by another project. Claims from deleted worktrees don't count (they're auto-reclaimed), so these are all live projects. Options: `npx rn-iso prune` if you suspect stale state, 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.
75
81
  - **"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.
76
82
  - **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
+ - **Blank screen / app installed but nothing renders** — check `npx rn-iso status`. Metro `stopped` almost always means the build ran WITHOUT `--managed-metro`, so Metro died with the shell that ran it: recover with `npx rn-iso start`, then relaunch the app (`xcrun simctl launch <UDID> <bundleId>`), and pass the flag next time. If Metro IS running, read `npx rn-iso logs -n 50` for bundle/resolution errors (a stale `node_modules` after a branch switch is a classic — reinstall deps, then `npx rn-iso stop` + `start`).
77
84
  - **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.
78
85
  - **Sim was deleted** — `npx rn-iso ios` detects the stale assignment and re-allocates.
79
86
  - **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>`.
@@ -81,7 +88,9 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
81
88
  ## Other useful commands
82
89
 
83
90
  - `npx rn-iso status` — show all projects, their assignments, and Metro state.
84
- - `npx rn-iso start [-- <extras...>]` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds. Extras after `--` are forwarded to `expo start` / `react-native start` (e.g. `--reset-cache`).
91
+ - `npx rn-iso logs [<port>|<shortcut>|<path>] [-n <lines>] [--follow]` — print the managed Metro log (default: last 50 lines of the current project's). This is where bundle progress, module-resolution errors, and client console logs land. **Check this first on a blank screen or red box** it's faster than screenshots.
92
+ - `npx rn-iso prune` — remove entries for projects whose directory no longer exists (deleted worktrees), freeing their sims/emulators and ports, and killing any orphaned Metro. Live projects are never touched. Claims from deleted worktrees are also ignored automatically during device selection, so prune is housekeeping, not a prerequisite.
93
+ - `npx rn-iso start [--reset-cache] [-- <extras...>]` — start Metro detached on the project's assigned port WITHOUT building/installing. `--reset-cache` clears Metro's transform cache. Other extras after `--` are forwarded to `expo start` / `react-native start`.
85
94
  - `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.
86
95
  - `npx rn-iso release [<port>|<shortcut>|<path>] [--platform <p>] [--shutdown]` — free a project's sim assignment. Defaults to the current project. Target can also be a Metro port (`8083`) or a shortcut (label / unique basename). `--shutdown` also stops the sim/emulator.
87
96
  - `npx rn-iso shutdown [<shortcut>|<path>] [-y] [--keep-sims]` — kill Metro, shut down sims/emulators, and clear device assignments. With no arg, scopes to **every** registered project (end-of-day reset); pass a project shortcut (label or unique basename) or absolute path to scope to one. Note this does NOT default to the current project (deliberate — `shutdown` is the explicit "tear it all down" command). Prompts unless `-y` / non-TTY; `--keep-sims` only kills Metro and clears assignments without touching the sims. Project entries themselves stay registered, so `metroPort` allocations and labels survive.
@@ -4,6 +4,7 @@ import prompts from 'prompts';
4
4
  import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
5
5
  import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices } from '../config.js';
6
6
  import { allocatePort, isMetroRunning } from '../ports.js';
7
+ import { ensureMetro, logFileFor } from '../metro.js';
7
8
  import {
8
9
  selectAndroidDevice,
9
10
  sortAndroidCandidates,
@@ -24,6 +25,7 @@ export default function androidCommand(program) {
24
25
  .description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed. Pass extra flags to the build CLI after `--`, e.g. `rn-iso android -- --mode=diaRelease`.')
25
26
  .argument('[extras...]', 'Flags forwarded as-is to the underlying build command (after `--`)')
26
27
  .option('--auto', 'Non-interactive: pick the first unclaimed AVD without prompting (also implied when stdin is not a TTY)')
28
+ .option('--managed-metro', 'rn-iso starts Metro itself: detached (survives the invoking shell), logged to the per-project file; the build CLI is passed --no-packager / --no-bundler. Recommended for agents and CI; without it the build CLI owns Metro as usual.')
27
29
  .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
28
30
  .option('--script <name>', 'package.json script to invoke for build/install (default: project setting `android.script`, else `android`)')
29
31
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
@@ -56,11 +58,28 @@ export default function androidCommand(program) {
56
58
  proj = getProject(root);
57
59
  console.log(chalk.dim(`Allocated Metro port: ${port}`));
58
60
  }
59
- const metroAlreadyUp = await isMetroRunning(proj.metroPort);
60
- console.log(chalk.dim(
61
- `Metro port: ${proj.metroPort}` +
62
- (metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
63
- ));
61
+ // With --managed-metro, rn-iso owns Metro: detached so it survives the
62
+ // invoking shell (agents run builds from finite shells), output to the
63
+ // per-project log file, and the build CLI gets --no-packager /
64
+ // --no-bundler so it cannot start a second Metro on the same port.
65
+ // Without the flag, the build CLI owns Metro as usual (interactive
66
+ // bundler UX for humans).
67
+ if (opts.managedMetro) {
68
+ const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
69
+ if (metro.alreadyRunning) {
70
+ console.log(chalk.dim(`Metro port: ${proj.metroPort} (already running)`));
71
+ } else {
72
+ setMetro(root, proj.metroPort, metro.pid);
73
+ console.log(chalk.dim(`Metro started detached (pid ${metro.pid}, port ${proj.metroPort})`));
74
+ console.log(chalk.dim(`Metro log: ${logFileFor(root)}`));
75
+ }
76
+ } else {
77
+ const metroAlreadyUp = await isMetroRunning(proj.metroPort);
78
+ console.log(chalk.dim(
79
+ `Metro port: ${proj.metroPort}` +
80
+ (metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
81
+ ));
82
+ }
64
83
 
65
84
  const auto = isAuto(opts);
66
85
  const claimed = allClaimedDevices();
@@ -225,6 +244,7 @@ export default function androidCommand(program) {
225
244
  serial,
226
245
  port: proj.metroPort,
227
246
  useScript,
247
+ noPackager: Boolean(opts.managedMetro),
228
248
  extras,
229
249
  });
230
250
  console.log(chalk.dim(`> ${cmd}`));
@@ -2,6 +2,8 @@
2
2
  import chalk from 'chalk';
3
3
  import { findProjectRoot } from '../project.js';
4
4
  import { getProject } from '../config.js';
5
+ import { isMetroRunning } from '../ports.js';
6
+ import { logFileFor } from '../metro.js';
5
7
 
6
8
  export default function deviceCommand(program) {
7
9
  program
@@ -9,7 +11,7 @@ export default function deviceCommand(program) {
9
11
  .description('Print the assigned device UDID/serial for the current project')
10
12
  .option('--platform <platform>', 'ios or android', 'ios')
11
13
  .option('--json', 'Emit JSON with full assignment info')
12
- .action((opts) => {
14
+ .action(async (opts) => {
13
15
  const root = findProjectRoot(process.cwd());
14
16
  if (!root) {
15
17
  console.error(chalk.red('Not in a React Native project (no package.json found).'));
@@ -27,9 +29,17 @@ export default function deviceCommand(program) {
27
29
  }
28
30
 
29
31
  if (opts.json) {
32
+ // Metro fields let agents verify the bundler is actually serving
33
+ // (metroHealthy pings /status) and find the log without guessing paths.
34
+ const metro = {
35
+ metroPort: proj.metroPort,
36
+ metroPid: proj.metroPid ?? null,
37
+ metroHealthy: proj.metroPort ? await isMetroRunning(proj.metroPort) : false,
38
+ metroLog: logFileFor(root),
39
+ };
30
40
  let payload;
31
41
  if (opts.platform === 'ios') {
32
- payload = { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort };
42
+ payload = { platform: 'ios', udid: platformEntry.deviceUdid, ...metro };
33
43
  } else if (platformEntry.serial && !platformEntry.avdName) {
34
44
  payload = {
35
45
  platform: 'android',
@@ -37,7 +47,7 @@ export default function deviceCommand(program) {
37
47
  serial: platformEntry.serial,
38
48
  avdName: null,
39
49
  consolePort: null,
40
- metroPort: proj.metroPort,
50
+ ...metro,
41
51
  };
42
52
  } else {
43
53
  payload = {
@@ -46,7 +56,7 @@ export default function deviceCommand(program) {
46
56
  serial: `emulator-${platformEntry.consolePort}`,
47
57
  avdName: platformEntry.avdName,
48
58
  consolePort: platformEntry.consolePort,
49
- metroPort: proj.metroPort,
59
+ ...metro,
50
60
  };
51
61
  }
52
62
  console.log(JSON.stringify(payload));
@@ -4,6 +4,7 @@ import prompts from 'prompts';
4
4
  import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
5
5
  import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices, recordSimUsage, getSimUsage } from '../config.js';
6
6
  import { allocatePort, isMetroRunning } from '../ports.js';
7
+ import { ensureMetro, logFileFor } from '../metro.js';
7
8
  import { selectIosDevice, bootIosSim, listIosRuntimes, createIosSim, parseRuntimeVersion, listAllIosSims, sortSims, formatIosLabel } from '../sim/ios.js';
8
9
  import { buildIosCommand, detectPackageManager } from '../runner.js';
9
10
  import { getExecutor } from '../exec.js';
@@ -17,6 +18,7 @@ export default function iosCommand(program) {
17
18
  .option('--device-type <name>', 'Explicit opt-in: create a NEW sim of this device type (e.g. "iPhone 17 Pro")')
18
19
  .option('--runtime <version>', 'iOS runtime version when creating a new sim (e.g. "26.2"); defaults to latest')
19
20
  .option('--auto', 'Non-interactive: pick the first unclaimed sim without prompting (also implied when stdin is not a TTY)')
21
+ .option('--managed-metro', 'rn-iso starts Metro itself: detached (survives the invoking shell), logged to the per-project file; the build CLI is passed --no-packager / --no-bundler. Recommended for agents and CI; without it the build CLI owns Metro as usual.')
20
22
  .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
21
23
  .option('--script <name>', 'package.json script to invoke for build/install (default: project setting `ios.script`, else `ios`)')
22
24
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
@@ -52,11 +54,28 @@ export default function iosCommand(program) {
52
54
  proj = getProject(root);
53
55
  console.log(chalk.dim(`Allocated Metro port: ${port}`));
54
56
  }
55
- const metroAlreadyUp = await isMetroRunning(proj.metroPort);
56
- console.log(chalk.dim(
57
- `Metro port: ${proj.metroPort}` +
58
- (metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
59
- ));
57
+ // With --managed-metro, rn-iso owns Metro: detached so it survives the
58
+ // invoking shell (agents run builds from finite shells), output to the
59
+ // per-project log file, and the build CLI gets --no-packager /
60
+ // --no-bundler so it cannot start a second Metro on the same port.
61
+ // Without the flag, the build CLI owns Metro as usual (interactive
62
+ // bundler UX for humans).
63
+ if (opts.managedMetro) {
64
+ const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
65
+ if (metro.alreadyRunning) {
66
+ console.log(chalk.dim(`Metro port: ${proj.metroPort} (already running)`));
67
+ } else {
68
+ setMetro(root, proj.metroPort, metro.pid);
69
+ console.log(chalk.dim(`Metro started detached (pid ${metro.pid}, port ${proj.metroPort})`));
70
+ console.log(chalk.dim(`Metro log: ${logFileFor(root)}`));
71
+ }
72
+ } else {
73
+ const metroAlreadyUp = await isMetroRunning(proj.metroPort);
74
+ console.log(chalk.dim(
75
+ `Metro port: ${proj.metroPort}` +
76
+ (metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
77
+ ));
78
+ }
60
79
 
61
80
  const claimedDevices = allClaimedDevices();
62
81
  const ownUdid = proj.platforms?.ios?.deviceUdid;
@@ -139,11 +158,6 @@ export default function iosCommand(program) {
139
158
  setDevice(root, 'ios', { deviceUdid: udid });
140
159
  recordSimUsage('ios', udid);
141
160
 
142
- // Metro is started by the build CLI (`expo run:ios` / `react-native
143
- // run-ios`) using the --port we pass below. We don't spawn a separate
144
- // Metro -- that caused two Metros on the same port. For Metro-only
145
- // (without build/install), use `rn-iso start`.
146
-
147
161
  if (opts.install !== false) {
148
162
  const settings = proj.settings || {};
149
163
  const packageManager = opts.pm ?? settings.packageManager ?? detectPackageManager(root);
@@ -159,6 +173,7 @@ export default function iosCommand(program) {
159
173
  udid,
160
174
  port: proj.metroPort,
161
175
  useScript,
176
+ noPackager: Boolean(opts.managedMetro),
162
177
  extras,
163
178
  });
164
179
  console.log(chalk.dim(`> ${cmd}`));
@@ -0,0 +1,46 @@
1
+ // src/commands/logs.js
2
+ import chalk from 'chalk';
3
+ import { existsSync } from 'fs';
4
+ import { resolveRegisteredProject } from '../project.js';
5
+ import { findProjectByMetroPort } from '../config.js';
6
+ import { logFileFor } from '../metro.js';
7
+ import { getExecutor } from '../exec.js';
8
+
9
+ export default function logsCommand(program) {
10
+ program
11
+ .command('logs [target]')
12
+ .description('Print the managed Metro log (bundle progress, resolution errors, client logs). With no arg, the current project. Pass a port (e.g. 8083), a project shortcut, or an absolute path.')
13
+ .option('-n, --lines <count>', 'Number of trailing lines to print', '50')
14
+ .option('-f, --follow', 'Stream the log as it grows (Ctrl-C to stop)')
15
+ .action(async (target, opts) => {
16
+ let root;
17
+ if (target && /^\d+$/.test(target)) {
18
+ root = findProjectByMetroPort(parseInt(target, 10));
19
+ if (!root) {
20
+ console.error(chalk.red(`No project owns Metro port ${target}.`));
21
+ process.exit(1);
22
+ }
23
+ } else {
24
+ const { found, error } = resolveRegisteredProject(target);
25
+ if (!found) {
26
+ console.error(chalk.red(error));
27
+ process.exit(1);
28
+ }
29
+ root = found;
30
+ }
31
+
32
+ const path = logFileFor(root);
33
+ if (!existsSync(path)) {
34
+ console.error(chalk.red(`No Metro log for ${root}.`));
35
+ console.error(chalk.dim('The log is created when rn-iso starts Metro (rn-iso ios / android / start).'));
36
+ process.exit(1);
37
+ }
38
+
39
+ console.log(chalk.dim(`log: ${path}`));
40
+ const args = opts.follow
41
+ ? ['-n', String(opts.lines), '-f', path]
42
+ : ['-n', String(opts.lines), path];
43
+ const child = getExecutor().spawn('tail', args, { stdio: 'inherit' });
44
+ await new Promise((resolve) => child.on('exit', resolve));
45
+ });
46
+ }
@@ -0,0 +1,44 @@
1
+ // src/commands/prune.js
2
+ import chalk from 'chalk';
3
+ import { pruneDeadProjects } from '../config.js';
4
+ import { findPidListeningOnPort } from '../metro.js';
5
+
6
+ export default function pruneCommand(program) {
7
+ program
8
+ .command('prune')
9
+ .description('Remove entries for projects whose directory no longer exists (deleted worktrees), freeing their sims/emulators and Metro ports. Live projects are never touched.')
10
+ .action(() => {
11
+ const removed = pruneDeadProjects();
12
+ if (removed.length === 0) {
13
+ console.log(chalk.dim('Nothing to prune: every registered project path still exists.'));
14
+ return;
15
+ }
16
+
17
+ for (const { path, project } of removed) {
18
+ const freed = [];
19
+ const ios = project.platforms?.ios;
20
+ if (ios?.deviceUdid) freed.push(`ios sim ${ios.deviceUdid}`);
21
+ const android = project.platforms?.android;
22
+ if (android?.avdName) freed.push(`android avd ${android.avdName}`);
23
+ else if (android?.serial) freed.push(`android device ${android.serial}`);
24
+
25
+ console.log(chalk.green(`Pruned ${path}`));
26
+ if (freed.length) console.log(chalk.dim(` freed: ${freed.join(', ')}`));
27
+
28
+ // A Metro started from the deleted directory can outlive it and squat
29
+ // on the port; kill it so the port is genuinely free.
30
+ if (typeof project.metroPort === 'number') {
31
+ const pid = findPidListeningOnPort(project.metroPort);
32
+ if (pid) {
33
+ try {
34
+ process.kill(pid, 'SIGTERM');
35
+ console.log(chalk.dim(` killed orphaned Metro pid ${pid} on port ${project.metroPort}`));
36
+ } catch {
37
+ console.log(chalk.dim(` could not kill pid ${pid} on port ${project.metroPort}`));
38
+ }
39
+ }
40
+ }
41
+ }
42
+ console.log(chalk.dim(`\n${removed.length} project entr${removed.length === 1 ? 'y' : 'ies'} removed.`));
43
+ });
44
+ }
@@ -8,9 +8,10 @@ import { ensureMetro } from '../metro.js';
8
8
  export default function startCommand(program) {
9
9
  program
10
10
  .command('start')
11
- .description('Ensure Metro is running for the current project (no platform action). Pass extra flags to `expo start` / `react-native start` after `--`, e.g. `rn-iso start -- --reset-cache`.')
11
+ .description('Ensure Metro is running for the current project (no platform action). Pass extra flags to `expo start` / `react-native start` after `--`.')
12
12
  .argument('[extras...]', 'Flags forwarded as-is to expo/react-native start (after `--`)')
13
- .action(async (extras) => {
13
+ .option('--reset-cache', 'Start Metro with a cleared transform cache (first-class flag: `npx` swallows the `--` separator, so `rn-iso start -- --reset-cache` does not survive an npx invocation)')
14
+ .action(async (extras, opts) => {
14
15
  const root = findProjectRoot(process.cwd());
15
16
  if (!root) {
16
17
  console.error(chalk.red('Not in a React Native project (no package.json found).'));
@@ -33,7 +34,8 @@ export default function startCommand(program) {
33
34
  proj = getProject(root);
34
35
  }
35
36
 
36
- const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort, extras });
37
+ const allExtras = [...(extras || []), ...(opts.resetCache ? ['--reset-cache'] : [])];
38
+ const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort, extras: allExtras });
37
39
  if (metro.alreadyRunning) {
38
40
  if (extras?.length) {
39
41
  console.log(chalk.yellow(
@@ -2,7 +2,7 @@
2
2
  import chalk from 'chalk';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { isMetroRunning } from '../ports.js';
5
- import { isPidAlive } from '../metro.js';
5
+ import { isPidAlive, logFileExists } from '../metro.js';
6
6
  import { findProjectRoot, projectShortcut } from '../project.js';
7
7
  import { listAllIosSims } from '../sim/ios.js';
8
8
 
@@ -42,6 +42,8 @@ export default function statusCommand(program) {
42
42
  ? chalk.green('running')
43
43
  : pidLive ? chalk.yellow('pid alive but not responding') : chalk.dim('stopped');
44
44
  console.log(` metro: port ${proj.metroPort} pid ${proj.metroPid ?? '?'} (${label})`);
45
+ const log = logFileExists(path);
46
+ if (log) console.log(chalk.dim(` log: ${log}`));
45
47
  } else {
46
48
  console.log(chalk.dim(' metro: unassigned'));
47
49
  }
package/src/config.js CHANGED
@@ -187,6 +187,10 @@ export function allClaimedDevices() {
187
187
  };
188
188
  if (!cfg) return result;
189
189
  for (const [path, proj] of Object.entries(cfg.projects || {})) {
190
+ // Claims from project paths that no longer exist on disk are orphaned --
191
+ // nothing can ever run from a deleted worktree again -- so pickers treat
192
+ // those devices as free. `prune` removes the dead entries themselves.
193
+ if (!existsSync(path)) continue;
190
194
  const label = path.split('/').pop() || path;
191
195
  const ios = proj.platforms?.ios;
192
196
  if (ios?.deviceUdid) {
@@ -218,6 +222,22 @@ export function allClaimedDevices() {
218
222
  return result;
219
223
  }
220
224
 
225
+ // Remove project entries whose path no longer exists on disk (deleted
226
+ // worktrees). Returns the removed entries so callers can report what was
227
+ // freed and clean up any process still bound to their Metro ports.
228
+ export function pruneDeadProjects() {
229
+ const cfg = loadConfig();
230
+ if (!cfg?.projects) return [];
231
+ const removed = [];
232
+ for (const [path, proj] of Object.entries(cfg.projects)) {
233
+ if (existsSync(path)) continue;
234
+ removed.push({ path, project: proj });
235
+ delete cfg.projects[path];
236
+ }
237
+ if (removed.length) saveConfig(cfg);
238
+ return removed;
239
+ }
240
+
221
241
  export function recordSimUsage(platform, identifier) {
222
242
  if (platform !== 'ios' && platform !== 'android') return;
223
243
  const cfg = ensureConfig();
package/src/ports.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { request } from 'http';
2
+ import { existsSync } from 'fs';
2
3
  import { loadConfig, allMetroPorts, removeProject } from './config.js';
3
4
 
4
5
  export function isMetroRunning(port) {
@@ -29,6 +30,10 @@ export async function findReclaimablePort(excludeProjectPath, probe = isMetroRun
29
30
  const candidates = [];
30
31
  for (const [path, proj] of Object.entries(cfg.projects)) {
31
32
  if (path === excludeProjectPath) continue;
33
+ // Only projects whose path no longer exists are reclaimable: reclaiming
34
+ // removes the whole entry, and doing that to a live project would also
35
+ // drop its device claim out from under it.
36
+ if (existsSync(path)) continue;
32
37
  if (typeof proj.metroPort === 'number') {
33
38
  candidates.push({ port: proj.metroPort, ownerPath: path });
34
39
  }
package/src/runner.js CHANGED
@@ -76,12 +76,19 @@ export function detectScriptCli(scriptBody) {
76
76
  return 'unknown';
77
77
  }
78
78
 
79
+ // Flag that stops the build CLI from spawning its own packager. rn-iso owns
80
+ // Metro (detached, log file, pid tracked), so the build must not start a
81
+ // second one on the same port.
82
+ function skipPackagerFlag(cli) {
83
+ return cli === 'expo' ? '--no-bundler' : '--no-packager';
84
+ }
85
+
79
86
  // iOS run command. Prefers the project's `ios` script if present (the most
80
87
  // reliable: respects user customization, picks the right CLI). Falls back to
81
88
  // expo run:ios / react-native run-ios when no script exists or --no-script.
82
89
  // Any `extras` are appended last so they can override earlier flags (CLIs
83
90
  // using commander/yargs are last-wins on repeated options).
84
- export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true, extras = [] }) {
91
+ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true, noPackager = false, extras = [] }) {
85
92
  const tail = (extras || []).map(shQuote);
86
93
  if (useScript && scriptName) {
87
94
  const script = getProjectScript(projectRoot, scriptName);
@@ -92,18 +99,21 @@ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExp
92
99
  return buildScriptCommand(packageManager, scriptName, [
93
100
  deviceFlag,
94
101
  `--port ${port}`,
102
+ ...(noPackager ? [skipPackagerFlag(cli)] : []),
95
103
  ...tail,
96
104
  ]);
97
105
  }
98
106
  }
99
107
  const tailStr = tail.length ? ' ' + tail.join(' ') : '';
100
108
  if (isExpo) {
101
- return `npx expo run:ios --device ${udid} --port ${port}${tailStr}`;
109
+ const skip = noPackager ? ' --no-bundler' : '';
110
+ return `npx expo run:ios --device ${udid} --port ${port}${skip}${tailStr}`;
102
111
  }
103
- return `npx react-native run-ios --udid ${udid} --port ${port}${tailStr}`;
112
+ const skip = noPackager ? ' --no-packager' : '';
113
+ return `npx react-native run-ios --udid ${udid} --port ${port}${skip}${tailStr}`;
104
114
  }
105
115
 
106
- export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, extras = [] }) {
116
+ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, noPackager = false, extras = [] }) {
107
117
  const tail = (extras || []).map(shQuote);
108
118
  // Expo `--device <id>` accepts either an AVD name (emulators) or a
109
119
  // hardware serial (physical devices). When no AVD name is available
@@ -118,15 +128,18 @@ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, i
118
128
  return buildScriptCommand(packageManager, scriptName, [
119
129
  deviceFlag,
120
130
  `--port ${port}`,
131
+ ...(noPackager ? [skipPackagerFlag(cli)] : []),
121
132
  ...tail,
122
133
  ]);
123
134
  }
124
135
  }
125
136
  const tailStr = tail.length ? ' ' + tail.join(' ') : '';
126
137
  if (isExpo) {
127
- return `npx expo run:android --device "${expoDeviceArg}" --port ${port}${tailStr}`;
138
+ const skip = noPackager ? ' --no-bundler' : '';
139
+ return `npx expo run:android --device "${expoDeviceArg}" --port ${port}${skip}${tailStr}`;
128
140
  }
129
- return `RCT_METRO_PORT=${port} npx react-native run-android --device ${serial}${tailStr}`;
141
+ const skip = noPackager ? ' --no-packager' : '';
142
+ return `RCT_METRO_PORT=${port} npx react-native run-android --device ${serial}${skip}${tailStr}`;
130
143
  }
131
144
 
132
145
  // POSIX-safe single-quote shell escape. Leaves "safe" tokens (alnum and a