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
@@ -2,11 +2,12 @@
2
2
  import chalk from 'chalk';
3
3
  import prompts from 'prompts';
4
4
  import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
5
- import { getProject, upsertProject, setMetro, setDevice, allClaimedDevices, recordSimUsage, getSimUsage } from '../config.js';
5
+ import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices, recordSimUsage, getSimUsage } from '../config.js';
6
6
  import { allocatePort, isMetroRunning } from '../ports.js';
7
7
  import { selectIosDevice, bootIosSim, listIosRuntimes, createIosSim, parseRuntimeVersion, listAllIosSims, sortSims } from '../sim/ios.js';
8
8
  import { buildIosCommand, detectPackageManager } from '../runner.js';
9
9
  import { getExecutor } from '../exec.js';
10
+ import { resolveLabel } from '../labels.js';
10
11
 
11
12
  export default function iosCommand(program) {
12
13
  program
@@ -14,7 +15,8 @@ export default function iosCommand(program) {
14
15
  .description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed')
15
16
  .option('--device-type <name>', 'Explicit opt-in: create a NEW sim of this device type (e.g. "iPhone 17 Pro")')
16
17
  .option('--runtime <version>', 'iOS runtime version when creating a new sim (e.g. "26.2"); defaults to latest')
17
- .option('--auto', 'Non-interactive: pick the first unclaimed sim without prompting')
18
+ .option('--auto', 'Non-interactive: pick the first unclaimed sim without prompting (also implied when stdin is not a TTY)')
19
+ .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
18
20
  .option('--script <name>', 'package.json script to invoke for build/install (default: ios)', 'ios')
19
21
  .option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
20
22
  .option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: detected from lockfile)')
@@ -26,11 +28,21 @@ export default function iosCommand(program) {
26
28
  process.exit(1);
27
29
  }
28
30
 
31
+ // Treat non-TTY environments (agents, CI) as if --auto was passed.
32
+ const auto = opts.auto || !process.stdin.isTTY;
33
+
29
34
  const bundleId = detectBundleId(root);
30
35
  const androidPackage = detectAndroidPackage(root);
31
36
  const isExpo = detectIsExpo(root);
32
37
 
33
- upsertProject(root, { bundleId, androidPackage, isExpo });
38
+ const existing = getProject(root);
39
+ const label = await resolveLabel({ root, existingProject: existing, optsLabel: opts.label });
40
+ upsertProject(root, {
41
+ bundleId,
42
+ androidPackage,
43
+ isExpo,
44
+ ...(label ? { label } : {}),
45
+ });
34
46
  let proj = getProject(root);
35
47
 
36
48
  if (!proj.metroPort) {
@@ -65,28 +77,59 @@ export default function iosCommand(program) {
65
77
  console.log(chalk.dim(`Reusing assigned sim ${udid} (already booted)`));
66
78
  }
67
79
  } else if (selection.kind === 'allocate') {
68
- const picked = (selection.candidates.length === 1 || opts.auto)
69
- ? selection.candidates[0]
70
- : await pickSim(selection.candidates, claimedDevices.iosClaims, usage);
71
- udid = picked.udid;
72
- if (picked.state !== 'Booted') {
73
- console.log(chalk.dim(`Booting ${picked.name} (${udid})...`));
80
+ const picked = (selection.candidates.length === 1 || auto)
81
+ ? { sim: selection.candidates[0], prevClaim: null }
82
+ : await pickSim({
83
+ candidates: selection.candidates,
84
+ iosClaims: claimedDevices.iosClaims,
85
+ usage,
86
+ });
87
+ udid = picked.sim.udid;
88
+ releasePriorClaim(picked.prevClaim);
89
+ if (picked.sim.state !== 'Booted') {
90
+ console.log(chalk.dim(`Booting ${picked.sim.name} (${udid})...`));
74
91
  bootIosSim(udid);
75
92
  } else {
76
- console.log(chalk.green(`Assigned ${picked.name} (${udid}, booted)`));
93
+ console.log(chalk.green(`Assigned ${picked.sim.name} (${udid}, booted)`));
94
+ }
95
+ } else if (selection.kind === 'allClaimed') {
96
+ // Sims exist but every one is claimed by another project. With
97
+ // --auto, refuse rather than silently stealing. Interactive: show
98
+ // picker so the user can confirm-steal one.
99
+ if (auto) {
100
+ if (opts.deviceType) {
101
+ udid = createNewSim({ deviceType: opts.deviceType, runtimeVersion: opts.runtime });
102
+ console.log(chalk.green(`Created and booted new sim ${udid}`));
103
+ } else {
104
+ console.error(chalk.red('All iOS simulators are claimed by other rn-iso projects.'));
105
+ console.error(chalk.dim('Re-run without --auto to confirm taking one over, or pass --device-type to create a new sim.'));
106
+ process.exit(1);
107
+ }
108
+ } else {
109
+ const picked = await pickSim({
110
+ candidates: [],
111
+ iosClaims: claimedDevices.iosClaims,
112
+ usage,
113
+ allClaimed: true,
114
+ });
115
+ udid = picked.sim.udid;
116
+ releasePriorClaim(picked.prevClaim);
117
+ if (picked.sim.state !== 'Booted') {
118
+ console.log(chalk.dim(`Booting ${picked.sim.name} (${udid})...`));
119
+ bootIosSim(udid);
120
+ } else {
121
+ console.log(chalk.green(`Took over ${picked.sim.name} (${udid}, booted)`));
122
+ }
77
123
  }
78
124
  } else {
79
- // needsBoot: no unclaimed sim available.
125
+ // noSims: no iOS simulators exist on this machine at all.
80
126
  if (opts.deviceType) {
81
127
  udid = createNewSim({ deviceType: opts.deviceType, runtimeVersion: opts.runtime });
82
128
  console.log(chalk.green(`Created and booted new sim ${udid}`));
83
129
  } else {
84
- console.error(chalk.red('No unclaimed iOS simulator available.'));
85
- console.error(chalk.dim('Options:'));
86
- console.error(chalk.dim(' - Open a sim in the Simulator app, then re-run'));
87
- console.error(chalk.dim(' - `rn-iso unreserve --all` if you have stale reservations'));
88
- console.error(chalk.dim(' - Free another rn-iso project (`rn-iso release` from there)'));
89
- console.error(chalk.dim(' - Pass --device-type "iPhone 17 Pro" [--runtime 26.2] to create a new sim'));
130
+ console.error(chalk.red('No iOS simulators found.'));
131
+ console.error(chalk.dim('Pass --device-type "iPhone 17 Pro" [--runtime 26.2] to create one,'));
132
+ console.error(chalk.dim('or install a simulator runtime via Xcode.'));
90
133
  process.exit(1);
91
134
  }
92
135
  }
@@ -125,10 +168,10 @@ export default function iosCommand(program) {
125
168
  });
126
169
  }
127
170
 
128
- async function pickSim(candidates, iosClaims = {}, usage = {}) {
129
- // Show ALL sims (not just unclaimed) so the user can see why something
130
- // they expected isn't selectable. Claimed sims are listed but disabled,
131
- // labeled with the project/reservation that owns them.
171
+ async function pickSim({ candidates, iosClaims = {}, usage = {}, allClaimed = false }) {
172
+ // Show ALL sims so the user has full context. Unclaimed candidates pick
173
+ // immediately; claimed sims are selectable too but require a confirm
174
+ // prompt so the steal is intentional.
132
175
  const allSims = listAllIosSims();
133
176
  const candidateUdids = new Set(candidates.map(s => s.udid));
134
177
  const sorted = sortSims(allSims, usage);
@@ -140,37 +183,56 @@ async function pickSim(candidates, iosClaims = {}, usage = {}) {
140
183
  const versionPart = version.padStart(6);
141
184
  const claim = iosClaims[s.udid];
142
185
  if (claim) {
143
- const tag = claim.source === 'project'
144
- ? `[claimed by ${claim.label}]`
145
- : `[reserved: ${claim.label}]`;
186
+ const stateTag = s.state === 'Booted' ? chalk.green(' [booted]') : '';
146
187
  return {
147
- title: chalk.dim(`${namePart} ${versionPart} ${tag}`),
148
- value: null,
149
- disabled: true,
188
+ title: chalk.yellow(`${namePart} ${versionPart} [claimed by ${claim.label}]${stateTag}`),
189
+ value: { sim: s, claim },
150
190
  };
151
191
  }
152
192
  const stateTag = s.state === 'Booted' ? chalk.green(' [booted]') : '';
153
193
  const isCandidate = candidateUdids.has(s.udid);
154
194
  if (!isCandidate) {
155
- // Shouldn't happen with current selectIosDevice, but be safe.
156
195
  return { title: chalk.dim(`${namePart} ${versionPart}`), value: null, disabled: true };
157
196
  }
158
197
  return {
159
198
  title: `${namePart} ${chalk.dim(versionPart)}${stateTag}`,
160
- value: s,
199
+ value: { sim: s, claim: null },
161
200
  };
162
201
  });
202
+ const message = allClaimed
203
+ ? 'All sims are claimed. Pick one to take over:'
204
+ : 'Pick a simulator (claimed sims will prompt to confirm):';
163
205
  const answer = await prompts({
164
206
  type: 'select',
165
- name: 'sim',
166
- message: 'Pick a simulator:',
207
+ name: 'pick',
208
+ message,
167
209
  choices,
168
210
  });
169
- if (!answer.sim) {
211
+ if (!answer.pick) {
170
212
  console.error(chalk.red('Cancelled.'));
171
213
  process.exit(1);
172
214
  }
173
- return answer.sim;
215
+ const { sim, claim } = answer.pick;
216
+ if (claim) {
217
+ const confirm = await prompts({
218
+ type: 'confirm',
219
+ name: 'ok',
220
+ message: `${sim.name} is currently held by project "${claim.label}". Take it over?`,
221
+ initial: false,
222
+ });
223
+ if (!confirm.ok) {
224
+ console.error(chalk.red('Cancelled.'));
225
+ process.exit(1);
226
+ }
227
+ return { sim, prevClaim: claim };
228
+ }
229
+ return { sim, prevClaim: null };
230
+ }
231
+
232
+ function releasePriorClaim(prevClaim) {
233
+ if (!prevClaim?.path) return;
234
+ clearDevice(prevClaim.path, 'ios');
235
+ console.log(chalk.dim(`Released prior assignment from "${prevClaim.label}"`));
174
236
  }
175
237
 
176
238
  function createNewSim({ deviceType, runtimeVersion }) {
@@ -1,36 +1,20 @@
1
1
  // src/commands/release.js
2
2
  import chalk from 'chalk';
3
3
  import { resolveRegisteredProject } from '../project.js';
4
- import { getProject, clearDevice, findReservations, removeReservation } from '../config.js';
4
+ import { getProject, clearDevice } from '../config.js';
5
+ import { shutdownIosSim } from '../sim/ios.js';
6
+ import { shutdownAndroidEmulator } from '../sim/android.js';
5
7
 
6
8
  export default function releaseCommand(program) {
7
9
  program
8
10
  .command('release [target]')
9
- .description('Free a project assignment OR a reservation. [target] is an absolute project path, a reservation label (set via `rn-iso reserve --label`), or a UDID/serial. Defaults to the current project.')
11
+ .description('Free a project assignment. [target] is an absolute project path; defaults to the current project.')
10
12
  .option('--platform <platform>', 'ios or android (default: both)')
13
+ .option('--shutdown', 'Also shut down the simulator/emulator after releasing')
11
14
  .action((target, opts) => {
12
- // 1. If a target was given, try it as a reservation label / id first.
13
- if (target) {
14
- const matches = findReservations(target, opts.platform);
15
- if (matches.length > 0) {
16
- for (const m of matches) {
17
- removeReservation(m.platform, m.id);
18
- console.log(chalk.green(
19
- `Released ${m.platform} reservation ${m.id}` +
20
- (m.label ? ` (${m.label})` : '')
21
- ));
22
- }
23
- return;
24
- }
25
- }
26
-
27
- // 2. Otherwise treat the target as a project path (or default to cwd).
28
15
  const { found, error } = resolveRegisteredProject(target);
29
16
  if (!found) {
30
17
  console.error(chalk.red(error));
31
- if (target) {
32
- console.error(chalk.dim('(Also tried as reservation label/id; no match.)'));
33
- }
34
18
  process.exit(1);
35
19
  }
36
20
  const proj = getProject(found);
@@ -40,12 +24,22 @@ export default function releaseCommand(program) {
40
24
  }
41
25
  const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
42
26
  for (const p of platforms) {
43
- if (proj.platforms?.[p]) {
44
- clearDevice(found, p);
45
- console.log(chalk.green(`Released ${p} assignment for ${found}.`));
46
- } else {
27
+ const entry = proj.platforms?.[p];
28
+ if (!entry) {
47
29
  console.log(chalk.dim(`No ${p} assignment to release for ${found}.`));
30
+ continue;
31
+ }
32
+ if (opts.shutdown) {
33
+ if (p === 'ios') {
34
+ shutdownIosSim(entry.deviceUdid);
35
+ console.log(chalk.green(`Shut down iOS sim ${entry.deviceUdid}`));
36
+ } else {
37
+ shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
38
+ console.log(chalk.green(`Shut down emulator-${entry.consolePort}`));
39
+ }
48
40
  }
41
+ clearDevice(found, p);
42
+ console.log(chalk.green(`Released ${p} assignment for ${found}.`));
49
43
  }
50
44
  });
51
45
  }
@@ -1,176 +1,173 @@
1
1
  // src/commands/reserve.js
2
2
  import chalk from 'chalk';
3
3
  import prompts from 'prompts';
4
- import { addReservation, listReservations, allClaimedDevices } from '../config.js';
5
- import { listBootedIosSims } from '../sim/ios.js';
4
+ import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
5
+ import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices } from '../config.js';
6
+ import { allocatePort } from '../ports.js';
7
+ import { listBootedIosSims, parseRuntimeVersion, sortSims } from '../sim/ios.js';
6
8
  import { listAdbDevices, getAvdNameForSerial } from '../sim/android.js';
9
+ import { resolveLabel } from '../labels.js';
7
10
 
8
11
  export default function reserveCommand(program) {
9
12
  program
10
- .command('reserve [platform] [identifier]')
11
- .description('Mark a sim/emulator as in-use by an external process so rn-iso skips it during allocation')
12
- .option('--label <text>', 'Optional label (e.g. "agent-1") for clarity in `rn-iso status`')
13
- .option('--list', 'List current reservations and exit')
14
- .action(async (platform, identifier, opts) => {
15
- if (opts.list) {
16
- printReservations();
17
- return;
18
- }
19
-
20
- // Direct path: both platform and identifier provided.
21
- if (platform && identifier) {
22
- addOne(platform, identifier, opts.label);
23
- return;
24
- }
25
-
26
- // Interactive path: list running devices and multi-select.
27
- if (platform && platform !== 'ios' && platform !== 'android') {
28
- console.error(chalk.red(`Unknown platform: ${platform}. Use ios or android.`));
13
+ .command('reserve [platform]')
14
+ .description('Lock a manually-started sim/emulator to the current project (registers without building).')
15
+ .option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
16
+ .action(async (platform, opts) => {
17
+ const plat = platform || 'ios';
18
+ if (plat !== 'ios' && plat !== 'android') {
19
+ console.error(chalk.red(`Unknown platform: ${plat}. Use ios or android.`));
29
20
  process.exit(1);
30
21
  }
31
22
 
32
- const showIos = !platform || platform === 'ios';
33
- const showAndroid = !platform || platform === 'android';
34
- const claimed = allClaimedDevices();
35
- const claimedIos = new Set(claimed.iosUdids);
36
- const claimedAndroidPorts = new Set(claimed.androidConsolePorts);
37
-
38
- const choices = [];
39
-
40
- if (showIos) {
41
- let iosSims = [];
42
- try {
43
- iosSims = listBootedIosSims();
44
- } catch (e) {
45
- console.error(chalk.dim(`(could not list iOS sims: ${e.message})`));
46
- }
47
- for (const sim of iosSims) {
48
- const taken = claimedIos.has(sim.udid);
49
- choices.push({
50
- title: `${chalk.bold('ios')} ${sim.name.padEnd(22)} ${chalk.dim(sim.udid)}${taken ? chalk.yellow(' [reserved]') : ''}`,
51
- value: { kind: 'ios', udid: sim.udid, name: sim.name },
52
- disabled: taken,
53
- });
54
- }
55
- }
56
-
57
- if (showAndroid) {
58
- let emulators = [];
59
- try {
60
- emulators = listAdbDevices().emulators;
61
- } catch (e) {
62
- console.error(chalk.dim(`(could not list Android emulators: ${e.message})`));
63
- }
64
- for (const e of emulators) {
65
- const taken = claimedAndroidPorts.has(e.consolePort);
66
- const avdName = taken ? null : getAvdNameForSerial(e.serial);
67
- const label = avdName ? `${avdName} on ${e.serial}` : e.serial;
68
- choices.push({
69
- title: `${chalk.bold('and')} ${label}${taken ? chalk.yellow(' [reserved]') : ''}`,
70
- value: { kind: 'android', serial: e.serial, consolePort: e.consolePort, avdName },
71
- disabled: taken,
72
- });
73
- }
74
- }
75
-
76
- if (choices.length === 0) {
77
- const what = !platform ? 'booted iOS sims or running Android emulators'
78
- : platform === 'ios' ? 'booted iOS sims'
79
- : 'running Android emulators';
80
- console.error(chalk.red(`No ${what} found.`));
23
+ const root = findProjectRoot(process.cwd());
24
+ if (!root) {
25
+ console.error(chalk.red('Not in a React Native project (no package.json found).'));
81
26
  process.exit(1);
82
27
  }
83
28
 
84
- const answer = await prompts({
85
- type: 'multiselect',
86
- name: 'selected',
87
- message: 'Pick devices to reserve (space to toggle, enter to confirm)',
88
- choices,
89
- instructions: false,
90
- hint: '- space to select, enter to confirm',
29
+ const existing = getProject(root);
30
+ const label = await resolveLabel({ root, existingProject: existing, optsLabel: opts.label });
31
+ upsertProject(root, {
32
+ bundleId: detectBundleId(root),
33
+ androidPackage: detectAndroidPackage(root),
34
+ isExpo: detectIsExpo(root),
35
+ ...(label ? { label } : {}),
91
36
  });
92
-
93
- if (!answer.selected || answer.selected.length === 0) {
94
- console.log(chalk.dim('Nothing selected.'));
95
- return;
37
+ let proj = getProject(root);
38
+ if (!proj.metroPort) {
39
+ const port = await allocatePort(root);
40
+ setMetro(root, port, null);
41
+ proj = getProject(root);
42
+ console.log(chalk.dim(`Allocated Metro port: ${port}`));
96
43
  }
97
44
 
98
- let label = opts.label;
99
- if (!label) {
100
- const labelAnswer = await prompts({
101
- type: 'text',
102
- name: 'label',
103
- message: 'Label (optional, e.g. "agent-1"):',
104
- });
105
- label = labelAnswer.label || undefined;
106
- }
107
-
108
- for (const sel of answer.selected) {
109
- if (sel.kind === 'ios') {
110
- addReservation('ios', { udid: sel.udid, label });
111
- console.log(chalk.green(`Reserved iOS ${sel.name} (${sel.udid})${label ? ` (${label})` : ''}`));
112
- } else {
113
- addReservation('android', {
114
- serial: sel.serial,
115
- consolePort: sel.consolePort,
116
- avdName: sel.avdName,
117
- label,
118
- });
119
- console.log(chalk.green(
120
- `Reserved emulator ${sel.serial}` +
121
- (sel.avdName ? ` (AVD: ${sel.avdName})` : '') +
122
- (label ? ` (${label})` : '')
123
- ));
124
- }
45
+ if (plat === 'ios') {
46
+ await reserveIos(root, proj);
47
+ } else {
48
+ await reserveAndroid(root, proj);
125
49
  }
126
50
  });
127
51
  }
128
52
 
129
- function addOne(platform, identifier, label) {
130
- if (platform === 'ios') {
131
- addReservation('ios', { udid: identifier, label });
132
- console.log(chalk.green(`Reserved iOS sim ${identifier}${label ? ` (${label})` : ''}`));
133
- return;
53
+ async function reserveIos(root, proj) {
54
+ const booted = listBootedIosSims();
55
+ if (booted.length === 0) {
56
+ console.error(chalk.red('No booted iOS simulators found.'));
57
+ console.error(chalk.dim('Boot one (Simulator.app, Xcode, or `xcrun simctl boot`) and re-run.'));
58
+ process.exit(1);
59
+ }
60
+
61
+ const auto = !process.stdin.isTTY;
62
+ const claims = allClaimedDevices().iosClaims;
63
+ const sorted = sortSims(booted);
64
+
65
+ let pick;
66
+ if (sorted.length === 1 || auto) {
67
+ pick = sorted[0];
68
+ } else {
69
+ const nameWidth = Math.max(...sorted.map(s => s.name.length), 18);
70
+ const choices = sorted.map(s => {
71
+ const claim = claims[s.udid];
72
+ const tag = !claim ? ''
73
+ : claim.path === root ? chalk.dim(' [already yours]')
74
+ : chalk.yellow(` [claimed by ${claim.label}]`);
75
+ const title = `${s.name.padEnd(nameWidth)} ${chalk.dim(parseRuntimeVersion(s.runtime).padStart(6))}${tag}`;
76
+ return { title, value: s };
77
+ });
78
+ const answer = await prompts({
79
+ type: 'select',
80
+ name: 'sim',
81
+ message: 'Pick a booted simulator to lock to this project:',
82
+ choices,
83
+ });
84
+ if (!answer.sim) {
85
+ console.error(chalk.red('Cancelled.'));
86
+ process.exit(1);
87
+ }
88
+ pick = answer.sim;
134
89
  }
135
- if (platform === 'android') {
136
- const m = identifier.match(/^emulator-(\d+)$/);
137
- if (!m) {
138
- console.error(chalk.red('Android identifier must look like emulator-5554'));
90
+
91
+ const claim = claims[pick.udid];
92
+ if (claim && claim.path !== root) {
93
+ if (auto) {
94
+ console.error(chalk.red(`${pick.name} is held by project "${claim.label}". Re-run interactively to confirm taking it over.`));
139
95
  process.exit(1);
140
96
  }
141
- const consolePort = parseInt(m[1], 10);
142
- const avdName = getAvdNameForSerial(identifier);
143
- addReservation('android', { serial: identifier, consolePort, avdName, label });
144
- console.log(chalk.green(
145
- `Reserved emulator ${identifier}` +
146
- (avdName ? ` (AVD: ${avdName})` : '') +
147
- (label ? ` (${label})` : '')
148
- ));
149
- return;
97
+ const ok = await prompts({
98
+ type: 'confirm',
99
+ name: 'ok',
100
+ message: `${pick.name} is currently held by project "${claim.label}". Take it over?`,
101
+ initial: false,
102
+ });
103
+ if (!ok.ok) {
104
+ console.error(chalk.red('Cancelled.'));
105
+ process.exit(1);
106
+ }
107
+ clearDevice(claim.path, 'ios');
108
+ console.log(chalk.dim(`Released prior assignment from "${claim.label}"`));
150
109
  }
151
- console.error(chalk.red(`Unknown platform: ${platform}. Use ios or android.`));
152
- process.exit(1);
110
+
111
+ setDevice(root, 'ios', { deviceUdid: pick.udid });
112
+ console.log(chalk.green(`Locked iOS sim ${pick.name} (${pick.udid}) to ${root}`));
153
113
  }
154
114
 
155
- function printReservations() {
156
- const r = listReservations();
157
- const ios = r.ios || [];
158
- const android = r.android || [];
159
- if (ios.length === 0 && android.length === 0) {
160
- console.log(chalk.dim('No reservations.'));
161
- return;
115
+ async function reserveAndroid(root, proj) {
116
+ const running = listAdbDevices().emulators;
117
+ if (running.length === 0) {
118
+ console.error(chalk.red('No running Android emulators found.'));
119
+ console.error(chalk.dim('Start one (Android Studio or `emulator -avd ...`) and re-run.'));
120
+ process.exit(1);
162
121
  }
163
- if (ios.length > 0) {
164
- console.log(chalk.bold('iOS:'));
165
- for (const e of ios) {
166
- console.log(` ${chalk.cyan(e.udid)}${e.label ? chalk.dim(` (${e.label})`) : ''}`);
122
+
123
+ const auto = !process.stdin.isTTY;
124
+ const claims = allClaimedDevices().androidClaims;
125
+
126
+ let pick;
127
+ if (running.length === 1 || auto) {
128
+ pick = running[0];
129
+ } else {
130
+ const choices = running.map(e => {
131
+ const claim = claims[e.consolePort];
132
+ const tag = !claim ? ''
133
+ : claim.path === root ? chalk.dim(' [already yours]')
134
+ : chalk.yellow(` [claimed by ${claim.label}]`);
135
+ return { title: `${e.serial}${tag}`, value: e };
136
+ });
137
+ const answer = await prompts({
138
+ type: 'select',
139
+ name: 'emu',
140
+ message: 'Pick a running emulator to lock to this project:',
141
+ choices,
142
+ });
143
+ if (!answer.emu) {
144
+ console.error(chalk.red('Cancelled.'));
145
+ process.exit(1);
167
146
  }
147
+ pick = answer.emu;
168
148
  }
169
- if (android.length > 0) {
170
- console.log(chalk.bold('Android:'));
171
- for (const e of android) {
172
- const tag = e.avdName ? `${e.avdName} on ${e.serial}` : e.serial;
173
- console.log(` ${chalk.cyan(tag)}${e.label ? chalk.dim(` (${e.label})`) : ''}`);
149
+
150
+ const claim = claims[pick.consolePort];
151
+ if (claim && claim.path !== root) {
152
+ if (auto) {
153
+ console.error(chalk.red(`${pick.serial} is held by project "${claim.label}". Re-run interactively to confirm taking it over.`));
154
+ process.exit(1);
174
155
  }
156
+ const ok = await prompts({
157
+ type: 'confirm',
158
+ name: 'ok',
159
+ message: `${pick.serial} is held by project "${claim.label}". Take it over?`,
160
+ initial: false,
161
+ });
162
+ if (!ok.ok) {
163
+ console.error(chalk.red('Cancelled.'));
164
+ process.exit(1);
165
+ }
166
+ clearDevice(claim.path, 'android');
167
+ console.log(chalk.dim(`Released prior assignment from "${claim.label}"`));
175
168
  }
169
+
170
+ const avdName = getAvdNameForSerial(pick.serial) || `emulator-${pick.consolePort}`;
171
+ setDevice(root, 'android', { avdName, consolePort: pick.consolePort });
172
+ console.log(chalk.green(`Locked emulator ${pick.serial} (AVD: ${avdName}) to ${root}`));
176
173
  }
@@ -12,10 +12,7 @@ export default function statusCommand(program) {
12
12
  .action(async () => {
13
13
  const cfg = loadConfig();
14
14
  const hasProjects = cfg && Object.keys(cfg.projects || {}).length > 0;
15
- const reservations = cfg?.reservations || { ios: [], android: [] };
16
- const hasReservations = (reservations.ios?.length || 0) + (reservations.android?.length || 0) > 0;
17
-
18
- if (!hasProjects && !hasReservations) {
15
+ if (!hasProjects) {
19
16
  console.log(chalk.dim('No projects registered.'));
20
17
  return;
21
18
  }
@@ -44,17 +41,6 @@ export default function statusCommand(program) {
44
41
  const android = proj.platforms?.android;
45
42
  if (android) console.log(` android: ${chalk.cyan(android.avdName)} on emulator-${android.consolePort}`);
46
43
  }
47
-
48
- if (hasReservations) {
49
- console.log('\n' + chalk.bold('Reservations (external):'));
50
- for (const r of reservations.ios || []) {
51
- console.log(` ios: ${chalk.cyan(r.udid)}${r.label ? chalk.dim(` (${r.label})`) : ''}`);
52
- }
53
- for (const r of reservations.android || []) {
54
- const tag = r.avdName ? `${r.avdName} on ${r.serial}` : r.serial;
55
- console.log(` android: ${chalk.cyan(tag)}${r.label ? chalk.dim(` (${r.label})`) : ''}`);
56
- }
57
- }
58
44
  console.log('');
59
45
  });
60
46
  }