rn-iso 0.1.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/.claude/settings.local.json +7 -0
- package/CLAUDE.md +178 -0
- package/README.md +90 -0
- package/bin/cli.js +35 -0
- package/docs/plans/2026-04-25-rn-iso-implementation.md +2653 -0
- package/docs/specs/2026-04-25-rn-iso-design.md +282 -0
- package/package.json +20 -0
- package/skill/SKILL.md +112 -0
- package/src/commands/android.js +112 -0
- package/src/commands/device.js +43 -0
- package/src/commands/ios.js +210 -0
- package/src/commands/logs.js +28 -0
- package/src/commands/prune.js +57 -0
- package/src/commands/release.js +51 -0
- package/src/commands/reserve.js +176 -0
- package/src/commands/shutdown.js +41 -0
- package/src/commands/start.js +43 -0
- package/src/commands/status.js +60 -0
- package/src/commands/stop.js +51 -0
- package/src/commands/unreserve.js +57 -0
- package/src/config.js +221 -0
- package/src/exec.js +31 -0
- package/src/metro.js +73 -0
- package/src/ports.js +50 -0
- package/src/project.js +186 -0
- package/src/runner.js +136 -0
- package/src/sim/android.js +103 -0
- package/src/sim/ios.js +128 -0
- package/test/config.test.js +208 -0
- package/test/exec.test.js +26 -0
- package/test/fixtures/sample-bare-project/android/app/build.gradle +6 -0
- package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +10 -0
- package/test/fixtures/sample-bare-project/package.json +4 -0
- package/test/fixtures/sample-expo-project/app.json +6 -0
- package/test/fixtures/sample-expo-project/package.json +4 -0
- package/test/fixtures/sample-expo-project/src/.keep +0 -0
- package/test/metro.test.js +34 -0
- package/test/ports.test.js +76 -0
- package/test/project.test.js +109 -0
- package/test/runner.test.js +209 -0
- package/test/sim-android.test.js +140 -0
- package/test/sim-ios.test.js +168 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// src/commands/ios.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
|
|
5
|
+
import { getProject, upsertProject, setMetro, setDevice, allClaimedDevices, recordSimUsage, getSimUsage } from '../config.js';
|
|
6
|
+
import { allocatePort, isMetroRunning } from '../ports.js';
|
|
7
|
+
import { selectIosDevice, bootIosSim, listIosRuntimes, createIosSim, parseRuntimeVersion, listAllIosSims, sortSims } from '../sim/ios.js';
|
|
8
|
+
import { buildIosCommand, detectPackageManager } from '../runner.js';
|
|
9
|
+
import { getExecutor } from '../exec.js';
|
|
10
|
+
|
|
11
|
+
export default function iosCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('ios')
|
|
14
|
+
.description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed')
|
|
15
|
+
.option('--device-type <name>', 'Explicit opt-in: create a NEW sim of this device type (e.g. "iPhone 17 Pro")')
|
|
16
|
+
.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('--script <name>', 'package.json script to invoke for build/install (default: ios)', 'ios')
|
|
19
|
+
.option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
|
|
20
|
+
.option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: detected from lockfile)')
|
|
21
|
+
.option('--no-install', 'Skip the build/install step (assume app is already installed)')
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
const root = findProjectRoot(process.cwd());
|
|
24
|
+
if (!root) {
|
|
25
|
+
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bundleId = detectBundleId(root);
|
|
30
|
+
const androidPackage = detectAndroidPackage(root);
|
|
31
|
+
const isExpo = detectIsExpo(root);
|
|
32
|
+
|
|
33
|
+
upsertProject(root, { bundleId, androidPackage, isExpo });
|
|
34
|
+
let proj = getProject(root);
|
|
35
|
+
|
|
36
|
+
if (!proj.metroPort) {
|
|
37
|
+
const port = await allocatePort(root);
|
|
38
|
+
setMetro(root, port, null);
|
|
39
|
+
proj = getProject(root);
|
|
40
|
+
console.log(chalk.dim(`Allocated Metro port: ${port}`));
|
|
41
|
+
}
|
|
42
|
+
const metroAlreadyUp = await isMetroRunning(proj.metroPort);
|
|
43
|
+
console.log(chalk.dim(
|
|
44
|
+
`Metro port: ${proj.metroPort}` +
|
|
45
|
+
(metroAlreadyUp ? ' (already running)' : ' (will be started by build CLI)')
|
|
46
|
+
));
|
|
47
|
+
|
|
48
|
+
const claimedDevices = allClaimedDevices();
|
|
49
|
+
const ownUdid = proj.platforms?.ios?.deviceUdid;
|
|
50
|
+
const claimed = claimedDevices.iosUdids.filter(u => u !== ownUdid);
|
|
51
|
+
const usage = getSimUsage().ios || {};
|
|
52
|
+
const selection = selectIosDevice({
|
|
53
|
+
existingUdid: ownUdid || null,
|
|
54
|
+
claimedUdids: claimed,
|
|
55
|
+
usage,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let udid;
|
|
59
|
+
if (selection.kind === 'reuse') {
|
|
60
|
+
udid = selection.udid;
|
|
61
|
+
if (selection.state !== 'Booted') {
|
|
62
|
+
console.log(chalk.dim(`Booting assigned sim ${udid}...`));
|
|
63
|
+
bootIosSim(udid);
|
|
64
|
+
} else {
|
|
65
|
+
console.log(chalk.dim(`Reusing assigned sim ${udid} (already booted)`));
|
|
66
|
+
}
|
|
67
|
+
} 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})...`));
|
|
74
|
+
bootIosSim(udid);
|
|
75
|
+
} else {
|
|
76
|
+
console.log(chalk.green(`Assigned ${picked.name} (${udid}, booted)`));
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// needsBoot: no unclaimed sim available.
|
|
80
|
+
if (opts.deviceType) {
|
|
81
|
+
udid = createNewSim({ deviceType: opts.deviceType, runtimeVersion: opts.runtime });
|
|
82
|
+
console.log(chalk.green(`Created and booted new sim ${udid}`));
|
|
83
|
+
} 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'));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setDevice(root, 'ios', { deviceUdid: udid });
|
|
95
|
+
recordSimUsage('ios', udid);
|
|
96
|
+
|
|
97
|
+
// Metro is started by the build CLI (`expo run:ios` / `react-native
|
|
98
|
+
// run-ios`) using the --port we pass below. We don't spawn a separate
|
|
99
|
+
// Metro -- that caused two Metros on the same port. For Metro-only
|
|
100
|
+
// (without build/install), use `rn-iso start`.
|
|
101
|
+
|
|
102
|
+
if (opts.install !== false) {
|
|
103
|
+
const packageManager = opts.pm || detectPackageManager(root);
|
|
104
|
+
const useScript = opts.script !== false;
|
|
105
|
+
const scriptName = useScript ? (typeof opts.script === 'string' ? opts.script : 'ios') : null;
|
|
106
|
+
const cmd = buildIosCommand({
|
|
107
|
+
projectRoot: root,
|
|
108
|
+
packageManager,
|
|
109
|
+
scriptName,
|
|
110
|
+
isExpo,
|
|
111
|
+
udid,
|
|
112
|
+
port: proj.metroPort,
|
|
113
|
+
useScript,
|
|
114
|
+
});
|
|
115
|
+
console.log(chalk.dim(`> ${cmd}`));
|
|
116
|
+
const exec = getExecutor();
|
|
117
|
+
const child = exec.spawn('sh', ['-c', cmd], { cwd: root, stdio: 'inherit' });
|
|
118
|
+
await new Promise((resolve, reject) => {
|
|
119
|
+
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Build failed (exit ${code})`)));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(chalk.green(`\nOK: iOS ready on sim ${udid}, Metro port ${proj.metroPort}`));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
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.
|
|
132
|
+
const allSims = listAllIosSims();
|
|
133
|
+
const candidateUdids = new Set(candidates.map(s => s.udid));
|
|
134
|
+
const sorted = sortSims(allSims, usage);
|
|
135
|
+
|
|
136
|
+
const nameWidth = Math.max(...sorted.map(s => s.name.length), 18);
|
|
137
|
+
const choices = sorted.map(s => {
|
|
138
|
+
const version = parseRuntimeVersion(s.runtime);
|
|
139
|
+
const namePart = s.name.padEnd(nameWidth);
|
|
140
|
+
const versionPart = version.padStart(6);
|
|
141
|
+
const claim = iosClaims[s.udid];
|
|
142
|
+
if (claim) {
|
|
143
|
+
const tag = claim.source === 'project'
|
|
144
|
+
? `[claimed by ${claim.label}]`
|
|
145
|
+
: `[reserved: ${claim.label}]`;
|
|
146
|
+
return {
|
|
147
|
+
title: chalk.dim(`${namePart} ${versionPart} ${tag}`),
|
|
148
|
+
value: null,
|
|
149
|
+
disabled: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const stateTag = s.state === 'Booted' ? chalk.green(' [booted]') : '';
|
|
153
|
+
const isCandidate = candidateUdids.has(s.udid);
|
|
154
|
+
if (!isCandidate) {
|
|
155
|
+
// Shouldn't happen with current selectIosDevice, but be safe.
|
|
156
|
+
return { title: chalk.dim(`${namePart} ${versionPart}`), value: null, disabled: true };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
title: `${namePart} ${chalk.dim(versionPart)}${stateTag}`,
|
|
160
|
+
value: s,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
const answer = await prompts({
|
|
164
|
+
type: 'select',
|
|
165
|
+
name: 'sim',
|
|
166
|
+
message: 'Pick a simulator:',
|
|
167
|
+
choices,
|
|
168
|
+
});
|
|
169
|
+
if (!answer.sim) {
|
|
170
|
+
console.error(chalk.red('Cancelled.'));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
return answer.sim;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createNewSim({ deviceType, runtimeVersion }) {
|
|
177
|
+
const runtimes = listIosRuntimes();
|
|
178
|
+
if (runtimes.length === 0) {
|
|
179
|
+
throw new Error('No iOS runtimes installed; install one via Xcode.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Pick the runtime: explicit version flag, else the latest.
|
|
183
|
+
let runtime;
|
|
184
|
+
if (runtimeVersion) {
|
|
185
|
+
runtime = runtimes.find(r => r.version === runtimeVersion || r.name === `iOS ${runtimeVersion}`);
|
|
186
|
+
if (!runtime) {
|
|
187
|
+
const available = runtimes.map(r => r.version).join(', ');
|
|
188
|
+
throw new Error(`Runtime "${runtimeVersion}" not installed. Available: ${available}`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
runtime = [...runtimes].sort(
|
|
192
|
+
(a, b) => b.version.localeCompare(a.version, undefined, { numeric: true })
|
|
193
|
+
)[0];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Resolve the device type within the chosen runtime's compatible list.
|
|
197
|
+
const supported = runtime.supportedDeviceTypes || [];
|
|
198
|
+
const dt = supported.find(d => d.name === deviceType || d.identifier === deviceType);
|
|
199
|
+
if (!dt) {
|
|
200
|
+
const names = supported.map(d => d.name).slice(0, 8).join(', ');
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Device type "${deviceType}" not compatible with runtime ${runtime.version}. ` +
|
|
203
|
+
`Compatible (sample): ${names}...`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const udid = createIosSim(dt.identifier, runtime.identifier);
|
|
208
|
+
bootIosSim(udid);
|
|
209
|
+
return udid;
|
|
210
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/commands/logs.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { findProjectRoot } from '../project.js';
|
|
4
|
+
import { logFileExists } from '../metro.js';
|
|
5
|
+
import { getExecutor } from '../exec.js';
|
|
6
|
+
|
|
7
|
+
export default function logsCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('logs')
|
|
10
|
+
.description('Tail the Metro log file for the current project')
|
|
11
|
+
.action(() => {
|
|
12
|
+
const root = findProjectRoot(process.cwd());
|
|
13
|
+
if (!root) {
|
|
14
|
+
console.error(chalk.red('Not in a React Native project.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const path = logFileExists(root);
|
|
18
|
+
if (!path) {
|
|
19
|
+
console.error(chalk.red('No Metro log file found. Have you run `rn-iso start` or `rn-iso ios/android`?'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
console.log(chalk.dim(`Tailing ${path}\n`));
|
|
23
|
+
const exec = getExecutor();
|
|
24
|
+
const child = exec.spawn('tail', ['-f', path], { stdio: 'inherit' });
|
|
25
|
+
// Forward SIGINT cleanly
|
|
26
|
+
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/commands/prune.js
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { loadConfig, removeProject, clearDevice } from '../config.js';
|
|
5
|
+
import { listAllIosSims, shutdownIosSim } from '../sim/ios.js';
|
|
6
|
+
import { listAvds, shutdownAndroidEmulator } from '../sim/android.js';
|
|
7
|
+
|
|
8
|
+
export default function pruneCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('prune')
|
|
11
|
+
.description('Garbage-collect dead project entries and missing device assignments')
|
|
12
|
+
.option('--shutdown', 'Also shut down sims/emulators referenced only by dropped entries')
|
|
13
|
+
.action((opts) => {
|
|
14
|
+
const cfg = loadConfig();
|
|
15
|
+
if (!cfg?.projects) {
|
|
16
|
+
console.log(chalk.dim('Nothing to prune.'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const allIosUdids = new Set(listAllIosSims().map(s => s.udid));
|
|
21
|
+
const allAvds = new Set(listAvds());
|
|
22
|
+
|
|
23
|
+
const droppedSims = [];
|
|
24
|
+
const droppedEmulators = [];
|
|
25
|
+
|
|
26
|
+
for (const [path, proj] of Object.entries(cfg.projects)) {
|
|
27
|
+
// Drop entire project if its dir is gone.
|
|
28
|
+
if (!existsSync(path)) {
|
|
29
|
+
if (opts.shutdown) {
|
|
30
|
+
if (proj.platforms?.ios?.deviceUdid) droppedSims.push(proj.platforms.ios.deviceUdid);
|
|
31
|
+
if (proj.platforms?.android?.consolePort) droppedEmulators.push(proj.platforms.android.consolePort);
|
|
32
|
+
}
|
|
33
|
+
removeProject(path);
|
|
34
|
+
console.log(chalk.yellow(`Dropped missing project: ${path}`));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Drop iOS assignment if UDID no longer exists.
|
|
39
|
+
if (proj.platforms?.ios && !allIosUdids.has(proj.platforms.ios.deviceUdid)) {
|
|
40
|
+
clearDevice(path, 'ios');
|
|
41
|
+
console.log(chalk.dim(`${path}: cleared stale iOS assignment ${proj.platforms.ios.deviceUdid}`));
|
|
42
|
+
}
|
|
43
|
+
// Drop Android assignment if AVD no longer exists.
|
|
44
|
+
if (proj.platforms?.android && !allAvds.has(proj.platforms.android.avdName)) {
|
|
45
|
+
clearDevice(path, 'android');
|
|
46
|
+
console.log(chalk.dim(`${path}: cleared stale Android assignment ${proj.platforms.android.avdName}`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (opts.shutdown) {
|
|
51
|
+
for (const udid of droppedSims) shutdownIosSim(udid);
|
|
52
|
+
for (const port of droppedEmulators) shutdownAndroidEmulator(`emulator-${port}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(chalk.green('Prune complete.'));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/commands/release.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { resolveRegisteredProject } from '../project.js';
|
|
4
|
+
import { getProject, clearDevice, findReservations, removeReservation } from '../config.js';
|
|
5
|
+
|
|
6
|
+
export default function releaseCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.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.')
|
|
10
|
+
.option('--platform <platform>', 'ios or android (default: both)')
|
|
11
|
+
.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
|
+
const { found, error } = resolveRegisteredProject(target);
|
|
29
|
+
if (!found) {
|
|
30
|
+
console.error(chalk.red(error));
|
|
31
|
+
if (target) {
|
|
32
|
+
console.error(chalk.dim('(Also tried as reservation label/id; no match.)'));
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const proj = getProject(found);
|
|
37
|
+
if (!proj) {
|
|
38
|
+
console.log(chalk.dim('No project entry to release.'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
|
|
42
|
+
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 {
|
|
47
|
+
console.log(chalk.dim(`No ${p} assignment to release for ${found}.`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/commands/reserve.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { addReservation, listReservations, allClaimedDevices } from '../config.js';
|
|
5
|
+
import { listBootedIosSims } from '../sim/ios.js';
|
|
6
|
+
import { listAdbDevices, getAvdNameForSerial } from '../sim/android.js';
|
|
7
|
+
|
|
8
|
+
export default function reserveCommand(program) {
|
|
9
|
+
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.`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
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.`));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
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',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!answer.selected || answer.selected.length === 0) {
|
|
94
|
+
console.log(chalk.dim('Nothing selected.'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
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;
|
|
134
|
+
}
|
|
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'));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
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;
|
|
150
|
+
}
|
|
151
|
+
console.error(chalk.red(`Unknown platform: ${platform}. Use ios or android.`));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
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;
|
|
162
|
+
}
|
|
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})`) : ''}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
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})`) : ''}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/commands/shutdown.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { findProjectRoot } from '../project.js';
|
|
4
|
+
import { getProject, clearDevice } from '../config.js';
|
|
5
|
+
import { shutdownIosSim } from '../sim/ios.js';
|
|
6
|
+
import { shutdownAndroidEmulator } from '../sim/android.js';
|
|
7
|
+
|
|
8
|
+
export default function shutdownCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('shutdown')
|
|
11
|
+
.description('Release and shut down the simulator/emulator(s) for the current project')
|
|
12
|
+
.option('--platform <platform>', 'ios or android (default: both)')
|
|
13
|
+
.action((opts) => {
|
|
14
|
+
const root = findProjectRoot(process.cwd());
|
|
15
|
+
if (!root) {
|
|
16
|
+
console.error(chalk.red('Not in a React Native project.'));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const proj = getProject(root);
|
|
20
|
+
if (!proj) {
|
|
21
|
+
console.log(chalk.dim('No project entry.'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
|
|
25
|
+
for (const p of platforms) {
|
|
26
|
+
const entry = proj.platforms?.[p];
|
|
27
|
+
if (!entry) {
|
|
28
|
+
console.log(chalk.dim(`No ${p} assignment.`));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (p === 'ios') {
|
|
32
|
+
shutdownIosSim(entry.deviceUdid);
|
|
33
|
+
console.log(chalk.green(`Shut down iOS sim ${entry.deviceUdid}`));
|
|
34
|
+
} else {
|
|
35
|
+
shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
|
|
36
|
+
console.log(chalk.green(`Shut down emulator-${entry.consolePort}`));
|
|
37
|
+
}
|
|
38
|
+
clearDevice(root, p);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/commands/start.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
|
|
4
|
+
import { getProject, upsertProject, setMetro } from '../config.js';
|
|
5
|
+
import { allocatePort } from '../ports.js';
|
|
6
|
+
import { ensureMetro } from '../metro.js';
|
|
7
|
+
|
|
8
|
+
export default function startCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('start')
|
|
11
|
+
.description('Ensure Metro is running for the current project (no platform action)')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const root = findProjectRoot(process.cwd());
|
|
14
|
+
if (!root) {
|
|
15
|
+
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const isExpo = detectIsExpo(root);
|
|
19
|
+
|
|
20
|
+
let proj = getProject(root);
|
|
21
|
+
if (!proj) {
|
|
22
|
+
upsertProject(root, {
|
|
23
|
+
bundleId: detectBundleId(root),
|
|
24
|
+
androidPackage: detectAndroidPackage(root),
|
|
25
|
+
isExpo,
|
|
26
|
+
});
|
|
27
|
+
proj = getProject(root);
|
|
28
|
+
}
|
|
29
|
+
if (!proj.metroPort) {
|
|
30
|
+
const port = await allocatePort(root);
|
|
31
|
+
setMetro(root, port, null);
|
|
32
|
+
proj = getProject(root);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
|
|
36
|
+
if (metro.alreadyRunning) {
|
|
37
|
+
console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
|
|
38
|
+
} else {
|
|
39
|
+
setMetro(root, proj.metroPort, metro.pid);
|
|
40
|
+
console.log(chalk.green(`Metro started (pid ${metro.pid}, port ${proj.metroPort})`));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/commands/status.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { isMetroRunning } from '../ports.js';
|
|
5
|
+
import { isPidAlive } from '../metro.js';
|
|
6
|
+
import { findProjectRoot } from '../project.js';
|
|
7
|
+
|
|
8
|
+
export default function statusCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('status')
|
|
11
|
+
.description('Show all rn-iso project assignments and Metro state')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const cfg = loadConfig();
|
|
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) {
|
|
19
|
+
console.log(chalk.dim('No projects registered.'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cwdRoot = findProjectRoot(process.cwd());
|
|
24
|
+
|
|
25
|
+
for (const [path, proj] of Object.entries(cfg?.projects || {})) {
|
|
26
|
+
const isCurrent = path === cwdRoot;
|
|
27
|
+
const header = isCurrent ? chalk.bold.cyan(`* ${path}`) : path;
|
|
28
|
+
console.log('\n' + header);
|
|
29
|
+
console.log(chalk.dim(` app: ${proj.bundleId} (${proj.isExpo ? 'expo' : 'bare'})`));
|
|
30
|
+
|
|
31
|
+
if (proj.metroPort) {
|
|
32
|
+
const running = await isMetroRunning(proj.metroPort);
|
|
33
|
+
const pidLive = isPidAlive(proj.metroPid);
|
|
34
|
+
const label = running
|
|
35
|
+
? chalk.green('running')
|
|
36
|
+
: pidLive ? chalk.yellow('pid alive but not responding') : chalk.dim('stopped');
|
|
37
|
+
console.log(` metro: port ${proj.metroPort} pid ${proj.metroPid ?? '?'} (${label})`);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(chalk.dim(' metro: unassigned'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ios = proj.platforms?.ios;
|
|
43
|
+
if (ios) console.log(` ios: ${chalk.cyan(ios.deviceUdid)}`);
|
|
44
|
+
const android = proj.platforms?.android;
|
|
45
|
+
if (android) console.log(` android: ${chalk.cyan(android.avdName)} on emulator-${android.consolePort}`);
|
|
46
|
+
}
|
|
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
|
+
console.log('');
|
|
59
|
+
});
|
|
60
|
+
}
|