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.
- package/LICENSE +21 -0
- package/README.md +41 -36
- package/bin/cli.js +4 -7
- package/package.json +28 -2
- package/skill/SKILL.md +41 -43
- package/src/commands/android.js +120 -14
- package/src/commands/ios.js +95 -33
- package/src/commands/release.js +19 -25
- package/src/commands/reserve.js +141 -144
- package/src/commands/status.js +1 -15
- package/src/commands/stop.js +62 -30
- package/src/commands/unreserve.js +23 -43
- package/src/config.js +14 -91
- package/src/labels.js +25 -0
- package/src/project.js +25 -7
- package/src/sim/android.js +31 -18
- package/src/sim/ios.js +7 -1
- package/.claude/settings.local.json +0 -7
- package/CLAUDE.md +0 -178
- package/docs/plans/2026-04-25-rn-iso-implementation.md +0 -2653
- package/docs/specs/2026-04-25-rn-iso-design.md +0 -282
- package/src/commands/logs.js +0 -28
- package/src/commands/prune.js +0 -57
- package/src/commands/shutdown.js +0 -41
- package/test/config.test.js +0 -208
- package/test/exec.test.js +0 -26
- package/test/fixtures/sample-bare-project/android/app/build.gradle +0 -6
- package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +0 -10
- package/test/fixtures/sample-bare-project/package.json +0 -4
- package/test/fixtures/sample-expo-project/app.json +0 -6
- package/test/fixtures/sample-expo-project/package.json +0 -4
- package/test/fixtures/sample-expo-project/src/.keep +0 -0
- package/test/metro.test.js +0 -34
- package/test/ports.test.js +0 -76
- package/test/project.test.js +0 -109
- package/test/runner.test.js +0 -209
- package/test/sim-android.test.js +0 -140
- package/test/sim-ios.test.js +0 -168
package/src/commands/stop.js
CHANGED
|
@@ -1,48 +1,80 @@
|
|
|
1
1
|
// src/commands/stop.js
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
4
|
-
import { getProject, setMetro } from '../config.js';
|
|
3
|
+
import { resolveRegisteredProject } from '../project.js';
|
|
4
|
+
import { loadConfig, getProject, setMetro } from '../config.js';
|
|
5
5
|
import { killMetroByPid } from '../metro.js';
|
|
6
6
|
import { getExecutor } from '../exec.js';
|
|
7
7
|
|
|
8
8
|
export default function stopCommand(program) {
|
|
9
9
|
program
|
|
10
|
-
.command('stop')
|
|
11
|
-
.description('Kill
|
|
12
|
-
.action(() => {
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
10
|
+
.command('stop [target]')
|
|
11
|
+
.description('Kill Metro. With no arg, stops the current project. Pass a port (e.g. 8083), a project shortcut (label or unique basename), or an absolute path.')
|
|
12
|
+
.action((target) => {
|
|
13
|
+
// Numeric -> kill whatever's on that port.
|
|
14
|
+
if (target && /^\d+$/.test(target)) {
|
|
15
|
+
return killByPort(parseInt(target, 10));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { found, error } = resolveRegisteredProject(target);
|
|
19
|
+
if (!found) {
|
|
20
|
+
console.error(chalk.red(error));
|
|
16
21
|
process.exit(1);
|
|
17
22
|
}
|
|
18
|
-
const proj = getProject(
|
|
23
|
+
const proj = getProject(found);
|
|
19
24
|
if (!proj?.metroPort) {
|
|
20
|
-
console.log(chalk.dim(
|
|
25
|
+
console.log(chalk.dim(`No Metro port assigned to ${found}.`));
|
|
21
26
|
return;
|
|
22
27
|
}
|
|
23
|
-
|
|
24
|
-
// Try the recorded PID first (Metro spawned by `rn-iso start`).
|
|
25
|
-
// If not set or stale, look up by port (Metro spawned by the build CLI).
|
|
26
|
-
let pid = proj.metroPid;
|
|
27
|
-
if (!pid || !killMetroByPid(pid)) {
|
|
28
|
-
pid = findPidListeningOnPort(proj.metroPort);
|
|
29
|
-
if (!pid) {
|
|
30
|
-
console.log(chalk.dim(`No Metro process found on port ${proj.metroPort}.`));
|
|
31
|
-
if (proj.metroPid) setMetro(root, proj.metroPort, null);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
process.kill(pid, 'SIGTERM');
|
|
36
|
-
} catch {
|
|
37
|
-
console.log(chalk.dim(`Could not kill pid ${pid}.`));
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
setMetro(root, proj.metroPort, null);
|
|
42
|
-
console.log(chalk.green(`Killed Metro pid ${pid} on port ${proj.metroPort}`));
|
|
28
|
+
killForProject(found, proj);
|
|
43
29
|
});
|
|
44
30
|
}
|
|
45
31
|
|
|
32
|
+
function killForProject(root, proj) {
|
|
33
|
+
const port = proj.metroPort;
|
|
34
|
+
let pid = proj.metroPid;
|
|
35
|
+
if (!pid || !killMetroByPid(pid)) {
|
|
36
|
+
pid = findPidListeningOnPort(port);
|
|
37
|
+
if (!pid) {
|
|
38
|
+
console.log(chalk.dim(`No Metro process found on port ${port}.`));
|
|
39
|
+
if (proj.metroPid) setMetro(root, port, null);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 'SIGTERM');
|
|
44
|
+
} catch {
|
|
45
|
+
console.log(chalk.dim(`Could not kill pid ${pid}.`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
setMetro(root, port, null);
|
|
50
|
+
console.log(chalk.green(`Killed Metro pid ${pid} on port ${port} (${root})`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function killByPort(port) {
|
|
54
|
+
const pid = findPidListeningOnPort(port);
|
|
55
|
+
if (!pid) {
|
|
56
|
+
console.log(chalk.dim(`No process listening on port ${port}.`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, 'SIGTERM');
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.log(chalk.dim(`Could not kill pid ${pid}: ${e.message}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.green(`Killed pid ${pid} on port ${port}`));
|
|
66
|
+
|
|
67
|
+
// If a project owned this port, clear its recorded pid so `status` reflects.
|
|
68
|
+
const cfg = loadConfig();
|
|
69
|
+
for (const [path, proj] of Object.entries(cfg?.projects || {})) {
|
|
70
|
+
if (proj.metroPort === port) {
|
|
71
|
+
setMetro(path, port, null);
|
|
72
|
+
console.log(chalk.dim(`Cleared metroPid for ${path}`));
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
function findPidListeningOnPort(port) {
|
|
47
79
|
const out = getExecutor().runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
|
|
48
80
|
if (!out) return null;
|
|
@@ -1,57 +1,37 @@
|
|
|
1
1
|
// src/commands/unreserve.js
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { findProjectRoot } from '../project.js';
|
|
4
|
+
import { getProject, clearDevice } from '../config.js';
|
|
4
5
|
|
|
5
6
|
export default function unreserveCommand(program) {
|
|
6
7
|
program
|
|
7
|
-
.command('unreserve [
|
|
8
|
-
.description(
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
console.log(chalk.green('Cleared all reservations.'));
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Two-arg form (backward compat): `unreserve ios <id-or-label>`.
|
|
19
|
-
// Single-arg form: `unreserve <id-or-label>` (search across platforms).
|
|
20
|
-
let platform = opts.platform || null;
|
|
21
|
-
let target;
|
|
22
|
-
if (arg2 !== undefined) {
|
|
23
|
-
if (arg1 !== 'ios' && arg1 !== 'android') {
|
|
24
|
-
console.error(chalk.red(`Unknown platform: ${arg1}. Use ios or android.`));
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
platform = arg1;
|
|
28
|
-
target = arg2;
|
|
29
|
-
} else {
|
|
30
|
-
target = arg1;
|
|
8
|
+
.command('unreserve [platform]')
|
|
9
|
+
.description("Release the current project's lock on its sim/emulator (alias of `release` without shutdown).")
|
|
10
|
+
.action((platform) => {
|
|
11
|
+
const plat = platform || null;
|
|
12
|
+
if (plat && plat !== 'ios' && plat !== 'android') {
|
|
13
|
+
console.error(chalk.red(`Unknown platform: ${plat}. Use ios or android.`));
|
|
14
|
+
process.exit(1);
|
|
31
15
|
}
|
|
32
16
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.error(chalk.
|
|
36
|
-
console.error(chalk.dim(' rn-iso unreserve --all'));
|
|
17
|
+
const root = findProjectRoot(process.cwd());
|
|
18
|
+
if (!root) {
|
|
19
|
+
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
37
20
|
process.exit(1);
|
|
38
21
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
console.log(chalk.dim(
|
|
43
|
-
`No reservation matches "${target}"` +
|
|
44
|
-
(platform ? ` on ${platform}` : '') +
|
|
45
|
-
'.'
|
|
46
|
-
));
|
|
22
|
+
const proj = getProject(root);
|
|
23
|
+
if (!proj) {
|
|
24
|
+
console.log(chalk.dim('No project entry to unlock.'));
|
|
47
25
|
return;
|
|
48
26
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(
|
|
54
|
-
|
|
27
|
+
const platforms = plat ? [plat] : ['ios', 'android'];
|
|
28
|
+
for (const p of platforms) {
|
|
29
|
+
if (proj.platforms?.[p]) {
|
|
30
|
+
clearDevice(root, p);
|
|
31
|
+
console.log(chalk.green(`Unlocked ${p} for ${root}.`));
|
|
32
|
+
} else {
|
|
33
|
+
console.log(chalk.dim(`No ${p} lock to release for ${root}.`));
|
|
34
|
+
}
|
|
55
35
|
}
|
|
56
36
|
});
|
|
57
37
|
}
|
package/src/config.js
CHANGED
|
@@ -28,14 +28,8 @@ export function saveConfig(config) {
|
|
|
28
28
|
|
|
29
29
|
export function ensureConfig() {
|
|
30
30
|
const existing = loadConfig();
|
|
31
|
-
if (existing)
|
|
32
|
-
|
|
33
|
-
existing.reservations = { ios: [], android: [] };
|
|
34
|
-
saveConfig(existing);
|
|
35
|
-
}
|
|
36
|
-
return existing;
|
|
37
|
-
}
|
|
38
|
-
const fresh = { version: 1, projects: {}, reservations: { ios: [], android: [] } };
|
|
31
|
+
if (existing) return existing;
|
|
32
|
+
const fresh = { version: 1, projects: {} };
|
|
39
33
|
saveConfig(fresh);
|
|
40
34
|
return fresh;
|
|
41
35
|
}
|
|
@@ -108,36 +102,31 @@ export function allClaimedDevices() {
|
|
|
108
102
|
iosUdids: [],
|
|
109
103
|
androidAvds: [],
|
|
110
104
|
androidConsolePorts: [],
|
|
111
|
-
// iosClaims: udid -> {
|
|
105
|
+
// iosClaims: udid -> { label, path }. androidClaims: consolePort ->
|
|
106
|
+
// { label, path, avdName }. `path` is the absolute project path so
|
|
107
|
+
// take-over flows can call clearDevice on the owning project.
|
|
112
108
|
iosClaims: {},
|
|
109
|
+
androidClaims: {},
|
|
113
110
|
};
|
|
114
111
|
if (!cfg) return result;
|
|
115
112
|
for (const [path, proj] of Object.entries(cfg.projects || {})) {
|
|
113
|
+
const label = path.split('/').pop() || path;
|
|
116
114
|
const ios = proj.platforms?.ios;
|
|
117
115
|
if (ios?.deviceUdid) {
|
|
118
116
|
result.iosUdids.push(ios.deviceUdid);
|
|
119
|
-
result.iosClaims[ios.deviceUdid] = {
|
|
120
|
-
source: 'project',
|
|
121
|
-
label: path.split('/').pop() || path,
|
|
122
|
-
};
|
|
117
|
+
result.iosClaims[ios.deviceUdid] = { label, path };
|
|
123
118
|
}
|
|
124
119
|
const android = proj.platforms?.android;
|
|
125
120
|
if (android?.avdName) result.androidAvds.push(android.avdName);
|
|
126
|
-
if (typeof android?.consolePort === 'number')
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
source: 'reservation',
|
|
133
|
-
label: r.label || 'reserved',
|
|
121
|
+
if (typeof android?.consolePort === 'number') {
|
|
122
|
+
result.androidConsolePorts.push(android.consolePort);
|
|
123
|
+
result.androidClaims[android.consolePort] = {
|
|
124
|
+
label,
|
|
125
|
+
path,
|
|
126
|
+
avdName: android.avdName,
|
|
134
127
|
};
|
|
135
128
|
}
|
|
136
129
|
}
|
|
137
|
-
for (const r of cfg.reservations?.android || []) {
|
|
138
|
-
if (r.avdName) result.androidAvds.push(r.avdName);
|
|
139
|
-
if (typeof r.consolePort === 'number') result.androidConsolePorts.push(r.consolePort);
|
|
140
|
-
}
|
|
141
130
|
return result;
|
|
142
131
|
}
|
|
143
132
|
|
|
@@ -153,69 +142,3 @@ export function getSimUsage() {
|
|
|
153
142
|
const cfg = loadConfig();
|
|
154
143
|
return cfg?.simUsage || { ios: {}, android: {} };
|
|
155
144
|
}
|
|
156
|
-
|
|
157
|
-
export function listReservations() {
|
|
158
|
-
const cfg = loadConfig();
|
|
159
|
-
return cfg?.reservations || { ios: [], android: [] };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Find reservations matching either an identifier (UDID for iOS, serial for
|
|
163
|
-
// Android) or a label (the --label set via `rn-iso reserve`). Returns
|
|
164
|
-
// `[{ platform, id, label }]`. Pass platform to restrict the search.
|
|
165
|
-
export function findReservations(idOrLabel, platform = null) {
|
|
166
|
-
const r = listReservations();
|
|
167
|
-
const matches = [];
|
|
168
|
-
if (!platform || platform === 'ios') {
|
|
169
|
-
for (const e of r.ios || []) {
|
|
170
|
-
if (e.udid === idOrLabel || e.label === idOrLabel) {
|
|
171
|
-
matches.push({ platform: 'ios', id: e.udid, label: e.label });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (!platform || platform === 'android') {
|
|
176
|
-
for (const e of r.android || []) {
|
|
177
|
-
if (e.serial === idOrLabel || e.label === idOrLabel) {
|
|
178
|
-
matches.push({ platform: 'android', id: e.serial, label: e.label });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return matches;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function addReservation(platform, fields) {
|
|
186
|
-
if (platform !== 'ios' && platform !== 'android') {
|
|
187
|
-
throw new Error(`Unknown platform: ${platform}`);
|
|
188
|
-
}
|
|
189
|
-
const cfg = ensureConfig();
|
|
190
|
-
cfg.reservations = cfg.reservations || { ios: [], android: [] };
|
|
191
|
-
const list = cfg.reservations[platform];
|
|
192
|
-
const key = platform === 'ios' ? 'udid' : 'serial';
|
|
193
|
-
const existing = list.find(r => r[key] === fields[key]);
|
|
194
|
-
if (existing) {
|
|
195
|
-
Object.assign(existing, fields);
|
|
196
|
-
} else {
|
|
197
|
-
list.push(fields);
|
|
198
|
-
}
|
|
199
|
-
saveConfig(cfg);
|
|
200
|
-
return fields;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function removeReservation(platform, identifier) {
|
|
204
|
-
if (platform !== 'ios' && platform !== 'android') {
|
|
205
|
-
throw new Error(`Unknown platform: ${platform}`);
|
|
206
|
-
}
|
|
207
|
-
const cfg = loadConfig();
|
|
208
|
-
if (!cfg?.reservations?.[platform]) return false;
|
|
209
|
-
const key = platform === 'ios' ? 'udid' : 'serial';
|
|
210
|
-
const before = cfg.reservations[platform].length;
|
|
211
|
-
cfg.reservations[platform] = cfg.reservations[platform].filter(r => r[key] !== identifier);
|
|
212
|
-
saveConfig(cfg);
|
|
213
|
-
return cfg.reservations[platform].length < before;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function clearAllReservations() {
|
|
217
|
-
const cfg = loadConfig();
|
|
218
|
-
if (!cfg) return;
|
|
219
|
-
cfg.reservations = { ios: [], android: [] };
|
|
220
|
-
saveConfig(cfg);
|
|
221
|
-
}
|
package/src/labels.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
|
|
3
|
+
// Decide what label to store on a project entry.
|
|
4
|
+
//
|
|
5
|
+
// Priority:
|
|
6
|
+
// 1. --label flag (explicit override)
|
|
7
|
+
// 2. existing label on the project (don't re-prompt)
|
|
8
|
+
// 3. interactive prompt with the basename as default
|
|
9
|
+
// 4. null (non-interactive and no prior label) -> projectShortcut falls
|
|
10
|
+
// back to the basename automatically.
|
|
11
|
+
export async function resolveLabel({ root, existingProject, optsLabel }) {
|
|
12
|
+
if (optsLabel) return optsLabel;
|
|
13
|
+
if (existingProject?.label) return existingProject.label;
|
|
14
|
+
if (!process.stdin.isTTY) return null;
|
|
15
|
+
|
|
16
|
+
const basename = root.split('/').pop() || root;
|
|
17
|
+
const answer = await prompts({
|
|
18
|
+
type: 'text',
|
|
19
|
+
name: 'label',
|
|
20
|
+
message: 'Project label (shortcut for stop / release):',
|
|
21
|
+
initial: basename,
|
|
22
|
+
});
|
|
23
|
+
if (!answer.label) return null;
|
|
24
|
+
return answer.label;
|
|
25
|
+
}
|
package/src/project.js
CHANGED
|
@@ -2,13 +2,21 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
|
|
|
2
2
|
import { join, dirname, resolve } from 'path';
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// A project's "shortcut": its `label` field if set, else the path basename.
|
|
6
|
+
// This is the user-facing handle for `stop`, `release`, etc.
|
|
7
|
+
export function projectShortcut(path, proj) {
|
|
8
|
+
if (proj?.label) return proj.label;
|
|
9
|
+
return path.split('/').pop() || path;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Resolve a project from one of three forms:
|
|
13
|
+
// - undefined / null -> walk up from cwd
|
|
14
|
+
// - absolute / relative path matching a registered project key
|
|
15
|
+
// - a shortcut (label or path basename) that uniquely identifies a project
|
|
8
16
|
//
|
|
9
|
-
// Basename
|
|
10
|
-
//
|
|
11
|
-
//
|
|
17
|
+
// Basename matching errors out on collision (multiple projects share the
|
|
18
|
+
// basename) — set a `--label` on one of them via `rn-iso reserve` / `ios` /
|
|
19
|
+
// `android` to disambiguate.
|
|
12
20
|
//
|
|
13
21
|
// Returns { found, error }.
|
|
14
22
|
export function resolveRegisteredProject(arg) {
|
|
@@ -29,9 +37,19 @@ export function resolveRegisteredProject(arg) {
|
|
|
29
37
|
if (projects[abs]) return { found: abs };
|
|
30
38
|
if (projects[arg]) return { found: arg };
|
|
31
39
|
|
|
40
|
+
// Shortcut match (label or basename).
|
|
41
|
+
const matches = Object.keys(projects).filter(p => projectShortcut(p, projects[p]) === arg);
|
|
42
|
+
if (matches.length === 1) return { found: matches[0] };
|
|
43
|
+
if (matches.length > 1) {
|
|
44
|
+
return {
|
|
45
|
+
found: null,
|
|
46
|
+
error: `Multiple projects share the shortcut "${arg}": ${matches.join(', ')}. Pass the absolute path or set a unique --label.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
return {
|
|
33
51
|
found: null,
|
|
34
|
-
error: `No registered project
|
|
52
|
+
error: `No registered project matches "${arg}". See \`rn-iso status\` for the list.`,
|
|
35
53
|
};
|
|
36
54
|
}
|
|
37
55
|
|
package/src/sim/android.js
CHANGED
|
@@ -37,35 +37,48 @@ export function nextConsolePort(claimedPorts) {
|
|
|
37
37
|
|
|
38
38
|
export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
|
|
39
39
|
const avds = listAvds();
|
|
40
|
+
if (avds.length === 0) return { kind: 'noAvd' };
|
|
41
|
+
|
|
40
42
|
const adbDevices = listAdbDevices();
|
|
41
|
-
|
|
43
|
+
// Resolve which AVD each running emulator is. Emulators that don't respond
|
|
44
|
+
// to `adb emu avd name` are dropped from the running map.
|
|
45
|
+
const runningByAvd = {};
|
|
46
|
+
for (const e of adbDevices.emulators) {
|
|
47
|
+
const avdName = getAvdNameForSerial(e.serial);
|
|
48
|
+
if (avdName) runningByAvd[avdName] = e.consolePort;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const buildCandidate = (avdName) => ({
|
|
52
|
+
avdName,
|
|
53
|
+
isRunning: avdName in runningByAvd,
|
|
54
|
+
consolePort: runningByAvd[avdName] ?? null,
|
|
55
|
+
});
|
|
42
56
|
|
|
43
57
|
if (existingAvd && avds.includes(existingAvd)) {
|
|
44
|
-
const
|
|
58
|
+
const runningPort = runningByAvd[existingAvd];
|
|
45
59
|
return {
|
|
46
60
|
kind: 'reuse',
|
|
47
61
|
avdName: existingAvd,
|
|
48
|
-
consolePort:
|
|
49
|
-
isRunning:
|
|
62
|
+
consolePort: runningPort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
|
|
63
|
+
isRunning: runningPort != null,
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
if (avds.length === 0) {
|
|
54
|
-
return { kind: 'noAvd' };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
67
|
const claimedAvdSet = new Set(claimedAvds);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
const all = avds.map(buildCandidate);
|
|
69
|
+
const unclaimed = all.filter(c => !claimedAvdSet.has(c.avdName));
|
|
70
|
+
|
|
71
|
+
if (unclaimed.length === 0) {
|
|
72
|
+
return { kind: 'allClaimed', candidates: sortAndroidCandidates(all) };
|
|
61
73
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
isRunning
|
|
68
|
-
|
|
74
|
+
return { kind: 'allocate', candidates: sortAndroidCandidates(unclaimed) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sortAndroidCandidates(list) {
|
|
78
|
+
return [...list].sort((a, b) => {
|
|
79
|
+
if (a.isRunning !== b.isRunning) return a.isRunning ? -1 : 1;
|
|
80
|
+
return a.avdName.localeCompare(b.avdName);
|
|
81
|
+
});
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
export function bootAndroidEmulator(avdName, consolePort) {
|
package/src/sim/ios.js
CHANGED
|
@@ -66,8 +66,14 @@ export function selectIosDevice({ existingUdid, claimedUdids, usage = {} }) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
if (sims.length === 0) return { kind: 'noSims' };
|
|
70
|
+
|
|
69
71
|
const unclaimed = sims.filter(s => !claimed.has(s.udid));
|
|
70
|
-
if (unclaimed.length === 0)
|
|
72
|
+
if (unclaimed.length === 0) {
|
|
73
|
+
// Sims exist but every one is claimed by another project or a
|
|
74
|
+
// reservation. The picker can offer to steal one; the caller decides.
|
|
75
|
+
return { kind: 'allClaimed', candidates: sortSims(sims, usage) };
|
|
76
|
+
}
|
|
71
77
|
|
|
72
78
|
return { kind: 'allocate', candidates: sortSims(unclaimed, usage) };
|
|
73
79
|
}
|
package/CLAUDE.md
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
# rn-iso — agent guide
|
|
2
|
-
|
|
3
|
-
Quick orientation for AI assistants working in this repo.
|
|
4
|
-
|
|
5
|
-
## What this is
|
|
6
|
-
|
|
7
|
-
A Node.js CLI that gives each React Native / Expo project (or git worktree)
|
|
8
|
-
its own Metro server and dedicated simulator/emulator, so multiple agents can
|
|
9
|
-
work on different projects in parallel without device or port collisions.
|
|
10
|
-
|
|
11
|
-
State lives in `~/.rn-iso/config.json`, keyed by absolute project path. The
|
|
12
|
-
`RN_ISO_HOME` env var redirects this for tests.
|
|
13
|
-
|
|
14
|
-
## Architecture conventions
|
|
15
|
-
|
|
16
|
-
- **ESM only.** `"type": "module"`, no transpiler, Node 20+ directly. No
|
|
17
|
-
CommonJS, no `require()`.
|
|
18
|
-
- **Single exec wrapper.** All `child_process` calls go through
|
|
19
|
-
`src/exec.js` (`getExecutor()`). Tests inject a mock via `setExecutor()`.
|
|
20
|
-
Anywhere outside `exec.js` that imports `child_process` directly is a bug.
|
|
21
|
-
- **Pure parsing separate from invocation.** Functions like `parseSimctlList`,
|
|
22
|
-
`parseAdbDevices`, `selectIosDevice`, `sortSims` are pure and unit-tested;
|
|
23
|
-
the I/O wrappers around them are thin.
|
|
24
|
-
- **ASCII in source files.** No em dashes, smart quotes, or check marks in
|
|
25
|
-
`src/`, `bin/`, `test/`. Markdown files (README, SKILL, this file) may use
|
|
26
|
-
them. The hooks have flagged this before.
|
|
27
|
-
|
|
28
|
-
## File layout
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
bin/cli.js # commander entry, registers each command module
|
|
32
|
-
src/
|
|
33
|
-
exec.js # mockable child_process wrapper
|
|
34
|
-
config.js # config CRUD, reservations, sim-usage tracking
|
|
35
|
-
project.js # project root walk, bundle-id detection (incl. native fallbacks)
|
|
36
|
-
ports.js # Metro port allocation + reclamation
|
|
37
|
-
runner.js # script-vs-CLI dispatch, package-manager detection (walks up for monorepos)
|
|
38
|
-
metro.js # detached Metro spawn, PID + log lifecycle
|
|
39
|
-
sim/
|
|
40
|
-
ios.js # simctl wrappers, sim selection, sortSims, parseRuntimeVersion
|
|
41
|
-
android.js # adb/emulator wrappers, AVD selection
|
|
42
|
-
commands/
|
|
43
|
-
ios.js android.js # the main user-facing commands
|
|
44
|
-
start.js stop.js logs.js
|
|
45
|
-
status.js
|
|
46
|
-
device.js # `rn-iso device --json` -> agent-device target
|
|
47
|
-
release.js shutdown.js prune.js
|
|
48
|
-
reserve.js unreserve.js
|
|
49
|
-
test/
|
|
50
|
-
*.test.js # `node --test` (no framework)
|
|
51
|
-
skill/SKILL.md # the agent-facing skill
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Particularities to remember
|
|
55
|
-
|
|
56
|
-
### 1. Update `skill/SKILL.md` whenever user-facing behavior changes
|
|
57
|
-
|
|
58
|
-
The skill is what installed AI agents read to learn how to use the CLI. When
|
|
59
|
-
you add a command, change a flag, change picker UX, or alter defaults — open
|
|
60
|
-
`skill/SKILL.md` and update the relevant section in the same change. Quick
|
|
61
|
-
checklist:
|
|
62
|
-
|
|
63
|
-
- New command? Add it under "Other useful commands" or its own section if
|
|
64
|
-
meaty (like `reserve`).
|
|
65
|
-
- New / changed flag on `ios` or `android`? Update "Core workflow" and
|
|
66
|
-
"Critical rules" if the flag matters for non-interactive agent use.
|
|
67
|
-
- Behavior change (e.g., picker now does X)? Update both the
|
|
68
|
-
description and the "When things go wrong" section.
|
|
69
|
-
|
|
70
|
-
The skill is shipped to users via the curl line in the README; staleness
|
|
71
|
-
breaks agent guidance.
|
|
72
|
-
|
|
73
|
-
### 2. Don't auto-create simulators
|
|
74
|
-
|
|
75
|
-
`selectIosDevice` returns `needsBoot` only when no unclaimed sim exists at
|
|
76
|
-
all. `commands/ios.js` then errors unless `--device-type` is passed. We do
|
|
77
|
-
NOT prompt and create on the user's behalf — that was the original UX and
|
|
78
|
-
was removed because it accumulated junk sims. The picker only chooses among
|
|
79
|
-
EXISTING sims (booted or shutdown). When you change device-selection logic,
|
|
80
|
-
preserve this invariant.
|
|
81
|
-
|
|
82
|
-
### 3. The post-install verification step is intentionally absent
|
|
83
|
-
|
|
84
|
-
Earlier versions ran `xcrun simctl install/launch` after the build CLI to
|
|
85
|
-
work around a wrong-sim bug in `@expo/cli` (since fixed in 54.0.24). That
|
|
86
|
-
step caused double-launches and was removed. If you find yourself wanting
|
|
87
|
-
to add it back, the upstream bug is the right place to fix things —
|
|
88
|
-
`patch-package` for stuck users, not workaround code in `commands/ios.js`.
|
|
89
|
-
|
|
90
|
-
### 3b. `rn-iso ios` / `android` do NOT spawn Metro
|
|
91
|
-
|
|
92
|
-
The build CLI (`expo run:ios` / `react-native run-ios`) starts Metro
|
|
93
|
-
itself on the `--port` we pass. We used to also pre-spawn a detached
|
|
94
|
-
Metro before the build, which led to two Metros on the same port.
|
|
95
|
-
Removed.
|
|
96
|
-
|
|
97
|
-
`rn-iso start` is still around for the explicit "I just want Metro" case
|
|
98
|
-
— it spawns Metro detached and tracks the PID + log file. The build
|
|
99
|
-
commands rely on the build CLI's Metro; `rn-iso stop` looks up the PID
|
|
100
|
-
by port (via `lsof`) so it works regardless of who started Metro.
|
|
101
|
-
|
|
102
|
-
### 4. Reservations are first-class claims
|
|
103
|
-
|
|
104
|
-
`allClaimedDevices()` returns BOTH project-claimed AND reservation-claimed
|
|
105
|
-
devices. If you add a new claim source (e.g., a new section in config.json),
|
|
106
|
-
extend `allClaimedDevices` AND `iosClaims` so the picker greys it out with a
|
|
107
|
-
useful label. Don't filter at any one call site — keep the policy in
|
|
108
|
-
`config.js`.
|
|
109
|
-
|
|
110
|
-
### 5. Package-manager / script detection
|
|
111
|
-
|
|
112
|
-
`runner.js` prefers the project's `ios` / `android` script over a direct
|
|
113
|
-
`expo run:ios` / `react-native run-ios` invocation. Reasons: respects user
|
|
114
|
-
flags, picks the right CLI, works with non-standard setups (rainbow has
|
|
115
|
-
`expo` in deps but uses `react-native run-ios`).
|
|
116
|
-
|
|
117
|
-
`detectScriptCli` regex-matches the script body to decide flag names
|
|
118
|
-
(`--device <UDID>` for Expo, `--udid <UDID>` for RN). If you ever need a
|
|
119
|
-
new flag, update both the script-path branch and the direct fallback.
|
|
120
|
-
|
|
121
|
-
`detectPackageManager` walks up from the project root looking for a
|
|
122
|
-
lockfile (monorepo support). Don't single-directory-check.
|
|
123
|
-
|
|
124
|
-
### 6. `RN_ISO_HOME` is the test redirect
|
|
125
|
-
|
|
126
|
-
All config + log paths derive from `getConfigDir()`, which respects
|
|
127
|
-
`RN_ISO_HOME`. Every config-touching test does:
|
|
128
|
-
|
|
129
|
-
```js
|
|
130
|
-
beforeEach(() => {
|
|
131
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
132
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
133
|
-
});
|
|
134
|
-
afterEach(() => {
|
|
135
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
136
|
-
delete process.env.RN_ISO_HOME;
|
|
137
|
-
});
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
If you add new state-touching code, follow this pattern.
|
|
141
|
-
|
|
142
|
-
### 7. `findProjectRoot` uses `realpath`
|
|
143
|
-
|
|
144
|
-
So symlinked worktrees collapse to the same canonical key as the
|
|
145
|
-
non-symlinked path. Don't add code that compares paths without
|
|
146
|
-
canonicalizing first.
|
|
147
|
-
|
|
148
|
-
## Local development
|
|
149
|
-
|
|
150
|
-
```bash
|
|
151
|
-
npm install # one-time
|
|
152
|
-
npm test # node --test test/*.test.js
|
|
153
|
-
npm link # symlink rn-iso onto your PATH for live testing
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
After `npm link`, edits to `src/` are picked up immediately by the linked
|
|
157
|
-
`rn-iso` command.
|
|
158
|
-
|
|
159
|
-
## Commit conventions
|
|
160
|
-
|
|
161
|
-
- GPG signing is enabled globally — commits sign automatically. Don't pass
|
|
162
|
-
`--no-gpg-sign`. If you need to re-sign an existing commit (e.g.,
|
|
163
|
-
someone forgot signing), `git commit --amend --no-edit -S` works.
|
|
164
|
-
- Conventional-style prefixes are used (`feat:`, `fix:`, `docs:`,
|
|
165
|
-
`chore:`, `revert:`). Keep titles under ~70 chars; details in the body.
|
|
166
|
-
- One commit per logical change. The post-install removal and the
|
|
167
|
-
script-based runner came in as separate commits even though they shipped
|
|
168
|
-
in the same session.
|
|
169
|
-
|
|
170
|
-
## Things explicitly out of scope (for now)
|
|
171
|
-
|
|
172
|
-
- Locking / mutex around device usage. The whole premise is dedicated sims.
|
|
173
|
-
- Auto-shutdown of sims after N hours of inactivity.
|
|
174
|
-
- Cross-platform support beyond macOS (iOS) + macOS/Linux (Android).
|
|
175
|
-
- Multi-app projects (one repo, multiple Expo apps via `--variant`).
|
|
176
|
-
- A daemon or TUI dashboard.
|
|
177
|
-
|
|
178
|
-
If a request edges into these, raise it instead of building it.
|