rn-iso 0.3.3 → 0.4.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.
package/README.md CHANGED
@@ -27,6 +27,13 @@ Both run side by side. For non-interactive / agent use, pass `--auto` to skip th
27
27
  npx rn-iso ios --auto
28
28
  ```
29
29
 
30
+ Forward extra flags to the underlying build command with `--`:
31
+
32
+ ```bash
33
+ npx rn-iso ios -- --variant=release
34
+ npx rn-iso android -- --mode=diaRelease --terminal=Ghostty
35
+ ```
36
+
30
37
  For AI coding agents, install the skill so the agent knows how to drive the CLI:
31
38
 
32
39
  ```bash
@@ -39,8 +46,8 @@ All commands below take the same `npx rn-iso` prefix.
39
46
 
40
47
  | Command | Purpose |
41
48
  |---|---|
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 |
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. |
50
+ | `android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Same for Android. |
44
51
  | `start` | Start Metro detached, no platform action |
45
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. |
46
53
  | `device [--platform ios\|android] [--json]` | Print the assigned device target |
@@ -48,6 +55,7 @@ All commands below take the same `npx rn-iso` prefix.
48
55
  | `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
49
56
  | `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
50
57
  | `release [<port>\|<shortcut>\|<path>] [--platform <p>] [--shutdown]` | Free a project's assignment. Target can be a Metro port (`8083`), a shortcut (label or unique basename), or an absolute path. `--shutdown` also stops the sim. |
58
+ | `config [<key> [<value>]] [--unset] [--project <target>]` | Get / set a per-project setting (`packageManager`, `ios.script`, `android.script`). |
51
59
 
52
60
  ## How it works
53
61
 
@@ -71,6 +79,25 @@ npx rn-iso unreserve # drop the lock without shutting the sim
71
79
 
72
80
  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
81
 
82
+ ## Per-project settings (`rn-iso config`)
83
+
84
+ A few options can be persisted per project so you don't have to repeat the same flags every run. Resolution order:
85
+
86
+ 1. CLI flag (`--script`, `--pm`)
87
+ 2. Stored project setting (this section)
88
+ 3. Default inferred from the project (`ios` / `android` script if present, package manager from lockfile)
89
+
90
+ ```bash
91
+ npx rn-iso config packageManager bun
92
+ npx rn-iso config ios.script dev:ios
93
+ npx rn-iso config android.script "dev:android --variant=debug"
94
+ npx rn-iso config # list current project's settings
95
+ npx rn-iso config ios.script # print one
96
+ npx rn-iso config ios.script --unset
97
+ ```
98
+
99
+ Allowed keys today: `packageManager` (one of `npm|yarn|pnpm|bun`), `ios.script`, `android.script`. Settings live in `~/.rn-iso/config.json` under the project's entry.
100
+
74
101
  ## Project shortcuts (--label)
75
102
 
76
103
  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>`:
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@ import statusCommand from '../src/commands/status.js';
10
10
  import releaseCommand from '../src/commands/release.js';
11
11
  import reserveCommand from '../src/commands/reserve.js';
12
12
  import unreserveCommand from '../src/commands/unreserve.js';
13
+ import configCommand from '../src/commands/config.js';
13
14
 
14
15
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
15
16
 
@@ -28,5 +29,6 @@ statusCommand(program);
28
29
  releaseCommand(program);
29
30
  reserveCommand(program);
30
31
  unreserveCommand(program);
32
+ configCommand(program);
31
33
 
32
34
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-iso",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "description": "Isolated React Native dev environments per project/worktree",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -30,6 +30,7 @@ From the project root (or any subdirectory):
30
30
  ## CRITICAL rules
31
31
 
32
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
+ - **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.
33
34
  - **`--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
35
  - **Always use `npx rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
35
36
  - **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
@@ -83,6 +84,7 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
83
84
  - `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
84
85
  - `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
86
  - `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
+ - `npx rn-iso config [<key> [<value>]] [--unset] [--project <target>]` — persist per-project settings. Allowed keys: `packageManager` (npm|yarn|pnpm|bun), `ios.script`, `android.script`. Resolution order on `ios`/`android`: CLI flag > stored setting > inferred default. Useful when a project's build script is named differently (`dev:ios` instead of `ios`) or when a different package manager is used than the lockfile suggests.
86
88
 
87
89
  ### Project shortcuts (--label)
88
90
 
@@ -20,14 +20,15 @@ import { resolveLabel } from '../labels.js';
20
20
  export default function androidCommand(program) {
21
21
  program
22
22
  .command('android')
23
- .description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed')
23
+ .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`.')
24
+ .argument('[extras...]', 'Flags forwarded as-is to the underlying build command (after `--`)')
24
25
  .option('--auto', 'Non-interactive: pick the first unclaimed AVD without prompting (also implied when stdin is not a TTY)')
25
26
  .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
26
- .option('--script <name>', 'package.json script to invoke for build/install (default: android)', 'android')
27
+ .option('--script <name>', 'package.json script to invoke for build/install (default: project setting `android.script`, else `android`)')
27
28
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
28
- .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: detected from lockfile)')
29
+ .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: project setting `packageManager`, else detected from lockfile)')
29
30
  .option('--no-install', 'Skip the build/install step')
30
- .action(async (opts) => {
31
+ .action(async (extras, opts) => {
31
32
  const root = findProjectRoot(process.cwd());
32
33
  if (!root) {
33
34
  console.error(chalk.red('Not in a React Native project (no package.json found).'));
@@ -142,9 +143,12 @@ export default function androidCommand(program) {
142
143
  console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
143
144
 
144
145
  if (opts.install !== false) {
145
- const packageManager = opts.pm || detectPackageManager(root);
146
+ const settings = proj.settings || {};
147
+ const packageManager = opts.pm ?? settings.packageManager ?? detectPackageManager(root);
146
148
  const useScript = opts.script !== false;
147
- const scriptName = useScript ? (typeof opts.script === 'string' ? opts.script : 'android') : null;
149
+ const scriptName = useScript
150
+ ? (typeof opts.script === 'string' ? opts.script : (settings.android?.script ?? 'android'))
151
+ : null;
148
152
  const cmd = buildAndroidCommand({
149
153
  projectRoot: root,
150
154
  packageManager,
@@ -154,6 +158,7 @@ export default function androidCommand(program) {
154
158
  serial,
155
159
  port: proj.metroPort,
156
160
  useScript,
161
+ extras,
157
162
  });
158
163
  console.log(chalk.dim(`> ${cmd}`));
159
164
  const exec = getExecutor();
@@ -0,0 +1,102 @@
1
+ // src/commands/config.js
2
+ import chalk from 'chalk';
3
+ import {
4
+ findProjectRoot,
5
+ resolveRegisteredProject,
6
+ detectIsExpo,
7
+ detectBundleId,
8
+ detectAndroidPackage,
9
+ } from '../project.js';
10
+ import {
11
+ getProject,
12
+ upsertProject,
13
+ getProjectSettings,
14
+ getProjectSetting,
15
+ setProjectSetting,
16
+ unsetProjectSetting,
17
+ } from '../config.js';
18
+
19
+ const ALLOWED_KEYS = ['packageManager', 'ios.script', 'android.script'];
20
+ const PM_VALUES = ['npm', 'yarn', 'pnpm', 'bun'];
21
+
22
+ export default function configCommand(program) {
23
+ program
24
+ .command('config [key] [value]')
25
+ .description(
26
+ 'Get or set a per-project setting. Allowed keys: ' + ALLOWED_KEYS.join(', ') +
27
+ '. With no args, lists current settings.'
28
+ )
29
+ .option('--unset', 'Remove the value for <key>')
30
+ .option('--project <target>', 'Run against another project (label / unique basename / absolute path) instead of cwd')
31
+ .action((key, value, opts) => {
32
+ const found = resolveTarget(opts.project);
33
+
34
+ if (!key) {
35
+ const settings = getProjectSettings(found);
36
+ if (Object.keys(settings).length === 0) {
37
+ console.log(chalk.dim(`No settings for ${found}.`));
38
+ return;
39
+ }
40
+ console.log(found);
41
+ for (const k of ALLOWED_KEYS) {
42
+ const v = getProjectSetting(found, k);
43
+ if (v !== undefined) console.log(` ${k} = ${chalk.cyan(v)}`);
44
+ }
45
+ return;
46
+ }
47
+
48
+ if (!ALLOWED_KEYS.includes(key)) {
49
+ console.error(chalk.red(`Unknown key "${key}". Allowed: ${ALLOWED_KEYS.join(', ')}.`));
50
+ process.exit(1);
51
+ }
52
+
53
+ if (opts.unset) {
54
+ const removed = unsetProjectSetting(found, key);
55
+ if (removed) console.log(chalk.green(`Unset ${key} for ${found}.`));
56
+ else console.log(chalk.dim(`${key} was already unset.`));
57
+ return;
58
+ }
59
+
60
+ if (value === undefined) {
61
+ const cur = getProjectSetting(found, key);
62
+ if (cur === undefined) console.log(chalk.dim('(unset)'));
63
+ else console.log(cur);
64
+ return;
65
+ }
66
+
67
+ if (key === 'packageManager' && !PM_VALUES.includes(value)) {
68
+ console.error(chalk.red(`Invalid packageManager "${value}". Must be one of: ${PM_VALUES.join(', ')}.`));
69
+ process.exit(1);
70
+ }
71
+
72
+ setProjectSetting(found, key, value);
73
+ console.log(chalk.green(`Set ${key} = ${value} for ${found}.`));
74
+ });
75
+ }
76
+
77
+ // `--project` targets an existing registered project; without it we fall
78
+ // back to the cwd and auto-register if needed (so users can configure a
79
+ // project before running `ios` / `android` for the first time).
80
+ function resolveTarget(projectArg) {
81
+ if (projectArg) {
82
+ const { found, error } = resolveRegisteredProject(projectArg);
83
+ if (!found) {
84
+ console.error(chalk.red(error));
85
+ process.exit(1);
86
+ }
87
+ return found;
88
+ }
89
+ const root = findProjectRoot(process.cwd());
90
+ if (!root) {
91
+ console.error(chalk.red('Not in a React Native project (no package.json found).'));
92
+ process.exit(1);
93
+ }
94
+ if (!getProject(root)) {
95
+ upsertProject(root, {
96
+ bundleId: detectBundleId(root),
97
+ androidPackage: detectAndroidPackage(root),
98
+ isExpo: detectIsExpo(root),
99
+ });
100
+ }
101
+ return root;
102
+ }
@@ -12,16 +12,17 @@ import { resolveLabel } from '../labels.js';
12
12
  export default function iosCommand(program) {
13
13
  program
14
14
  .command('ios')
15
- .description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed')
15
+ .description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed. Pass extra flags to the build CLI after `--`, e.g. `rn-iso ios -- --variant=release`.')
16
+ .argument('[extras...]', 'Flags forwarded as-is to the underlying build command (after `--`)')
16
17
  .option('--device-type <name>', 'Explicit opt-in: create a NEW sim of this device type (e.g. "iPhone 17 Pro")')
17
18
  .option('--runtime <version>', 'iOS runtime version when creating a new sim (e.g. "26.2"); defaults to latest')
18
19
  .option('--auto', 'Non-interactive: pick the first unclaimed sim without prompting (also implied when stdin is not a TTY)')
19
20
  .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
20
- .option('--script <name>', 'package.json script to invoke for build/install (default: ios)', 'ios')
21
+ .option('--script <name>', 'package.json script to invoke for build/install (default: project setting `ios.script`, else `ios`)')
21
22
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
22
- .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: detected from lockfile)')
23
+ .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: project setting `packageManager`, else detected from lockfile)')
23
24
  .option('--no-install', 'Skip the build/install step (assume app is already installed)')
24
- .action(async (opts) => {
25
+ .action(async (extras, opts) => {
25
26
  const root = findProjectRoot(process.cwd());
26
27
  if (!root) {
27
28
  console.error(chalk.red('Not in a React Native project (no package.json found).'));
@@ -144,9 +145,12 @@ export default function iosCommand(program) {
144
145
  // (without build/install), use `rn-iso start`.
145
146
 
146
147
  if (opts.install !== false) {
147
- const packageManager = opts.pm || detectPackageManager(root);
148
+ const settings = proj.settings || {};
149
+ const packageManager = opts.pm ?? settings.packageManager ?? detectPackageManager(root);
148
150
  const useScript = opts.script !== false;
149
- const scriptName = useScript ? (typeof opts.script === 'string' ? opts.script : 'ios') : null;
151
+ const scriptName = useScript
152
+ ? (typeof opts.script === 'string' ? opts.script : (settings.ios?.script ?? 'ios'))
153
+ : null;
150
154
  const cmd = buildIosCommand({
151
155
  projectRoot: root,
152
156
  packageManager,
@@ -155,6 +159,7 @@ export default function iosCommand(program) {
155
159
  udid,
156
160
  port: proj.metroPort,
157
161
  useScript,
162
+ extras,
158
163
  });
159
164
  console.log(chalk.dim(`> ${cmd}`));
160
165
  const exec = getExecutor();
package/src/config.js CHANGED
@@ -88,6 +88,70 @@ export function clearDevice(projectPath, platform) {
88
88
  saveConfig(cfg);
89
89
  }
90
90
 
91
+ // --- Per-project settings (scripts, package manager, ...) ---
92
+
93
+ export function getProjectSettings(projectPath) {
94
+ return getProject(projectPath)?.settings || {};
95
+ }
96
+
97
+ export function getProjectSetting(projectPath, dottedKey) {
98
+ return readNested(getProjectSettings(projectPath), dottedKey);
99
+ }
100
+
101
+ export function setProjectSetting(projectPath, dottedKey, value) {
102
+ const cfg = ensureConfig();
103
+ const proj = cfg.projects[projectPath];
104
+ if (!proj) throw new Error(`Project not registered: ${projectPath}`);
105
+ proj.settings = proj.settings || {};
106
+ writeNested(proj.settings, dottedKey, value);
107
+ saveConfig(cfg);
108
+ }
109
+
110
+ export function unsetProjectSetting(projectPath, dottedKey) {
111
+ const cfg = loadConfig();
112
+ const proj = cfg?.projects?.[projectPath];
113
+ if (!proj?.settings) return false;
114
+ const removed = deleteNested(proj.settings, dottedKey);
115
+ if (removed) saveConfig(cfg);
116
+ return removed;
117
+ }
118
+
119
+ function readNested(obj, dottedKey) {
120
+ if (!obj) return undefined;
121
+ const keys = dottedKey.split('.');
122
+ let cur = obj;
123
+ for (const k of keys) {
124
+ if (cur == null || typeof cur !== 'object') return undefined;
125
+ cur = cur[k];
126
+ }
127
+ return cur;
128
+ }
129
+
130
+ function writeNested(obj, dottedKey, value) {
131
+ const keys = dottedKey.split('.');
132
+ let cur = obj;
133
+ for (let i = 0; i < keys.length - 1; i++) {
134
+ if (typeof cur[keys[i]] !== 'object' || cur[keys[i]] === null) {
135
+ cur[keys[i]] = {};
136
+ }
137
+ cur = cur[keys[i]];
138
+ }
139
+ cur[keys[keys.length - 1]] = value;
140
+ }
141
+
142
+ function deleteNested(obj, dottedKey) {
143
+ const keys = dottedKey.split('.');
144
+ let cur = obj;
145
+ for (let i = 0; i < keys.length - 1; i++) {
146
+ if (cur[keys[i]] == null || typeof cur[keys[i]] !== 'object') return false;
147
+ cur = cur[keys[i]];
148
+ }
149
+ const leaf = keys[keys.length - 1];
150
+ if (!(leaf in cur)) return false;
151
+ delete cur[leaf];
152
+ return true;
153
+ }
154
+
91
155
  export function allMetroPorts() {
92
156
  const cfg = loadConfig();
93
157
  if (!cfg?.projects) return [];
package/src/runner.js CHANGED
@@ -79,7 +79,10 @@ export function detectScriptCli(scriptBody) {
79
79
  // iOS run command. Prefers the project's `ios` script if present (the most
80
80
  // reliable: respects user customization, picks the right CLI). Falls back to
81
81
  // expo run:ios / react-native run-ios when no script exists or --no-script.
82
- export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true }) {
82
+ // Any `extras` are appended last so they can override earlier flags (CLIs
83
+ // using commander/yargs are last-wins on repeated options).
84
+ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true, extras = [] }) {
85
+ const tail = (extras || []).map(shQuote);
83
86
  if (useScript && scriptName) {
84
87
  const script = getProjectScript(projectRoot, scriptName);
85
88
  if (script) {
@@ -89,16 +92,19 @@ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExp
89
92
  return buildScriptCommand(packageManager, scriptName, [
90
93
  deviceFlag,
91
94
  `--port ${port}`,
95
+ ...tail,
92
96
  ]);
93
97
  }
94
98
  }
99
+ const tailStr = tail.length ? ' ' + tail.join(' ') : '';
95
100
  if (isExpo) {
96
- return `npx expo run:ios --device ${udid} --port ${port}`;
101
+ return `npx expo run:ios --device ${udid} --port ${port}${tailStr}`;
97
102
  }
98
- return `npx react-native run-ios --udid ${udid} --port ${port}`;
103
+ return `npx react-native run-ios --udid ${udid} --port ${port}${tailStr}`;
99
104
  }
100
105
 
101
- export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true }) {
106
+ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, extras = [] }) {
107
+ const tail = (extras || []).map(shQuote);
102
108
  if (useScript && scriptName) {
103
109
  const script = getProjectScript(projectRoot, scriptName);
104
110
  if (script) {
@@ -108,13 +114,24 @@ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, i
108
114
  return buildScriptCommand(packageManager, scriptName, [
109
115
  deviceFlag,
110
116
  `--port ${port}`,
117
+ ...tail,
111
118
  ]);
112
119
  }
113
120
  }
121
+ const tailStr = tail.length ? ' ' + tail.join(' ') : '';
114
122
  if (isExpo) {
115
- return `npx expo run:android --device "${avdName}" --port ${port}`;
123
+ return `npx expo run:android --device "${avdName}" --port ${port}${tailStr}`;
116
124
  }
117
- return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}`;
125
+ return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}${tailStr}`;
126
+ }
127
+
128
+ // POSIX-safe single-quote shell escape. Leaves "safe" tokens (alnum and a
129
+ // few harmless punctuation marks like `=`, `.`, `,`, `:`, `/`, `-`, `@`,
130
+ // `+`, `_`, `%`) alone, single-quotes everything else.
131
+ export function shQuote(s) {
132
+ if (s === '') return "''";
133
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s;
134
+ return "'" + s.replace(/'/g, "'\\''") + "'";
118
135
  }
119
136
 
120
137
  export function buildMetroCommand({ isExpo, port }) {