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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/CLAUDE.md +178 -0
  3. package/README.md +90 -0
  4. package/bin/cli.js +35 -0
  5. package/docs/plans/2026-04-25-rn-iso-implementation.md +2653 -0
  6. package/docs/specs/2026-04-25-rn-iso-design.md +282 -0
  7. package/package.json +20 -0
  8. package/skill/SKILL.md +112 -0
  9. package/src/commands/android.js +112 -0
  10. package/src/commands/device.js +43 -0
  11. package/src/commands/ios.js +210 -0
  12. package/src/commands/logs.js +28 -0
  13. package/src/commands/prune.js +57 -0
  14. package/src/commands/release.js +51 -0
  15. package/src/commands/reserve.js +176 -0
  16. package/src/commands/shutdown.js +41 -0
  17. package/src/commands/start.js +43 -0
  18. package/src/commands/status.js +60 -0
  19. package/src/commands/stop.js +51 -0
  20. package/src/commands/unreserve.js +57 -0
  21. package/src/config.js +221 -0
  22. package/src/exec.js +31 -0
  23. package/src/metro.js +73 -0
  24. package/src/ports.js +50 -0
  25. package/src/project.js +186 -0
  26. package/src/runner.js +136 -0
  27. package/src/sim/android.js +103 -0
  28. package/src/sim/ios.js +128 -0
  29. package/test/config.test.js +208 -0
  30. package/test/exec.test.js +26 -0
  31. package/test/fixtures/sample-bare-project/android/app/build.gradle +6 -0
  32. package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +10 -0
  33. package/test/fixtures/sample-bare-project/package.json +4 -0
  34. package/test/fixtures/sample-expo-project/app.json +6 -0
  35. package/test/fixtures/sample-expo-project/package.json +4 -0
  36. package/test/fixtures/sample-expo-project/src/.keep +0 -0
  37. package/test/metro.test.js +34 -0
  38. package/test/ports.test.js +76 -0
  39. package/test/project.test.js +109 -0
  40. package/test/runner.test.js +209 -0
  41. package/test/sim-android.test.js +140 -0
  42. package/test/sim-ios.test.js +168 -0
@@ -0,0 +1,51 @@
1
+ // src/commands/stop.js
2
+ import chalk from 'chalk';
3
+ import { findProjectRoot } from '../project.js';
4
+ import { getProject, setMetro } from '../config.js';
5
+ import { killMetroByPid } from '../metro.js';
6
+ import { getExecutor } from '../exec.js';
7
+
8
+ export default function stopCommand(program) {
9
+ program
10
+ .command('stop')
11
+ .description('Kill the Metro process for the current project')
12
+ .action(() => {
13
+ const root = findProjectRoot(process.cwd());
14
+ if (!root) {
15
+ console.error(chalk.red('Not in a React Native project.'));
16
+ process.exit(1);
17
+ }
18
+ const proj = getProject(root);
19
+ if (!proj?.metroPort) {
20
+ console.log(chalk.dim('No Metro port assigned to this project.'));
21
+ return;
22
+ }
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}`));
43
+ });
44
+ }
45
+
46
+ function findPidListeningOnPort(port) {
47
+ const out = getExecutor().runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
48
+ if (!out) return null;
49
+ const pid = parseInt(out.split('\n')[0], 10);
50
+ return Number.isFinite(pid) ? pid : null;
51
+ }
@@ -0,0 +1,57 @@
1
+ // src/commands/unreserve.js
2
+ import chalk from 'chalk';
3
+ import { findReservations, removeReservation, clearAllReservations } from '../config.js';
4
+
5
+ export default function unreserveCommand(program) {
6
+ program
7
+ .command('unreserve [arg1] [arg2]')
8
+ .description('Release a reservation by UDID/serial or by the --label set when reserving')
9
+ .option('--all', 'Remove all reservations')
10
+ .option('--platform <platform>', 'Restrict to ios or android')
11
+ .action((arg1, arg2, opts) => {
12
+ if (opts.all) {
13
+ clearAllReservations();
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;
31
+ }
32
+
33
+ if (!target) {
34
+ console.error(chalk.red('Usage: rn-iso unreserve <UDID|emulator-PORT|label>'));
35
+ console.error(chalk.dim(' rn-iso unreserve <ios|android> <UDID|emulator-PORT|label>'));
36
+ console.error(chalk.dim(' rn-iso unreserve --all'));
37
+ process.exit(1);
38
+ }
39
+
40
+ const matches = findReservations(target, platform);
41
+ if (matches.length === 0) {
42
+ console.log(chalk.dim(
43
+ `No reservation matches "${target}"` +
44
+ (platform ? ` on ${platform}` : '') +
45
+ '.'
46
+ ));
47
+ return;
48
+ }
49
+ for (const m of matches) {
50
+ removeReservation(m.platform, m.id);
51
+ console.log(chalk.green(
52
+ `Released ${m.platform} reservation ${m.id}` +
53
+ (m.label ? ` (${m.label})` : '')
54
+ ));
55
+ }
56
+ });
57
+ }
package/src/config.js ADDED
@@ -0,0 +1,221 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export function getConfigDir() {
6
+ return process.env.RN_ISO_HOME || join(homedir(), '.rn-iso');
7
+ }
8
+
9
+ function getConfigPath() {
10
+ return join(getConfigDir(), 'config.json');
11
+ }
12
+
13
+ function ensureDir() {
14
+ const dir = getConfigDir();
15
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
16
+ }
17
+
18
+ export function loadConfig() {
19
+ const p = getConfigPath();
20
+ if (!existsSync(p)) return null;
21
+ return JSON.parse(readFileSync(p, 'utf-8'));
22
+ }
23
+
24
+ export function saveConfig(config) {
25
+ ensureDir();
26
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
27
+ }
28
+
29
+ export function ensureConfig() {
30
+ const existing = loadConfig();
31
+ if (existing) {
32
+ if (!existing.reservations) {
33
+ existing.reservations = { ios: [], android: [] };
34
+ saveConfig(existing);
35
+ }
36
+ return existing;
37
+ }
38
+ const fresh = { version: 1, projects: {}, reservations: { ios: [], android: [] } };
39
+ saveConfig(fresh);
40
+ return fresh;
41
+ }
42
+
43
+ export function getProject(projectPath) {
44
+ const cfg = loadConfig();
45
+ return cfg?.projects?.[projectPath] || null;
46
+ }
47
+
48
+ export function upsertProject(projectPath, fields) {
49
+ const cfg = ensureConfig();
50
+ const existing = cfg.projects[projectPath] || {
51
+ metroPort: null,
52
+ metroPid: null,
53
+ platforms: {},
54
+ };
55
+ cfg.projects[projectPath] = {
56
+ ...existing,
57
+ ...fields,
58
+ };
59
+ saveConfig(cfg);
60
+ return cfg.projects[projectPath];
61
+ }
62
+
63
+ export function removeProject(projectPath) {
64
+ const cfg = loadConfig();
65
+ if (!cfg?.projects?.[projectPath]) return;
66
+ delete cfg.projects[projectPath];
67
+ saveConfig(cfg);
68
+ }
69
+
70
+ export function setMetro(projectPath, metroPort, metroPid) {
71
+ const cfg = ensureConfig();
72
+ if (!cfg.projects[projectPath]) {
73
+ throw new Error(`Project not registered: ${projectPath}`);
74
+ }
75
+ cfg.projects[projectPath].metroPort = metroPort;
76
+ cfg.projects[projectPath].metroPid = metroPid;
77
+ saveConfig(cfg);
78
+ }
79
+
80
+ export function setDevice(projectPath, platform, deviceFields) {
81
+ const cfg = ensureConfig();
82
+ if (!cfg.projects[projectPath]) {
83
+ throw new Error(`Project not registered: ${projectPath}`);
84
+ }
85
+ cfg.projects[projectPath].platforms = cfg.projects[projectPath].platforms || {};
86
+ cfg.projects[projectPath].platforms[platform] = deviceFields;
87
+ saveConfig(cfg);
88
+ }
89
+
90
+ export function clearDevice(projectPath, platform) {
91
+ const cfg = loadConfig();
92
+ if (!cfg?.projects?.[projectPath]?.platforms) return;
93
+ delete cfg.projects[projectPath].platforms[platform];
94
+ saveConfig(cfg);
95
+ }
96
+
97
+ export function allMetroPorts() {
98
+ const cfg = loadConfig();
99
+ if (!cfg?.projects) return [];
100
+ return Object.values(cfg.projects)
101
+ .map(p => p.metroPort)
102
+ .filter(p => typeof p === 'number');
103
+ }
104
+
105
+ export function allClaimedDevices() {
106
+ const cfg = loadConfig();
107
+ const result = {
108
+ iosUdids: [],
109
+ androidAvds: [],
110
+ androidConsolePorts: [],
111
+ // iosClaims: udid -> { source: 'project'|'reservation', label: string }
112
+ iosClaims: {},
113
+ };
114
+ if (!cfg) return result;
115
+ for (const [path, proj] of Object.entries(cfg.projects || {})) {
116
+ const ios = proj.platforms?.ios;
117
+ if (ios?.deviceUdid) {
118
+ result.iosUdids.push(ios.deviceUdid);
119
+ result.iosClaims[ios.deviceUdid] = {
120
+ source: 'project',
121
+ label: path.split('/').pop() || path,
122
+ };
123
+ }
124
+ const android = proj.platforms?.android;
125
+ if (android?.avdName) result.androidAvds.push(android.avdName);
126
+ if (typeof android?.consolePort === 'number') result.androidConsolePorts.push(android.consolePort);
127
+ }
128
+ for (const r of cfg.reservations?.ios || []) {
129
+ if (r.udid) {
130
+ result.iosUdids.push(r.udid);
131
+ result.iosClaims[r.udid] = {
132
+ source: 'reservation',
133
+ label: r.label || 'reserved',
134
+ };
135
+ }
136
+ }
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
+ return result;
142
+ }
143
+
144
+ export function recordSimUsage(platform, identifier) {
145
+ if (platform !== 'ios' && platform !== 'android') return;
146
+ const cfg = ensureConfig();
147
+ cfg.simUsage = cfg.simUsage || { ios: {}, android: {} };
148
+ cfg.simUsage[platform][identifier] = (cfg.simUsage[platform][identifier] || 0) + 1;
149
+ saveConfig(cfg);
150
+ }
151
+
152
+ export function getSimUsage() {
153
+ const cfg = loadConfig();
154
+ return cfg?.simUsage || { ios: {}, android: {} };
155
+ }
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/exec.js ADDED
@@ -0,0 +1,31 @@
1
+ import { execSync, spawn } from 'child_process';
2
+
3
+ const defaultExecutor = {
4
+ run(cmd) {
5
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
6
+ },
7
+ runQuiet(cmd) {
8
+ try {
9
+ return this.run(cmd);
10
+ } catch {
11
+ return null;
12
+ }
13
+ },
14
+ spawn(cmd, args, opts) {
15
+ return spawn(cmd, args, opts);
16
+ },
17
+ };
18
+
19
+ let active = defaultExecutor;
20
+
21
+ export function setExecutor(e) {
22
+ active = e;
23
+ }
24
+
25
+ export function resetExecutor() {
26
+ active = defaultExecutor;
27
+ }
28
+
29
+ export function getExecutor() {
30
+ return active;
31
+ }
package/src/metro.js ADDED
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdirSync, existsSync, openSync, statSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getExecutor } from './exec.js';
5
+ import { getConfigDir } from './config.js';
6
+ import { isMetroRunning } from './ports.js';
7
+
8
+ export function projectHash(projectPath) {
9
+ return createHash('sha256').update(projectPath).digest('hex').slice(0, 12);
10
+ }
11
+
12
+ export function logFileFor(projectPath) {
13
+ const dir = join(getConfigDir(), 'logs');
14
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
15
+ return join(dir, `${projectHash(projectPath)}.log`);
16
+ }
17
+
18
+ export function buildMetroSpawnArgs({ isExpo, port }) {
19
+ return {
20
+ cmd: 'npx',
21
+ args: isExpo
22
+ ? ['expo', 'start', '--port', String(port)]
23
+ : ['react-native', 'start', '--port', String(port)],
24
+ };
25
+ }
26
+
27
+ export async function ensureMetro({ projectPath, isExpo, port, detach = true }) {
28
+ if (await isMetroRunning(port)) return { alreadyRunning: true, pid: null };
29
+
30
+ const log = logFileFor(projectPath);
31
+ const fd = openSync(log, 'a');
32
+
33
+ const { cmd, args } = buildMetroSpawnArgs({ isExpo, port });
34
+ const exec = getExecutor();
35
+ const child = exec.spawn(cmd, args, {
36
+ cwd: projectPath,
37
+ detached: detach,
38
+ stdio: ['ignore', fd, fd],
39
+ env: { ...process.env, RCT_METRO_PORT: String(port) },
40
+ });
41
+ if (detach) child.unref();
42
+ return { alreadyRunning: false, pid: child.pid };
43
+ }
44
+
45
+ export function killMetroByPid(pid) {
46
+ if (!pid) return false;
47
+ try {
48
+ process.kill(pid, 'SIGTERM');
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export function isPidAlive(pid) {
56
+ if (!pid) return false;
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export function logFileExists(projectPath) {
66
+ const path = logFileFor(projectPath);
67
+ try {
68
+ statSync(path);
69
+ return path;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
package/src/ports.js ADDED
@@ -0,0 +1,50 @@
1
+ import { request } from 'http';
2
+ import { loadConfig, allMetroPorts, removeProject } from './config.js';
3
+
4
+ export function isMetroRunning(port) {
5
+ return new Promise((resolve) => {
6
+ const req = request(
7
+ { hostname: 'localhost', port, path: '/status', timeout: 2000 },
8
+ (res) => {
9
+ let data = '';
10
+ res.on('data', (chunk) => { data += chunk; });
11
+ res.on('end', () => resolve(data.includes('packager-status:running')));
12
+ }
13
+ );
14
+ req.on('error', () => resolve(false));
15
+ req.on('timeout', () => { req.destroy(); resolve(false); });
16
+ req.end();
17
+ });
18
+ }
19
+
20
+ export function computeNextPort() {
21
+ const ports = allMetroPorts();
22
+ if (ports.length === 0) return 8082;
23
+ return Math.max(...ports, 8081) + 1;
24
+ }
25
+
26
+ export async function findReclaimablePort(excludeProjectPath, probe = isMetroRunning) {
27
+ const cfg = loadConfig();
28
+ if (!cfg?.projects) return null;
29
+ const candidates = [];
30
+ for (const [path, proj] of Object.entries(cfg.projects)) {
31
+ if (path === excludeProjectPath) continue;
32
+ if (typeof proj.metroPort === 'number') {
33
+ candidates.push({ port: proj.metroPort, ownerPath: path });
34
+ }
35
+ }
36
+ for (const c of candidates) {
37
+ const alive = await probe(c.port);
38
+ if (!alive) return c;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export async function allocatePort(projectPath, probe = isMetroRunning) {
44
+ const reclaim = await findReclaimablePort(projectPath, probe);
45
+ if (reclaim) {
46
+ removeProject(reclaim.ownerPath);
47
+ return reclaim.port;
48
+ }
49
+ return computeNextPort();
50
+ }
package/src/project.js ADDED
@@ -0,0 +1,186 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
2
+ import { join, dirname, resolve } from 'path';
3
+ import { loadConfig } from './config.js';
4
+
5
+ // Resolve a project from one of two forms:
6
+ // - undefined / null -> walk up from cwd (current behavior)
7
+ // - absolute or relative path that matches a registered project key
8
+ //
9
+ // Basename / fuzzy matching is intentionally NOT supported -- collisions
10
+ // across worktrees with the same dir name make it ambiguous. Use full paths
11
+ // or the reservation label (`--label` on `rn-iso reserve`) instead.
12
+ //
13
+ // Returns { found, error }.
14
+ export function resolveRegisteredProject(arg) {
15
+ const cfg = loadConfig();
16
+ const projects = cfg?.projects || {};
17
+
18
+ if (!arg) {
19
+ const root = findProjectRoot(process.cwd());
20
+ if (!root) return { found: null, error: 'Not in a React Native project (no package.json found).' };
21
+ if (!projects[root]) return { found: null, error: `No rn-iso entry for ${root}. Run \`rn-iso ios\` or \`rn-iso android\` first.` };
22
+ return { found: root };
23
+ }
24
+
25
+ // Exact path match. realpath() canonicalizes symlinks the same way
26
+ // findProjectRoot does, so the keys line up.
27
+ let abs;
28
+ try { abs = realpathSync(resolve(arg)); } catch { abs = resolve(arg); }
29
+ if (projects[abs]) return { found: abs };
30
+ if (projects[arg]) return { found: arg };
31
+
32
+ return {
33
+ found: null,
34
+ error: `No registered project at "${arg}". Pass an absolute path; see \`rn-iso status\` for the list.`,
35
+ };
36
+ }
37
+
38
+ export function findProjectRoot(startDir) {
39
+ let dir;
40
+ try {
41
+ dir = realpathSync(resolve(startDir));
42
+ } catch {
43
+ dir = resolve(startDir);
44
+ }
45
+ while (true) {
46
+ if (existsSync(join(dir, 'package.json'))) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) return null;
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ function readPackageJson(projectRoot) {
54
+ const p = join(projectRoot, 'package.json');
55
+ if (!existsSync(p)) return null;
56
+ try {
57
+ return JSON.parse(readFileSync(p, 'utf-8'));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function detectIsExpo(projectRoot) {
64
+ // The `expo` package can be in deps for prebuild / config / modules without
65
+ // the project actually using `expo run:ios`. The strongest signal is what the
66
+ // project's `ios` script invokes -- check that first.
67
+ const iosScript = readPackageJson(projectRoot)?.scripts?.ios;
68
+ if (typeof iosScript === 'string') {
69
+ if (/\bexpo\s+run:ios\b/.test(iosScript)) return true;
70
+ if (/\breact-native\s+run-ios\b/.test(iosScript)) return false;
71
+ }
72
+ // No conclusive script. Require `expo` in deps AND an expo config block to
73
+ // be reasonably sure expo run:ios is appropriate as a fallback.
74
+ const pkg = readPackageJson(projectRoot);
75
+ if (!pkg) return false;
76
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
77
+ if (!('expo' in deps)) return false;
78
+ const appJson = readAppJson(projectRoot);
79
+ if (appJson?.expo) return true;
80
+ const text = readAppConfigText(projectRoot);
81
+ if (text && /\b(?:from\s+['"]expo['"]|expo\/config|ExpoConfig)\b/.test(text)) return true;
82
+ return false;
83
+ }
84
+
85
+ function readAppJson(projectRoot) {
86
+ const p = join(projectRoot, 'app.json');
87
+ if (!existsSync(p)) return null;
88
+ try {
89
+ return JSON.parse(readFileSync(p, 'utf-8'));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function readAppConfigText(projectRoot) {
96
+ for (const name of ['app.config.js', 'app.config.ts', 'app.config.cjs', 'app.config.mjs']) {
97
+ const p = join(projectRoot, name);
98
+ if (existsSync(p)) {
99
+ try {
100
+ return readFileSync(p, 'utf-8');
101
+ } catch { /* ignore */ }
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ export function detectBundleId(projectRoot) {
108
+ const appJson = readAppJson(projectRoot);
109
+ const fromJson = appJson?.expo?.ios?.bundleIdentifier;
110
+ if (fromJson) return fromJson;
111
+
112
+ const text = readAppConfigText(projectRoot);
113
+ if (text) {
114
+ const m = text.match(/bundleIdentifier\s*:\s*["']([^"']+)["']/);
115
+ if (m) return m[1];
116
+ }
117
+
118
+ // Fallback: hybrid Expo / bare RN projects keep the bundle ID in the Xcode
119
+ // project file rather than app config. Pick the most common concrete value
120
+ // (the main app target appears in multiple build configs; extensions show up
121
+ // less often) and tie-break by shortest length (extensions usually have a
122
+ // suffix on the main app's id).
123
+ return detectBundleIdFromPbxproj(projectRoot);
124
+ }
125
+
126
+ export function detectAndroidPackage(projectRoot) {
127
+ const appJson = readAppJson(projectRoot);
128
+ const fromJson = appJson?.expo?.android?.package;
129
+ if (fromJson) return fromJson;
130
+
131
+ const text = readAppConfigText(projectRoot);
132
+ if (text) {
133
+ const m = text.match(/package\s*:\s*["']([^"']+)["']/);
134
+ if (m) return m[1];
135
+ }
136
+
137
+ // Fallback: bare RN keeps the package in android/app/build.gradle.
138
+ return detectAndroidPackageFromGradle(projectRoot);
139
+ }
140
+
141
+ export function detectBundleIdFromPbxproj(projectRoot) {
142
+ const iosDir = join(projectRoot, 'ios');
143
+ if (!existsSync(iosDir)) return null;
144
+ let entries;
145
+ try {
146
+ entries = readdirSync(iosDir, { withFileTypes: true });
147
+ } catch {
148
+ return null;
149
+ }
150
+ for (const entry of entries) {
151
+ if (!entry.isDirectory() || !entry.name.endsWith('.xcodeproj')) continue;
152
+ const pbx = join(iosDir, entry.name, 'project.pbxproj');
153
+ if (!existsSync(pbx)) continue;
154
+ let text;
155
+ try { text = readFileSync(pbx, 'utf-8'); } catch { continue; }
156
+ const all = [...text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;\s"]+)\s*;/g)].map(m => m[1]);
157
+ const concrete = all.filter(id => id && !id.startsWith('$') && !id.includes('('));
158
+ if (concrete.length === 0) continue;
159
+ const counts = {};
160
+ for (const id of concrete) counts[id] = (counts[id] || 0) + 1;
161
+ let best = null, bestCount = 0, bestLen = Infinity;
162
+ for (const [id, count] of Object.entries(counts)) {
163
+ if (count > bestCount || (count === bestCount && id.length < bestLen)) {
164
+ best = id;
165
+ bestCount = count;
166
+ bestLen = id.length;
167
+ }
168
+ }
169
+ return best;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ export function detectAndroidPackageFromGradle(projectRoot) {
175
+ const gradle = join(projectRoot, 'android', 'app', 'build.gradle');
176
+ if (!existsSync(gradle)) return null;
177
+ let text;
178
+ try { text = readFileSync(gradle, 'utf-8'); } catch { return null; }
179
+ // Try `namespace "com.foo"` (modern AGP) first, then fall back to
180
+ // `applicationId "com.foo"`.
181
+ const ns = text.match(/namespace\s+["']([^"']+)["']/);
182
+ if (ns) return ns[1];
183
+ const app = text.match(/applicationId\s+["']([^"']+)["']/);
184
+ if (app) return app[1];
185
+ return null;
186
+ }