rn-iso 0.4.1 → 0.4.4
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 +1 -0
- package/bin/cli.js +2 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
- package/src/commands/android.js +128 -46
- package/src/commands/device.js +24 -3
- package/src/commands/release.js +3 -1
- package/src/commands/shutdown.js +120 -0
- package/src/commands/status.js +5 -1
- package/src/config.js +9 -2
- package/src/runner.js +7 -3
- package/src/sim/android.js +81 -17
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ All commands below take the same `npx rn-iso` prefix.
|
|
|
55
55
|
| `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
|
|
56
56
|
| `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
|
|
57
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
|
+
| `shutdown [<shortcut>\|<path>] [-y] [--keep-sims]` | Kill Metro, shut down sims/emulators, and clear device assignments. No arg = every registered project (end-of-day reset). Pass a shortcut or path to scope to one. |
|
|
58
59
|
| `config [<key> [<value>]] [--unset] [--project <target>]` | Get / set a per-project setting (`packageManager`, `ios.script`, `android.script`). |
|
|
59
60
|
|
|
60
61
|
## How it works
|
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 shutdownCommand from '../src/commands/shutdown.js';
|
|
13
14
|
import configCommand from '../src/commands/config.js';
|
|
14
15
|
|
|
15
16
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
@@ -29,6 +30,7 @@ statusCommand(program);
|
|
|
29
30
|
releaseCommand(program);
|
|
30
31
|
reserveCommand(program);
|
|
31
32
|
unreserveCommand(program);
|
|
33
|
+
shutdownCommand(program);
|
|
32
34
|
configCommand(program);
|
|
33
35
|
|
|
34
36
|
program.parse();
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ From the project root (or any subdirectory):
|
|
|
23
23
|
```json
|
|
24
24
|
{"platform":"ios","udid":"ABC-...","metroPort":8083}
|
|
25
25
|
```
|
|
26
|
-
Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` to use with `adb -s`.
|
|
26
|
+
Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` (or the hardware serial for a physical device) to use with `adb -s`. The Android JSON payload also includes `kind: "emulator" | "physical"`.
|
|
27
27
|
|
|
28
28
|
3. **Interact with the device** — pass the UDID/serial to your UI tools. Never call `simctl <verb>` without `<UDID>` — `booted` could be the wrong sim.
|
|
29
29
|
|
|
@@ -38,7 +38,7 @@ From the project root (or any subdirectory):
|
|
|
38
38
|
- `adb -s emulator-5556 shell input tap 100 200`
|
|
39
39
|
- **Don't call `release` or `release --shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
|
|
40
40
|
- **Don't manually start Metro on a different port.** `npx rn-iso start` (or `npx rn-iso ios/android`) already handles port assignment.
|
|
41
|
-
- **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown). If
|
|
41
|
+
- **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown) and, on Android, also surfaces any physical device adb can see. If nothing is available, it errors. To create a new iOS sim explicitly, pass `--device-type "iPhone 17 Pro" [--runtime 26.2]`.
|
|
42
42
|
|
|
43
43
|
## Typical agent workflow
|
|
44
44
|
|
|
@@ -84,6 +84,7 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
|
|
|
84
84
|
- `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
|
|
85
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.
|
|
86
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 shutdown [<shortcut>|<path>] [-y] [--keep-sims]` — kill Metro, shut down sims/emulators, and clear device assignments. With no arg, scopes to **every** registered project (end-of-day reset); pass a project shortcut (label or unique basename) or absolute path to scope to one. Note this does NOT default to the current project (deliberate — `shutdown` is the explicit "tear it all down" command). Prompts unless `-y` / non-TTY; `--keep-sims` only kills Metro and clears assignments without touching the sims. Project entries themselves stay registered, so `metroPort` allocations and labels survive.
|
|
87
88
|
- `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.
|
|
88
89
|
|
|
89
90
|
### Project shortcuts (--label)
|
|
@@ -103,7 +104,7 @@ When the iOS picker fires, sims are sorted by:
|
|
|
103
104
|
3. Usage count (most-used floats up; tracked per UDID across all projects)
|
|
104
105
|
4. Name (alphabetical, stable tiebreak)
|
|
105
106
|
|
|
106
|
-
When the Android picker fires, AVDs are sorted by running state (running emulators first), then alphabetically.
|
|
107
|
+
When the Android picker fires, candidates include both AVDs on disk and physical devices currently visible to `adb`. They are sorted by running state (running emulators and connected physical devices first), then physical above AVDs within the same running group, then alphabetically. Physical devices show with a `[physical]` tag. Once selected, a physical device is claimed by serial just like an AVD; `release` clears the claim but never shuts the device down.
|
|
107
108
|
|
|
108
109
|
Sims/AVDs claimed by other rn-iso projects show in yellow with a `[claimed by ...]` tag. They're selectable but require a confirm prompt before being taken over.
|
|
109
110
|
|
package/src/commands/android.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
waitForBoot,
|
|
13
13
|
adbReverse,
|
|
14
14
|
nextConsolePort,
|
|
15
|
+
listAdbDevices,
|
|
15
16
|
} from '../sim/android.js';
|
|
16
17
|
import { buildAndroidCommand, detectPackageManager } from '../runner.js';
|
|
17
18
|
import { getExecutor } from '../exec.js';
|
|
@@ -65,79 +66,145 @@ export default function androidCommand(program) {
|
|
|
65
66
|
const claimed = allClaimedDevices();
|
|
66
67
|
const myAvd = proj.platforms?.android?.avdName || null;
|
|
67
68
|
const myPort = proj.platforms?.android?.consolePort || null;
|
|
69
|
+
const mySerial = proj.platforms?.android?.serial || null;
|
|
68
70
|
const claimedAvds = claimed.androidAvds.filter(a => a !== myAvd);
|
|
69
71
|
const claimedPorts = claimed.androidConsolePorts.filter(p => p !== myPort);
|
|
72
|
+
const claimedSerials = claimed.androidPhysicalSerials.filter(s => s !== mySerial);
|
|
70
73
|
|
|
71
74
|
const selection = selectAndroidDevice({
|
|
72
75
|
existingAvd: myAvd,
|
|
76
|
+
existingSerial: mySerial,
|
|
73
77
|
existingConsolePort: myPort,
|
|
74
78
|
claimedAvds,
|
|
79
|
+
claimedSerials,
|
|
75
80
|
claimedConsolePorts: claimedPorts,
|
|
76
81
|
});
|
|
77
82
|
|
|
78
|
-
let avdName
|
|
83
|
+
let avdName = null;
|
|
84
|
+
let consolePort = null;
|
|
85
|
+
let serial = null;
|
|
86
|
+
let isRunning = false;
|
|
87
|
+
let isPhysical = false;
|
|
79
88
|
if (selection.kind === 'reuse') {
|
|
80
|
-
(
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
if (selection.deviceKind === 'physical') {
|
|
90
|
+
isPhysical = true;
|
|
91
|
+
serial = selection.serial;
|
|
92
|
+
isRunning = true;
|
|
93
|
+
console.log(chalk.dim(`Reusing physical device ${serial}`));
|
|
83
94
|
} else {
|
|
84
|
-
|
|
95
|
+
({ avdName, consolePort, isRunning } = selection);
|
|
96
|
+
if (isRunning) {
|
|
97
|
+
console.log(chalk.dim(`Reusing running ${avdName} (emulator-${consolePort})`));
|
|
98
|
+
} else {
|
|
99
|
+
console.log(chalk.dim(`Booting assigned ${avdName} (emulator-${consolePort})...`));
|
|
100
|
+
}
|
|
85
101
|
}
|
|
86
102
|
} else if (selection.kind === 'allocate') {
|
|
87
103
|
const picked = (selection.candidates.length === 1 || auto)
|
|
88
104
|
? { c: selection.candidates[0], prevClaim: null }
|
|
89
|
-
: await
|
|
105
|
+
: await pickAndroidDevice({
|
|
90
106
|
candidates: selection.candidates,
|
|
91
107
|
androidClaimsByAvd: claimed.androidClaimsByAvd,
|
|
108
|
+
androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
|
|
92
109
|
});
|
|
93
110
|
await releasePriorClaim(picked.prevClaim);
|
|
94
|
-
(
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
if (picked.c.kind === 'physical') {
|
|
112
|
+
isPhysical = true;
|
|
113
|
+
serial = picked.c.serial;
|
|
114
|
+
isRunning = true;
|
|
115
|
+
console.log(chalk.green(`Picked physical device ${serial}`));
|
|
116
|
+
} else {
|
|
117
|
+
({ avdName, isRunning, consolePort } = picked.c);
|
|
118
|
+
if (!isRunning) {
|
|
119
|
+
consolePort = nextConsolePort(claimedPorts);
|
|
120
|
+
}
|
|
121
|
+
console.log(isRunning
|
|
122
|
+
? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
|
|
123
|
+
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
97
124
|
}
|
|
98
|
-
console.log(isRunning
|
|
99
|
-
? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
|
|
100
|
-
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
101
125
|
} else if (selection.kind === 'allClaimed') {
|
|
102
126
|
if (auto) {
|
|
103
|
-
console.error(chalk.red('All Android
|
|
127
|
+
console.error(chalk.red('All Android devices are claimed by other rn-iso projects.'));
|
|
104
128
|
console.error(chalk.dim('Re-run without --auto to confirm taking one over, or create a new AVD via Android Studio.'));
|
|
105
129
|
process.exit(1);
|
|
106
130
|
}
|
|
107
|
-
const picked = await
|
|
131
|
+
const picked = await pickAndroidDevice({
|
|
108
132
|
candidates: selection.candidates,
|
|
109
133
|
androidClaimsByAvd: claimed.androidClaimsByAvd,
|
|
134
|
+
androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
|
|
110
135
|
allClaimed: true,
|
|
111
136
|
});
|
|
112
137
|
await releasePriorClaim(picked.prevClaim);
|
|
113
|
-
(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
if (picked.c.kind === 'physical') {
|
|
139
|
+
isPhysical = true;
|
|
140
|
+
serial = picked.c.serial;
|
|
141
|
+
isRunning = true;
|
|
142
|
+
console.log(chalk.green(`Took over physical device ${serial}`));
|
|
143
|
+
} else {
|
|
144
|
+
({ avdName, isRunning, consolePort } = picked.c);
|
|
145
|
+
if (!isRunning) {
|
|
146
|
+
// Prior owner's port is freed by releasePriorClaim, but compute fresh.
|
|
147
|
+
const fresh = allClaimedDevices().androidConsolePorts.filter(p => p !== myPort);
|
|
148
|
+
consolePort = nextConsolePort(fresh);
|
|
149
|
+
}
|
|
150
|
+
console.log(isRunning
|
|
151
|
+
? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
|
|
152
|
+
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
118
153
|
}
|
|
119
|
-
console.log(isRunning
|
|
120
|
-
? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
|
|
121
|
-
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
122
154
|
} else {
|
|
123
155
|
console.error(chalk.red(
|
|
124
|
-
'No AVDs available. Create
|
|
156
|
+
'No AVDs or physical devices available. Create an AVD via Android Studio (Tools -> Device Manager), or plug in a device with USB debugging enabled.'
|
|
125
157
|
));
|
|
126
158
|
process.exit(1);
|
|
127
159
|
}
|
|
128
160
|
|
|
129
|
-
|
|
130
|
-
if (!isRunning) {
|
|
161
|
+
if (!isPhysical) serial = `emulator-${consolePort}`;
|
|
162
|
+
if (!isPhysical && !isRunning) {
|
|
163
|
+
// Pre-spawn sanity check: an emulator may already be attached on
|
|
164
|
+
// this console port but in `unauthorized` / `offline` state, in
|
|
165
|
+
// which case enumerateAndroidCandidates marked isRunning=false.
|
|
166
|
+
// Spawning a second emulator on the same port would silently
|
|
167
|
+
// collide and the boot wait would poll forever.
|
|
168
|
+
const adb = listAdbDevices();
|
|
169
|
+
const stuck = adb.unhealthy.find(u => u.consolePort === consolePort);
|
|
170
|
+
if (stuck) {
|
|
171
|
+
console.error(chalk.red(
|
|
172
|
+
`adb sees ${stuck.serial} but its status is "${stuck.status}". rn-iso can't drive it in this state.`
|
|
173
|
+
));
|
|
174
|
+
if (stuck.status === 'unauthorized') {
|
|
175
|
+
console.error(chalk.dim('Likely cause: the emulator and your ~/.android/adbkey.pub are out of sync.'));
|
|
176
|
+
console.error(chalk.dim('Try: `adb kill-server && adb start-server` first; if it stays unauthorized,'));
|
|
177
|
+
console.error(chalk.dim('cold-boot the AVD from Android Studio Device Manager.'));
|
|
178
|
+
} else {
|
|
179
|
+
console.error(chalk.dim('Try: `adb kill-server && adb start-server`, then re-run.'));
|
|
180
|
+
}
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
131
183
|
bootAndroidEmulator(avdName, consolePort);
|
|
132
184
|
console.log(chalk.dim('Waiting for boot to complete (this can take 10-30s)...'));
|
|
133
|
-
const
|
|
134
|
-
if (!ok) {
|
|
185
|
+
const result = await waitForBoot(serial, 120000);
|
|
186
|
+
if (!result.ok) {
|
|
135
187
|
console.error(chalk.red(`Emulator ${serial} did not finish booting within 2 minutes.`));
|
|
188
|
+
const d = result.diagnostic;
|
|
189
|
+
console.error(chalk.dim('adb devices ->'));
|
|
190
|
+
console.error(chalk.dim((d.devices || '<no output>').split('\n').map(l => ' ' + l).join('\n')));
|
|
191
|
+
console.error(chalk.dim(
|
|
192
|
+
`getprop sys.boot_completed=${d.sysBoot || '<empty>'} ` +
|
|
193
|
+
`dev.bootcomplete=${d.devBoot || '<empty>'} ` +
|
|
194
|
+
`init.svc.bootanim=${d.bootAnim || '<empty>'}`
|
|
195
|
+
));
|
|
196
|
+
if (!d.devices.includes(serial)) {
|
|
197
|
+
console.error(chalk.dim(`Hint: ${serial} is not in adb's device list. Try \`adb kill-server && adb start-server\`.`));
|
|
198
|
+
}
|
|
136
199
|
process.exit(1);
|
|
137
200
|
}
|
|
138
201
|
}
|
|
139
202
|
|
|
140
|
-
|
|
203
|
+
if (isPhysical) {
|
|
204
|
+
setDevice(root, 'android', { serial });
|
|
205
|
+
} else {
|
|
206
|
+
setDevice(root, 'android', { avdName, consolePort });
|
|
207
|
+
}
|
|
141
208
|
|
|
142
209
|
adbReverse(serial, proj.metroPort);
|
|
143
210
|
console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
|
|
@@ -168,39 +235,46 @@ export default function androidCommand(program) {
|
|
|
168
235
|
});
|
|
169
236
|
}
|
|
170
237
|
|
|
171
|
-
|
|
238
|
+
const readyLabel = isPhysical ? `physical device ${serial}` : `${avdName} (${serial})`;
|
|
239
|
+
console.log(chalk.green(`\nAndroid ready on ${readyLabel}, Metro port ${proj.metroPort}`));
|
|
172
240
|
});
|
|
173
241
|
}
|
|
174
242
|
|
|
175
|
-
async function
|
|
176
|
-
// Show every AVD on disk
|
|
177
|
-
// see what's claimed and optionally
|
|
178
|
-
// unclaimed set passed in by
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
const
|
|
243
|
+
async function pickAndroidDevice({ candidates, androidClaimsByAvd = {}, androidPhysicalClaimsBySerial = {}, allClaimed = false }) {
|
|
244
|
+
// Show every AVD on disk plus every physical device adb sees (parallel
|
|
245
|
+
// to the iOS picker), so the user can see what's claimed and optionally
|
|
246
|
+
// take it over. `candidates` is the unclaimed set passed in by
|
|
247
|
+
// selectAndroidDevice; devices outside it are claimed and require a
|
|
248
|
+
// confirm prompt on selection.
|
|
249
|
+
const all = enumerateAndroidCandidates();
|
|
250
|
+
const candidateKeys = new Set(candidates.map(c => candidateKey(c)));
|
|
251
|
+
const sorted = sortAndroidCandidates(all);
|
|
183
252
|
|
|
184
|
-
const nameWidth = Math.max(...sorted.map(c => c.
|
|
253
|
+
const nameWidth = Math.max(...sorted.map(c => candidateDisplayName(c).length), 18);
|
|
185
254
|
const choices = sorted.map(c => {
|
|
186
|
-
const claim =
|
|
187
|
-
|
|
188
|
-
|
|
255
|
+
const claim = c.kind === 'physical'
|
|
256
|
+
? androidPhysicalClaimsBySerial[c.serial]
|
|
257
|
+
: androidClaimsByAvd[c.avdName];
|
|
258
|
+
const isCandidate = candidateKeys.has(candidateKey(c));
|
|
259
|
+
const runTag = c.kind === 'physical'
|
|
260
|
+
? chalk.green(' [physical]')
|
|
261
|
+
: (c.isRunning ? chalk.green(` [emulator-${c.consolePort}, running]`) : '');
|
|
262
|
+
const name = candidateDisplayName(c);
|
|
189
263
|
if (claim || !isCandidate) {
|
|
190
264
|
const tag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
|
|
191
265
|
return {
|
|
192
|
-
title: chalk.yellow(`${
|
|
266
|
+
title: chalk.yellow(`${name.padEnd(nameWidth)}${tag}${runTag}`),
|
|
193
267
|
value: { c, claim: claim || null },
|
|
194
268
|
};
|
|
195
269
|
}
|
|
196
270
|
return {
|
|
197
|
-
title: `${
|
|
271
|
+
title: `${name.padEnd(nameWidth)}${runTag}`,
|
|
198
272
|
value: { c, claim: null },
|
|
199
273
|
};
|
|
200
274
|
});
|
|
201
275
|
const message = allClaimed
|
|
202
|
-
? 'All
|
|
203
|
-
: 'Pick an
|
|
276
|
+
? 'All Android devices are claimed. Pick one to take over:'
|
|
277
|
+
: 'Pick an Android device (claimed devices will prompt to confirm):';
|
|
204
278
|
const answer = await prompts({
|
|
205
279
|
type: 'select',
|
|
206
280
|
name: 'pick',
|
|
@@ -216,7 +290,7 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
|
|
|
216
290
|
const ok = await prompts({
|
|
217
291
|
type: 'confirm',
|
|
218
292
|
name: 'ok',
|
|
219
|
-
message: `${c
|
|
293
|
+
message: `${candidateDisplayName(c)} is currently held by project "${claim.label}". Take it over?`,
|
|
220
294
|
initial: false,
|
|
221
295
|
});
|
|
222
296
|
if (!ok.ok) {
|
|
@@ -228,6 +302,14 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
|
|
|
228
302
|
return { c, prevClaim: null };
|
|
229
303
|
}
|
|
230
304
|
|
|
305
|
+
function candidateKey(c) {
|
|
306
|
+
return c.kind === 'physical' ? `p:${c.serial}` : `a:${c.avdName}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function candidateDisplayName(c) {
|
|
310
|
+
return c.kind === 'physical' ? c.serial : c.avdName;
|
|
311
|
+
}
|
|
312
|
+
|
|
231
313
|
async function releasePriorClaim(prevClaim) {
|
|
232
314
|
if (!prevClaim?.path) return;
|
|
233
315
|
clearDevice(prevClaim.path, 'android');
|
package/src/commands/device.js
CHANGED
|
@@ -27,15 +27,36 @@ export default function deviceCommand(program) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (opts.json) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
let payload;
|
|
31
|
+
if (opts.platform === 'ios') {
|
|
32
|
+
payload = { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort };
|
|
33
|
+
} else if (platformEntry.serial && !platformEntry.avdName) {
|
|
34
|
+
payload = {
|
|
35
|
+
platform: 'android',
|
|
36
|
+
kind: 'physical',
|
|
37
|
+
serial: platformEntry.serial,
|
|
38
|
+
avdName: null,
|
|
39
|
+
consolePort: null,
|
|
40
|
+
metroPort: proj.metroPort,
|
|
41
|
+
};
|
|
42
|
+
} else {
|
|
43
|
+
payload = {
|
|
44
|
+
platform: 'android',
|
|
45
|
+
kind: 'emulator',
|
|
46
|
+
serial: `emulator-${platformEntry.consolePort}`,
|
|
47
|
+
avdName: platformEntry.avdName,
|
|
48
|
+
consolePort: platformEntry.consolePort,
|
|
49
|
+
metroPort: proj.metroPort,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
33
52
|
console.log(JSON.stringify(payload));
|
|
34
53
|
return;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
if (opts.platform === 'ios') {
|
|
38
57
|
console.log(platformEntry.deviceUdid);
|
|
58
|
+
} else if (platformEntry.serial && !platformEntry.avdName) {
|
|
59
|
+
console.log(platformEntry.serial);
|
|
39
60
|
} else {
|
|
40
61
|
console.log(`emulator-${platformEntry.consolePort}`);
|
|
41
62
|
}
|
package/src/commands/release.js
CHANGED
|
@@ -46,9 +46,11 @@ export default function releaseCommand(program) {
|
|
|
46
46
|
if (p === 'ios') {
|
|
47
47
|
shutdownIosSim(entry.deviceUdid);
|
|
48
48
|
console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(entry.deviceUdid)}`));
|
|
49
|
-
} else {
|
|
49
|
+
} else if (entry.avdName && typeof entry.consolePort === 'number') {
|
|
50
50
|
shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
|
|
51
51
|
console.log(chalk.green(`Shut down ${entry.avdName} (emulator-${entry.consolePort})`));
|
|
52
|
+
} else if (entry.serial) {
|
|
53
|
+
console.log(chalk.dim(`Skipping shutdown for physical device ${entry.serial}`));
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
clearDevice(found, p);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/commands/shutdown.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { resolveRegisteredProject } from '../project.js';
|
|
5
|
+
import { loadConfig, setMetro, clearDevice } from '../config.js';
|
|
6
|
+
import { killMetroByPid, findPidListeningOnPort } from '../metro.js';
|
|
7
|
+
import { shutdownIosSim, formatIosLabel } from '../sim/ios.js';
|
|
8
|
+
import { shutdownAndroidEmulator } from '../sim/android.js';
|
|
9
|
+
|
|
10
|
+
export default function shutdownCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('shutdown [target]')
|
|
13
|
+
.description('Stop Metro and shut down sims/emulators across rn-iso projects, then clear their device assignments. With no arg, targets every registered project; pass a project shortcut (label or unique basename) or absolute path to scope to one.')
|
|
14
|
+
.option('-y, --yes', 'Skip the confirmation prompt (also implied when stdin is not a TTY)')
|
|
15
|
+
.option('--keep-sims', "Don't shut down simulators/emulators; only kill Metro and clear assignments")
|
|
16
|
+
.action(async (target, opts) => {
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
let projects = cfg ? Object.entries(cfg.projects || {}) : [];
|
|
19
|
+
if (projects.length === 0) {
|
|
20
|
+
console.log(chalk.dim('No projects registered.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Optional [target] narrows the scope to a single project. We
|
|
25
|
+
// intentionally do NOT default to the current project when no arg is
|
|
26
|
+
// given — `shutdown` is the explicit "tear everything down" command,
|
|
27
|
+
// so omitting the target means "all projects".
|
|
28
|
+
if (target) {
|
|
29
|
+
const { found, error } = resolveRegisteredProject(target);
|
|
30
|
+
if (!found) {
|
|
31
|
+
console.error(chalk.red(error));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
projects = projects.filter(([path]) => path === found);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build the work plan up front so the prompt can show counts and so we
|
|
38
|
+
// do all the I/O in clearly separated phases.
|
|
39
|
+
const metros = []; // { path, port, pid }
|
|
40
|
+
const iosSims = []; // { path, udid }
|
|
41
|
+
const androidEmus = []; // { path, avdName, consolePort }
|
|
42
|
+
for (const [path, proj] of projects) {
|
|
43
|
+
if (typeof proj.metroPort === 'number') {
|
|
44
|
+
metros.push({ path, port: proj.metroPort, pid: proj.metroPid });
|
|
45
|
+
}
|
|
46
|
+
const ios = proj.platforms?.ios;
|
|
47
|
+
if (ios?.deviceUdid) iosSims.push({ path, udid: ios.deviceUdid });
|
|
48
|
+
const android = proj.platforms?.android;
|
|
49
|
+
if (android?.avdName || typeof android?.consolePort === 'number') {
|
|
50
|
+
androidEmus.push({ path, avdName: android.avdName, consolePort: android.consolePort });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasDeviceAssignments = iosSims.length > 0 || androidEmus.length > 0;
|
|
55
|
+
if (metros.length === 0 && !hasDeviceAssignments) {
|
|
56
|
+
console.log(chalk.dim('Nothing to do (no Metro / device assignments tracked).'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const yes = opts.yes || !process.stdin.isTTY;
|
|
61
|
+
if (!yes) {
|
|
62
|
+
const summary = [];
|
|
63
|
+
if (metros.length) summary.push(`kill ${metros.length} Metro instance${metros.length === 1 ? '' : 's'}`);
|
|
64
|
+
if (!opts.keepSims) {
|
|
65
|
+
if (iosSims.length) summary.push(`shut down ${iosSims.length} iOS sim${iosSims.length === 1 ? '' : 's'}`);
|
|
66
|
+
if (androidEmus.length) summary.push(`shut down ${androidEmus.length} Android emulator${androidEmus.length === 1 ? '' : 's'}`);
|
|
67
|
+
}
|
|
68
|
+
if (hasDeviceAssignments) summary.push('clear device assignments');
|
|
69
|
+
const answer = await prompts({
|
|
70
|
+
type: 'confirm',
|
|
71
|
+
name: 'ok',
|
|
72
|
+
message: `About to ${summary.join(', ')} across ${projects.length} project${projects.length === 1 ? '' : 's'}. Proceed?`,
|
|
73
|
+
initial: false,
|
|
74
|
+
});
|
|
75
|
+
if (!answer.ok) {
|
|
76
|
+
console.error(chalk.red('Cancelled.'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Phase 1: kill Metro instances. Try the recorded pid first; if that
|
|
82
|
+
// misses, look up whoever's listening on the port. Always clear the
|
|
83
|
+
// recorded metroPid so `status` reflects reality afterward.
|
|
84
|
+
for (const m of metros) {
|
|
85
|
+
let pid = m.pid;
|
|
86
|
+
if (!pid || !killMetroByPid(pid)) {
|
|
87
|
+
pid = findPidListeningOnPort(m.port);
|
|
88
|
+
if (pid) killMetroByPid(pid);
|
|
89
|
+
}
|
|
90
|
+
setMetro(m.path, m.port, null);
|
|
91
|
+
if (pid) {
|
|
92
|
+
console.log(chalk.green(`Killed Metro pid ${pid} on port ${m.port} ${chalk.dim(`(${m.path})`)}`));
|
|
93
|
+
} else {
|
|
94
|
+
console.log(chalk.dim(`No Metro running on port ${m.port} (${m.path})`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 2: shut down sims / emulators. shutdownIosSim and
|
|
99
|
+
// shutdownAndroidEmulator both go through runQuiet so failures (e.g.
|
|
100
|
+
// sim already shut down, adb missing) don't throw.
|
|
101
|
+
if (!opts.keepSims) {
|
|
102
|
+
for (const s of iosSims) {
|
|
103
|
+
shutdownIosSim(s.udid);
|
|
104
|
+
console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(s.udid)} ${chalk.dim(`(${s.path})`)}`));
|
|
105
|
+
}
|
|
106
|
+
for (const a of androidEmus) {
|
|
107
|
+
const serial = `emulator-${a.consolePort}`;
|
|
108
|
+
shutdownAndroidEmulator(serial);
|
|
109
|
+
console.log(chalk.green(`Shut down ${a.avdName ?? serial} (${serial}) ${chalk.dim(`(${a.path})`)}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Phase 3: clear device assignments so subsequent `rn-iso ios/android`
|
|
114
|
+
// calls re-pick instead of trying to reuse a now-shutdown device.
|
|
115
|
+
for (const [path, proj] of projects) {
|
|
116
|
+
if (proj.platforms?.ios) clearDevice(path, 'ios');
|
|
117
|
+
if (proj.platforms?.android) clearDevice(path, 'android');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -53,7 +53,11 @@ export default function statusCommand(program) {
|
|
|
53
53
|
}
|
|
54
54
|
const android = proj.platforms?.android;
|
|
55
55
|
if (android) {
|
|
56
|
-
|
|
56
|
+
if (android.serial && !android.avdName) {
|
|
57
|
+
console.log(` android: ${chalk.cyan(android.serial)} ${chalk.dim('(physical)')}`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(` android: ${chalk.cyan(android.avdName)} ${chalk.dim(`(emulator-${android.consolePort})`)}`);
|
|
60
|
+
}
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
console.log('');
|
package/src/config.js
CHANGED
|
@@ -174,13 +174,16 @@ export function allClaimedDevices() {
|
|
|
174
174
|
iosUdids: [],
|
|
175
175
|
androidAvds: [],
|
|
176
176
|
androidConsolePorts: [],
|
|
177
|
+
androidPhysicalSerials: [],
|
|
177
178
|
// iosClaims: udid -> { label, path }. androidClaims: consolePort ->
|
|
178
179
|
// { label, path, avdName }. androidClaimsByAvd: avdName -> { label,
|
|
179
|
-
// path, consolePort }.
|
|
180
|
-
//
|
|
180
|
+
// path, consolePort }. androidPhysicalClaimsBySerial: serial ->
|
|
181
|
+
// { label, path }. `path` is the absolute project path so take-over
|
|
182
|
+
// flows can call clearDevice on the owning project.
|
|
181
183
|
iosClaims: {},
|
|
182
184
|
androidClaims: {},
|
|
183
185
|
androidClaimsByAvd: {},
|
|
186
|
+
androidPhysicalClaimsBySerial: {},
|
|
184
187
|
};
|
|
185
188
|
if (!cfg) return result;
|
|
186
189
|
for (const [path, proj] of Object.entries(cfg.projects || {})) {
|
|
@@ -207,6 +210,10 @@ export function allClaimedDevices() {
|
|
|
207
210
|
avdName: android.avdName,
|
|
208
211
|
};
|
|
209
212
|
}
|
|
213
|
+
if (android?.serial && !android.avdName) {
|
|
214
|
+
result.androidPhysicalSerials.push(android.serial);
|
|
215
|
+
result.androidPhysicalClaimsBySerial[android.serial] = { label, path };
|
|
216
|
+
}
|
|
210
217
|
}
|
|
211
218
|
return result;
|
|
212
219
|
}
|
package/src/runner.js
CHANGED
|
@@ -105,12 +105,16 @@ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExp
|
|
|
105
105
|
|
|
106
106
|
export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, extras = [] }) {
|
|
107
107
|
const tail = (extras || []).map(shQuote);
|
|
108
|
+
// Expo `--device <id>` accepts either an AVD name (emulators) or a
|
|
109
|
+
// hardware serial (physical devices). When no AVD name is available
|
|
110
|
+
// (physical), fall back to the serial.
|
|
111
|
+
const expoDeviceArg = avdName ?? serial;
|
|
108
112
|
if (useScript && scriptName) {
|
|
109
113
|
const script = getProjectScript(projectRoot, scriptName);
|
|
110
114
|
if (script) {
|
|
111
115
|
const cli = detectScriptCli(script);
|
|
112
|
-
// Expo: --device <AVD name>; RN: --deviceId <serial>.
|
|
113
|
-
const deviceFlag = cli === 'expo' ? `--device "${
|
|
116
|
+
// Expo: --device <AVD name | serial>; RN: --deviceId <serial>.
|
|
117
|
+
const deviceFlag = cli === 'expo' ? `--device "${expoDeviceArg}"` : `--deviceId ${serial}`;
|
|
114
118
|
return buildScriptCommand(packageManager, scriptName, [
|
|
115
119
|
deviceFlag,
|
|
116
120
|
`--port ${port}`,
|
|
@@ -120,7 +124,7 @@ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, i
|
|
|
120
124
|
}
|
|
121
125
|
const tailStr = tail.length ? ' ' + tail.join(' ') : '';
|
|
122
126
|
if (isExpo) {
|
|
123
|
-
return `npx expo run:android --device "${
|
|
127
|
+
return `npx expo run:android --device "${expoDeviceArg}" --port ${port}${tailStr}`;
|
|
124
128
|
}
|
|
125
129
|
return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}${tailStr}`;
|
|
126
130
|
}
|
package/src/sim/android.js
CHANGED
|
@@ -10,15 +10,28 @@ export function parseAvdList(text) {
|
|
|
10
10
|
export function parseAdbDevices(text) {
|
|
11
11
|
const lines = text.split('\n').slice(1); // skip "List of devices attached"
|
|
12
12
|
const emulators = [];
|
|
13
|
+
const physical = []; // USB or adb-over-TCP serials (e.g. R5CR70XXX, 192.168.1.5:5555)
|
|
14
|
+
const unhealthy = []; // serials adb sees but can't talk to (unauthorized/offline)
|
|
13
15
|
for (const line of lines) {
|
|
14
16
|
const trimmed = line.trim();
|
|
15
17
|
if (!trimmed) continue;
|
|
16
18
|
const [serial, status] = trimmed.split(/\s+/);
|
|
17
|
-
if (status !== 'device') continue;
|
|
18
19
|
const m = serial.match(/^emulator-(\d+)$/);
|
|
19
|
-
if (m)
|
|
20
|
+
if (m) {
|
|
21
|
+
if (status === 'device') {
|
|
22
|
+
emulators.push({ serial, consolePort: parseInt(m[1], 10) });
|
|
23
|
+
} else {
|
|
24
|
+
unhealthy.push({ serial, kind: 'emulator', consolePort: parseInt(m[1], 10), status });
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
if (status === 'device') {
|
|
28
|
+
physical.push({ serial });
|
|
29
|
+
} else {
|
|
30
|
+
unhealthy.push({ serial, kind: 'physical', status });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
20
33
|
}
|
|
21
|
-
return { emulators };
|
|
34
|
+
return { emulators, physical, unhealthy };
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
export function listAvds() {
|
|
@@ -35,37 +48,66 @@ export function nextConsolePort(claimedPorts) {
|
|
|
35
48
|
return max + 2; // emulator console ports are even
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
// Build the full candidate list: every AVD on disk
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
51
|
+
// Build the full candidate list: every AVD on disk plus every physical
|
|
52
|
+
// device adb currently sees. AVDs are paired with whether they have a
|
|
53
|
+
// running emulator (and on which console port); physical devices are
|
|
54
|
+
// always treated as running. Returns [] when neither exist.
|
|
42
55
|
export function enumerateAndroidCandidates() {
|
|
43
56
|
const avds = listAvds();
|
|
44
|
-
if (avds.length === 0) return [];
|
|
45
|
-
|
|
46
57
|
const adbDevices = listAdbDevices();
|
|
58
|
+
|
|
47
59
|
const runningByAvd = {};
|
|
48
60
|
for (const e of adbDevices.emulators) {
|
|
49
61
|
const avdName = getAvdNameForSerial(e.serial);
|
|
50
62
|
if (avdName) runningByAvd[avdName] = e.consolePort;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
const avdCandidates = avds.map(avdName => ({
|
|
66
|
+
kind: 'avd',
|
|
54
67
|
avdName,
|
|
55
68
|
isRunning: avdName in runningByAvd,
|
|
56
69
|
consolePort: runningByAvd[avdName] ?? null,
|
|
57
70
|
}));
|
|
71
|
+
|
|
72
|
+
const physicalCandidates = adbDevices.physical.map(p => ({
|
|
73
|
+
kind: 'physical',
|
|
74
|
+
serial: p.serial,
|
|
75
|
+
isRunning: true,
|
|
76
|
+
consolePort: null,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return [...avdCandidates, ...physicalCandidates];
|
|
58
80
|
}
|
|
59
81
|
|
|
60
|
-
export function selectAndroidDevice({
|
|
82
|
+
export function selectAndroidDevice({
|
|
83
|
+
existingAvd,
|
|
84
|
+
existingSerial,
|
|
85
|
+
existingConsolePort,
|
|
86
|
+
claimedAvds,
|
|
87
|
+
claimedSerials,
|
|
88
|
+
claimedConsolePorts,
|
|
89
|
+
}) {
|
|
61
90
|
const all = enumerateAndroidCandidates();
|
|
62
91
|
if (all.length === 0) return { kind: 'noAvd' };
|
|
63
92
|
|
|
93
|
+
if (existingSerial) {
|
|
94
|
+
const found = all.find(c => c.kind === 'physical' && c.serial === existingSerial);
|
|
95
|
+
if (found) {
|
|
96
|
+
return {
|
|
97
|
+
kind: 'reuse',
|
|
98
|
+
deviceKind: 'physical',
|
|
99
|
+
serial: existingSerial,
|
|
100
|
+
isRunning: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
if (existingAvd) {
|
|
65
|
-
const found = all.find(c => c.avdName === existingAvd);
|
|
106
|
+
const found = all.find(c => c.kind === 'avd' && c.avdName === existingAvd);
|
|
66
107
|
if (found) {
|
|
67
108
|
return {
|
|
68
109
|
kind: 'reuse',
|
|
110
|
+
deviceKind: 'avd',
|
|
69
111
|
avdName: existingAvd,
|
|
70
112
|
consolePort: found.consolePort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
|
|
71
113
|
isRunning: found.isRunning,
|
|
@@ -74,7 +116,10 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
|
|
|
74
116
|
}
|
|
75
117
|
|
|
76
118
|
const claimedAvdSet = new Set(claimedAvds);
|
|
77
|
-
const
|
|
119
|
+
const claimedSerialSet = new Set(claimedSerials || []);
|
|
120
|
+
const unclaimed = all.filter(c => c.kind === 'avd'
|
|
121
|
+
? !claimedAvdSet.has(c.avdName)
|
|
122
|
+
: !claimedSerialSet.has(c.serial));
|
|
78
123
|
|
|
79
124
|
if (unclaimed.length === 0) {
|
|
80
125
|
return { kind: 'allClaimed', candidates: sortAndroidCandidates(all) };
|
|
@@ -85,7 +130,11 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
|
|
|
85
130
|
export function sortAndroidCandidates(list) {
|
|
86
131
|
return [...list].sort((a, b) => {
|
|
87
132
|
if (a.isRunning !== b.isRunning) return a.isRunning ? -1 : 1;
|
|
88
|
-
|
|
133
|
+
// Mixed lists: physical devices float above AVDs within the same running state.
|
|
134
|
+
if (a.kind !== b.kind) return a.kind === 'physical' ? -1 : 1;
|
|
135
|
+
const an = a.kind === 'physical' ? a.serial : a.avdName;
|
|
136
|
+
const bn = b.kind === 'physical' ? b.serial : b.avdName;
|
|
137
|
+
return an.localeCompare(bn);
|
|
89
138
|
});
|
|
90
139
|
}
|
|
91
140
|
|
|
@@ -101,11 +150,26 @@ export async function waitForBoot(serial, timeoutMs = 60000) {
|
|
|
101
150
|
const exec = getExecutor();
|
|
102
151
|
const start = Date.now();
|
|
103
152
|
while (Date.now() - start < timeoutMs) {
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
// sys.boot_completed is the canonical "system fully up" signal; some
|
|
154
|
+
// older AVD images set dev.bootcomplete sooner. Either is fine.
|
|
155
|
+
const sysBoot = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim();
|
|
156
|
+
if (sysBoot === '1') return { ok: true };
|
|
157
|
+
const devBoot = exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim();
|
|
158
|
+
if (devBoot === '1') return { ok: true };
|
|
106
159
|
await new Promise(r => setTimeout(r, 1000));
|
|
107
160
|
}
|
|
108
|
-
|
|
161
|
+
// Diagnostic snapshot for the timeout error: shows the user exactly
|
|
162
|
+
// what adb sees and why the polling never resolved.
|
|
163
|
+
const devices = exec.runQuiet('adb devices').trim();
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
diagnostic: {
|
|
167
|
+
devices,
|
|
168
|
+
sysBoot: exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim(),
|
|
169
|
+
devBoot: exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim(),
|
|
170
|
+
bootAnim: exec.runQuiet(`adb -s ${serial} shell getprop init.svc.bootanim`).trim(),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
109
173
|
}
|
|
110
174
|
|
|
111
175
|
export function shutdownAndroidEmulator(serial) {
|