react-native-worktree
Advanced tools
+1
-3
| #!/usr/bin/env node | ||
| import { Command } from 'commander'; | ||
| import initCommand from '../src/commands/init.js'; | ||
| import addCommand from '../src/commands/add.js'; | ||
@@ -16,5 +15,4 @@ import switchCommand from '../src/commands/switch.js'; | ||
| .description('Metro port switcher with mutex for multi-agent RN development') | ||
| .version('1.0.0'); | ||
| .version('1.1.0'); | ||
| initCommand(program); | ||
| addCommand(program); | ||
@@ -21,0 +19,0 @@ switchCommand(program); |
+1
-1
| { | ||
| "name": "react-native-worktree", | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "description": "Metro port switcher with mutex for multi-agent React Native development", | ||
@@ -5,0 +5,0 @@ "bin": { |
+8
-18
@@ -38,7 +38,6 @@ <img width="1440" height="505" alt="Header" src="https://github.com/user-attachments/assets/c77276e5-fef4-46a7-9704-281bc9826cb4" /> | ||
| The agent will (guided by the skill): | ||
| 1. Initialize the tool if needed (auto-detects bundle ID and platform) | ||
| 2. Create a git worktree and register it with an auto-assigned port | ||
| 3. Install dependencies and start Metro on that port | ||
| 4. Call `react-native-worktree switch --platform ios` to acquire the device and preview | ||
| 5. Heartbeat while you test, then release when done | ||
| 1. Create a git worktree and register it with `add` (auto-detects bundle ID and platform on first run) | ||
| 2. Install dependencies and start Metro on the assigned port | ||
| 3. Call `react-native-worktree switch --platform ios` to acquire the device and preview | ||
| 4. Heartbeat while you test, then release when done | ||
@@ -65,6 +64,7 @@ Meanwhile, another agent in a separate session: | ||
| **Android Emulator** — remaps the default Metro port via `adb reverse`, then force-stops and relaunches: | ||
| **Android Emulator** — writes `debug_http_host` to the app's default SharedPreferences, sets up `adb reverse` for the actual port, then force-stops and relaunches: | ||
| ``` | ||
| adb reverse tcp:8081 tcp:<port> | ||
| echo '...localhost:<port>...' | adb shell run-as <packageName> sh -c 'cat > .../<packageName>_preferences.xml' | ||
| adb reverse tcp:<port> tcp:<port> | ||
| adb shell am force-stop <packageName> | ||
@@ -91,15 +91,5 @@ adb shell monkey -p <packageName> -c android.intent.category.LAUNCHER 1 | ||
| ### `react-native-worktree init` | ||
| Initialize configuration. Auto-detects bundle ID from `app.json` / `app.config.js`. | ||
| ```bash | ||
| react-native-worktree init # auto-detect, ios only | ||
| react-native-worktree init --bundle-id com.myapp # manual bundle ID | ||
| react-native-worktree init --platforms ios,android # both platforms | ||
| ``` | ||
| ### `react-native-worktree add <name>` | ||
| Register a worktree. Port auto-assigned (reuses dead ports, or increments from max). | ||
| Register a worktree. On first run, auto-detects bundle ID and platforms from `app.json` / `app.config.js` and creates the config. Port auto-assigned (reuses dead ports, or increments from max). | ||
@@ -106,0 +96,0 @@ ```bash |
+29
-18
@@ -1,2 +0,2 @@ | ||
| import { addWorktree, loadConfig, resolveApp, getAllPorts, computeNextPort } from '../config.js'; | ||
| import { addWorktree, ensureConfig, ensureApp, saveConfig, getAllPorts, computeNextPort } from '../config.js'; | ||
| import { isMetroRunning } from '../switcher.js'; | ||
@@ -6,13 +6,16 @@ import chalk from 'chalk'; | ||
| async function findReusablePort(config) { | ||
| const ports = getAllPorts(config); | ||
| if (ports.length === 0) return null; | ||
| // Build port→worktree name map for logging | ||
| async function findReusablePort(config, excludeName) { | ||
| const ports = []; | ||
| const portOwners = {}; | ||
| for (const [appId, app] of Object.entries(config.apps || {})) { | ||
| for (const [name, wt] of Object.entries(app.worktrees || {})) { | ||
| portOwners[wt.port] = { name, app: appId }; | ||
| // Skip the worktree being (re-)added — don't reclaim our own port | ||
| if (name === excludeName) continue; | ||
| if (wt.port) { | ||
| ports.push(wt.port); | ||
| portOwners[wt.port] = { name, app: appId }; | ||
| } | ||
| } | ||
| } | ||
| if (ports.length === 0) return null; | ||
@@ -33,2 +36,10 @@ // Probe each port | ||
| function removeWorktreeEntry(config, appId, name) { | ||
| const app = config.apps[appId]; | ||
| if (app?.worktrees?.[name]) { | ||
| delete app.worktrees[name]; | ||
| saveConfig(config); | ||
| } | ||
| } | ||
| export default function addCommand(program) { | ||
@@ -42,14 +53,12 @@ program | ||
| .action(async (name, opts) => { | ||
| const config = loadConfig(); | ||
| if (!config) { | ||
| console.error(chalk.red('Not initialized. Run `react-native-worktree init` first.')); | ||
| process.exit(1); | ||
| } | ||
| const config = ensureConfig(); | ||
| const bundleId = resolveApp(config, opts.app); | ||
| const { config: updatedConfig, bundleId } = ensureApp(config, opts.app); | ||
| if (!bundleId) { | ||
| if (opts.app) { | ||
| console.error(chalk.red(`App '${opts.app}' not found in config.`)); | ||
| } else if (Object.keys(updatedConfig.apps).length > 1) { | ||
| console.error(chalk.red('Multiple apps configured. Use --app <bundleId> to specify which one.')); | ||
| } else { | ||
| console.error(chalk.red('Multiple apps configured. Use --app <bundleId> to specify which one.')); | ||
| console.error(chalk.red('Could not auto-detect bundle ID. Use --app <bundleId> or run from a directory with app.json.')); | ||
| } | ||
@@ -62,9 +71,11 @@ process.exit(1); | ||
| if (!port) { | ||
| // Try port reclamation | ||
| const dead = await findReusablePort(config); | ||
| // Try port reclamation (excludes the worktree being added to avoid self-reclaim) | ||
| const dead = await findReusablePort(updatedConfig, name); | ||
| if (dead) { | ||
| port = dead.port; | ||
| console.log(chalk.dim(`Reusing port ${port} (Metro stopped for '${dead.owner.name}')`)); | ||
| // Remove the old worktree entry that owned this port | ||
| removeWorktreeEntry(updatedConfig, dead.owner.app, dead.owner.name); | ||
| console.log(chalk.dim(`Reusing port ${port} (removed stale worktree '${dead.owner.name}')`)); | ||
| } else { | ||
| port = computeNextPort(config); | ||
| port = computeNextPort(updatedConfig); | ||
| } | ||
@@ -71,0 +82,0 @@ } |
@@ -1,2 +0,2 @@ | ||
| import { loadConfig } from '../config.js'; | ||
| import { ensureConfig } from '../config.js'; | ||
| import { getStatus } from '../lock.js'; | ||
@@ -12,6 +12,7 @@ import { isMetroRunning } from '../switcher.js'; | ||
| .action(async (opts) => { | ||
| const config = loadConfig(); | ||
| if (!config || !config.apps) { | ||
| console.error(chalk.red('Not initialized. Run `react-native-worktree init` first.')); | ||
| process.exit(1); | ||
| const config = ensureConfig(); | ||
| if (Object.keys(config.apps).length === 0) { | ||
| console.log(chalk.dim('No apps configured. Run `react-native-worktree add <name>` to get started.')); | ||
| return; | ||
| } | ||
@@ -18,0 +19,0 @@ |
@@ -1,2 +0,2 @@ | ||
| import { loadConfig, getWorktree, resolveApp, getApp } from '../config.js'; | ||
| import { ensureConfig, getWorktree, resolveApp, getApp, getPackageName } from '../config.js'; | ||
| import { waitForLock } from '../lock.js'; | ||
@@ -14,7 +14,3 @@ import { switchPort, isMetroRunning } from '../switcher.js'; | ||
| .action(async (name, opts) => { | ||
| const config = loadConfig(); | ||
| if (!config) { | ||
| console.error(chalk.red('Not initialized. Run `react-native-worktree init` first.')); | ||
| process.exit(1); | ||
| } | ||
| const config = ensureConfig(); | ||
@@ -25,2 +21,4 @@ const bundleId = resolveApp(config, opts.app); | ||
| console.error(chalk.red(`App '${opts.app}' not found in config.`)); | ||
| } else if (Object.keys(config.apps).length === 0) { | ||
| console.error(chalk.red('No apps configured. Run `react-native-worktree add <name>` first.')); | ||
| } else { | ||
@@ -65,4 +63,5 @@ console.error(chalk.red('Multiple apps configured. Use --app <bundleId> to specify which one.')); | ||
| // Switch port and relaunch app | ||
| const packageName = getPackageName(app, bundleId, platform); | ||
| try { | ||
| switchPort(bundleId, wt.port, platform); | ||
| switchPort(packageName, wt.port, platform); | ||
| console.log(chalk.green(`[${platform}] Switched to '${chalk.bold(name)}' (port ${wt.port}). App restarting...`)); | ||
@@ -69,0 +68,0 @@ } catch (err) { |
+113
-2
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { homedir } from 'os'; | ||
| import chalk from 'chalk'; | ||
@@ -54,2 +55,105 @@ export function getConfigDir() { | ||
| export function detectBundleId(platform) { | ||
| // Try app.json | ||
| const appJsonPath = join(process.cwd(), 'app.json'); | ||
| if (existsSync(appJsonPath)) { | ||
| try { | ||
| const appJson = JSON.parse(readFileSync(appJsonPath, 'utf-8')); | ||
| if (platform === 'ios') { | ||
| const id = appJson?.expo?.ios?.bundleIdentifier; | ||
| if (id) return id; | ||
| } else { | ||
| const id = appJson?.expo?.android?.package; | ||
| if (id) return id; | ||
| } | ||
| } catch { /* ignore */ } | ||
| } | ||
| // Try app.config.js (read as text and extract) | ||
| const appConfigPath = join(process.cwd(), 'app.config.js'); | ||
| if (existsSync(appConfigPath)) { | ||
| try { | ||
| const content = readFileSync(appConfigPath, 'utf-8'); | ||
| const key = platform === 'ios' ? 'bundleIdentifier' : 'package'; | ||
| const match = content.match(new RegExp(`${key}\\s*:\\s*["']([^"']+)["']`)); | ||
| if (match) return match[1]; | ||
| } catch { /* ignore */ } | ||
| } | ||
| // Try app.config.ts | ||
| const appConfigTsPath = join(process.cwd(), 'app.config.ts'); | ||
| if (existsSync(appConfigTsPath)) { | ||
| try { | ||
| const content = readFileSync(appConfigTsPath, 'utf-8'); | ||
| const key = platform === 'ios' ? 'bundleIdentifier' : 'package'; | ||
| const match = content.match(new RegExp(`${key}\\s*:\\s*["']([^"']+)["']`)); | ||
| if (match) return match[1]; | ||
| } catch { /* ignore */ } | ||
| } | ||
| return null; | ||
| } | ||
| export function ensureConfig() { | ||
| const existing = loadConfig(); | ||
| if (existing) return existing; | ||
| const config = { apps: {} }; | ||
| saveConfig(config); | ||
| return config; | ||
| } | ||
| export function ensureApp(config, bundleIdOpt) { | ||
| // If explicit bundleId provided, check if it exists | ||
| if (bundleIdOpt) { | ||
| if (config.apps[bundleIdOpt]) { | ||
| return { config, bundleId: bundleIdOpt }; | ||
| } | ||
| return { config, bundleId: null }; | ||
| } | ||
| // If apps already exist, resolve from them | ||
| const appIds = Object.keys(config.apps || {}); | ||
| if (appIds.length === 1) { | ||
| return { config, bundleId: appIds[0] }; | ||
| } | ||
| if (appIds.length > 1) { | ||
| // Try auto-detect from cwd app.json to match existing app | ||
| const iosBundleId = detectBundleId('ios'); | ||
| const androidPkg = detectBundleId('android'); | ||
| if (iosBundleId && config.apps[iosBundleId]) return { config, bundleId: iosBundleId }; | ||
| if (androidPkg && config.apps[androidPkg]) return { config, bundleId: androidPkg }; | ||
| return { config, bundleId: null }; | ||
| } | ||
| // No apps — auto-detect and create | ||
| const iosId = detectBundleId('ios'); | ||
| const androidId = detectBundleId('android'); | ||
| let bundleId = iosId || androidId; | ||
| if (!bundleId) return { config, bundleId: null }; | ||
| let platforms; | ||
| if (iosId && androidId) { | ||
| platforms = ['ios', 'android']; | ||
| } else if (iosId) { | ||
| platforms = ['ios']; | ||
| } else { | ||
| platforms = ['android']; | ||
| } | ||
| console.log(chalk.dim(`Auto-detected app: ${bundleId} (${platforms.join(', ')})`)); | ||
| const appEntry = { platforms, worktrees: {} }; | ||
| // Store androidPackage if it differs from the primary bundleId | ||
| if (androidId && androidId !== bundleId) { | ||
| appEntry.androidPackage = androidId; | ||
| console.log(chalk.dim(`Auto-detected Android package: ${androidId}`)); | ||
| } | ||
| config.apps[bundleId] = appEntry; | ||
| saveConfig(config); | ||
| return { config, bundleId }; | ||
| } | ||
| export function getApp(config, bundleId) { | ||
@@ -59,2 +163,9 @@ return config.apps?.[bundleId] || null; | ||
| export function getPackageName(app, bundleId, platform) { | ||
| if (platform === 'android' && app.androidPackage) { | ||
| return app.androidPackage; | ||
| } | ||
| return bundleId; | ||
| } | ||
| export function resolveApp(config, bundleId) { | ||
@@ -117,7 +228,7 @@ if (!config || !config.apps) { | ||
| if (!config || !config.apps) { | ||
| throw new Error('Not initialized. Run `react-native-worktree init` first.'); | ||
| throw new Error('No config found. Run `react-native-worktree add <name>` from your project directory.'); | ||
| } | ||
| const app = config.apps[bundleId]; | ||
| if (!app) { | ||
| throw new Error(`App '${bundleId}' not found. Run \`react-native-worktree init --bundle-id ${bundleId}\` first.`); | ||
| throw new Error(`App '${bundleId}' not found in config.`); | ||
| } | ||
@@ -124,0 +235,0 @@ if (!port) { |
+5
-1
@@ -33,3 +33,7 @@ import { execSync } from 'child_process'; | ||
| function switchAndroid(packageName, port) { | ||
| run(`adb reverse tcp:8081 tcp:${port}`); | ||
| // Write debug_http_host to default SharedPreferences (<package>_preferences.xml) | ||
| const prefsFile = `/data/data/${packageName}/shared_prefs/${packageName}_preferences.xml`; | ||
| const xml = `<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?><map><string name=\\"debug_http_host\\">localhost:${port}</string></map>`; | ||
| run(`adb shell "echo '${xml}' | run-as ${packageName} sh -c 'cat > ${prefsFile}'"`); | ||
| run(`adb reverse tcp:${port} tcp:${port}`); | ||
| runQuiet(`adb shell am force-stop ${packageName}`); | ||
@@ -36,0 +40,0 @@ run(`adb shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`); |
| import { existsSync, readFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { saveConfig, loadConfig } from '../config.js'; | ||
| import chalk from 'chalk'; | ||
| function detectBundleId(platform) { | ||
| // Try app.json | ||
| const appJsonPath = join(process.cwd(), 'app.json'); | ||
| if (existsSync(appJsonPath)) { | ||
| try { | ||
| const appJson = JSON.parse(readFileSync(appJsonPath, 'utf-8')); | ||
| if (platform === 'ios') { | ||
| const id = appJson?.expo?.ios?.bundleIdentifier; | ||
| if (id) return id; | ||
| } else { | ||
| const id = appJson?.expo?.android?.package; | ||
| if (id) return id; | ||
| } | ||
| } catch { /* ignore */ } | ||
| } | ||
| // Try app.config.js (read as text and extract) | ||
| const appConfigPath = join(process.cwd(), 'app.config.js'); | ||
| if (existsSync(appConfigPath)) { | ||
| try { | ||
| const content = readFileSync(appConfigPath, 'utf-8'); | ||
| const key = platform === 'ios' ? 'bundleIdentifier' : 'package'; | ||
| const match = content.match(new RegExp(`${key}\\s*:\\s*["']([^"']+)["']`)); | ||
| if (match) return match[1]; | ||
| } catch { /* ignore */ } | ||
| } | ||
| // Try app.config.ts | ||
| const appConfigTsPath = join(process.cwd(), 'app.config.ts'); | ||
| if (existsSync(appConfigTsPath)) { | ||
| try { | ||
| const content = readFileSync(appConfigTsPath, 'utf-8'); | ||
| const key = platform === 'ios' ? 'bundleIdentifier' : 'package'; | ||
| const match = content.match(new RegExp(`${key}\\s*:\\s*["']([^"']+)["']`)); | ||
| if (match) return match[1]; | ||
| } catch { /* ignore */ } | ||
| } | ||
| return null; | ||
| } | ||
| function parsePlatforms(input) { | ||
| const platforms = input.split(',').map(p => p.trim()).filter(Boolean); | ||
| for (const p of platforms) { | ||
| if (p !== 'ios' && p !== 'android') { | ||
| return { error: `Invalid platform: ${p}. Must be 'ios' or 'android'.` }; | ||
| } | ||
| } | ||
| if (platforms.length === 0) { | ||
| return { error: 'At least one platform is required.' }; | ||
| } | ||
| return { platforms: [...new Set(platforms)] }; | ||
| } | ||
| export default function initCommand(program) { | ||
| program | ||
| .command('init') | ||
| .description('Initialize config with bundle ID and platforms') | ||
| .option('--bundle-id <id>', 'App bundle identifier') | ||
| .option('--platforms <list>', 'Target platforms, comma-separated (ios,android)', 'ios') | ||
| .action((opts) => { | ||
| const { platforms, error } = parsePlatforms(opts.platforms); | ||
| if (error) { | ||
| console.error(chalk.red(error)); | ||
| process.exit(1); | ||
| } | ||
| let bundleId = opts.bundleId; | ||
| if (!bundleId) { | ||
| // Try detecting with first platform | ||
| bundleId = detectBundleId(platforms[0]); | ||
| if (bundleId) { | ||
| console.log(chalk.dim(`Auto-detected bundle ID: ${bundleId}`)); | ||
| } else { | ||
| console.error(chalk.red('Could not auto-detect bundle ID. Use --bundle-id <id>.')); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| const existing = loadConfig(); | ||
| if (existing && existing.apps) { | ||
| // Add or update app entry | ||
| existing.apps[bundleId] = existing.apps[bundleId] || { platforms: [], worktrees: {} }; | ||
| existing.apps[bundleId].platforms = platforms; | ||
| saveConfig(existing); | ||
| } else { | ||
| saveConfig({ | ||
| apps: { | ||
| [bundleId]: { | ||
| platforms, | ||
| worktrees: {}, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| console.log(chalk.green(`Initialized react-native-worktree for ${chalk.bold(bundleId)} (${platforms.join(', ')})`)); | ||
| }); | ||
| } |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
32377
2.96%705
1.88%4
-20%11
-8.33%186
-5.1%