diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index 3f5998f4..509d47a3 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -701,7 +701,7 @@ il spin --complexity=complex --yolo ### il open -Open loom in browser (web projects) or run configured CLI tool (CLI projects). +Open loom in browser (web projects), Xcode (iOS projects), or run configured CLI tool (CLI projects). **Alias:** `run` @@ -717,6 +717,11 @@ il open [identifier] **Behavior by Project Type:** +**iOS Projects (requires macOS):** +- React Native (`web` + `ios` capabilities): Opens the `ios/` subdirectory in Xcode +- Native iOS (`ios` capability only): Opens the project root in Xcode (searches for `.xcworkspace` or `.xcodeproj`) +- Non-macOS platforms produce a clear error message + **Web Projects:** - Opens development server in default browser - Uses the loom's unique port (e.g., http://localhost:3025) @@ -810,7 +815,19 @@ il dev-server [identifier] [options] - If omitted and inside a loom, starts dev server for current loom - If omitted outside a loom, prompts for selection -**Behavior:** +**Behavior by Project Type:** + +**React Native iOS (`web` + `ios` capabilities):** +- Starts the Metro bundler as a foreground dev server +- Metro is cross-platform and works on macOS, Linux, and Windows +- The iOS Simulator must be launched separately via `il run` or Xcode + +**Native iOS (`ios` capability only, requires macOS):** +- Not applicable — native iOS does not use a dev server +- Displays a guidance message directing you to `il run` +- Non-macOS platforms produce a clear error message + +**Web Projects:** 1. Resolves the target loom 2. Loads environment variables from `.env` files @@ -930,6 +947,47 @@ Containers are named `iloom-dev-` where the identifier is derived fr --- +### iOS Project Configuration + +For iOS projects, declare the `"ios"` capability in `.iloom/package.iloom.json` and configure Xcode build settings in `.iloom/settings.json`: + +**`.iloom/package.iloom.json`:** +```json +{ + "name": "MyiOSApp", + "capabilities": ["ios"] +} +``` + +**`.iloom/settings.json`:** +```json +{ + "capabilities": { + "ios": { + "simulatorDevice": "iPhone 16", + "scheme": "MyApp", + "bundleId": "com.example.myapp", + "configuration": "Debug", + "deployTarget": "simulator", + "developmentTeam": "TEAM123456" + } + } +} +``` + +**iOS Configuration Fields:** + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `simulatorDevice` | `string` | `"iPhone 16"` | Target simulator device name as shown in Xcode (e.g., `"iPhone 16"`, `"iPad Pro 13-inch"`). | +| `scheme` | `string` | — | Xcode scheme to build and run. Corresponds to the scheme name in your `.xcodeproj` or `.xcworkspace`. | +| `bundleId` | `string` | — | App bundle identifier (e.g., `"com.example.myapp"`). Used for device installation and code signing. | +| `configuration` | `"Debug"` \| `"Release"` | `"Debug"` | Xcode build configuration. | +| `deployTarget` | `"simulator"` \| `"device"` | `"simulator"` | Whether to deploy to a simulator or a physical device connected via USB. | +| `developmentTeam` | `string` | — | Apple Developer Team ID for code signing. Required when `deployTarget` is `"device"`. Find your Team ID in Xcode under Signing & Capabilities. | + +--- + ### il build Run the build script for a workspace. diff --git a/docs/multi-language-projects.md b/docs/multi-language-projects.md index 23f9cac3..dfeecbe5 100644 --- a/docs/multi-language-projects.md +++ b/docs/multi-language-projects.md @@ -77,6 +77,9 @@ An array defining what type of project this is: - `"web"` - Web application with a development server - Enables automatic port assignment (3000 + issue number) - Prevents port conflicts when running multiple dev servers +- `"ios"` - iOS application built with Xcode + - Enables iOS-specific build configuration (simulator device, scheme, bundle ID, etc.) + - Activates Xcode build strategy for dev server commands You can specify both capabilities if your project is both a CLI and web app. @@ -347,6 +350,54 @@ Some projects are both CLI and web applications: - Compiles to a binary (`cli`) - Runs a web server (`web`) +### iOS Capability + +Declare `"ios"` capability when your project is an iOS application built with Xcode: + +```json +{ + "capabilities": ["ios"] +} +``` + +This tells iloom that: +- Your project uses Xcode for building and running +- iOS-specific settings (simulator device, scheme, bundle ID, etc.) apply +- The dev server strategy will use `xcodebuild` or `simctl` + +**Use cases:** +- Native iOS apps +- Swift Package Manager libraries with iOS targets +- iOS app extensions + +#### iOS-Specific Settings + +Configure iOS build behavior in `.iloom/settings.json` under the `capabilities.ios` key: + +```json +{ + "capabilities": { + "ios": { + "simulatorDevice": "iPhone 16", + "scheme": "MyApp", + "bundleId": "com.example.myapp", + "configuration": "Debug", + "deployTarget": "simulator", + "developmentTeam": "TEAM123456" + } + } +} +``` + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `simulatorDevice` | string | `"iPhone 16"` | Target simulator device name | +| `scheme` | string | — | Xcode scheme to build | +| `bundleId` | string | — | App bundle identifier | +| `configuration` | `"Debug"` \| `"Release"` | `"Debug"` | Build configuration | +| `deployTarget` | `"simulator"` \| `"device"` | `"simulator"` | Whether to deploy to simulator or physical device | +| `developmentTeam` | string | — | Apple Developer Team ID (required for physical device deployment) | + ## Secret Storage Limitations iloom's environment variable isolation works by managing `.env` files. Features like database branching and loom-specific environment variables require your framework to read configuration from `.env` files. diff --git a/src/commands/dev-server.test.ts b/src/commands/dev-server.test.ts index ba3a800a..19c4e60f 100644 --- a/src/commands/dev-server.test.ts +++ b/src/commands/dev-server.test.ts @@ -8,6 +8,7 @@ import { DockerManager } from '../lib/DockerManager.js' import { SettingsManager } from '../lib/SettingsManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { loadWorkspaceEnv, isNoEnvFilesFoundError } from '../utils/env.js' +import { assertMacOS, isReactNativeProject, MacOSRequiredError } from '../utils/ios.js' import type { GitWorktree } from '../types/worktree.js' import type { ProjectCapabilities } from '../types/loom.js' import fs from 'fs-extra' @@ -30,6 +31,18 @@ vi.mock('../utils/env.js', async (importOriginal) => { } }) +// Mock iOS utilities +vi.mock('../utils/ios.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + assertMacOS: vi.fn(), + isReactNativeProject: vi.fn().mockReturnValue(false), + openIOSProject: vi.fn().mockResolvedValue(undefined), + buildAndRunIOS: vi.fn().mockResolvedValue(undefined), + } +}) + // Mock the logger to prevent console output during tests vi.mock('../utils/logger.js', () => ({ logger: { @@ -223,7 +236,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.any(Object), undefined, - undefined + undefined, + false ) }) @@ -274,6 +288,59 @@ describe('DevServerCommand', () => { expect(result.message).toContain('No web capability detected') expect(mockDevServerManager.runServerForeground).not.toHaveBeenCalled() }) + + it('should start Metro bundler for ios+web project (React Native)', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['web', 'ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities) + vi.mocked(isReactNativeProject).mockReturnValue(true) + + vi.mocked(fs.pathExists).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n') + + const result = await command.execute({ identifier: '87' }) + + // Metro is cross-platform — assertMacOS should NOT be called for React Native + expect(assertMacOS).not.toHaveBeenCalled() + expect(result.status).toBe('started') + expect(mockDevServerManager.runServerForeground).toHaveBeenCalled() + }) + + it('should throw for ios-only project (native iOS, macOS only)', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities) + vi.mocked(isReactNativeProject).mockReturnValue(false) + + await expect(command.execute({ identifier: '87' })).rejects.toThrow( + 'Native iOS projects do not use a dev server' + ) + + expect(assertMacOS).toHaveBeenCalled() + expect(mockDevServerManager.runServerForeground).not.toHaveBeenCalled() + }) + + it('should throw macOS-only error on non-darwin for native ios project', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities) + vi.mocked(isReactNativeProject).mockReturnValue(false) + + // Make assertMacOS throw + vi.mocked(assertMacOS).mockImplementationOnce(() => { + throw new MacOSRequiredError() + }) + + await expect(command.execute({ identifier: '87' })).rejects.toThrow( + 'iOS development requires macOS' + ) + }) }) describe('server state detection', () => { @@ -329,7 +396,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.any(Object), undefined, - undefined + undefined, + false ) }) @@ -353,7 +421,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.any(Object), undefined, - undefined + undefined, + false ) }) }) @@ -442,7 +511,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.any(Object), undefined, - undefined + undefined, + false ) }) @@ -459,7 +529,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.any(Object), undefined, - undefined + undefined, + false ) }) }) @@ -502,7 +573,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ DATABASE_URL: 'postgres://test', API_KEY: 'secret', ILOOM_LOOM: '87' }), undefined, - undefined + undefined, + false ) }) @@ -521,7 +593,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ ILOOM_LOOM: '87' }), undefined, - undefined + undefined, + false ) }) @@ -538,7 +611,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ ILOOM_LOOM: '87' }), undefined, - undefined + undefined, + false ) }) @@ -563,7 +637,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ ILOOM_LOOM: '87' }), undefined, - undefined + undefined, + false ) }) @@ -734,7 +809,8 @@ describe('DevServerCommand', () => { expect(DockerManager.assertAvailable).toHaveBeenCalled() expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith( 3087, - expectedDockerConfig + expectedDockerConfig, + false ) expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith( mockWorktree.path, @@ -743,7 +819,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ ILOOM_LOOM: '87' }), expectedDockerConfig, - undefined + undefined, + false ) }) @@ -778,7 +855,8 @@ describe('DevServerCommand', () => { dockerFile: './Dockerfile', identifier: '87', }), - undefined + undefined, + false ) }) @@ -818,7 +896,8 @@ describe('DevServerCommand', () => { expect.objectContaining({ identifier: 'feat/docker-support', }), - undefined + undefined, + false ) }) @@ -868,7 +947,8 @@ describe('DevServerCommand', () => { expect(DockerManager.assertAvailable).not.toHaveBeenCalled() expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith( 3087, - undefined + undefined, + false ) expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith( mockWorktree.path, @@ -877,7 +957,8 @@ describe('DevServerCommand', () => { expect.any(Function), expect.objectContaining({ ILOOM_LOOM: '87' }), undefined, - undefined + undefined, + false ) }) @@ -889,7 +970,8 @@ describe('DevServerCommand', () => { expect(DockerManager.assertAvailable).not.toHaveBeenCalled() expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith( 3087, - undefined + undefined, + false ) }) }) diff --git a/src/commands/dev-server.ts b/src/commands/dev-server.ts index 388ee23f..1ca6e3db 100644 --- a/src/commands/dev-server.ts +++ b/src/commands/dev-server.ts @@ -15,6 +15,8 @@ import { extractIssueNumber } from '../utils/git.js' import { buildDevServerUrl } from '../utils/dev-server.js' import { logger } from '../utils/logger.js' import { extractSettingsOverrides } from '../utils/cli-overrides.js' +import { assertMacOS, isReactNativeProject } from '../utils/ios.js' +import { TelemetryService } from '../lib/TelemetryService.js' import type { GitWorktree } from '../types/worktree.js' export interface DevServerCommandInput { @@ -123,8 +125,40 @@ export class DevServerCommand { logger.debug(`Detected capabilities: ${capabilities.join(', ')}`) - // 4. If no web capability, return gracefully with info message - if (!capabilities.includes('web')) { + // 4. Handle iOS capability + let useMetroMode = false + if (capabilities.includes('ios')) { + if (isReactNativeProject(capabilities)) { + // React Native iOS: Metro bundler is cross-platform — proceed to web dev server path + useMetroMode = true + logger.debug('React Native iOS project detected — starting Metro bundler via dev server') + try { + TelemetryService.getInstance().track('ios.command_invoked', { + command: 'dev-server', + is_react_native: true, + success: true, + }) + } catch { + logger.debug('Telemetry tracking failed (non-fatal)') + } + } else { + // Native iOS: macOS-only and no dev server applicable + assertMacOS() + try { + TelemetryService.getInstance().track('ios.command_invoked', { + command: 'dev-server', + is_react_native: false, + success: false, + }) + } catch { + logger.debug('Telemetry tracking failed (non-fatal)') + } + const message = 'Native iOS projects do not use a dev server. Use \'il run\' to build and run the app on a simulator or device.' + logger.info(message) + throw new Error(message) + } + } else if (!capabilities.includes('web')) { + // No web or iOS capability const message = 'No web capability detected in this workspace. Dev server not started.' if (input.json) { this.outputJson({ @@ -155,7 +189,7 @@ export class DevServerCommand { const url = buildDevServerUrl(port, protocol) // 6. Check if server already running - const isRunning = await this.devServerManager.isServerRunning(port, dockerConfig) + const isRunning = await this.devServerManager.isServerRunning(port, dockerConfig, useMetroMode) if (isRunning) { const message = `Dev server already running at ${url}` @@ -254,7 +288,8 @@ export class DevServerCommand { }, envOverrides, dockerConfig, - onOutput + onOutput, + useMetroMode ) if (processInfo.pid) { diff --git a/src/commands/open.test.ts b/src/commands/open.test.ts index 470b44ff..6d0309e1 100644 --- a/src/commands/open.test.ts +++ b/src/commands/open.test.ts @@ -10,6 +10,7 @@ import fs from 'fs-extra' import path from 'path' import { execa } from 'execa' import { openBrowser } from '../utils/browser.js' +import { openIOSProject, MacOSRequiredError } from '../utils/ios.js' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') @@ -33,6 +34,17 @@ vi.mock('../utils/browser.js', () => ({ detectPlatform: vi.fn().mockReturnValue('darwin'), })) +// Mock iOS utilities +vi.mock('../utils/ios.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + openIOSProject: vi.fn().mockResolvedValue(undefined), + assertMacOS: vi.fn(), + isReactNativeProject: vi.fn().mockReturnValue(false), + } +}) + // Mock the logger to prevent console output during tests vi.mock('../utils/logger.js', () => ({ logger: { @@ -328,6 +340,54 @@ describe('OpenCommand', () => { 'No web or CLI capabilities detected' ) }) + + it('should launch iOS for ios-only project', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + await command.execute({ identifier: '87' }) + + expect(openIOSProject).toHaveBeenCalledWith(mockWorktree.path, ['ios']) + expect(openBrowser).not.toHaveBeenCalled() + expect(execa).not.toHaveBeenCalled() + }) + + it('should launch iOS for web+ios project (ios takes precedence)', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['web', 'ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + await command.execute({ identifier: '87' }) + + expect(openIOSProject).toHaveBeenCalledWith(mockWorktree.path, ['web', 'ios']) + expect(openBrowser).not.toHaveBeenCalled() + }) + + it('should throw macOS-only error on non-darwin platform for ios project', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + // Make openIOSProject throw MacOSRequiredError + vi.mocked(openIOSProject).mockRejectedValueOnce(new MacOSRequiredError()) + + await expect(command.execute({ identifier: '87' })).rejects.toThrow( + 'iOS development requires macOS' + ) + }) }) describe('browser launching', () => { diff --git a/src/commands/open.ts b/src/commands/open.ts index 3e3c15be..019adc00 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -13,6 +13,8 @@ import { extractIssueNumber } from '../utils/git.js' import { buildDevServerUrl } from '../utils/dev-server.js' import { logger } from '../utils/logger.js' import { extractSettingsOverrides } from '../utils/cli-overrides.js' +import { openIOSProject, isReactNativeProject } from '../utils/ios.js' +import { TelemetryService } from '../lib/TelemetryService.js' import type { GitWorktree } from '../types/worktree.js' export interface OpenCommandInput { @@ -61,8 +63,24 @@ export class OpenCommand { logger.debug(`Detected capabilities: ${capabilities.join(', ')}`) - // 4. Execute based on capabilities (web first, CLI fallback) - if (capabilities.includes('web')) { + // 4. Execute based on capabilities (iOS first, then web, CLI fallback) + if (capabilities.includes('ios')) { + let iosSuccess = false + try { + await openIOSProject(worktree.path, capabilities) + iosSuccess = true + } finally { + try { + TelemetryService.getInstance().track('ios.command_invoked', { + command: 'open', + is_react_native: isReactNativeProject(capabilities), + success: iosSuccess, + }) + } catch { + logger.debug('Telemetry tracking failed (non-fatal)') + } + } + } else if (capabilities.includes('web')) { await this.openWebBrowser(worktree, input.env) } else if (capabilities.includes('cli')) { await this.runCLITool(worktree.path, binEntries, input.args ?? []) diff --git a/src/commands/run.test.ts b/src/commands/run.test.ts index 46600fdf..6f49b873 100644 --- a/src/commands/run.test.ts +++ b/src/commands/run.test.ts @@ -10,6 +10,7 @@ import fs from 'fs-extra' import path from 'path' import { execa } from 'execa' import { openBrowser } from '../utils/browser.js' +import { MacOSRequiredError, buildAndRunIOS } from '../utils/ios.js' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') @@ -33,6 +34,18 @@ vi.mock('../utils/browser.js', () => ({ detectPlatform: vi.fn().mockReturnValue('darwin'), })) +// Mock iOS utilities +vi.mock('../utils/ios.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + assertMacOS: vi.fn(), + buildAndRunIOS: vi.fn().mockResolvedValue(undefined), + openIOSProject: vi.fn().mockResolvedValue(undefined), + isReactNativeProject: vi.fn().mockReturnValue(false), + } +}) + // Mock the logger to prevent console output during tests vi.mock('../utils/logger.js', () => ({ logger: { @@ -309,6 +322,68 @@ describe('RunCommand', () => { 'No CLI or web capabilities detected' ) }) + + it('should build and run iOS app for ios-only project', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + await command.execute({ identifier: '87' }) + + expect(buildAndRunIOS).toHaveBeenCalledWith(mockWorktree.path, ['ios'], []) + expect(execa).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() + }) + + it('should build iOS for ios+cli project (ios takes precedence)', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios', 'cli'], + binEntries: { il: './dist/cli.js' }, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + await command.execute({ identifier: '87' }) + + expect(buildAndRunIOS).toHaveBeenCalledWith(mockWorktree.path, ['ios', 'cli'], []) + expect(execa).not.toHaveBeenCalled() + }) + + it('should pass args to buildAndRunIOS', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + await command.execute({ identifier: '87', args: ['--device', 'iPhone 15'] }) + + expect(buildAndRunIOS).toHaveBeenCalledWith(mockWorktree.path, ['ios'], ['--device', 'iPhone 15']) + }) + + it('should throw macOS-only error on non-darwin platform for ios project', async () => { + const mockCapabilities: ProjectCapabilities = { + capabilities: ['ios'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue( + mockCapabilities + ) + + // buildAndRunIOS internally calls assertMacOS — simulate it throwing + vi.mocked(buildAndRunIOS).mockRejectedValueOnce(new MacOSRequiredError()) + + await expect(command.execute({ identifier: '87' })).rejects.toThrow( + 'iOS development requires macOS' + ) + }) }) describe('CLI execution', () => { diff --git a/src/commands/run.ts b/src/commands/run.ts index 63f95a16..896f813d 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -13,6 +13,8 @@ import { extractIssueNumber } from '../utils/git.js' import { buildDevServerUrl } from '../utils/dev-server.js' import { logger } from '../utils/logger.js' import { extractSettingsOverrides } from '../utils/cli-overrides.js' +import { buildAndRunIOS, buildForDevice, isReactNativeProject } from '../utils/ios.js' +import { TelemetryService } from '../lib/TelemetryService.js' import type { GitWorktree } from '../types/worktree.js' export interface RunCommandInput { @@ -61,8 +63,41 @@ export class RunCommand { logger.debug(`Detected capabilities: ${capabilities.join(', ')}`) - // 4. Execute based on capabilities (CLI first, web fallback) - if (capabilities.includes('cli')) { + // 4. Execute based on capabilities (iOS first, then CLI, web fallback) + if (capabilities.includes('ios')) { + let iosSuccess = false + try { + if (!isReactNativeProject(capabilities)) { + // Native iOS: check settings for device deployment + const settings = await this.settingsManager.loadSettings() + const iosSettings = settings.capabilities?.ios + if (iosSettings?.deployTarget === 'device' && iosSettings.developmentTeam) { + await buildForDevice({ + scheme: iosSettings.scheme ?? 'App', + developmentTeam: iosSettings.developmentTeam, + configuration: iosSettings.configuration, + extraArgs: input.args ?? [], + cwd: worktree.path, + }) + } else { + await buildAndRunIOS(worktree.path, capabilities, input.args ?? []) + } + } else { + await buildAndRunIOS(worktree.path, capabilities, input.args ?? []) + } + iosSuccess = true + } finally { + try { + TelemetryService.getInstance().track('ios.command_invoked', { + command: 'run', + is_react_native: isReactNativeProject(capabilities), + success: iosSuccess, + }) + } catch { + logger.debug('Telemetry tracking failed (non-fatal)') + } + } + } else if (capabilities.includes('cli')) { await this.runCLITool(worktree.path, binEntries, input.args ?? [], input.env) } else if (capabilities.includes('web')) { await this.openWebBrowser(worktree, input.env) diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index fffca52e..ae49db47 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -1640,6 +1640,7 @@ describe('StartCommand', () => { one_shot_mode: 'default', complexity_override: false, create_only: false, + has_ios_capability: false, }) }) diff --git a/src/commands/start.ts b/src/commands/start.ts index 154fe2c0..7a6d7cf5 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -403,6 +403,7 @@ export class StartCommand { one_shot_mode: oneShotMap[input.options.oneShot ?? ''] ?? 'default', complexity_override: !!input.options.complexity, create_only: !!input.options.createOnly, + has_ios_capability: !!(loom.capabilities?.includes('ios')), }) } catch (error: unknown) { getLogger().debug(`Failed to track loom.created telemetry: ${error instanceof Error ? error.message : String(error)}`) diff --git a/src/lib/DevServerManager.test.ts b/src/lib/DevServerManager.test.ts index 37c51f1d..96345575 100644 --- a/src/lib/DevServerManager.test.ts +++ b/src/lib/DevServerManager.test.ts @@ -3,6 +3,7 @@ import { DevServerManager, type DockerConfig } from './DevServerManager.js' import { ProcessManager } from './process/ProcessManager.js' import { DockerManager } from './DockerManager.js' import { DockerDevServerStrategy } from './DockerDevServerStrategy.js' +import { MetroDevServerStrategy } from './MetroDevServerStrategy.js' import { execa, type ExecaChildProcess } from 'execa' import { setTimeout } from 'timers/promises' import * as devServerUtils from '../utils/dev-server.js' @@ -15,6 +16,7 @@ vi.mock('timers/promises') vi.mock('./process/ProcessManager.js') vi.mock('./DockerManager.js') vi.mock('./DockerDevServerStrategy.js') +vi.mock('./MetroDevServerStrategy.js') vi.mock('../utils/dev-server.js') vi.mock('../utils/package-manager.js') vi.mock('../utils/package-json.js') @@ -1027,6 +1029,158 @@ describe('DevServerManager', () => { }) }) + describe('Metro mode', () => { + let mockMetroInstance: { + isRunning: ReturnType + startBackground: ReturnType + startForeground: ReturnType + stop: ReturnType + stopAll: ReturnType + waitForReady: ReturnType + } + + beforeEach(() => { + mockMetroInstance = { + isRunning: vi.fn(), + startBackground: vi.fn(), + startForeground: vi.fn(), + stop: vi.fn(), + stopAll: vi.fn(), + waitForReady: vi.fn(), + } + + vi.mocked(MetroDevServerStrategy).mockImplementation(() => mockMetroInstance as unknown as MetroDevServerStrategy) + + // Recreate manager so it picks up the mocked MetroDevServerStrategy + manager = new DevServerManager(mockProcessManager, { + startupTimeout: 5000, + checkInterval: 100, + }) + }) + + describe('ensureServerRunning', () => { + it('should delegate to MetroDevServerStrategy when metroMode is true', async () => { + const port = 3087 + + mockMetroInstance.isRunning.mockResolvedValue(false) + mockMetroInstance.startBackground.mockResolvedValue(undefined) + + const result = await manager.ensureServerRunning(mockWorktreePath, port, undefined, undefined, true) + + expect(result).toBe(true) + expect(mockMetroInstance.isRunning).toHaveBeenCalledWith(port) + expect(mockMetroInstance.startBackground).toHaveBeenCalledWith(mockWorktreePath, port, undefined) + // Should NOT use native strategy + expect(mockProcessManager.detectDevServer).not.toHaveBeenCalled() + expect(execa).not.toHaveBeenCalled() + }) + + it('should return true when Metro is already running', async () => { + const port = 3087 + + mockMetroInstance.isRunning.mockResolvedValue(true) + + const result = await manager.ensureServerRunning(mockWorktreePath, port, undefined, undefined, true) + + expect(result).toBe(true) + expect(mockMetroInstance.startBackground).not.toHaveBeenCalled() + }) + + it('should throw when Metro fails to start', async () => { + const port = 3087 + + mockMetroInstance.isRunning.mockResolvedValue(false) + mockMetroInstance.startBackground.mockRejectedValue(new Error('Metro bundler failed to start')) + + await expect( + manager.ensureServerRunning(mockWorktreePath, port, undefined, undefined, true) + ).rejects.toThrow('Metro bundler failed to start') + }) + + it('should pass envOverrides to Metro startBackground', async () => { + const port = 3087 + const envOverrides = { API_URL: 'http://localhost:3000' } + + mockMetroInstance.isRunning.mockResolvedValue(false) + mockMetroInstance.startBackground.mockResolvedValue(undefined) + + await manager.ensureServerRunning(mockWorktreePath, port, undefined, envOverrides, true) + + expect(mockMetroInstance.startBackground).toHaveBeenCalledWith(mockWorktreePath, port, envOverrides) + }) + }) + + describe('isServerRunning', () => { + it('should delegate to MetroDevServerStrategy when metroMode is true', async () => { + const port = 3087 + + mockMetroInstance.isRunning.mockResolvedValue(true) + + const result = await manager.isServerRunning(port, undefined, true) + + expect(result).toBe(true) + expect(mockMetroInstance.isRunning).toHaveBeenCalledWith(port) + expect(mockProcessManager.detectDevServer).not.toHaveBeenCalled() + }) + }) + + describe('runServerForeground', () => { + it('should delegate to MetroDevServerStrategy when metroMode is true', async () => { + const port = 3087 + const onStart = vi.fn() + + mockMetroInstance.startForeground.mockResolvedValue({ pid: 12345 }) + + const result = await manager.runServerForeground( + mockWorktreePath, port, false, onStart, undefined, undefined, undefined, true + ) + + expect(result).toEqual({ pid: 12345 }) + expect(mockMetroInstance.startForeground).toHaveBeenCalledWith( + mockWorktreePath, + port, + expect.objectContaining({ + redirectToStderr: false, + onProcessStarted: onStart, + }) + ) + // Should NOT use native strategy + expect(packageManagerUtils.runScript).not.toHaveBeenCalled() + expect(execa).not.toHaveBeenCalled() + }) + + it('should pass redirectToStderr and envOverrides to Metro strategy', async () => { + const port = 3087 + const envOverrides = { DEBUG: 'true' } + + mockMetroInstance.startForeground.mockResolvedValue({ pid: 12345 }) + + await manager.runServerForeground( + mockWorktreePath, port, true, undefined, envOverrides, undefined, undefined, true + ) + + expect(mockMetroInstance.startForeground).toHaveBeenCalledWith( + mockWorktreePath, + port, + expect.objectContaining({ + redirectToStderr: true, + envOverrides, + }) + ) + }) + }) + + describe('cleanup', () => { + it('should stop all Metro processes during cleanup', async () => { + mockMetroInstance.stopAll.mockResolvedValue(undefined) + + await manager.cleanup() + + expect(mockMetroInstance.stopAll).toHaveBeenCalled() + }) + }) + }) + describe('default options', () => { it('should use default timeout (180s) and interval if not specified', () => { const defaultManager = new DevServerManager() diff --git a/src/lib/DevServerManager.ts b/src/lib/DevServerManager.ts index b1428dc9..6d16f0dd 100644 --- a/src/lib/DevServerManager.ts +++ b/src/lib/DevServerManager.ts @@ -3,6 +3,7 @@ import { ProcessManager } from './process/ProcessManager.js' import { DockerManager, type DockerConfig } from './DockerManager.js' import { DockerDevServerStrategy, type DockerConfig as StrategyDockerConfig, type DockerUtils } from './DockerDevServerStrategy.js' import { NativeDevServerStrategy } from './NativeDevServerStrategy.js' +import { MetroDevServerStrategy } from './MetroDevServerStrategy.js' import { logger } from '../utils/logger.js' /** @@ -81,6 +82,7 @@ export class DevServerManager { private readonly processManager: ProcessManager private readonly options: Required private readonly nativeStrategy: NativeDevServerStrategy + private readonly metroStrategy: MetroDevServerStrategy private runningDockerContainers: Map = new Map() constructor( @@ -97,6 +99,11 @@ export class DevServerManager { this.options.startupTimeout, this.options.checkInterval ) + this.metroStrategy = new MetroDevServerStrategy( + this.processManager, + this.options.startupTimeout, + this.options.checkInterval + ) } /** @@ -116,9 +123,22 @@ export class DevServerManager { * @param dockerConfig - Optional Docker configuration for container-based server * @returns true if server is ready, false if startup failed/timed out */ - async ensureServerRunning(worktreePath: string, port: number, dockerConfig?: DockerConfig, envOverrides?: Record): Promise { + async ensureServerRunning(worktreePath: string, port: number, dockerConfig?: DockerConfig, envOverrides?: Record, metroMode?: boolean): Promise { logger.debug(`Checking if dev server is running on port ${port}...`) + // Metro mode: delegate to MetroDevServerStrategy + if (metroMode) { + const isRunning = await this.metroStrategy.isRunning(port) + if (isRunning) { + logger.debug(`Metro bundler already running on port ${port}`) + return true + } + + logger.info(`Metro bundler not running on port ${port}, starting...`) + await this.metroStrategy.startBackground(worktreePath, port, envOverrides) + return true + } + // Docker mode: check if container is already running if (dockerConfig) { const strategy = this.createDockerStrategy(dockerConfig) @@ -231,7 +251,10 @@ export class DevServerManager { * @param dockerConfig - Optional Docker configuration; when provided, checks container status * @returns true if server is running, false otherwise */ - async isServerRunning(port: number, dockerConfig?: DockerConfig): Promise { + async isServerRunning(port: number, dockerConfig?: DockerConfig, metroMode?: boolean): Promise { + if (metroMode) { + return this.metroStrategy.isRunning(port) + } if (dockerConfig) { const strategy = this.createDockerStrategy(dockerConfig) const containerName = dockerUtils.buildContainerName(dockerConfig.identifier) @@ -258,8 +281,19 @@ export class DevServerManager { onProcessStarted?: (pid?: number) => void, envOverrides?: Record, dockerConfig?: DockerConfig, - onOutput?: (data: Buffer) => void + onOutput?: (data: Buffer) => void, + metroMode?: boolean ): Promise<{ pid?: number }> { + // Metro mode: delegate to MetroDevServerStrategy + if (metroMode) { + return this.metroStrategy.startForeground(worktreePath, port, { + redirectToStderr, + ...(onProcessStarted !== undefined && { onProcessStarted }), + ...(envOverrides !== undefined && { envOverrides }), + ...(onOutput !== undefined && { onOutput }), + }) + } + // Docker mode: build image and run container in foreground if (dockerConfig) { logger.debug(`Starting Docker dev server in foreground on port ${port}`) @@ -320,6 +354,9 @@ export class DevServerManager { // Clean up native process-based servers await this.nativeStrategy.stopAll() + // Clean up Metro bundler processes + await this.metroStrategy.stopAll() + // Clean up Docker containers using DockerDevServerStrategy for (const [port, containerName] of this.runningDockerContainers.entries()) { try { diff --git a/src/lib/MetroDevServerStrategy.test.ts b/src/lib/MetroDevServerStrategy.test.ts new file mode 100644 index 00000000..70ca02d4 --- /dev/null +++ b/src/lib/MetroDevServerStrategy.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MetroDevServerStrategy } from './MetroDevServerStrategy.js' +import { ProcessManager } from './process/ProcessManager.js' +import { execa, type ExecaChildProcess } from 'execa' +import { setTimeout } from 'timers/promises' + +// Mock dependencies +vi.mock('execa') +vi.mock('timers/promises') +vi.mock('./process/ProcessManager.js') + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + }, +})) + +vi.mock('../utils/terminal.js', () => ({ + restoreTerminalState: vi.fn(), +})) + +describe('MetroDevServerStrategy', () => { + let strategy: MetroDevServerStrategy + let mockProcessManager: ProcessManager + const mockWorktreePath = '/test/worktrees/issue-87' + + beforeEach(() => { + mockProcessManager = new ProcessManager() + strategy = new MetroDevServerStrategy(mockProcessManager, 5000, 100) + }) + + describe('isRunning', () => { + it('should return true when ProcessManager detects a process on the port', async () => { + const port = 3087 + + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + const result = await strategy.isRunning(port) + + expect(result).toBe(true) + expect(mockProcessManager.detectDevServer).toHaveBeenCalledWith(port) + }) + + it('should return false when no process detected', async () => { + const port = 3087 + + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue(null) + + const result = await strategy.isRunning(port) + + expect(result).toBe(false) + }) + }) + + describe('startBackground', () => { + it('should start Metro with npx react-native start --port PORT', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + await strategy.startBackground(mockWorktreePath, port) + + expect(execa).toHaveBeenCalledWith( + 'npx', + ['react-native', 'start', '--port', '3087'], + expect.objectContaining({ + cwd: mockWorktreePath, + env: expect.objectContaining({ + PORT: '3087', + }), + stdio: 'ignore', + detached: true, + }) + ) + expect(mockProcess.unref).toHaveBeenCalled() + }) + + it('should set env PORT and pass worktreePath as cwd', async () => { + const port = 4000 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + await strategy.startBackground('/custom/worktree', port) + + expect(execa).toHaveBeenCalledWith( + 'npx', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/worktree', + env: expect.objectContaining({ + PORT: '4000', + }), + }) + ) + }) + + it('should throw when Metro fails to start within timeout', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + // Server never starts + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue(null) + vi.mocked(setTimeout).mockResolvedValue(undefined) + + // Short timeout strategy + const shortTimeoutStrategy = new MetroDevServerStrategy(mockProcessManager, 500, 100) + + await expect(shortTimeoutStrategy.startBackground(mockWorktreePath, port)).rejects.toThrow( + 'Metro bundler failed to start within 500ms timeout' + ) + }) + + it('should forward envOverrides to the process', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + await strategy.startBackground(mockWorktreePath, port, { API_URL: 'http://localhost:3000' }) + + expect(execa).toHaveBeenCalledWith( + 'npx', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + API_URL: 'http://localhost:3000', + PORT: '3087', + }), + }) + ) + }) + + it('should handle early process exit (crash detection)', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + exitCode: 1, // Process already exited + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue(null) + vi.mocked(setTimeout).mockResolvedValue(undefined) + + await expect(strategy.startBackground(mockWorktreePath, port)).rejects.toThrow( + 'Metro bundler failed to start within 5000ms timeout' + ) + }) + }) + + describe('startForeground', () => { + it('should start Metro in foreground with inherited stdio', async () => { + const port = 3087 + + const mockProcess = { + pid: 12345, + then: (resolve: (value: unknown) => void) => { + resolve(undefined) + return mockProcess + }, + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + const result = await strategy.startForeground(mockWorktreePath, port, {}) + + expect(execa).toHaveBeenCalledWith( + 'npx', + ['react-native', 'start', '--port', '3087'], + expect.objectContaining({ + cwd: mockWorktreePath, + env: expect.objectContaining({ + PORT: '3087', + }), + stdio: ['inherit', 'inherit', 'inherit'], + }) + ) + expect(result).toEqual({ pid: 12345 }) + }) + + it('should redirect to stderr when redirectToStderr is true', async () => { + const port = 3087 + + const mockProcess = { + pid: 12345, + then: (resolve: (value: unknown) => void) => { + resolve(undefined) + return mockProcess + }, + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + await strategy.startForeground(mockWorktreePath, port, { + redirectToStderr: true, + }) + + expect(execa).toHaveBeenCalledWith( + 'npx', + expect.any(Array), + expect.objectContaining({ + stdio: [process.stdin, process.stderr, process.stderr], + }) + ) + }) + + it('should call onProcessStarted callback with PID', async () => { + const port = 3087 + const onProcessStarted = vi.fn() + + const mockProcess = { + pid: 12345, + then: (resolve: (value: unknown) => void) => { + resolve(undefined) + return mockProcess + }, + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + await strategy.startForeground(mockWorktreePath, port, { + onProcessStarted, + }) + + expect(onProcessStarted).toHaveBeenCalledWith(12345) + }) + + it('should pipe stdout/stderr to onOutput callback when provided', async () => { + const port = 3087 + const onOutput = vi.fn() + const mockStdout = { on: vi.fn() } + const mockStderr = { on: vi.fn() } + + const mockProcess = { + pid: 12345, + stdout: mockStdout, + stderr: mockStderr, + then: (resolve: (value: unknown) => void) => { + resolve(undefined) + return mockProcess + }, + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + await strategy.startForeground(mockWorktreePath, port, { onOutput }) + + expect(execa).toHaveBeenCalledWith( + 'npx', + expect.any(Array), + expect.objectContaining({ + stdio: ['ignore', 'pipe', 'pipe'], + }) + ) + expect(mockStdout.on).toHaveBeenCalledWith('data', onOutput) + expect(mockStderr.on).toHaveBeenCalledWith('data', onOutput) + }) + }) + + describe('stop', () => { + it('should kill tracked Metro process and return true', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + // Start a server to track it + await strategy.startBackground(mockWorktreePath, port) + + const result = await strategy.stop(port) + + expect(result).toBe(true) + expect(mockProcess.kill).toHaveBeenCalled() + }) + + it('should return false when no Metro process is tracked for the port', async () => { + const port = 3087 + + const result = await strategy.stop(port) + + expect(result).toBe(false) + }) + + it('should kill process group (negative PID) for detached processes', async () => { + const port = 3087 + + const mockProcess = { + pid: 54321, + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 54321, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + const processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + + await strategy.startBackground(mockWorktreePath, port) + + const result = await strategy.stop(port) + + expect(result).toBe(true) + expect(processKillSpy).toHaveBeenCalledWith(-54321, 'SIGTERM') + + processKillSpy.mockRestore() + }) + }) + + describe('stopAll', () => { + it('should stop all tracked Metro processes', async () => { + const port = 3087 + + const mockProcess = { + unref: vi.fn(), + kill: vi.fn(), + on: vi.fn(), + catch: vi.fn(), + } as unknown as ExecaChildProcess + vi.mocked(execa).mockReturnValue(mockProcess) + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + await strategy.startBackground(mockWorktreePath, port) + await strategy.stopAll() + + expect(mockProcess.kill).toHaveBeenCalled() + }) + }) + + describe('waitForReady', () => { + it('should return true when server starts within timeout', async () => { + const port = 3087 + + vi.mocked(mockProcessManager.detectDevServer) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + pid: 12345, + name: 'node', + command: 'npx react-native start', + port, + isDevServer: true, + }) + + vi.mocked(setTimeout).mockResolvedValue(undefined) + + const result = await strategy.waitForReady(port) + + expect(result).toBe(true) + }) + + it('should return false when server does not start within timeout', async () => { + const port = 3087 + + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue(null) + vi.mocked(setTimeout).mockResolvedValue(undefined) + + const shortTimeoutStrategy = new MetroDevServerStrategy(mockProcessManager, 500, 100) + const result = await shortTimeoutStrategy.waitForReady(port) + + expect(result).toBe(false) + }) + + it('should return false early when process exits before becoming ready', async () => { + const port = 3087 + + vi.mocked(mockProcessManager.detectDevServer).mockResolvedValue(null) + vi.mocked(setTimeout).mockResolvedValue(undefined) + + const mockProcess = { + exitCode: 1, + } as unknown as ExecaChildProcess + + const result = await strategy.waitForReady(port, mockProcess) + + expect(result).toBe(false) + }) + }) +}) diff --git a/src/lib/MetroDevServerStrategy.ts b/src/lib/MetroDevServerStrategy.ts new file mode 100644 index 00000000..91cc6c43 --- /dev/null +++ b/src/lib/MetroDevServerStrategy.ts @@ -0,0 +1,225 @@ +import { execa, type ExecaChildProcess, type ExecaError } from 'execa' +import { setTimeout } from 'timers/promises' +import { ProcessManager } from './process/ProcessManager.js' +import { logger } from '../utils/logger.js' +import { restoreTerminalState } from '../utils/terminal.js' +import type { DevServerStrategy, ForegroundOpts } from './DevServerStrategy.js' + +/** + * MetroDevServerStrategy implements DevServerStrategy for React Native's Metro bundler. + * This strategy starts Metro via `npx react-native start --port ` and uses + * ProcessManager for port-based detection and lifecycle management. + * + * Metro binds to a TCP port like any Node.js server, so ProcessManager.detectDevServer + * works for readiness checks. The key difference from NativeDevServerStrategy is the + * command used — Metro does not use package.json `dev` scripts. + */ +export class MetroDevServerStrategy implements DevServerStrategy { + private readonly processManager: ProcessManager + private readonly startupTimeout: number + private readonly checkInterval: number + private runningServers: Map = new Map() + + constructor( + processManager: ProcessManager, + startupTimeout: number, + checkInterval: number + ) { + this.processManager = processManager + this.startupTimeout = startupTimeout + this.checkInterval = checkInterval + } + + async isRunning(port: number): Promise { + const process = await this.processManager.detectDevServer(port) + return process !== null + } + + async startBackground( + worktreePath: string, + port: number, + envOverrides?: Record + ): Promise { + logger.debug(`Starting Metro bundler in background on port ${port}`) + + const serverProcess = execa('npx', ['react-native', 'start', '--port', port.toString()], { + cwd: worktreePath, + env: { + ...process.env, + ...envOverrides, + PORT: port.toString(), + }, + stdio: 'ignore', + detached: true, + }) + + // Attach no-op catch to suppress unhandled promise rejection if the process + // fails to spawn (e.g. npx not found). Actual errors are detected via waitForReady. + serverProcess.catch((error: unknown) => { + logger.debug(`Metro bundler process rejected: ${error instanceof Error ? error.message : 'Unknown error'}`) + }) + + this.runningServers.set(port, serverProcess) + + serverProcess.on('exit', () => { + this.runningServers.delete(port) + }) + + serverProcess.unref() + + logger.info(`Waiting for Metro bundler to start on port ${port}...`) + const ready = await this.waitForReady(port, serverProcess) + + if (!ready) { + // Clean up the zombie process before throwing — it may still be running + // and holding the port, causing subsequent attempts to fail with EADDRINUSE. + await this.stop(port) + throw new Error( + `Metro bundler failed to start within ${this.startupTimeout}ms timeout` + ) + } + + logger.success(`Metro bundler started successfully on port ${port}`) + } + + async startForeground( + worktreePath: string, + port: number, + opts: ForegroundOpts + ): Promise<{ pid?: number }> { + const { redirectToStderr = false, onProcessStarted, envOverrides, onOutput } = opts + + logger.debug(`Starting Metro bundler in foreground on port ${port}`) + + const metroArgs = ['react-native', 'start', '--port', port.toString()] + + // Determine stdio based on mode + const stdio = onOutput + ? (['ignore', 'pipe', 'pipe'] as const) + : redirectToStderr + ? ([process.stdin, process.stderr, process.stderr] as const) + : (['inherit', 'inherit', 'inherit'] as const) + + const serverProcess = execa('npx', metroArgs, { + cwd: worktreePath, + env: { + ...process.env, + ...envOverrides, + PORT: port.toString(), + }, + stdio, + }) + + // When onOutput is provided, pipe stdout/stderr to the callback + if (onOutput) { + serverProcess.stdout?.on('data', onOutput) + serverProcess.stderr?.on('data', onOutput) + } + + const processInfo: { pid?: number } = + serverProcess.pid !== undefined ? { pid: serverProcess.pid } : {} + + if (onProcessStarted) { + onProcessStarted(processInfo.pid) + } + + // Register no-op SIGINT handler to prevent signal-exit from re-raising SIGINT + // before finally blocks can run, ensuring terminal state is restored on Ctrl+C. + const onSigint = (): void => {} + process.on('SIGINT', onSigint) + + try { + await serverProcess + } catch (error) { + const execaError = error as ExecaError + // If killed by SIGINT, the user intentionally cancelled — return silently + if (execaError.signal !== 'SIGINT') { + throw error + } + } finally { + process.removeListener('SIGINT', onSigint) + restoreTerminalState() + } + + return processInfo + } + + async stop(port: number): Promise { + const serverProcess = this.runningServers.get(port) + if (!serverProcess) { + return false + } + + try { + // Kill the entire process group (negative PID) since the server is + // spawned with detached:true. Without this, only the npx process + // receives the signal and the actual Metro bundler remains running. + if (serverProcess.pid) { + process.kill(-serverProcess.pid, 'SIGTERM') + } else { + serverProcess.kill() + } + this.runningServers.delete(port) + return true + } catch (error) { + // ESRCH means the process already exited — not a real failure + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + this.runningServers.delete(port) + return true + } + logger.warn( + `Failed to kill Metro bundler process on port ${port}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + return false + } + } + + /** + * Stop all tracked Metro bundler processes. Called during cleanup. + */ + async stopAll(): Promise { + for (const [port] of this.runningServers.entries()) { + await this.stop(port) + } + } + + /** + * Wait for Metro bundler to be ready by polling the port. + * Exits early if the spawned process has already exited (crash detection). + * + * @param port - Port to poll + * @param processRef - Optional spawned process to monitor for early exit + */ + async waitForReady(port: number, processRef?: ExecaChildProcess): Promise { + const startTime = Date.now() + let attempts = 0 + + while (Date.now() - startTime < this.startupTimeout) { + attempts++ + + // Early exit: if the spawned process has already exited, stop polling + if (processRef && processRef.exitCode != null) { + logger.warn( + `Metro bundler process exited with code ${processRef.exitCode} before becoming ready (after ${attempts} attempts, ${Date.now() - startTime}ms)` + ) + return false + } + + const processInfo = await this.processManager.detectDevServer(port) + + if (processInfo) { + logger.debug( + `Metro bundler detected on port ${port} after ${attempts} attempts (${Date.now() - startTime}ms)` + ) + return true + } + + await setTimeout(this.checkInterval) + } + + logger.warn( + `Metro bundler did not start on port ${port} after ${this.startupTimeout}ms (${attempts} attempts)` + ) + return false + } +} diff --git a/src/lib/ProjectCapabilityDetector.test.ts b/src/lib/ProjectCapabilityDetector.test.ts index bc21c00c..6bcef569 100644 --- a/src/lib/ProjectCapabilityDetector.test.ts +++ b/src/lib/ProjectCapabilityDetector.test.ts @@ -2,22 +2,46 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ProjectCapabilityDetector } from './ProjectCapabilityDetector.js' import * as packageJsonUtils from '../utils/package-json.js' import type { PackageJson } from '../utils/package-json.js' +import fs from 'fs-extra' vi.mock('../utils/package-json.js', () => ({ getPackageConfig: vi.fn(), parseBinField: vi.fn(), hasWebDependencies: vi.fn(), + hasIosDependencies: vi.fn(), getExplicitCapabilities: vi.fn() })) +vi.mock('fs-extra', () => ({ + default: { + readdir: vi.fn(), + pathExists: vi.fn(), + readJson: vi.fn(), + } +})) + +vi.mock('../utils/logger-context.js', () => ({ + getLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })) +})) + describe('ProjectCapabilityDetector', () => { let detector: ProjectCapabilityDetector beforeEach(() => { - vi.clearAllMocks() detector = new ProjectCapabilityDetector() // Default: no explicit capabilities (fallback to package.json detection) vi.mocked(packageJsonUtils.getExplicitCapabilities).mockReturnValue([]) + // Default: no iOS dependencies + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValue(false) + // Default: no iOS filesystem markers + vi.mocked(fs.readdir).mockResolvedValue([] as unknown as Awaited>) + vi.mocked(fs.pathExists).mockResolvedValue(false as never) + vi.mocked(fs.readJson).mockResolvedValue({}) }) describe('detectCapabilities', () => { @@ -329,4 +353,333 @@ describe('ProjectCapabilityDetector', () => { expect(result.binEntries).toEqual({}) }) }) + + describe('iOS detection', () => { + describe('dependency-based detection', () => { + it('should detect iOS capability from react-native dependency', async () => { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: { 'react-native': '^0.73.0' } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS capability from expo dependency', async () => { + const mockPackageJson: PackageJson = { + name: 'my-expo-app', + dependencies: { expo: '^50.0.0' } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect hybrid web + iOS from React Native project with web deps', async () => { + const mockPackageJson: PackageJson = { + name: 'my-rn-web-app', + dependencies: { + 'react-native': '^0.73.0', + 'react-native-web': '^0.19.0', + next: '^14.0.0' + } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('web') + expect(result.capabilities).toContain('ios') + }) + }) + + describe('filesystem-based detection', () => { + it('should detect iOS from .xcodeproj file in root', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + // First readdir call: root dir. pathExists for ios/ returns false (default), so no second readdir. + vi.mocked(fs.readdir).mockResolvedValueOnce(['MyApp.xcodeproj', 'src'] as unknown as Awaited>) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS from .xcworkspace file in root', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.readdir).mockResolvedValueOnce(['MyApp.xcworkspace', 'Pods'] as unknown as Awaited>) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS from .xcodeproj file in ios/ subdirectory', async () => { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + // root has no Xcode files; ios/ subdir exists and has .xcodeproj + vi.mocked(fs.readdir).mockResolvedValueOnce([] as unknown as Awaited>) // root + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return (p as string).endsWith('/ios') + }) + vi.mocked(fs.readdir).mockResolvedValueOnce(['MyRNApp.xcodeproj'] as unknown as Awaited>) // ios/ + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS from Podfile in root', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return p.endsWith('/Podfile') && !p.includes('/ios/') + }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS from Podfile in ios/ subdirectory', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return (p as string).endsWith('/ios/Podfile') + }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should detect iOS from app.json with expo key (Expo)', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return (p as string).endsWith('/app.json') + }) + vi.mocked(fs.readJson).mockResolvedValueOnce({ expo: { name: 'my-app', slug: 'my-app' } }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + + it('should NOT detect iOS from generic app.json without expo or ios keys', async () => { + const mockPackageJson: PackageJson = { + name: 'my-node-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return (p as string).endsWith('/app.json') + }) + vi.mocked(fs.readJson).mockResolvedValueOnce({ name: 'my-app', scripts: {} }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).not.toContain('ios') + }) + + it('should detect iOS from app.config.js in root (Expo)', async () => { + const mockPackageJson: PackageJson = { + name: 'my-app', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists).mockImplementation(async (p: string) => { + return (p as string).endsWith('/app.config.js') + }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + }) + }) + + describe('platform guard', () => { + it('should skip iOS detection on linux and log debug message', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + try { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: { 'react-native': '^0.73.0' } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).not.toContain('ios') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('should skip iOS detection on win32 and log debug message', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + try { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: { 'react-native': '^0.73.0' } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).not.toContain('ios') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('should detect iOS on darwin', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: { 'react-native': '^0.73.0' } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('ios') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + }) + + describe('combined detection', () => { + it('should not duplicate ios capability when multiple markers present', async () => { + const mockPackageJson: PackageJson = { + name: 'my-rn-app', + dependencies: { 'react-native': '^0.73.0' } + } + + // Both dependency-based and filesystem-based markers present + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + // The detector returns early from dependency check, so filesystem won't be checked + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({}) + + const result = await detector.detectCapabilities('/test/path') + + const iosCount = result.capabilities.filter(c => c === 'ios').length + expect(iosCount).toBe(1) + }) + + it('should return cli + web + ios for a full-stack RN project with bin', async () => { + const mockPackageJson: PackageJson = { + name: 'my-full-stack', + bin: './dist/cli.js', + dependencies: { + 'react-native': '^0.73.0', + next: '^14.0.0' + } + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.hasIosDependencies).mockReturnValueOnce(true) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({ + 'my-full-stack': './dist/cli.js' + }) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toEqual(['cli', 'web', 'ios']) + }) + + it('should return empty capabilities when no package.json and no iOS markers on darwin', async () => { + const error = new Error('package.json not found in /test/path') + vi.mocked(packageJsonUtils.getPackageConfig).mockRejectedValueOnce(error) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toEqual([]) + expect(result.binEntries).toEqual({}) + }) + + it('should detect iOS from filesystem when package.json is missing on darwin', async () => { + const error = new Error('package.json not found in /test/path') + vi.mocked(packageJsonUtils.getPackageConfig).mockRejectedValueOnce(error) + vi.mocked(fs.readdir).mockResolvedValueOnce(['MyApp.xcodeproj'] as unknown as Awaited>) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toEqual(['ios']) + expect(result.binEntries).toEqual({}) + }) + }) + }) }) diff --git a/src/lib/ProjectCapabilityDetector.ts b/src/lib/ProjectCapabilityDetector.ts index 7c9a7b03..76d13a67 100644 --- a/src/lib/ProjectCapabilityDetector.ts +++ b/src/lib/ProjectCapabilityDetector.ts @@ -1,5 +1,9 @@ -import { getPackageConfig, parseBinField, hasWebDependencies, getExplicitCapabilities } from '../utils/package-json.js' +import fs from 'fs-extra' +import path from 'path' +import { getPackageConfig, parseBinField, hasWebDependencies, hasIosDependencies, getExplicitCapabilities } from '../utils/package-json.js' +import type { PackageJson } from '../utils/package-json.js' import type { ProjectCapability } from '../types/loom.js' +import { getLogger } from '../utils/logger-context.js' export interface ProjectCapabilities { capabilities: ProjectCapability[] @@ -13,6 +17,7 @@ export class ProjectCapabilityDetector { * Detection priority: * 1. Explicit capabilities from package.iloom.json (for non-Node.js projects) * 2. Inferred capabilities from package.json (bin field, web dependencies) + * 3. iOS detection from dependencies and filesystem markers (macOS only) * * @param worktreePath Path to the worktree directory * @returns Project capabilities and bin entries @@ -42,6 +47,11 @@ export class ProjectCapabilityDetector { capabilities.push('web') } + // iOS detection: dependency-based and filesystem-based (macOS only) + if (await this.detectIosCapability(worktreePath, pkgJson)) { + capabilities.push('ios') + } + // Parse bin entries for CLI projects const binEntries = pkgJson.bin ? parseBinField(pkgJson.bin, pkgJson.name) : {} @@ -49,10 +59,107 @@ export class ProjectCapabilityDetector { } catch (error) { // Handle missing package.json - return empty capabilities for non-Node.js projects if (error instanceof Error && error.message.includes('package.json not found')) { - return { capabilities: [], binEntries: {} } + // Still check for iOS filesystem markers even without package.json + const capabilities: ProjectCapability[] = [] + if (await this.detectIosCapability(worktreePath, null)) { + capabilities.push('ios') + } + return { capabilities, binEntries: {} } } // Re-throw other errors (invalid JSON, etc.) throw error } } + + /** + * Detect iOS capability from package dependencies and filesystem markers. + * Only runs on macOS - logs a debug message and returns false on other platforms. + * + * @param worktreePath Path to the worktree directory + * @param pkgJson Parsed package.json, or null if not available + * @returns true if iOS markers are found on macOS + */ + private async detectIosCapability(worktreePath: string, pkgJson: PackageJson | null): Promise { + if (process.platform !== 'darwin') { + getLogger().debug('Skipping iOS detection: not running on macOS') + return false + } + + // Check dependency-based markers + if (pkgJson && hasIosDependencies(pkgJson)) { + return true + } + + // Check filesystem-based markers + return this.hasIosFilesystemMarkers(worktreePath) + } + + /** + * Check for iOS-specific filesystem markers in the project directory. + * Looks for Xcode project files, Podfiles, and Expo config files. + * + * @param worktreePath Path to the worktree directory + * @returns true if any iOS filesystem markers are found + */ + private async hasIosFilesystemMarkers(worktreePath: string): Promise { + // Check for .xcodeproj or .xcworkspace in root and ios/ subdirectory + const rootEntries = await this.readdirSafe(worktreePath) + const iosSubdir = path.join(worktreePath, 'ios') + const iosEntries = (await fs.pathExists(iosSubdir)) ? await this.readdirSafe(iosSubdir) : [] + const allEntries = [...rootEntries, ...iosEntries] + const hasXcodeProject = allEntries.some( + entry => entry.endsWith('.xcodeproj') || entry.endsWith('.xcworkspace') + ) + if (hasXcodeProject) { + return true + } + + // Check for Podfile in root + if (await fs.pathExists(path.join(worktreePath, 'Podfile'))) { + return true + } + + // Check for Podfile in ios/ subdirectory + if (await fs.pathExists(path.join(worktreePath, 'ios', 'Podfile'))) { + return true + } + + // Check for Expo config files — parse app.json to verify Expo-specific keys + const appJsonPath = path.join(worktreePath, 'app.json') + if (await fs.pathExists(appJsonPath)) { + try { + const content = await fs.readJson(appJsonPath) + if (content?.expo || content?.ios) { + return true + } + } catch { + // Malformed JSON - skip this marker + } + } + + if (await fs.pathExists(path.join(worktreePath, 'app.config.js'))) { + return true + } + + return false + } + + /** + * Read directory entries, only swallowing ENOENT/ENOTDIR errors. + * Permission errors and other unexpected errors are re-thrown. + */ + private async readdirSafe(dirPath: string): Promise { + try { + return await fs.readdir(dirPath) + } catch (error: unknown) { + const isExpectedError = + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + if (!isExpectedError) { + throw error + } + return [] + } + } } diff --git a/src/lib/SettingsManager.test.ts b/src/lib/SettingsManager.test.ts index 8aeffd18..b1ca01a8 100644 --- a/src/lib/SettingsManager.test.ts +++ b/src/lib/SettingsManager.test.ts @@ -3961,7 +3961,7 @@ const error: { code?: string; message: string } = { }) -import { DevServerSettingsSchema, DevServerSettingsSchemaNoDefaults } from './SettingsManager.js' +import { DevServerSettingsSchema, DevServerSettingsSchemaNoDefaults, CapabilitiesSettingsSchema, CapabilitiesSettingsSchemaNoDefaults } from './SettingsManager.js' describe('DevServerSettingsSchema', () => { describe('valid configs', () => { @@ -4104,3 +4104,77 @@ describe('DevServerSettingsSchemaNoDefaults', () => { ).toThrow('dockerFile must be a relative path') }) }) + +describe('CapabilitiesSettingsSchema - iOS settings', () => { + it('should accept capabilities.ios settings with all fields', () => { + const input = { + ios: { + simulatorDevice: 'iPhone 16', + scheme: 'MyApp', + bundleId: 'com.example.MyApp', + configuration: 'Release' as const, + deployTarget: 'device' as const, + developmentTeam: 'ABC123DEF4', + }, + } + const result = CapabilitiesSettingsSchema.parse(input) + expect(result.ios).toMatchObject(input.ios) + }) + + it('should accept capabilities.ios with only optional fields omitted', () => { + const input = { ios: {} } + const result = CapabilitiesSettingsSchema.parse(input) + expect(result.ios).toBeDefined() + expect(result.ios!.configuration).toBe('Debug') + expect(result.ios!.deployTarget).toBe('simulator') + }) + + it('should use Debug as default configuration', () => { + const result = CapabilitiesSettingsSchema.parse({ ios: {} }) + expect(result.ios!.configuration).toBe('Debug') + }) + + it('should use simulator as default deployTarget', () => { + const result = CapabilitiesSettingsSchema.parse({ ios: {} }) + expect(result.ios!.deployTarget).toBe('simulator') + }) + + it('should reject invalid configuration value', () => { + expect(() => + CapabilitiesSettingsSchema.parse({ ios: { configuration: 'Profile' } }), + ).toThrow(/Invalid enum value/) + }) + + it('should reject invalid deployTarget value', () => { + expect(() => + CapabilitiesSettingsSchema.parse({ ios: { deployTarget: 'emulator' } }), + ).toThrow(/Invalid enum value/) + }) +}) + +describe('CapabilitiesSettingsSchemaNoDefaults - iOS settings', () => { + it('should not apply defaults for configuration', () => { + const result = CapabilitiesSettingsSchemaNoDefaults.parse({ ios: {} }) + expect(result.ios!.configuration).toBeUndefined() + }) + + it('should not apply defaults for deployTarget', () => { + const result = CapabilitiesSettingsSchemaNoDefaults.parse({ ios: {} }) + expect(result.ios!.deployTarget).toBeUndefined() + }) + + it('should accept valid iOS config without applying defaults', () => { + const input = { + ios: { + simulatorDevice: 'iPhone 16 Pro', + scheme: 'MyApp', + bundleId: 'com.example.MyApp', + configuration: 'Release' as const, + deployTarget: 'device' as const, + developmentTeam: 'XYZ789', + }, + } + const result = CapabilitiesSettingsSchemaNoDefaults.parse(input) + expect(result.ios).toMatchObject(input.ios) + }) +}) diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index a54274ae..54b46dbb 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -273,6 +273,35 @@ export const CapabilitiesSettingsSchema = z }) .optional() .describe('Web dev server settings. To declare a project as a web project, add "web" to the capabilities array in .iloom/package.iloom.json or .iloom/package.iloom.local.json.'), + ios: z + .object({ + simulatorDevice: z + .string() + .optional() + .describe('iOS Simulator device name (e.g., "iPhone 16")'), + scheme: z + .string() + .optional() + .describe('Xcode scheme name for building the project'), + bundleId: z + .string() + .optional() + .describe('Application bundle identifier (e.g., "com.example.MyApp")'), + configuration: z + .enum(['Debug', 'Release']) + .default('Debug') + .describe('Xcode build configuration'), + deployTarget: z + .enum(['simulator', 'device']) + .default('simulator') + .describe('Target for deployment: simulator or physical device'), + developmentTeam: z + .string() + .optional() + .describe('Apple Development Team ID for code signing on physical devices'), + }) + .optional() + .describe('iOS project settings. To declare a project as an iOS project, add "ios" to the capabilities array in .iloom/package.iloom.json or .iloom/package.iloom.local.json.'), database: z .object({ databaseUrlEnvVarName: z @@ -337,6 +366,35 @@ export const CapabilitiesSettingsSchemaNoDefaults = z }) .optional() .describe('Web dev server settings. To declare a project as a web project, add "web" to the capabilities array in .iloom/package.iloom.json or .iloom/package.iloom.local.json.'), + ios: z + .object({ + simulatorDevice: z + .string() + .optional() + .describe('iOS Simulator device name (e.g., "iPhone 16")'), + scheme: z + .string() + .optional() + .describe('Xcode scheme name for building the project'), + bundleId: z + .string() + .optional() + .describe('Application bundle identifier (e.g., "com.example.MyApp")'), + configuration: z + .enum(['Debug', 'Release']) + .optional() + .describe('Xcode build configuration'), + deployTarget: z + .enum(['simulator', 'device']) + .optional() + .describe('Target for deployment: simulator or physical device'), + developmentTeam: z + .string() + .optional() + .describe('Apple Development Team ID for code signing on physical devices'), + }) + .optional() + .describe('iOS project settings.'), database: z .object({ databaseUrlEnvVarName: z diff --git a/src/types/loom.ts b/src/types/loom.ts index 4c8f4c5e..a8d9739c 100644 --- a/src/types/loom.ts +++ b/src/types/loom.ts @@ -1,4 +1,4 @@ -export type ProjectCapability = 'cli' | 'web' +export type ProjectCapability = 'cli' | 'web' | 'ios' export type Capability = ProjectCapability export interface Loom { diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts index 453b001d..d945b765 100644 --- a/src/types/telemetry.ts +++ b/src/types/telemetry.ts @@ -29,6 +29,7 @@ export interface LoomCreatedProperties { one_shot_mode: 'default' | 'skip-reviews' | 'yolo' complexity_override: boolean create_only: boolean + has_ios_capability: boolean } export interface LoomFinishedProperties { @@ -143,6 +144,15 @@ export interface DevServerStoppedEvent { reason: 'user' | 'cleanup' | 'error' } +export interface IOSCommandInvokedProperties { + /** Which command invoked iOS behavior */ + command: 'open' | 'run' | 'dev-server' + /** Whether it's a React Native project (has both web+ios) or native iOS */ + is_react_native: boolean + /** Whether the command succeeded */ + success: boolean +} + // --- Event name → properties map (for type-safe track() in downstream issues) --- export interface TelemetryEventMap { 'cli.installed': CliInstalledProperties @@ -166,6 +176,7 @@ export interface TelemetryEventMap { 'epic.report_generated': EpicReportGeneratedProperties 'devServer.started': DevServerStartedEvent 'devServer.stopped': DevServerStoppedEvent + 'ios.command_invoked': IOSCommandInvokedProperties } export type TelemetryEventName = keyof TelemetryEventMap diff --git a/src/utils/ios.test.ts b/src/utils/ios.test.ts new file mode 100644 index 00000000..841472fc --- /dev/null +++ b/src/utils/ios.test.ts @@ -0,0 +1,807 @@ +import path from 'node:path' + +import { describe, it, expect, vi } from 'vitest' +import { execa } from 'execa' +import fs from 'fs-extra' + +import { + assertIOSAvailable, + assertMacOS, + MacOSRequiredError, + isReactNativeProject, + listSimulators, + bootSimulator, + shutdownSimulator, + installApp, + launchApp, + buildForSimulator, + buildForDevice, + listConnectedDevices, + trackSimulator, + getTrackedSimulator, + clearTrackedSimulator, +} from './ios.js' + +vi.mock('execa') +vi.mock('fs-extra') +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + }, +})) + +// --- Platform Guard: assertMacOS --- + +describe('assertMacOS', () => { + it('should not throw on darwin platform', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + expect(() => assertMacOS()).not.toThrow() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('should throw MacOSRequiredError on linux platform', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + try { + expect(() => assertMacOS()).toThrow(MacOSRequiredError) + expect(() => assertMacOS()).toThrow('iOS development requires macOS') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('should throw MacOSRequiredError on win32 platform', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + try { + expect(() => assertMacOS()).toThrow(MacOSRequiredError) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- MacOSRequiredError --- + +describe('MacOSRequiredError', () => { + it('should have correct name and message', () => { + const error = new MacOSRequiredError() + expect(error.name).toBe('MacOSRequiredError') + expect(error.message).toContain('iOS development requires macOS') + expect(error.message).toContain('Xcode') + expect(error).toBeInstanceOf(Error) + }) +}) + +// --- Capability Helpers --- + +describe('isReactNativeProject', () => { + it('should return true when project has both web and ios capabilities', () => { + expect(isReactNativeProject(['web', 'ios'])).toBe(true) + }) + + it('should return false when project has only ios capability', () => { + expect(isReactNativeProject(['ios'])).toBe(false) + }) + + it('should return false when project has only web capability', () => { + expect(isReactNativeProject(['web'])).toBe(false) + }) + + it('should return false when project has no capabilities', () => { + expect(isReactNativeProject([])).toBe(false) + }) + + it('should return true when project has web, ios, and cli capabilities', () => { + expect(isReactNativeProject(['web', 'ios', 'cli'])).toBe(true) + }) +}) + +// --- Platform Guard: assertIOSAvailable --- + +describe('assertIOSAvailable', () => { + it('succeeds on macOS with xcode-select present', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa).mockResolvedValue({ + exitCode: 0, + stdout: '/Applications/Xcode.app/Contents/Developer', + stderr: '', + } as never) + + await expect(assertIOSAvailable()).resolves.toBeUndefined() + expect(execa).toHaveBeenCalledWith('xcode-select', ['-p']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws on non-macOS platforms with MacOSRequiredError', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + try { + await expect(assertIOSAvailable()).rejects.toThrow(MacOSRequiredError) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws on macOS without Xcode CLI tools', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa).mockRejectedValue( + new Error('xcode-select: error: command line tools are not installed') + ) + + await expect(assertIOSAvailable()).rejects.toThrow( + 'Xcode Command Line Tools are not installed' + ) + await expect(assertIOSAvailable()).rejects.toThrow('xcode-select --install') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- Simulator Management --- + +describe('listSimulators', () => { + const setDarwin = () => { + const orig = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + return orig + } + + it('parses xcrun simctl list output and returns simulator array', async () => { + const originalPlatform = setDarwin() + try { + const simctlOutput = { + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'AAA-BBB', name: 'iPhone 16', state: 'Shutdown', isAvailable: true }, + { udid: 'CCC-DDD', name: 'iPad Pro', state: 'Booted', isAvailable: true }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-17-5': [ + { udid: 'EEE-FFF', name: 'iPhone 15', state: 'Shutdown', isAvailable: true }, + ], + }, + } + + vi.mocked(execa) + // assertIOSAvailable call + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + // simctl list call + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify(simctlOutput), + stderr: '', + } as never) + + const simulators = await listSimulators() + + expect(simulators).toHaveLength(3) + expect(simulators[0]).toEqual({ + udid: 'AAA-BBB', + name: 'iPhone 16', + state: 'Shutdown', + runtime: 'iOS 18.0', + }) + expect(simulators[1]).toEqual({ + udid: 'CCC-DDD', + name: 'iPad Pro', + state: 'Booted', + runtime: 'iOS 18.0', + }) + expect(simulators[2]).toEqual({ + udid: 'EEE-FFF', + name: 'iPhone 15', + state: 'Shutdown', + runtime: 'iOS 17.5', + }) + + expect(execa).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'devices', 'available', '-j']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws when xcrun fails', async () => { + const originalPlatform = setDarwin() + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(new Error('xcrun failed')) + + await expect(listSimulators()).rejects.toThrow('xcrun failed') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +describe('bootSimulator', () => { + it('boots simulator by UDID', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await expect(bootSimulator('AAA-BBB')).resolves.toBeUndefined() + expect(execa).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAA-BBB']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('succeeds silently when simulator already booted (exit code 149)', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const alreadyBootedError = Object.assign( + new Error('Unable to boot device in current state: Booted'), + { exitCode: 149 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(alreadyBootedError) + + await expect(bootSimulator('AAA-BBB')).resolves.toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws on other xcrun errors', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const otherError = Object.assign( + new Error('Invalid device UDID'), + { exitCode: 1 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(otherError) + + await expect(bootSimulator('INVALID')).rejects.toThrow('Invalid device UDID') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +describe('shutdownSimulator', () => { + it('shuts down simulator by UDID', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await expect(shutdownSimulator('AAA-BBB')).resolves.toBeUndefined() + expect(execa).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'AAA-BBB']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('succeeds when simulator is already shut down', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const alreadyShutdownError = Object.assign( + new Error('Unable to shutdown device'), + { stderr: 'Unable to shutdown device in current state: Shutdown', exitCode: 1 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(alreadyShutdownError) + + await expect(shutdownSimulator('AAA-BBB')).resolves.toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +describe('installApp', () => { + it('installs .app bundle on simulator by UDID', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await expect(installApp('AAA-BBB', '/path/to/MyApp.app')).resolves.toBeUndefined() + expect(execa).toHaveBeenCalledWith('xcrun', ['simctl', 'install', 'AAA-BBB', '/path/to/MyApp.app']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws when install fails', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(new Error('Unable to install app')) + + await expect(installApp('AAA-BBB', '/bad/path.app')).rejects.toThrow('Unable to install app') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +describe('launchApp', () => { + it('launches app by bundleId on simulator', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await expect(launchApp('AAA-BBB', 'com.example.MyApp')).resolves.toBeUndefined() + expect(execa).toHaveBeenCalledWith('xcrun', ['simctl', 'launch', 'AAA-BBB', 'com.example.MyApp']) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws when launch fails', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(new Error('App not found')) + + await expect(launchApp('AAA-BBB', 'com.bad.app')).rejects.toThrow('App not found') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- xcodebuild Wrapper --- + +describe('buildForSimulator', () => { + it('calls xcodebuild with correct simulator destination args', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await buildForSimulator({ + workspacePath: '/path/to/MyApp.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 16', + derivedDataPath: '/tmp/DerivedData', + }) + + expect(execa).toHaveBeenCalledWith('xcodebuild', [ + '-workspace', '/path/to/MyApp.xcworkspace', + '-scheme', 'MyApp', + '-configuration', 'Debug', + '-derivedDataPath', '/tmp/DerivedData', + '-destination', 'platform=iOS Simulator,name=iPhone 16', + '-sdk', 'iphonesimulator', + 'build', + ]) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('includes scheme, configuration, and derivedDataPath in args', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await buildForSimulator({ + projectPath: '/path/to/MyApp.xcodeproj', + scheme: 'MyApp', + configuration: 'Release', + derivedDataPath: '/tmp/DerivedData', + }) + + expect(execa).toHaveBeenCalledWith('xcodebuild', [ + '-project', '/path/to/MyApp.xcodeproj', + '-scheme', 'MyApp', + '-configuration', 'Release', + '-derivedDataPath', '/tmp/DerivedData', + '-destination', 'platform=iOS Simulator,name=iPhone 16', + '-sdk', 'iphonesimulator', + 'build', + ]) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('surfaces xcodebuild stderr on failure', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const buildError = Object.assign( + new Error('xcodebuild failed'), + { stderr: 'error: Scheme "BadScheme" is not found in the project', exitCode: 65 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(buildError) + + await expect( + buildForSimulator({ scheme: 'BadScheme', derivedDataPath: '/tmp/DerivedData' }) + ).rejects.toThrow('Scheme "BadScheme" is not found') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('surfaces xcodebuild stdout on failure when stderr is empty', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const buildError = Object.assign( + new Error('xcodebuild failed'), + { stderr: '', stdout: 'BUILD FAILED\nerror: Missing target "MyApp"', exitCode: 65 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(buildError) + + await expect( + buildForSimulator({ scheme: 'MyApp', derivedDataPath: '/tmp/DerivedData' }) + ).rejects.toThrow('Missing target "MyApp"') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws when derivedDataPath is not provided', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await expect( + buildForSimulator({ scheme: 'MyApp' }) + ).rejects.toThrow('derivedDataPath is required') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('returns the derived data products path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + const result = await buildForSimulator({ + scheme: 'MyApp', + derivedDataPath: '/custom/DerivedData', + }) + + expect(result).toBe( + path.join('/custom/DerivedData', 'Build', 'Products', 'Debug-iphonesimulator') + ) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +describe('buildForDevice', () => { + it('calls xcodebuild with device destination and developmentTeam', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await buildForDevice({ + workspacePath: '/path/to/MyApp.xcworkspace', + scheme: 'MyApp', + developmentTeam: 'ABC123DEF', + deviceUDID: '00008101-001A2B3C4D5E6F7G', + }) + + expect(execa).toHaveBeenCalledWith('xcodebuild', [ + '-workspace', '/path/to/MyApp.xcworkspace', + '-scheme', 'MyApp', + '-configuration', 'Debug', + '-destination', 'platform=iOS,id=00008101-001A2B3C4D5E6F7G', + 'DEVELOPMENT_TEAM=ABC123DEF', + 'build', + ], undefined) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('uses generic platform destination when no deviceUDID specified', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' } as never) + + await buildForDevice({ + scheme: 'MyApp', + developmentTeam: 'ABC123DEF', + }) + + expect(execa).toHaveBeenCalledWith('xcodebuild', expect.arrayContaining([ + '-destination', 'generic/platform=iOS', + ]), undefined) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('throws with xcodebuild stderr on signing failure', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const signingError = Object.assign( + new Error('xcodebuild failed'), + { + stderr: 'error: No signing certificate "iOS Development" found', + exitCode: 65, + } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(signingError) + + await expect( + buildForDevice({ scheme: 'MyApp', developmentTeam: 'BAD_TEAM' }) + ).rejects.toThrow('No signing certificate') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('surfaces xcodebuild stdout on failure when stderr is empty', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const buildError = Object.assign( + new Error('xcodebuild failed'), + { stderr: '', stdout: 'error: No profile matching team ID', exitCode: 65 } + ) + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockRejectedValueOnce(buildError) + + await expect( + buildForDevice({ scheme: 'MyApp', developmentTeam: 'BAD_TEAM' }) + ).rejects.toThrow('No profile matching team ID') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- Device Management --- + +describe('listConnectedDevices', () => { + it('parses xcrun xctrace list devices output', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const xctraceOutput = [ + '== Devices ==', + "Adam's iPhone (17.5) (00008101-001A2B3C4D5E6F7G)", + 'iPad Pro (18.0) (00008103-AAAA-BBBB-CCCC)', + '', + '== Simulators ==', + 'iPhone 16 Simulator (18.0) (AAA-BBB-CCC)', + ].join('\n') + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: xctraceOutput, stderr: '' } as never) + + const devices = await listConnectedDevices() + + expect(devices).toHaveLength(2) + expect(devices[0]).toEqual({ + name: "Adam's iPhone", + udid: '00008101-001A2B3C4D5E6F7G', + }) + expect(devices[1]).toEqual({ + name: 'iPad Pro', + udid: '00008103-AAAA-BBBB-CCCC', + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('returns empty array when no devices connected', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + const xctraceOutput = [ + '== Devices ==', + '', + '== Simulators ==', + 'iPhone 16 Simulator (18.0) (AAA-BBB-CCC)', + ].join('\n') + + vi.mocked(execa) + .mockResolvedValueOnce({ exitCode: 0, stdout: '/path', stderr: '' } as never) + .mockResolvedValueOnce({ exitCode: 0, stdout: xctraceOutput, stderr: '' } as never) + + const devices = await listConnectedDevices() + + expect(devices).toHaveLength(0) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- Simulator UDID Tracking --- + +describe('trackSimulator / getTrackedSimulator / clearTrackedSimulator', () => { + it('stores and retrieves simulator UDID by worktree identifier', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + let stored: Record = {} + + vi.mocked(fs.readJson).mockImplementation(async () => stored) + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never) + vi.mocked(fs.writeJson).mockImplementation(async (_path: string, data: unknown) => { + stored = data as Record + }) + + await trackSimulator('issue-123', 'AAA-BBB-CCC') + + expect(stored['issue-123']).toBe('AAA-BBB-CCC') + expect(fs.writeJson).toHaveBeenCalledWith( + expect.stringContaining('ios-simulators.json'), + expect.objectContaining({ 'issue-123': 'AAA-BBB-CCC' }), + { spaces: 2 } + ) + + const result = await getTrackedSimulator('issue-123') + expect(result).toBe('AAA-BBB-CCC') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('returns null when no simulator tracked', async () => { + vi.mocked(fs.readJson).mockResolvedValue({}) + + const result = await getTrackedSimulator('nonexistent') + + expect(result).toBeNull() + }) + + it('returns null when tracking file does not exist', async () => { + vi.mocked(fs.readJson).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ) + + const result = await getTrackedSimulator('issue-123') + + expect(result).toBeNull() + }) + + it('rethrows non-ENOENT errors from tracking file', async () => { + vi.mocked(fs.readJson).mockRejectedValue( + Object.assign(new Error('Permission denied'), { code: 'EACCES' }) + ) + + await expect(getTrackedSimulator('issue-123')).rejects.toThrow('Permission denied') + }) + + it('overwrites existing tracking entry', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + let stored: Record = { 'issue-123': 'OLD-UDID' } + + vi.mocked(fs.readJson).mockImplementation(async () => stored) + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never) + vi.mocked(fs.writeJson).mockImplementation(async (_path: string, data: unknown) => { + stored = data as Record + }) + + await trackSimulator('issue-123', 'NEW-UDID') + + expect(stored['issue-123']).toBe('NEW-UDID') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('clears a tracked simulator entry', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + let stored: Record = { 'issue-123': 'AAA-BBB', 'issue-456': 'CCC-DDD' } + + vi.mocked(fs.readJson).mockImplementation(async () => ({ ...stored })) + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never) + vi.mocked(fs.writeJson).mockImplementation(async (_path: string, data: unknown) => { + stored = data as Record + }) + + await clearTrackedSimulator('issue-123') + + expect(stored['issue-123']).toBeUndefined() + expect(stored['issue-456']).toBe('CCC-DDD') + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) +}) + +// --- Platform Guard Behavior (parameterized) --- + +describe('platform guard behavior', () => { + const functionsToTest = [ + { name: 'listSimulators', fn: () => listSimulators() }, + { name: 'bootSimulator', fn: () => bootSimulator('AAA') }, + { name: 'shutdownSimulator', fn: () => shutdownSimulator('AAA') }, + { name: 'installApp', fn: () => installApp('AAA', '/path.app') }, + { name: 'launchApp', fn: () => launchApp('AAA', 'com.example') }, + { name: 'buildForSimulator', fn: () => buildForSimulator({ scheme: 'X' }) }, + { name: 'buildForDevice', fn: () => buildForDevice({ scheme: 'X', developmentTeam: 'T' }) }, + { name: 'listConnectedDevices', fn: () => listConnectedDevices() }, + ] + + it.each(functionsToTest)( + '$name throws on non-macOS', + async ({ fn }) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + try { + await expect(fn()).rejects.toThrow(MacOSRequiredError) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + } + ) +}) diff --git a/src/utils/ios.ts b/src/utils/ios.ts new file mode 100644 index 00000000..1d55e383 --- /dev/null +++ b/src/utils/ios.ts @@ -0,0 +1,493 @@ +import os from 'node:os' +import path from 'node:path' + +import { execa } from 'execa' +import fs from 'fs-extra' + +import { logger } from './logger.js' +import type { ProjectCapability } from '../types/loom.js' + +// --- Types --- + +export interface SimulatorInfo { + udid: string + name: string + state: string // 'Booted' | 'Shutdown' | etc. + runtime: string // e.g. 'iOS 18.0' +} + +export interface ConnectedDevice { + udid: string + name: string +} + +export interface XcodeBuildOptions { + workspacePath?: string // path to .xcworkspace + projectPath?: string // path to .xcodeproj (used if no workspace) + scheme: string + configuration?: string // default 'Debug' + derivedDataPath?: string +} + +export interface SimulatorBuildOptions extends XcodeBuildOptions { + simulatorName?: string // e.g. 'iPhone 16' — used in destination filter +} + +export interface DeviceBuildOptions extends XcodeBuildOptions { + deviceUDID?: string + developmentTeam: string + extraArgs?: string[] + cwd?: string +} + +// --- Constants --- + +const TRACKING_DIR = path.join(os.homedir(), '.config', 'iloom-ai') +const TRACKING_FILE = path.join(TRACKING_DIR, 'ios-simulators.json') + +// --- Error Classes --- + +/** + * Error thrown when an iOS operation is attempted on a non-macOS platform. + */ +export class MacOSRequiredError extends Error { + constructor() { + super( + 'iOS development requires macOS. ' + + 'Please run this command on a Mac with Xcode installed.' + ) + this.name = 'MacOSRequiredError' + } +} + +// --- Platform Guard --- + +/** + * Assert that the current platform is macOS. + * Throws MacOSRequiredError if not running on darwin. + */ +export function assertMacOS(): void { + if (process.platform !== 'darwin') { + throw new MacOSRequiredError() + } +} + +/** + * Assert that iOS development tools are available. + * Checks that we're on macOS and that Xcode command line tools are installed. + * Throws a clear, actionable error if either check fails. + */ +export async function assertIOSAvailable(): Promise { + assertMacOS() + + try { + await execa('xcode-select', ['-p']) + } catch { + throw new Error( + 'Xcode Command Line Tools are not installed. ' + + 'Please install them by running: xcode-select --install' + ) + } +} + +// --- Capability Helpers --- + +/** + * Check if a project is a React Native project (has both 'web' and 'ios' capabilities). + * React Native projects use Metro bundler and have web capabilities alongside iOS. + */ +export function isReactNativeProject(capabilities: ProjectCapability[]): boolean { + return capabilities.includes('web') && capabilities.includes('ios') +} + +// --- iOS Project Operations --- + +/** + * Open an iOS project — opens Xcode for both React Native and native iOS projects. + * For React Native, opens the `ios/` subdirectory (which contains the .xcworkspace). + * For native iOS, searches for a .xcworkspace or .xcodeproj in the worktree root. + * + * @param worktreePath Path to the worktree directory + * @param capabilities Detected project capabilities + */ +export async function openIOSProject( + worktreePath: string, + capabilities: ProjectCapability[] +): Promise { + assertMacOS() + + if (isReactNativeProject(capabilities)) { + logger.info('Opening Xcode for React Native iOS project...') + // React Native convention: the iOS project lives in the `ios/` subdirectory + const iosDirPath = path.join(worktreePath, 'ios') + await execa('open', ['-a', 'Xcode', iosDirPath], { + stdio: 'inherit', + cwd: worktreePath, + }) + logger.success('Xcode opened for React Native iOS project') + } else { + logger.info('Opening Xcode project...') + // Native iOS: open the .xcworkspace or .xcodeproj in Xcode + await execa('open', ['-a', 'Xcode', worktreePath], { + stdio: 'inherit', + cwd: worktreePath, + }) + logger.success('Xcode project opened') + } +} + +/** + * Build and run an iOS app on the configured deploy target (simulator or device). + * React Native projects use `npx react-native run-ios`. + * Native iOS projects use `xcodebuild` directly. + * + * @param worktreePath Path to the worktree directory + * @param capabilities Detected project capabilities + * @param args Additional arguments to pass to the build/run command + */ +export async function buildAndRunIOS( + worktreePath: string, + capabilities: ProjectCapability[], + args: string[] = [] +): Promise { + assertMacOS() + + if (isReactNativeProject(capabilities)) { + logger.info('Building and running React Native iOS app...') + await execa('npx', ['react-native', 'run-ios', ...args], { + stdio: 'inherit', + cwd: worktreePath, + }) + } else { + logger.info('Building and running native iOS app...') + // Native iOS: use xcodebuild to build and run on simulator or device + await execa('xcodebuild', ['-allowProvisioningUpdates', ...args], { + stdio: 'inherit', + cwd: worktreePath, + }) + } + logger.success('iOS app launched') +} + +// --- Simulator Management --- + +/** + * List available iOS simulators. + * Calls `xcrun simctl list devices available -j` and parses the JSON output. + * @returns Array of available simulators with UDID, name, state, and runtime + */ +export async function listSimulators(): Promise { + await assertIOSAvailable() + + const result = await execa('xcrun', ['simctl', 'list', 'devices', 'available', '-j']) + const parsed: unknown = JSON.parse(result.stdout) + + if ( + typeof parsed !== 'object' || + parsed === null || + !('devices' in parsed) || + typeof (parsed as Record).devices !== 'object' + ) { + return [] + } + + const devices = (parsed as { devices: Record }).devices + const simulators: SimulatorInfo[] = [] + + for (const [runtimeIdentifier, deviceList] of Object.entries(devices)) { + if (!Array.isArray(deviceList)) continue + + // Extract human-readable runtime from identifier + // e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-0" -> "iOS 18.0" + const runtimeMatch = /SimRuntime\.(.+)$/.exec(runtimeIdentifier) + const runtime = runtimeMatch?.[1] + ? runtimeMatch[1].replace(/-/g, '.').replace(/\.(\d)/, ' $1') + : runtimeIdentifier + + for (const device of deviceList) { + const d = device as Record + if (typeof d.udid === 'string' && typeof d.name === 'string') { + simulators.push({ + udid: d.udid, + name: d.name, + state: typeof d.state === 'string' ? d.state : 'Unknown', + runtime, + }) + } + } + } + + return simulators +} + +/** + * Boot an iOS simulator by UDID. + * Handles the case where the simulator is already booted gracefully. + * @param udid - The UDID of the simulator to boot + */ +export async function bootSimulator(udid: string): Promise { + await assertIOSAvailable() + + try { + await execa('xcrun', ['simctl', 'boot', udid]) + } catch (error: unknown) { + // simctl returns exit code 149 when the simulator is already booted + if ( + error instanceof Error && + 'exitCode' in error && + (error as { exitCode: number }).exitCode === 149 + ) { + return // Already booted — not an error + } + throw error + } +} + +/** + * Shutdown an iOS simulator by UDID. + * Handles the case where the simulator is already shut down gracefully. + * @param udid - The UDID of the simulator to shut down + */ +export async function shutdownSimulator(udid: string): Promise { + await assertIOSAvailable() + + try { + await execa('xcrun', ['simctl', 'shutdown', udid]) + } catch (error: unknown) { + // Gracefully handle when the simulator is already shut down. + // Match on the stable substring rather than the full localized message. + if ( + error instanceof Error && + 'stderr' in error && + typeof (error as { stderr: unknown }).stderr === 'string' && + (error as { stderr: string }).stderr.includes('current state: Shutdown') + ) { + return + } + throw error + } +} + +/** + * Install an .app bundle on a simulator. + * @param udid - The UDID of the target simulator + * @param appPath - Path to the .app bundle to install + */ +export async function installApp(udid: string, appPath: string): Promise { + await assertIOSAvailable() + await execa('xcrun', ['simctl', 'install', udid, appPath]) +} + +/** + * Launch an app by bundle identifier on a simulator. + * @param udid - The UDID of the target simulator + * @param bundleId - The bundle identifier of the app to launch + */ +export async function launchApp(udid: string, bundleId: string): Promise { + await assertIOSAvailable() + await execa('xcrun', ['simctl', 'launch', udid, bundleId]) +} + +// --- xcodebuild Wrapper --- + +/** + * Build common xcodebuild arguments from options. + */ +function buildBaseArgs(options: XcodeBuildOptions): string[] { + const args: string[] = [] + + if (options.workspacePath) { + args.push('-workspace', options.workspacePath) + } else if (options.projectPath) { + args.push('-project', options.projectPath) + } + + args.push('-scheme', options.scheme) + args.push('-configuration', options.configuration ?? 'Debug') + + if (options.derivedDataPath) { + args.push('-derivedDataPath', options.derivedDataPath) + } + + return args +} + +/** + * Build an iOS app for the simulator. + * @param options - Build options including scheme, configuration, and simulator name + * @returns Path to the built .app bundle in DerivedData + */ +export async function buildForSimulator(options: SimulatorBuildOptions): Promise { + await assertIOSAvailable() + + const args = buildBaseArgs(options) + const simulatorName = options.simulatorName ?? 'iPhone 16' + args.push('-destination', `platform=iOS Simulator,name=${simulatorName}`) + args.push('-sdk', 'iphonesimulator') + args.push('build') + + try { + await execa('xcodebuild', args) + } catch (error: unknown) { + const e = error as { stderr?: string; stdout?: string } + const output = e.stderr !== '' && e.stderr != null ? e.stderr : (e.stdout ?? '') + throw new Error(`xcodebuild failed for simulator build:\n${output}`) + } + + if (!options.derivedDataPath) { + throw new Error( + 'derivedDataPath is required to locate the built .app bundle. ' + + 'Xcode places builds in a hashed subdirectory that cannot be predicted without it.' + ) + } + + return path.join( + options.derivedDataPath, + 'Build', + 'Products', + `${options.configuration ?? 'Debug'}-iphonesimulator` + ) +} + +/** + * Build an iOS app for a physical device. + * @param options - Build options including scheme, configuration, development team, and optional device UDID + */ +export async function buildForDevice(options: DeviceBuildOptions): Promise { + await assertIOSAvailable() + + const args = buildBaseArgs(options) + + if (options.deviceUDID) { + args.push('-destination', `platform=iOS,id=${options.deviceUDID}`) + } else { + args.push('-destination', 'generic/platform=iOS') + } + + args.push(`DEVELOPMENT_TEAM=${options.developmentTeam}`) + args.push('build') + + if (options.extraArgs?.length) { + args.push(...options.extraArgs) + } + + try { + await execa('xcodebuild', args, options.cwd ? { cwd: options.cwd } : undefined) + } catch (error: unknown) { + const e = error as { stderr?: string; stdout?: string } + const output = e.stderr !== '' && e.stderr != null ? e.stderr : (e.stdout ?? '') + throw new Error(`xcodebuild failed for device build:\n${output}`) + } +} + +// --- Device Management --- + +/** + * List connected physical iOS devices. + * Calls `xcrun xctrace list devices` and parses the output for physical devices. + * @returns Array of connected devices with UDID and name + */ +export async function listConnectedDevices(): Promise { + await assertIOSAvailable() + + const result = await execa('xcrun', ['xctrace', 'list', 'devices']) + const lines = result.stdout.split('\n') + const devices: ConnectedDevice[] = [] + + // xctrace output has a "== Devices ==" section followed by "== Simulators ==" section + let inDevicesSection = false + + for (const line of lines) { + const trimmed = line.trim() + + if (trimmed === '== Devices ==') { + inDevicesSection = true + continue + } + + if (trimmed.startsWith('== ') && trimmed !== '== Devices ==') { + inDevicesSection = false + continue + } + + if (!inDevicesSection || !trimmed) continue + + // Format: "Device Name (OS Version) (UDID)" or "Device Name (OS Version (Build)) (UDID)" + // Match greedily to handle nested parentheses in OS version; extract only the final (UDID) group. + const match = /^(.*)\s+\(.*?\)\s+\(([0-9A-Za-z-]+)\)$/.exec(trimmed) + if (match?.[1] && match[2]) { + devices.push({ + name: match[1].trim(), + udid: match[2], + }) + } + } + + return devices +} + +// --- Simulator UDID Tracking --- + +/** + * Read the simulator tracking file. + * @returns The parsed tracking data, or an empty object if the file doesn't exist + */ +async function readTrackingFile(): Promise> { + try { + const data = await fs.readJson(TRACKING_FILE) + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + return data as Record + } + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return {} + } + throw error + } +} + +/** + * Track a simulator UDID for a worktree identifier. + * Stores the mapping in ~/.config/iloom-ai/ios-simulators.json. + * @param worktreeIdentifier - The worktree identifier (e.g., issue number, branch name) + * @param udid - The simulator UDID to track + */ +export async function trackSimulator( + worktreeIdentifier: string, + udid: string +): Promise { + const tracking = await readTrackingFile() + tracking[worktreeIdentifier] = udid + await fs.ensureDir(TRACKING_DIR) + await fs.writeJson(TRACKING_FILE, tracking, { spaces: 2 }) +} + +/** + * Get the tracked simulator UDID for a worktree identifier. + * @param worktreeIdentifier - The worktree identifier to look up + * @returns The tracked simulator UDID, or null if none tracked + */ +export async function getTrackedSimulator( + worktreeIdentifier: string +): Promise { + const tracking = await readTrackingFile() + return tracking[worktreeIdentifier] ?? null +} + +/** + * Clear the tracked simulator for a worktree identifier. + * @param worktreeIdentifier - The worktree identifier to remove + */ +export async function clearTrackedSimulator( + worktreeIdentifier: string +): Promise { + const tracking = await readTrackingFile() + delete tracking[worktreeIdentifier] + await fs.ensureDir(TRACKING_DIR) + await fs.writeJson(TRACKING_FILE, tracking, { spaces: 2 }) +} diff --git a/src/utils/package-json.test.ts b/src/utils/package-json.test.ts index 265b7735..803d8e02 100644 --- a/src/utils/package-json.test.ts +++ b/src/utils/package-json.test.ts @@ -807,4 +807,26 @@ describe('getExplicitCapabilities', () => { expect(result).toEqual([]) }) + + it('should accept ios as valid capability', () => { + const pkgJson: PackageJson = { + name: 'my-ios-project', + capabilities: ['ios'], + } + + const result = getExplicitCapabilities(pkgJson) + + expect(result).toEqual(['ios']) + }) + + it('should accept combination of cli, web, and ios capabilities', () => { + const pkgJson: PackageJson = { + name: 'my-multi-project', + capabilities: ['cli', 'web', 'ios'], + } + + const result = getExplicitCapabilities(pkgJson) + + expect(result).toEqual(['cli', 'web', 'ios']) + }) }) diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index e3730869..18fe7e9a 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -16,8 +16,8 @@ export const ILOOM_PACKAGE_LOCAL_PATH = '.iloom/package.iloom.local.json' * Defines project capabilities and custom shell commands for non-Node.js projects */ export const PackageIloomSchema = z.object({ - capabilities: z.array(z.enum(['cli', 'web'])).optional() - .describe('Project capabilities - "cli" for command-line tools (enables CLI isolation), "web" for web applications (enables port assignment and dev server)'), + capabilities: z.array(z.enum(['cli', 'web', 'ios'])).optional() + .describe('Project capabilities - "cli" for command-line tools (enables CLI isolation), "web" for web applications (enables port assignment and dev server), "ios" for iOS projects (enables Xcode build configuration)'), scripts: z.object({ install: z.string().optional().describe('Install command (e.g., "bundle install", "poetry install")'), build: z.string().optional().describe('Build/compile command'), @@ -146,12 +146,14 @@ export async function getPackageConfig(dir: string): Promise { const basePackage = await readPackageJson(dir) getLogger().debug('Merging scripts from .iloom/package.iloom.json over package.json') // Merge: base package.json with iloom scripts taking precedence + const mergedCapabilities = iloomPackage.capabilities ?? basePackage.capabilities return { ...basePackage, scripts: { ...basePackage.scripts, ...iloomPackage.scripts, }, + ...(mergedCapabilities && { capabilities: mergedCapabilities }), } } catch { // No package.json - use iloom package as-is (non-Node project) @@ -217,6 +219,25 @@ export function hasWebDependencies(pkgJson: PackageJson): boolean { return webIndicators.some(indicator => indicator in allDeps) } +/** + * Check if package.json indicates an iOS/mobile application + * @param pkgJson Parsed package.json object + * @returns true if package has iOS/mobile framework dependencies + */ +export function hasIosDependencies(pkgJson: PackageJson): boolean { + const iosIndicators = [ + 'react-native', + 'expo' + ] + + const allDeps = { + ...pkgJson.dependencies, + ...pkgJson.devDependencies + } + + return iosIndicators.some(indicator => indicator in allDeps) +} + /** * Check if package.json has a specific script * @param pkgJson Parsed package.json object @@ -263,7 +284,7 @@ export async function getPackageScripts(dir: string): Promise` from the `.xcodeproj` directory name (strip the `.xcodeproj` extension — e.g., `MyApp.xcodeproj` → scheme `MyApp`). +```json +{ + "capabilities": ["ios"], + "scripts": { + "install": "pod install", + "build": "xcodebuild -scheme MyApp -sdk iphonesimulator build", + "test": "xcodebuild -scheme MyApp -sdk iphonesimulator test", + "dev": "xcodebuild -scheme MyApp -sdk iphonesimulator build" + }, + "_metadata": { + "detectedLanguage": "swift", + "detectedFramework": "xcode-native", + "generatedBy": "iloom-framework-detector" + } +} +``` + +#### Native Xcode with Swift Package Manager (SwiftUI/UIKit) +Use when `*.xcodeproj` or `*.xcworkspace` is detected without `react-native` dependency AND no `Podfile` is present (SPM-only project). Derive scheme name the same way. +```json +{ + "capabilities": ["ios"], + "scripts": { + "build": "xcodebuild -scheme MyApp -sdk iphonesimulator build", + "test": "xcodebuild -scheme MyApp -sdk iphonesimulator test", + "dev": "xcodebuild -scheme MyApp -sdk iphonesimulator build" + }, + "_metadata": { + "detectedLanguage": "swift", + "detectedFramework": "xcode-native", + "generatedBy": "iloom-framework-detector" + } +} +``` + #### Library (no CLI or web) ```json { @@ -426,7 +535,7 @@ Detected: - Language: [language] - Framework: [framework or "None detected"] - Package Manager: [package manager] -- Capabilities: [cli, web, or none] +- Capabilities: [cli, web, ios, or none] - Dockerfile: [Found | Not found] - Fork Status: [Yes (origin + upstream) | No]