From 713f0bf70dee677d955970bf080d06ce6d8802fa Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Sun, 28 Jun 2026 16:15:02 -0300 Subject: [PATCH] Harden MCP tooling: correctness fixes, validation, and new sim/device tools A reliability/consistency pass across the MCP tools and CLI, plus new simulator/device tools (tool count 120 -> 125). All changes verified against a real Bazel iOS workspace. Highlights: - Fix combined stdout/stderr JSON parsing (simctl list, swift dump-package, idb accessibility snapshot) - Host-arch-aware iOS simulator CPU (BAZEL_IOS_SIMULATOR_CPU); quote-aware BAZEL_IOS_STARTUP_ARGS; env-overridable discover scope - LLDB: per-command output buffer (fixes >500KB deadlock), newline/quote injection guards, session cleanup on shutdown - Streaming tools return structured content; Bazel exit-code annotations - Scaffold: bundleId/minimumOs/rulesVersion validation, families & bazelVersion options, iOS Info.plist keys, absolute config path - New tools: bazel_ios_uninstall_app, bazel_ios_device_uninstall_app, bazel_ios_device_list_apps, bazel_ios_add_media, bazel_ios_get_app_container - Doctor reports idb/pymobiledevice3/maxOutput; README env-var section - Runtime smoke script + CI smoke step; +45 tests (575 -> 619 passing) Note: also snapshots in-progress xcode-native-mcp, command-log, and diagnostics modules already present in the working tree. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 + README.md | 96 ++++++---- docs/xcode-native-mcp.md | 92 +++++++++ scripts/smoke.mjs | 28 ++- src/cli.test.ts | 26 ++- src/cli.ts | 69 ++++++- src/cli/commands.ts | 142 +++++++++----- src/cli/help.ts | 24 ++- src/cli/parsers.test.ts | 266 +++++++++++++++++++++++++++ src/cli/parsers.ts | 29 ++- src/core/agent-debug-log.test.ts | 12 ++ src/core/agent-debug-log.ts | 24 ++- src/core/bazel.test.ts | 61 ++++++ src/core/bazel.ts | 153 +++++++++++++-- src/core/command-log.test.ts | 87 +++++++++ src/core/command-log.ts | 74 ++++++++ src/core/devices.ts | 91 +++++++-- src/core/diagnostics.test.ts | 94 ++++++++++ src/core/diagnostics.ts | 114 ++++++++++++ src/core/lldb.test.ts | 37 ++++ src/core/lldb.ts | 140 ++++++++------ src/core/scaffold.test.ts | 69 +++++++ src/core/scaffold.ts | 76 ++++++-- src/core/simulators.test.ts | 102 ++++++++++ src/core/simulators.ts | 127 +++++++++++-- src/core/swift-package.test.ts | 14 ++ src/core/swift-package.ts | 33 +++- src/core/test-simulator.ts | 25 ++- src/core/ui-interaction.ts | 4 +- src/core/upgrade.test.ts | 18 +- src/core/upgrade.ts | 53 +++++- src/core/workflows.test.ts | 116 +++++------- src/core/workflows.ts | 20 +- src/core/xcode-mcp.test.ts | 100 ++++++++++ src/core/xcode-mcp.ts | 164 +++++++++++++++++ src/doctor-cli.ts | 64 ++++++- src/mcp/server.test.ts | 2 +- src/mcp/server.ts | 13 +- src/runtime/config.test.ts | 78 ++++++++ src/runtime/config.ts | 55 ++++-- src/tools/bazel-tools.test.ts | 10 +- src/tools/bazel-tools.ts | 3 +- src/tools/handlers/agent-debug.ts | 19 +- src/tools/handlers/build.ts | 29 ++- src/tools/handlers/device.ts | 91 ++++++++- src/tools/handlers/handlers.test.ts | 126 ++++++++++++- src/tools/handlers/lldb.ts | 9 +- src/tools/handlers/macos.ts | 68 ++++++- src/tools/handlers/multi-platform.ts | 55 +++--- src/tools/handlers/scaffold.ts | 8 +- src/tools/handlers/session.ts | 25 ++- src/tools/handlers/simulator.ts | 169 ++++++++++++++--- src/tools/handlers/spm.ts | 5 + src/tools/handlers/ui-automation.ts | 43 +++-- src/tools/handlers/xcode.ts | 117 ++++++++++++ src/tools/helpers.test.ts | 24 +++ src/tools/helpers.ts | 24 ++- src/tools/streaming.ts | 50 +++-- src/types/index.ts | 21 +++ src/utils/output.test.ts | 98 ++++++++++ src/utils/output.ts | 79 +++++++- src/utils/process.test.ts | 93 ++++++++++ src/utils/process.ts | 186 ++++++++++++++----- vitest.config.ts | 2 + 64 files changed, 3635 insertions(+), 514 deletions(-) create mode 100644 docs/xcode-native-mcp.md create mode 100644 src/cli/parsers.test.ts create mode 100644 src/core/command-log.test.ts create mode 100644 src/core/command-log.ts create mode 100644 src/core/diagnostics.test.ts create mode 100644 src/core/diagnostics.ts create mode 100644 src/core/xcode-mcp.test.ts create mode 100644 src/core/xcode-mcp.ts create mode 100644 src/tools/handlers/xcode.ts create mode 100644 src/utils/process.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22e5532..20a6c2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Build run: npm run build + - name: Smoke (CLI boot, tool inventory, scaffold) + run: npm run smoke + tag: name: Bump version & tag needs: [ci, build] diff --git a/README.md b/README.md index 06c112d..736d9c8 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,21 @@

A Model Context Protocol (MCP) server and CLI for Bazel-based Apple platform development. - Ships 112 tools across 19 workflow categories covering iOS, macOS, tvOS, watchOS, + Ships 125 tools across 21 workflow categories covering iOS, macOS, tvOS, watchOS, visionOS, and Swift Package Manager.

- [![CI](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml) - [![npm package](https://img.shields.io/npm/v/xcodebazelmcp)](https://www.npmjs.com/package/xcodebazelmcp) - [![License MIT](https://img.shields.io/npm/l/xcodebazelmcp)](https://github.com/XcodeBazelMCP/XcodeBazelMCP/blob/main/LICENSE) - [![node >=18.x](https://img.shields.io/node/v/xcodebazelmcp)](https://nodejs.org/) - [![Xcode 16](https://img.shields.io/badge/Xcode-16-blue)](https://developer.apple.com/xcode/) - [![platform macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://github.com/XcodeBazelMCP/XcodeBazelMCP) - [![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-green)](https://modelcontextprotocol.io) - [![codecov](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP/branch/main/graph/badge.svg)](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP) - [![Socket Badge](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0)](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0) - [![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTEgMTVoLTJ2LTJoMnYyem0wLTRoLTJWN2gydjZ6IiBmaWxsPSJ3aGl0ZSIvPjwvc3ZnPg==)](https://deepwiki.com/XcodeBazelMCP/XcodeBazelMCP) +[![CI](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml) +[![npm package](https://img.shields.io/npm/v/xcodebazelmcp)](https://www.npmjs.com/package/xcodebazelmcp) +[![License MIT](https://img.shields.io/npm/l/xcodebazelmcp)](https://github.com/XcodeBazelMCP/XcodeBazelMCP/blob/main/LICENSE) +[![node >=18.x](https://img.shields.io/node/v/xcodebazelmcp)](https://nodejs.org/) +[![Xcode 16](https://img.shields.io/badge/Xcode-16-blue)](https://developer.apple.com/xcode/) +[![platform macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://github.com/XcodeBazelMCP/XcodeBazelMCP) +[![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-green)](https://modelcontextprotocol.io) +[![codecov](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP/branch/main/graph/badge.svg)](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP) +[![Socket Badge](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0)](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0) +[![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTEgMTVoLTJ2LTJoMnYyem0wLTRoLTJWN2gydjZ6IiBmaWxsPSJ3aGl0ZSIvPjwvc3ZnPg==)](https://deepwiki.com/XcodeBazelMCP/XcodeBazelMCP) +
@@ -147,7 +148,7 @@ npm run build XcodeBazelMCP needs to know which Bazel workspace to operate on. There are several ways to set it, in order of precedence: -1. **MCP tool at runtime** — `bazel_ios_set_workspace` (or CLI `set-defaults --target //app:app`) +1. **MCP tool at runtime** — `bazel_ios_set_workspace` (sets the workspace for later tool calls) 2. **CLI flag** — `xcodebazelmcp mcp --workspace /path/to/workspace` 3. **Environment variable** — `BAZEL_IOS_WORKSPACE=/path/to/workspace` 4. **Config file** — `.xcodebazelmcp/config.yaml` in the workspace root (supports profiles) @@ -158,16 +159,31 @@ For multi-workspace setups, use **profiles** in `config.yaml`: ```yaml profiles: app: - target: '//app:app' - platform: simulator - buildMode: debug + defaultTarget: '//app:app' + defaultPlatform: simulator + defaultBuildMode: debug mac: - target: '//mac:mac' - platform: macos + defaultTarget: '//mac:mac' + defaultPlatform: macos ``` Then switch at runtime: `xcodebazelmcp set-defaults --profile app` +## Environment Variables + +| Variable | Purpose | +| ------------------------------ | ----------------------------------------------------------------------- | +| `BAZEL_IOS_WORKSPACE` | Bazel workspace root (highest-precedence workspace source). | +| `BAZEL_PATH` / `MCP_BAZEL_PATH`| Path to the Bazel/Bazelisk binary (defaults to `bazel` on `PATH`). | +| `BAZEL_IOS_STARTUP_ARGS` | Bazel startup args prepended to every invocation (quote-aware). | +| `BAZEL_IOS_MCP_MAX_OUTPUT` | Max captured command output in characters (default 200000). | +| `BAZEL_IOS_SIMULATOR_CPU` | Override the iOS simulator CPU (`sim_arm64` / `x86_64`; default: host). | +| `BAZEL_IOS_DISCOVER_SCOPE` | Default `discover` query scope for non-monorepo workspaces (e.g. `//...`). | +| `BAZEL_IOS_COMMAND_LOG` | Path to the persistent NDJSON command log. | +| `BAZEL_IOS_COMMAND_LOG_MAX_BYTES` | Rotate the command log past this size (default 5 MiB). | +| `BAZEL_IOS_COMMAND_LOG_DISABLE`| Set to `1` to disable command logging. | +| `IDB_PATH` | Path to the `idb` binary for UI automation (else discovered on `PATH`). | + ## CLI Examples ```sh @@ -207,31 +223,33 @@ xcodebazelmcp set-defaults --target //app:app --simulator-name "iPhone 16 Pro" xcodebazelmcp workflows ``` -## Workflow Categories (112 tools) +## Workflow Categories (125 tools) Workflows control which tools are advertised to MCP clients. Smart defaults enable the most common workflows; use `toggle-workflow` to customize. -| Category | Tools | Description | -| ----------------- | ----- | ----------------------------------------------------- | -| **build** | 2 | Build iOS targets for simulator or device | -| **test** | 2 | Run iOS tests with optional coverage | -| **simulator** | 10 | Manage simulator lifecycle and settings | -| **app_lifecycle** | 5 | Install, launch, stop apps on simulator | -| **capture** | 5 | Screenshot, video recording, log capture (simulator) | -| **ui_automation** | 9 | Tap, swipe, type, drag, accessibility snapshot | -| **deep_links** | 2 | Open URLs and send push notifications | -| **device** | 13 | Physical device build, deploy, test, screenshot, logs | -| **lldb** | 10 | LLDB debugger: breakpoints, variables, stepping | -| **macos** | 13 | macOS build, run, test, discover | -| **tvos** | 4 | tvOS build, run, test, discover | -| **watchos** | 4 | watchOS build, run, test, discover | -| **visionos** | 4 | visionOS build, run, test, discover | -| **spm** | 7 | Swift Package Manager operations | -| **project** | 6 | Target discovery, query, deps, rdeps | -| **scaffold** | 2 | Generate new Bazel projects | -| **session** | 7 | Workspace, defaults, profiles, health | -| **daemon** | 3 | Background daemon management | -| **update** | 2 | Self-update and version check | +| Category | Tools | Description | +| ----------------- | ----- | -------------------------------------------------------------------------------------- | +| **build** | 2 | Build iOS targets for simulator or device | +| **test** | 2 | Run iOS tests with optional coverage | +| **simulator** | 12 | Manage simulator lifecycle, settings, media, and containers | +| **app_lifecycle** | 6 | Install, launch, stop, uninstall apps on simulator | +| **capture** | 5 | Screenshot, video recording, log capture (simulator) | +| **agent_debug** | 4 | NDJSON agent debug logs (Cursor DEBUG MODE) | +| **ui_automation** | 9 | Tap, swipe, type, drag, accessibility snapshot | +| **deep_links** | 2 | Open URLs and send push notifications | +| **device** | 16 | Physical device build, deploy, test, screenshot, logs, uninstall, list apps | +| **lldb** | 10 | LLDB debugger: breakpoints, variables, stepping | +| **macos** | 13 | macOS build, run, test, discover | +| **tvos** | 4 | tvOS build, run, test, discover | +| **watchos** | 4 | watchOS build, run, test, discover | +| **visionos** | 4 | visionOS build, run, test, discover | +| **spm** | 7 | Swift Package Manager operations | +| **project** | 6 | Target discovery, query, deps, rdeps | +| **scaffold** | 2 | Generate new Bazel projects | +| **session** | 7 | Workspace, defaults, profiles, health | +| **daemon** | 3 | Background daemon management | +| **update** | 2 | Self-update and version check | +| **xcode** | 3 | Apple-native Xcode MCP detection, DeviceHub, skills ([docs](docs/xcode-native-mcp.md)) | ## iOS 17+ Device Notes diff --git a/docs/xcode-native-mcp.md b/docs/xcode-native-mcp.md new file mode 100644 index 0000000..cb892b8 --- /dev/null +++ b/docs/xcode-native-mcp.md @@ -0,0 +1,92 @@ +# Xcode Native MCP Integration (Xcode 26.3+) + +Apple ships a native Model Context Protocol (MCP) integration inside Xcode and +LLDB. XcodeBazelMCP **detects and bridges** this tooling so an agent can use +Xcode's own tools alongside the Bazel/simctl/devicectl flows — without replacing +anything. Everything here is additive and degrades gracefully on older Xcode. + +> Status: `mcpbridge` ships in **Xcode 26.3 (stable)**. `lldb-mcp` and +> `DeviceHub.app` currently ship only in **Xcode-beta / Xcode 27**. The +> integration is treated as **beta**: detection is always safe, and the existing +> XcodeBazelMCP tools remain the supported path. + +## What Apple provides + +| Tool | Where | What it does | +|------|-------|--------------| +| `xcrun mcpbridge` | Xcode 26.3+ (`/usr/bin/mcpbridge`) | STDIO MCP bridge between an MCP client and a **running Xcode**'s tool service. Auto-selects the Xcode via `MCP_XCODE_PID` or `xcode-select`. | +| `xcrun mcpbridge run-agent ` | Xcode 26.3+ | Launch a coding agent (e.g. `claude`) pre-wired with Xcode's MCP tools, auth, and env. `--dry-run` prints the resolved command; `--no-xcode-tools` excludes them. | +| `xcrun mcpbridge run-agent skills export` | Xcode 26.3+ | Export Xcode's globally available `SKILL.md` bundles (e.g. `device-interaction`, `swiftui-whats-new-27`). | +| `lldb-mcp` | Xcode-beta / 27 (`/usr/bin/lldb-mcp`) | LLDB MCP server for debugging. | +| `DeviceHub.app` | Xcode-beta / 27 (`/Contents/Applications/DeviceHub.app`, bundle `com.apple.dt.Devices`) | GUI device manager — the successor to Xcode's Devices window. | + +### Xcode's native device-interaction MCP tools + +When connected through `mcpbridge` to a running Xcode 27, the agent gains +Xcode's own device tools (surfaced by the `device-interaction` skill): + +``` +DeviceInteractionStartSession → start a background device/sim session + DeviceInteractionInstallAndRun → build + install + launch (commandLineArguments, environmentVariables) + DeviceEventSynthesize → perform a touch/UI interaction and observe state (repeatable) + DeviceInteractionEndSession → tear down (sessions are resource-heavy) +``` + +These complement XcodeBazelMCP's `bazel_ios_*` UI-automation/device tools, which +remain available for Bazel-built apps and pre-27 Xcode. + +## What XcodeBazelMCP adds + +Three opt-in tools (workflow `xcode`) and matching CLI commands: + +| MCP tool | CLI | Purpose | +|----------|-----|---------| +| `bazel_xcode_native_mcp_status` | `xcodebazelmcp xcode-mcp-status` | Detect installs, `mcpbridge`/`lldb-mcp`/DeviceHub availability, running Xcode PIDs, and emit a ready-to-paste MCP client config. | +| `bazel_xcode_open_device_hub` | `xcodebazelmcp devicehub` | Open DeviceHub.app for device interaction (clear guidance when not installed). | +| `bazel_xcode_export_skills` | `xcodebazelmcp xcode-export-skills [--output-dir ] [--replace-existing]` | Export Xcode's agent skill bundles (needs a running Xcode). | + +Enable the workflow for MCP clients (it is **off by default**, like other +optional categories): + +```sh +xcodebazelmcp toggle-workflow xcode on +``` + +The CLI commands work regardless of workflow filtering. + +## Wiring Xcode's native tools into your MCP client + +`xcode-mcp-status` prints a drop-in snippet. Add it next to XcodeBazelMCP so the +agent sees both tool sets (Bazel tools from XcodeBazelMCP, Xcode IDE/device tools +from `mcpbridge`): + +```json +{ + "mcpServers": { + "xcodebazelmcp": { "command": "npx", "args": ["xcodebazelmcp", "mcp"] }, + "xcode-native": { "command": "xcrun", "args": ["mcpbridge"] } + } +} +``` + +- `mcpbridge` requires a **running Xcode**. With multiple Xcodes installed it + uses `xcode-select`; pin one with `MCP_XCODE_PID=` or + `env: { "DEVELOPER_DIR": "/Applications/Xcode-beta.app/Contents/Developer" }`. +- To target the beta toolchain for `lldb-mcp`/DeviceHub, set `DEVELOPER_DIR` + before invoking, or select Xcode-beta via `xcode-select`. + +## Backwards compatibility + +- Detection never throws and never shells out destructively. +- On Xcode < 26.3 (no `mcpbridge`), `xcode-mcp-status` reports it's unavailable + and the Bazel/simctl/devicectl tools continue to be the supported path. +- `bazel_xcode_open_device_hub` returns actionable guidance (use + `bazel_ios_list_devices` / `bazel_ios_device_info`) when DeviceHub is absent. + +## Quick check + +```sh +xcodebazelmcp xcode-mcp-status +# → lists Xcode.app / Xcode-beta.app with mcpbridge/lldb-mcp/DeviceHub flags, +# running Xcode PIDs, and the client-config snippet. +``` diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index a4c5e21..349ec23 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1,4 +1,6 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const root = resolve(new URL('..', import.meta.url).pathname); @@ -31,4 +33,28 @@ for (const toolName of ['bazel_ios_build', 'bazel_ios_test', 'bazel_ios_discover } } +// Runtime smoke: boot the CLI (no Bazel/Xcode needed) and exercise scaffolding. +const cli = join(root, 'src/cli.ts'); +const runCli = (args) => + execFileSync('npx', ['tsx', cli, ...args], { cwd: root, encoding: 'utf8', timeout: 60_000 }); + +const toolsOut = runCli(['tools']); +const toolCount = toolsOut.split('\n').filter((l) => /^[a-z_]+$/.test(l)).length; +if (toolCount !== 125) { + throw new Error(`Expected 125 tools, CLI listed ${toolCount}`); +} +for (const t of ['bazel_ios_uninstall_app', 'bazel_ios_device_uninstall_app', 'bazel_ios_device_list_apps']) { + if (!toolsOut.includes(t)) throw new Error(`tools output missing ${t}`); +} + +const scaffoldDir = mkdtempSync(join(tmpdir(), 'xbmcp-smoke-')); +try { + runCli(['new', 'ios_app', 'SmokeApp', '-o', scaffoldDir]); + for (const f of ['MODULE.bazel', '.bazelrc', 'SmokeApp/BUILD.bazel', 'SmokeApp/Info.plist']) { + if (!existsSync(join(scaffoldDir, f))) throw new Error(`scaffold missing ${f}`); + } +} finally { + rmSync(scaffoldDir, { recursive: true, force: true }); +} + console.log('smoke ok'); diff --git a/src/cli.test.ts b/src/cli.test.ts index 2af1f1a..6d89de8 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -17,6 +17,7 @@ describe('CLI help', () => { expect(out).toContain('Usage:'); expect(out).toContain('xcodebazelmcp mcp'); expect(out).toContain('Build & Run:'); + expect(out).toContain('--launch-env KEY=VAL'); expect(out).toContain('Query & Inspect:'); expect(out).toContain('Config & Defaults:'); expect(out).toContain('Simulator:'); @@ -40,13 +41,34 @@ describe('CLI help', () => { }); }); +describe('CLI error handling', () => { + it('prints a clean error (no stack trace) for an invalid label', () => { + let stdout = ''; + let stderr = ''; + let exitCode = 0; + try { + stdout = run(['deps', 'not a label']); + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + exitCode = err.status ?? 1; + stdout = err.stdout ?? ''; + stderr = err.stderr ?? ''; + } + expect(exitCode).toBe(1); + const combined = stdout + stderr; + expect(combined).toContain('Error: target must be a Bazel label'); + expect(combined).not.toContain('at requireLabel'); + expect(combined).not.toContain('.js:'); + }); +}); + describe('CLI tools', () => { - it('lists all 117 tools', () => { + it('lists all 125 tools', () => { const out = run(['tools']); const toolLines = out .split('\n') .filter((line) => line.match(/^[a-z_]+$/)); - expect(toolLines.length).toBe(117); + expect(toolLines.length).toBe(125); expect(out).toContain('bazel_ios_build'); expect(out).toContain('bazel_macos_build'); expect(out).toContain('bazel_tvos_build'); diff --git a/src/cli.ts b/src/cli.ts index 2aa7e56..350b422 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ import { } from './cli/parsers.js'; import { printTool, runSkillInit, runUpgrade, runDaemon, - runVideoRecord, runLogStream, runSetupWizard, + runVideoRecord, runLogStream, runSetupWizard, runCheckUpdate, } from './cli/commands.js'; import { printHelp } from './cli/help.js'; @@ -25,6 +25,17 @@ const argv = process.argv.slice(2); const command = argv[0] || 'help'; switch (command) { + case '-h': + case '--help': + printHelp(); + break; + case 'version': + case '--version': + case '-v': { + const { getCurrentVersion } = await import('./core/upgrade.js'); + console.log(getCurrentVersion()); + break; + } case 'mcp': await startMcpServer(); break; @@ -111,9 +122,29 @@ switch (command) { case 'last-command': await printTool('bazel_ios_last_command', {}); break; + case 'command-log': { + const { readRecentCommands, commandLogPath } = await import('./core/command-log.js'); + const limitIdx = argv.indexOf('--limit'); + const limit = limitIdx >= 0 ? Number(argv[limitIdx + 1]) || 20 : 20; + const entries = readRecentCommands(limit); + if (entries.length === 0) { + console.log(`No commands logged yet. Log file: ${commandLogPath()}`); + } else { + console.log(`Recent commands (${entries.length}) — ${commandLogPath()}\n`); + for (const e of entries) { + const when = new Date(e.timestamp).toISOString(); + const status = e.exitCode === 0 ? 'OK' : `FAIL(${e.failureKind || e.exitCode})`; + console.log(`${when} [${e.id || '--------'}] ${status} ${(e.durationMs / 1000).toFixed(1)}s ${e.argv.join(' ')}`); + } + } + break; + } case 'stop': await printTool('bazel_ios_stop_app', parseStopApp(argv.slice(1))); break; + case 'uninstall': + await printTool('bazel_ios_uninstall_app', parseStopApp(argv.slice(1))); + break; case 'app-path': await printTool('bazel_ios_get_app_path', parseTargetInfo(argv.slice(1))); break; @@ -146,6 +177,18 @@ switch (command) { case 'ui-dump': await printTool('bazel_ios_ui_dump', parseSimSelector(argv.slice(1))); break; + case 'add-media': { + const mediaPaths = argv.slice(1).filter((a) => !a.startsWith('--')); + await printTool('bazel_ios_add_media', { paths: mediaPaths, ...parseSimSelector(argv.slice(1)) }); + break; + } + case 'app-container': + await printTool('bazel_ios_get_app_container', { + bundleId: argv[1], + kind: argv.includes('--kind') ? argv[argv.indexOf('--kind') + 1] : undefined, + ...parseSimSelector(argv.slice(2)), + }); + break; case 'tap': await printTool('bazel_ios_tap', { x: Number(argv[1]), y: Number(argv[2]), ...parseSimSelector(argv.slice(3)) }); break; @@ -190,12 +233,21 @@ switch (command) { case 'device-stop': await printTool('bazel_ios_device_stop_app', parseDeviceStop(argv.slice(1))); break; + case 'device-uninstall': + await printTool('bazel_ios_device_uninstall_app', parseDeviceStop(argv.slice(1))); + break; + case 'device-list-apps': + await printTool('bazel_ios_device_list_apps', parseDeviceSelector(argv.slice(1))); + break; case 'device-test': await printTool('bazel_ios_device_test', parseDeviceTest(argv.slice(1))); break; case 'device-screenshot': await printTool('bazel_ios_device_screenshot', parseDeviceScreenshot(argv.slice(1))); break; + case 'device-app-path': + await printTool('bazel_ios_device_get_app_path', parseTargetInfo(argv.slice(1))); + break; case 'device-log-start': await printTool('bazel_ios_device_log_start', parseDeviceSelector(argv.slice(1))); break; @@ -380,7 +432,7 @@ switch (command) { await runUpgrade(argv.slice(1)); break; case 'check-update': - await printTool('bazel_check_update', {}); + await runCheckUpdate(argv.includes('--exit-code')); break; case 'workflows': await printTool('bazel_list_workflows', {}); @@ -388,6 +440,19 @@ switch (command) { case 'toggle-workflow': await printTool('bazel_toggle_workflow', { id: argv[1], enabled: argv[2] !== 'off' && argv[2] !== 'false' && argv[2] !== 'disable' }); break; + case 'xcode-mcp-status': + await printTool('bazel_xcode_native_mcp_status', {}); + break; + case 'devicehub': + case 'device-hub': + await printTool('bazel_xcode_open_device_hub', {}); + break; + case 'xcode-export-skills': + await printTool('bazel_xcode_export_skills', { + outputDir: argv.includes('--output-dir') ? argv[argv.indexOf('--output-dir') + 1] : undefined, + replaceExisting: argv.includes('--replace-existing'), + }); + break; case 'setup': await runSetupWizard(); break; diff --git a/src/cli/commands.ts b/src/cli/commands.ts index ce72db3..5c81fdb 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -15,18 +15,26 @@ export function extractText(result: Awaited>): } export async function printTool(name: string, args: JsonObject): Promise { - if (args.streaming) { - const result = await callBazelToolStreaming(name, args, (chunk) => { - process.stdout.write(chunk); - }); - console.log(''); + try { + if (args.streaming) { + let streamedAny = false; + const result = await callBazelToolStreaming(name, args, (chunk) => { + streamedAny = true; + process.stdout.write(chunk); + }); + if (streamedAny) console.log(''); // separate streamed progress from the summary + console.log(extractText(result)); + if (result.isError) process.exitCode = 1; + return; + } + const result = await callBazelTool(name, args); console.log(extractText(result)); if (result.isError) process.exitCode = 1; - return; + } catch (err) { + // Surface a clean message to CLI users instead of a raw Node stack trace. + console.error(`Error: ${(err as Error).message}`); + process.exitCode = 1; } - const result = await callBazelTool(name, args); - console.log(extractText(result)); - if (result.isError) process.exitCode = 1; } function bundledSkillsDir(): string | null { @@ -147,6 +155,24 @@ xcodebazelmcp coverage //tests:tests installBundledSkills(); } +/** + * Print update status. With `withExitCode`, set a scripting-friendly exit code: + * 0 = up to date / unknown, 1 = a newer version is available. + */ +export async function runCheckUpdate(withExitCode = false): Promise { + const { checkForUpdate, upgradeHint } = await import('../core/upgrade.js'); + const info = await checkForUpdate(); + console.log(`Current version: ${info.current}`); + console.log(`Latest version: ${info.latest || '(unable to fetch)'}`); + console.log(`Install method: ${info.installMethod}`); + if (info.updateAvailable) { + console.log(`\nUpdate available! Run: ${upgradeHint(info.installMethod)}`); + if (withExitCode) process.exitCode = 1; + } else { + console.log('\nYou are up to date.'); + } +} + export async function runUpgrade(args: string[]): Promise { const { checkForUpdate, performUpgrade, upgradeHint } = await import('../core/upgrade.js'); let method: import('../core/upgrade.js').InstallMethod | undefined; @@ -164,9 +190,7 @@ export async function runUpgrade(args: string[]): Promise { return; } - if (info.updateAvailable) { - console.log(`\nUpgrading via: ${upgradeHint(method || info.installMethod)}`); - } + console.log(`\nUpgrading via: ${upgradeHint(method || info.installMethod)}`); const result = await performUpgrade(method); console.log(result.output); @@ -190,27 +214,49 @@ export async function runDaemon(args: string[]): Promise { console.log(`Socket: ${info.socketPath}`); console.log('Press Ctrl+C to stop.'); } - await new Promise(() => {}); + await new Promise((resolve) => { + process.once('SIGINT', () => { + if (!process.env.XBMCP_DAEMON) console.log('\nDaemon stopping.'); + resolve(); + }); + process.once('SIGTERM', resolve); + }); } -export async function runVideoRecord(args: JsonObject): Promise { - if (typeof args.outputPath !== 'string') { - console.error('Usage: xcodebazelmcp video-record [--simulator-name "..."]'); - process.exit(1); - } +/** + * Resolve a booted simulator UDID from --simulator-id / --simulator-name, or + * fall back to the first booted device. Exits the process with a clear message + * if none can be found. Shared by the streaming CLI commands. + */ +async function resolveBootedSimulatorUdid(args: JsonObject): Promise { const result = await callBazelTool('bazel_ios_list_simulators', { onlyBooted: true }); - const text = extractText(result); let devices: Array<{ udid: string; name: string }> = []; try { - devices = JSON.parse(text); + devices = JSON.parse(extractText(result)); } catch { /* empty */ } - const udid = (args.simulatorId as string) || devices[0]?.udid; + let udid = args.simulatorId as string | undefined; + if (!udid && typeof args.simulatorName === 'string') { + const match = devices.find( + (d) => d.name.toLowerCase() === (args.simulatorName as string).toLowerCase(), + ); + if (match) udid = match.udid; + } + if (!udid) udid = devices[0]?.udid; if (!udid) { - console.error('No booted simulator found. Boot one first or pass --simulator-id.'); + console.error('No booted simulator found. Boot one first or pass --simulator-id / --simulator-name.'); + process.exit(1); + } + return udid; +} + +export async function runVideoRecord(args: JsonObject): Promise { + if (typeof args.outputPath !== 'string') { + console.error('Usage: xcodebazelmcp video-record [--simulator-name "..."]'); process.exit(1); } + const udid = await resolveBootedSimulatorUdid(args); console.log(`Recording video from simulator ${udid}...`); console.log(`Output: ${args.outputPath}`); @@ -231,40 +277,26 @@ export async function runVideoRecord(args: JsonObject): Promise { } export async function runLogStream(args: JsonObject): Promise { - const result = await callBazelTool('bazel_ios_list_simulators', { onlyBooted: true }); - const text = extractText(result); - let devices: Array<{ udid: string; name: string }> = []; - try { - devices = JSON.parse(text); - } catch { - /* empty */ - } - - let udid = args.simulatorId as string | undefined; - if (!udid && typeof args.simulatorName === 'string') { - const match = devices.find( - (d) => d.name.toLowerCase() === (args.simulatorName as string).toLowerCase(), - ); - if (match) udid = match.udid; - } - if (!udid) udid = devices[0]?.udid; - if (!udid) { - console.error('No booted simulator found. Boot one first or pass --simulator-id / --simulator-name.'); - process.exit(1); - } + const udid = await resolveBootedSimulatorUdid(args); const logArgs = ['simctl', 'spawn', udid, 'log', 'stream', '--style', 'compact']; if (typeof args.level === 'string') logArgs.push('--level', args.level); - const predicates: string[] = []; - if (typeof args.processName === 'string') predicates.push(`process == "${args.processName}"`); - if (typeof args.subsystem === 'string') predicates.push(`subsystem == "${args.subsystem}"`); - if (predicates.length > 0) { - logArgs.push('--predicate', predicates.join(' OR ')); + const { buildLogPredicate } = await import('../core/simulators.js'); + let predicate: string | undefined; + try { + predicate = buildLogPredicate({ + processName: typeof args.processName === 'string' ? args.processName : undefined, + subsystem: typeof args.subsystem === 'string' ? args.subsystem : undefined, + }); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); } + if (predicate) logArgs.push('--predicate', predicate); console.log(`Streaming logs from simulator ${udid}...`); - if (predicates.length > 0) console.log(`Filter: ${predicates.join(' OR ')}`); + if (predicate) console.log(`Filter: ${predicate}`); console.log('Press Ctrl+C to stop.\n'); const child = spawn('xcrun', logArgs, { stdio: ['ignore', 'inherit', 'inherit'] }); @@ -311,6 +343,14 @@ export async function runSetupWizard(): Promise { writeFileSync(configPath, lines.join('\n') + '\n'); console.log(`\nConfig written to ${configPath}`); - console.log('\nTo use with MCP, add to your agent config:'); - console.log(` "command": "npx", "args": ["xcodebazelmcp", "mcp"]`); + console.log('\nTo use with MCP, add this to your agent config (e.g. .mcp.json / mcp.json):'); + console.log(JSON.stringify({ + mcpServers: { + XcodeBazelMCP: { + command: 'npx', + args: ['-y', 'xcodebazelmcp', 'mcp'], + env: { BAZEL_IOS_WORKSPACE: resolve(workspacePath) }, + }, + }, + }, null, 2)); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 27490a3..9dd4af9 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -16,8 +16,9 @@ Build & Run: xcodebazelmcp build [--debug|--release] [--simulator|--device] [--stream] xcodebazelmcp run [--debug] [--simulator-name "iPhone 16 Pro"] [--stream] xcodebazelmcp install [--simulator-id ] - xcodebazelmcp launch [--simulator-id ] [--launch-arg ...] + xcodebazelmcp launch [--simulator-id ] [--simulator-name "..."] [--launch-arg ...] [--launch-env KEY=VAL] xcodebazelmcp stop [--simulator-name "..."] + xcodebazelmcp uninstall [--simulator-name "..."] xcodebazelmcp test [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] [--stream] xcodebazelmcp coverage [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] xcodebazelmcp clean [--expunge] [--stream] @@ -32,10 +33,12 @@ Query & Inspect: xcodebazelmcp rdeps [--scope //Apps/...] xcodebazelmcp bsp-status [--query-targets] xcodebazelmcp last-command + xcodebazelmcp command-log [--limit N] Tail the persistent NDJSON command log (postmortems) Config & Defaults: xcodebazelmcp defaults - xcodebazelmcp set-defaults [--target //app:app] [--simulator-name "..."] [--build-mode debug] [--profile name] [--clear] + xcodebazelmcp set-defaults [--target //app:app] [--simulator-name "..."] [--simulator-id ] + [--build-mode debug] [--platform simulator|device|none] [--profile name] [--clear] xcodebazelmcp profiles Simulator: @@ -45,21 +48,26 @@ Simulator: xcodebazelmcp sim-erase --simulator-id xcodebazelmcp sim-location --latitude 37.7749 --longitude -122.4194 xcodebazelmcp sim-appearance --appearance dark - xcodebazelmcp sim-open [--simulator-id ] + xcodebazelmcp sim-open [--simulator-id ] [--simulator-name "..."] xcodebazelmcp screenshot [--simulator-name "..."] [--mask alpha|black|ignored] xcodebazelmcp video-record [--simulator-name "..."] Records until Ctrl+C. xcodebazelmcp status-bar [--time "9:41"] [--battery-level 100] [--network wifi] [--clear] xcodebazelmcp ui-dump [--simulator-name "..."] + xcodebazelmcp add-media [--simulator-name "..."] Add photos/videos to the photo library + xcodebazelmcp app-container [--kind app|data|groups] [--simulator-name "..."] Device: xcodebazelmcp devices [--all] xcodebazelmcp device-run [--debug|--release] [--device-id ] [--device-name "..."] [--stream] xcodebazelmcp device-install [--device-id ] - xcodebazelmcp device-launch [--device-id ] [--launch-arg ...] + xcodebazelmcp device-launch [--device-id ] [--launch-arg ...] [--launch-env KEY=VAL] xcodebazelmcp device-stop [--device-id ] - xcodebazelmcp device-test [--filter ...] [--device-id ] [--stream] + xcodebazelmcp device-uninstall [--device-id ] + xcodebazelmcp device-list-apps [--device-id ] + xcodebazelmcp device-test [--filter ...] [--device-id ] [--timeout ] [--stream] xcodebazelmcp device-screenshot [--device-id ] + xcodebazelmcp device-app-path Locate the built .app for a device target xcodebazelmcp device-log-start [--device-id ] [--process ] xcodebazelmcp device-log-stop xcodebazelmcp device-info [--device-id ] @@ -100,6 +108,12 @@ visionOS: xcodebazelmcp visionos-test [--filter XCTestFilter] [--stream] xcodebazelmcp visionos-discover [--scope //vision/...] [--kind visionos_apps|visionos_tests|visionos_all] +Xcode Native MCP (Xcode 26.3+): + xcodebazelmcp xcode-mcp-status Detect Apple's native Xcode MCP (mcpbridge), lldb-mcp, DeviceHub + xcodebazelmcp devicehub Open DeviceHub.app (Xcode-beta / Xcode 27) for device interaction + xcodebazelmcp xcode-export-skills [--output-dir ] [--replace-existing] + Export Xcode's agent SKILL.md bundles (needs a running Xcode) + Daemon: xcodebazelmcp daemon [--workspace ] Start the background daemon (foreground, Ctrl+C to stop) xcodebazelmcp daemon-start Ensure daemon is running (spawns in background) diff --git a/src/cli/parsers.test.ts b/src/cli/parsers.test.ts new file mode 100644 index 0000000..74b58c3 --- /dev/null +++ b/src/cli/parsers.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from 'vitest'; +import { + parseLaunch, + parseDeviceLaunch, + parseDeviceBuildAndRun, + parseBuild, + parseBuildAndRun, + parseTest, + parseQuery, + parseDeps, + parseSwipe, + parseSimAppearance, + parseSpmBuild, + parseSpmTest, + parseSpmRun, + parsePrivacy, + parseScaffold, + parseDeviceTest, + parseStatusBar, + parseLldbAttach, + parseLldbBreakpoint, +} from './parsers.js'; + +describe('parseLaunch', () => { + it('parses bundleId positional', () => { + expect(parseLaunch(['com.example.App'])).toEqual({ bundleId: 'com.example.App' }); + }); + + it('supports --simulator-name (regression: previously ignored)', () => { + expect(parseLaunch(['com.example.App', '--simulator-name', 'iPhone 16 Pro'])).toEqual({ + bundleId: 'com.example.App', + simulatorName: 'iPhone 16 Pro', + }); + }); + + it('supports --simulator-id and --launch-arg', () => { + expect(parseLaunch(['com.x', '--simulator-id', 'UDID', '--launch-arg', '-a', '--launch-arg', '-b'])).toEqual({ + bundleId: 'com.x', + simulatorId: 'UDID', + launchArgs: ['-a', '-b'], + }); + }); + + it('parses --launch-env KEY=VAL into launchEnv object', () => { + expect(parseLaunch(['com.x', '--launch-env', 'FOO=bar', '--launch-env', 'BAZ=qux'])).toEqual({ + bundleId: 'com.x', + launchEnv: { FOO: 'bar', BAZ: 'qux' }, + }); + }); + + it('keeps "=" in launch-env values', () => { + expect(parseLaunch(['com.x', '--launch-env', 'URL=a=b'])).toEqual({ + bundleId: 'com.x', + launchEnv: { URL: 'a=b' }, + }); + }); +}); + +describe('parseDeviceLaunch', () => { + it('parses --launch-env for device parity', () => { + expect(parseDeviceLaunch(['com.x', '--device-id', 'D1', '--launch-env', 'FOO=bar'])).toEqual({ + bundleId: 'com.x', + deviceId: 'D1', + launchEnv: { FOO: 'bar' }, + }); + }); +}); + +describe('parseDeviceBuildAndRun', () => { + it('forces device platform and parses launch-env', () => { + expect(parseDeviceBuildAndRun(['//a', '--launch-env', 'A=1'])).toMatchObject({ + target: '//a', + platform: 'device', + launchEnv: { A: '1' }, + }); + }); +}); + +describe('parseBuild', () => { + it('defaults buildMode/platform to none', () => { + expect(parseBuild(['//app:app'])).toEqual({ target: '//app:app', buildMode: 'none', platform: 'none' }); + }); + + it('parses --release and --device', () => { + const out = parseBuild(['//app:app', '--release', '--device']); + expect(out.buildMode).toBe('release'); + expect(out.platform).toBe('device'); + }); + + it('parses --stream into streaming flag', () => { + expect(parseBuild(['//a', '--stream'])).toMatchObject({ streaming: true }); + }); +}); + +describe('parseBuildAndRun', () => { + it('defaults platform to simulator', () => { + expect(parseBuildAndRun(['//app:app'])).toMatchObject({ platform: 'simulator', buildMode: 'none' }); + }); + + it('collects repeated --config and --launch-arg', () => { + const out = parseBuildAndRun(['//a', '--config', 'foo', '--config', 'bar', '--launch-arg', 'x']); + expect(out.configs).toEqual(['foo', 'bar']); + expect(out.launchArgs).toEqual(['x']); + }); +}); + +describe('parseTest', () => { + it('parses filter and simulator flags', () => { + expect(parseTest(['//t', '--filter', 'MyTest', '--minimize-simulator'])).toMatchObject({ + target: '//t', + testFilter: 'MyTest', + minimizeSimulator: true, + }); + }); +}); + +describe('parseQuery', () => { + it('joins expression parts', () => { + expect(parseQuery(['deps(//a)', '+', 'deps(//b)']).expression).toBe('deps(//a) + deps(//b)'); + }); + + it('extracts --output without polluting expression', () => { + const out = parseQuery(['kind(x,//...)', '--output', 'label']); + expect(out.output).toBe('label'); + expect(out.expression).toBe('kind(x,//...)'); + }); +}); + +describe('parseDeps', () => { + it('parses --depth as number', () => { + expect(parseDeps(['//a', '--depth', '3'])).toMatchObject({ target: '//a', depth: 3 }); + }); +}); + +describe('parseSwipe', () => { + it('reads positional direction', () => { + expect(parseSwipe(['up'])).toMatchObject({ direction: 'up' }); + }); + + it('explicit --direction overrides positional', () => { + expect(parseSwipe(['up', '--direction', 'down'])).toMatchObject({ direction: 'down' }); + }); +}); + +describe('parseSimAppearance', () => { + it('reads positional dark/light', () => { + expect(parseSimAppearance(['dark'])).toMatchObject({ appearance: 'dark' }); + }); + it('reads --appearance flag', () => { + expect(parseSimAppearance(['--appearance', 'light'])).toMatchObject({ appearance: 'light' }); + }); +}); + +describe('parseSpmBuild', () => { + it('handles --release', () => { + expect(parseSpmBuild(['--release'])).toMatchObject({ configuration: 'release' }); + }); + it('handles -c release', () => { + expect(parseSpmBuild(['-c', 'release'])).toMatchObject({ configuration: 'release' }); + }); + it('handles -c debug (regression)', () => { + expect(parseSpmBuild(['-c', 'debug'])).toMatchObject({ configuration: 'debug' }); + }); +}); + +describe('parseSpmTest', () => { + it('handles --release, -c release, and -c debug for parity with build', () => { + expect(parseSpmTest(['--release'])).toMatchObject({ configuration: 'release' }); + expect(parseSpmTest(['-c', 'release'])).toMatchObject({ configuration: 'release' }); + expect(parseSpmTest(['-c', 'debug'])).toMatchObject({ configuration: 'debug' }); + expect(parseSpmTest(['--debug'])).toMatchObject({ configuration: 'debug' }); + }); + it('still parses --filter and --path', () => { + expect(parseSpmTest(['--filter', 'MyTests', '--path', '/pkg'])).toMatchObject({ + filter: 'MyTests', + packagePath: '/pkg', + }); + }); +}); + +describe('parseSpmRun', () => { + it('reads the executable without mistaking -c values for it', () => { + expect(parseSpmRun(['MyTool', '-c', 'release'])).toMatchObject({ + executable: 'MyTool', + configuration: 'release', + }); + }); + it('handles -c release with no executable', () => { + expect(parseSpmRun(['-c', 'release'])).toMatchObject({ configuration: 'release' }); + expect(parseSpmRun(['-c', 'release']).executable).toBeUndefined(); + }); + it('forwards --run-arg and --stream', () => { + expect(parseSpmRun(['MyTool', '--run-arg', '--verbose', '--stream'])).toMatchObject({ + executable: 'MyTool', + runArgs: ['--verbose'], + streaming: true, + }); + }); +}); + +describe('parsePrivacy', () => { + it('reads positional action/service/bundleId', () => { + expect(parsePrivacy(['grant', 'photos', 'com.x'])).toMatchObject({ + action: 'grant', + service: 'photos', + bundleId: 'com.x', + }); + }); +}); + +describe('parseScaffold', () => { + it('defaults outputPath to name', () => { + expect(parseScaffold(['ios_app', 'MyApp'])).toMatchObject({ + template: 'ios_app', + name: 'MyApp', + outputPath: 'MyApp', + }); + }); + it('respects explicit -o', () => { + expect(parseScaffold(['ios_app', 'MyApp', '-o', 'out'])).toMatchObject({ outputPath: 'out' }); + }); + it('parses --bazel-version', () => { + expect(parseScaffold(['ios_app', 'MyApp', '--bazel-version', '8.0.0'])).toMatchObject({ bazelVersion: '8.0.0' }); + }); +}); + +describe('parseDeviceTest', () => { + it('parses --timeout as number', () => { + expect(parseDeviceTest(['//t', '--timeout', '120'])).toMatchObject({ timeoutSeconds: 120 }); + }); +}); + +describe('parseStatusBar', () => { + it('parses --clear', () => { + expect(parseStatusBar(['--clear'])).toMatchObject({ clear: true }); + }); + it('parses numeric battery level', () => { + expect(parseStatusBar(['--battery-level', '80'])).toMatchObject({ batteryLevel: 80 }); + }); + it('parses --operator (carrier name)', () => { + expect(parseStatusBar(['--operator', 'Carrier'])).toMatchObject({ operatorName: 'Carrier' }); + }); +}); + +describe('parseLldbAttach', () => { + it('treats bare number as pid', () => { + expect(parseLldbAttach(['1234'])).toMatchObject({ pid: 1234 }); + }); + it('treats bare string as processName', () => { + expect(parseLldbAttach(['MyApp'])).toMatchObject({ processName: 'MyApp' }); + }); + it('--device sets device target', () => { + expect(parseLldbAttach(['MyApp', '--device'])).toMatchObject({ target: 'device' }); + }); +}); + +describe('parseLldbBreakpoint', () => { + it('parses set with file+line', () => { + expect(parseLldbBreakpoint(['set', '--session', 's1', '--file', 'A.swift', '--line', '42'])).toMatchObject({ + action: 'set', + sessionId: 's1', + file: 'A.swift', + line: 42, + }); + }); +}); diff --git a/src/cli/parsers.ts b/src/cli/parsers.ts index b7cc05a..c5b2b6a 100644 --- a/src/cli/parsers.ts +++ b/src/cli/parsers.ts @@ -4,6 +4,13 @@ function append(value: unknown, next: string): string[] { return [...(Array.isArray(value) ? (value as string[]) : []), next]; } +function appendEnv(existing: unknown, pair: string | undefined): Record { + const env = (existing as Record) ?? {}; + const eq = pair?.indexOf('=') ?? -1; + if (pair && eq > 0) env[pair.slice(0, eq)] = pair.slice(eq + 1); + return env; +} + export function parseDiscover(args: string[]): JsonObject { const parsed: JsonObject = {}; for (let index = 0; index < args.length; index += 1) { @@ -164,7 +171,9 @@ export function parseLaunch(args: string[]): JsonObject { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--simulator-id') parsed.simulatorId = args[++index]; + else if (arg === '--simulator-name') parsed.simulatorName = args[++index]; else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]); + else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]); } return parsed; } @@ -306,6 +315,7 @@ export function parseStatusBar(args: string[]): JsonObject { else if (arg === '--battery-level') parsed.batteryLevel = Number(args[++index]); else if (arg === '--battery-state') parsed.batteryState = args[++index]; else if (arg === '--network') parsed.networkType = args[++index]; + else if (arg === '--operator') parsed.operatorName = args[++index]; else if (arg === '--wifi-bars') parsed.wifiBars = Number(args[++index]); else if (arg === '--cellular-bars') parsed.cellularBars = Number(args[++index]); else if (arg === '--clear') parsed.clear = true; @@ -384,6 +394,8 @@ export function parseScaffold(args: string[]): JsonObject { else if (arg === '--bundle-id') parsed.bundleId = args[++index]; else if (arg === '--minimum-os') parsed.minimumOs = args[++index]; else if (arg === '--rules-version') parsed.rulesVersion = args[++index]; + else if (arg === '--bazel-version') parsed.bazelVersion = args[++index]; + else if (arg === '--family') parsed.families = append(parsed.families, args[++index]); } if (!parsed.outputPath && parsed.name) { parsed.outputPath = parsed.name; @@ -406,8 +418,8 @@ export function parseSpmBuild(args: string[]): JsonObject { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index]; - else if (arg === '--release' || arg === '-c' && args[index + 1] === 'release') { parsed.configuration = 'release'; if (arg === '-c') index++; } - else if (arg === '--debug') parsed.configuration = 'debug'; + else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; } + else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; } else if (arg === '--target') parsed.target = args[++index]; else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]); else if (arg === '--stream') parsed.streaming = true; @@ -421,7 +433,8 @@ export function parseSpmTest(args: string[]): JsonObject { const arg = args[index]; if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index]; else if (arg === '--filter') parsed.filter = args[++index]; - else if (arg === '--release') parsed.configuration = 'release'; + else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; } + else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; } else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]); else if (arg === '--stream') parsed.streaming = true; } @@ -430,14 +443,16 @@ export function parseSpmTest(args: string[]): JsonObject { export function parseSpmRun(args: string[]): JsonObject { const parsed: JsonObject = {}; - const positional = args.find((arg) => !arg.startsWith('--')); - if (positional) parsed.executable = positional; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index]; - else if (arg === '--release') parsed.configuration = 'release'; + else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; } + else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; } else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]); else if (arg === '--run-arg') parsed.runArgs = append(parsed.runArgs, args[++index]); + else if (arg === '--stream') parsed.streaming = true; + // First bare token (not a flag, not a flag value consumed above) is the executable. + else if (!arg.startsWith('-') && parsed.executable === undefined) parsed.executable = arg; } return parsed; } @@ -611,6 +626,7 @@ export function parseDeviceBuildAndRun(args: string[]): JsonObject { else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]); else if (arg === '--startup-arg') parsed.startupArgs = append(parsed.startupArgs, args[++index]); else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]); + else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]); else if (arg === '--stream') parsed.streaming = true; } return parsed; @@ -637,6 +653,7 @@ export function parseDeviceLaunch(args: string[]): JsonObject { if (arg === '--device-id') parsed.deviceId = args[++index]; else if (arg === '--device-name') parsed.deviceName = args[++index]; else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]); + else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]); } return parsed; } diff --git a/src/core/agent-debug-log.test.ts b/src/core/agent-debug-log.test.ts index ceb547f..877b221 100644 --- a/src/core/agent-debug-log.test.ts +++ b/src/core/agent-debug-log.test.ts @@ -61,6 +61,18 @@ describe('agent-debug-log', () => { expect(result.hypothesisStatusHints.B).toBe('REJECTED'); }); + it('hypothesis status uses the latest verdict (REJECTED after CONFIRMED wins)', () => { + writeFileSync( + logPath, + [ + '{"hypothesisId":"H","message":"CONFIRMED early"}', + '{"hypothesisId":"H","message":"REJECTED later, was wrong"}', + ].join('\n'), + ); + const result = readAgentDebugLog({ logPath }); + expect(result.hypothesisStatusHints.H).toBe('REJECTED'); + }); + it('readAgentDebugLog filters by hypothesisId and runId', () => { writeFileSync( logPath, diff --git a/src/core/agent-debug-log.ts b/src/core/agent-debug-log.ts index 4240770..e621e7d 100644 --- a/src/core/agent-debug-log.ts +++ b/src/core/agent-debug-log.ts @@ -49,10 +49,14 @@ export function agentDebugLaunchEnv(logPath: string, sessionId: string): Record< }; } -export function clearAgentDebugLog(logPath: string): { logPath: string; cleared: boolean; existed: boolean } { +export function clearAgentDebugLog(logPath: string): { logPath: string; cleared: boolean; existed: boolean; error?: string } { const existed = existsSync(logPath); if (existed) { - unlinkSync(logPath); + try { + unlinkSync(logPath); + } catch (err) { + return { logPath, cleared: false, existed, error: (err as Error).message }; + } } return { logPath, cleared: true, existed }; } @@ -80,7 +84,10 @@ export function parseAgentDebugNdjson(content: string): { } function inferHypothesisStatus(entries: AgentDebugLogEntry[]): 'CONFIRMED' | 'REJECTED' | 'INCONCLUSIVE' | 'UNKNOWN' { - for (const entry of entries) { + // Scan newest-first so a later verdict (e.g. REJECTED after an earlier + // CONFIRMED) wins instead of whichever appeared first. + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; const blob = `${entry.message ?? ''} ${JSON.stringify(entry.data ?? {})}`.toUpperCase(); if (blob.includes('CONFIRMED')) return 'CONFIRMED'; if (blob.includes('REJECTED')) return 'REJECTED'; @@ -165,7 +172,11 @@ export function discoverAgentDebugLogPaths(searchRoots: string[]): string[] { const logPath = resolve(cursorDir, entry); if (seen.has(logPath)) continue; seen.add(logPath); - discovered.push({ path: logPath, mtimeMs: statSync(logPath).mtimeMs }); + try { + discovered.push({ path: logPath, mtimeMs: statSync(logPath).mtimeMs }); + } catch { + // file vanished between readdir and stat — skip it + } } } @@ -231,6 +242,7 @@ export async function pullAgentDebugLogFromSimulator( simulatorId: string; containerPath: string; sourcePath: string; + sourceExists: boolean; destPath?: string; read: AgentDebugReadResult; commandOutput: string; @@ -250,6 +262,7 @@ export async function pullAgentDebugLogFromSimulator( const containerPath = result.output.trim(); const sourcePath = join(containerPath, simRelPath); + const sourceExists = existsSync(sourcePath); let read: AgentDebugReadResult; let destPath: string | undefined; @@ -258,7 +271,7 @@ export async function pullAgentDebugLogFromSimulator( destPath = options.destPath; const parent = dirname(destPath); if (!existsSync(parent)) mkdirSync(parent, { recursive: true }); - if (existsSync(sourcePath)) { + if (sourceExists) { copyFileSync(sourcePath, destPath); } else { writeFileSync(destPath, ''); @@ -273,6 +286,7 @@ export async function pullAgentDebugLogFromSimulator( simulatorId: options.simulatorId, containerPath, sourcePath, + sourceExists, destPath, read, commandOutput: result.output.trim(), diff --git a/src/core/bazel.test.ts b/src/core/bazel.test.ts index 93cd84a..f3eee53 100644 --- a/src/core/bazel.test.ts +++ b/src/core/bazel.test.ts @@ -9,12 +9,17 @@ import { buildCommandArgs, configArgs, discoverExpression, + defaultDiscoverScope, + iosSimulatorCpu, + iosSimulatorCpuArg, modeArgs, + parseTargetLabels, platformArgs, requireLabel, sanitizeQueryExpression, simulatorArgs, testFilterArgs, + tokenizeArgs, runBazel, runBazelStreaming, getLastCommand, @@ -40,6 +45,34 @@ vi.mock('./workspace.js', () => ({ const mockRunCommand = vi.mocked(runCommand); const mockRunCommandStreaming = vi.mocked(runCommandStreaming); +describe('parseTargetLabels', () => { + it('keeps only label-like lines and drops bazel noise', () => { + const output = [ + 'INFO: Invocation ID: abc', + 'Loading: 0 packages loaded', + 'DEBUG: /Users/x/foo.bzl:34:18: unknown handled dep @swiftpkg_foo//:Bar', + 'WARNING: something happened', + '//Apps/Consumer/ConsumerApp:DoorDash', + '//Apps/Consumer/ConsumerApp:Caviar', + '@rules_swift//swift:foo', + '', + ].join('\n'); + expect(parseTargetLabels(output)).toEqual([ + '//Apps/Consumer/ConsumerApp:Caviar', + '//Apps/Consumer/ConsumerApp:DoorDash', + '@rules_swift//swift:foo', + ]); + }); + + it('dedupes and sorts labels', () => { + expect(parseTargetLabels('//b:b\n//a:a\n//b:b\n')).toEqual(['//a:a', '//b:b']); + }); + + it('returns empty array for no labels', () => { + expect(parseTargetLabels('INFO: Empty results\nLoading: 0 packages loaded')).toEqual([]); + }); +}); + let tempDir: string; beforeEach(() => { @@ -157,6 +190,32 @@ describe('Bazel argument helpers', () => { expect(platformArgs('visionos')).toEqual(['--platforms=@build_bazel_apple_support//platforms:visionos_sim_arm64', '--visionos_cpus=sim_arm64']); }); + it('tokenizeArgs splits on whitespace and honors quotes', () => { + expect(tokenizeArgs('--batch --noautodetect_server_javabase')).toEqual(['--batch', '--noautodetect_server_javabase']); + expect(tokenizeArgs('--output_base="/path with space" --batch')).toEqual(['--output_base=/path with space', '--batch']); + expect(tokenizeArgs("--x='a b'")).toEqual(['--x=a b']); + expect(tokenizeArgs(' ')).toEqual([]); + }); + + it('iosSimulatorCpu defaults to host arch and respects BAZEL_IOS_SIMULATOR_CPU', () => { + const expected = process.arch === 'x64' ? 'x86_64' : 'sim_arm64'; + expect(iosSimulatorCpu()).toBe(expected); + expect(iosSimulatorCpuArg()).toBe(`--ios_multi_cpus=${expected}`); + + process.env.BAZEL_IOS_SIMULATOR_CPU = 'x86_64'; + expect(iosSimulatorCpu()).toBe('x86_64'); + expect(platformArgs('simulator')).toEqual(['--platforms=@build_bazel_apple_support//platforms:ios_x86_64', '--ios_multi_cpus=x86_64']); + delete process.env.BAZEL_IOS_SIMULATOR_CPU; + }); + + it('defaultDiscoverScope is overridable via BAZEL_IOS_DISCOVER_SCOPE', () => { + expect(defaultDiscoverScope()).toBe('(//Apps/... union //Packages/...)'); + process.env.BAZEL_IOS_DISCOVER_SCOPE = '//...'; + expect(defaultDiscoverScope()).toBe('//...'); + expect(discoverExpression('apps')).toBe('kind("ios_application rule", //...)'); + delete process.env.BAZEL_IOS_DISCOVER_SCOPE; + }); + it('maps simulator and config options to Bazel flags', () => { expect( simulatorArgs({ @@ -485,6 +544,7 @@ describe('runBazel', () => { cwd: tempDir, timeoutSeconds: undefined, maxOutput: expect.any(Number), + id: expect.any(String), }); }); @@ -542,6 +602,7 @@ describe('runBazelStreaming', () => { cwd: tempDir, timeoutSeconds: undefined, maxOutput: expect.any(Number), + id: expect.any(String), }); expect(chunks).toHaveLength(2); }); diff --git a/src/core/bazel.ts b/src/core/bazel.ts index 19af5b3..c95bf0b 100644 --- a/src/core/bazel.ts +++ b/src/core/bazel.ts @@ -1,10 +1,93 @@ +import { randomUUID } from 'node:crypto'; import { getConfig } from '../runtime/config.js'; import type { BuildArgs, BuildMode, BuildPlatform, CommandResult, TargetKind } from '../types/index.js'; import { runCommand, runCommandStreaming, type StreamChunk } from '../utils/process.js'; +import { logCommand } from './command-log.js'; import { assertBazelWorkspace } from './workspace.js'; let lastCommand: CommandResult | null = null; +/** + * Split a shell-like string into argv tokens, honoring single/double quotes so + * a startup arg containing spaces (e.g. `--output_base="/path with space"`) + * survives as one token. Falls back to whitespace splitting for unquoted input. + */ +export function tokenizeArgs(input: string): string[] { + const tokens: string[] = []; + let cur = ''; + let quote: '"' | "'" | null = null; + let hasToken = false; + for (const ch of input) { + if (quote) { + if (ch === quote) quote = null; + else cur += ch; + } else if (ch === '"' || ch === "'") { + quote = ch; + hasToken = true; // even an empty "" is a token + } else if (/\s/.test(ch)) { + if (hasToken) { tokens.push(cur); cur = ''; hasToken = false; } + } else { + cur += ch; + hasToken = true; + } + } + if (hasToken) tokens.push(cur); + return tokens; +} + +function resolveStartupArgs(startupArgs: string[]): { envStartupArgs: string[]; all: string[] } { + const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS + ? tokenizeArgs(process.env.BAZEL_IOS_STARTUP_ARGS) + : []; + return { envStartupArgs, all: [...envStartupArgs, ...startupArgs] }; +} + +/** + * iOS simulator CPU token. Defaults to the host architecture (`sim_arm64` on + * Apple silicon, `x86_64` on Intel) so simulator builds work on both. Override + * with `BAZEL_IOS_SIMULATOR_CPU`. + */ +export function iosSimulatorCpu(): string { + const override = process.env.BAZEL_IOS_SIMULATOR_CPU?.trim(); + if (override) return override; + return process.arch === 'x64' ? 'x86_64' : 'sim_arm64'; +} + +/** `--ios_multi_cpus=` for simulator builds/tests, host-arch aware. */ +export function iosSimulatorCpuArg(): string { + return `--ios_multi_cpus=${iosSimulatorCpu()}`; +} + +/** + * Attach resolved-config context (surfaced on failure) and record the + * invocation to the persistent command log with a correlation id. + */ +function finalizeBazelResult( + result: CommandResult, + ctx: { id: string; bazelPath: string; workspacePath: string; allStartupArgs: string[] }, +): CommandResult { + result.context = { + bazel: ctx.bazelPath, + workspace: ctx.workspacePath, + startupArgs: ctx.allStartupArgs.length ? ctx.allStartupArgs.join(' ') : '(none)', + }; + logCommand({ + id: ctx.id, + timestamp: Date.now(), + argv: [ctx.bazelPath, ...result.args], + cwd: ctx.workspacePath, + envOverrides: ctx.allStartupArgs.length ? ctx.allStartupArgs : undefined, + exitCode: result.exitCode, + durationMs: result.durationMs, + truncated: result.truncated || undefined, + failureKind: result.failureKind, + timedOut: result.timedOut, + bytesDropped: result.bytesDropped, + }); + lastCommand = result; + return result; +} + export function getLastCommand(): CommandResult | null { return lastCommand; } @@ -32,7 +115,7 @@ export function sanitizeQueryExpression(expression: unknown): string { throw new Error('expression is required.'); } if (/[;&|`$<>]/.test(expression)) { - throw new Error('expression contains shell-like control characters.'); + throw new Error('expression contains shell-like control characters (none of ; & | ` $ < > are valid in a bazel query).'); } return expression; } @@ -71,8 +154,11 @@ export function platformArgs(platform?: BuildPlatform): string[] { switch (p) { case 'none': return []; - case 'simulator': - return ['--platforms=@build_bazel_apple_support//platforms:ios_sim_arm64', '--ios_multi_cpus=sim_arm64']; + case 'simulator': { + const cpu = iosSimulatorCpu(); + const platformName = cpu === 'sim_arm64' ? 'ios_sim_arm64' : `ios_${cpu}`; + return [`--platforms=@build_bazel_apple_support//platforms:${platformName}`, `--ios_multi_cpus=${cpu}`]; + } case 'device': return ['--platforms=@build_bazel_apple_support//platforms:ios_arm64', '--ios_multi_cpus=arm64']; case 'macos': @@ -109,8 +195,33 @@ export function configArgs(value: unknown): string[] { }); } +/** + * Extract Bazel target labels from `bazel query` output. Bazel writes + * progress/INFO/WARNING/DEBUG lines to stderr which `runCommand` merges into the + * combined output, so we keep only lines that look like a bare label/pattern. + */ +export function parseTargetLabels(output: string): string[] { + const seen = new Set(); + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (!line || line.includes(' ') || line.includes('\t')) continue; + if (!line.includes('//')) continue; + if (line.startsWith('//') || line.startsWith('@')) seen.add(line); + } + return [...seen].sort(); +} + +/** + * Default bazel query scope for target discovery. Overridable via + * `BAZEL_IOS_DISCOVER_SCOPE` so non-monorepo workspaces (without //Apps and + * //Packages) can point discovery at e.g. `//...`. + */ +export function defaultDiscoverScope(): string { + return process.env.BAZEL_IOS_DISCOVER_SCOPE?.trim() || '(//Apps/... union //Packages/...)'; +} + export function discoverExpression(kind: TargetKind = 'all', scope?: string): string { - const queryScope = scope || '(//Apps/... union //Packages/...)'; + const queryScope = scope || defaultDiscoverScope(); switch (kind) { case 'apps': return `kind("ios_application rule", ${queryScope})`; @@ -156,16 +267,20 @@ export async function runBazel( ): Promise { const config = getConfig(); assertBazelWorkspace(config.workspacePath); - const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS - ? process.env.BAZEL_IOS_STARTUP_ARGS.split(/\s+/).filter(Boolean) - : []; - const result = await runCommand(config.bazelPath, [...envStartupArgs, ...startupArgs, ...args], { + const { all: allStartupArgs } = resolveStartupArgs(startupArgs); + const id = randomUUID().slice(0, 8); + const result = await runCommand(config.bazelPath, [...allStartupArgs, ...args], { cwd: config.workspacePath, timeoutSeconds, maxOutput: config.maxOutput, + id, + }); + return finalizeBazelResult(result, { + id, + bazelPath: config.bazelPath, + workspacePath: config.workspacePath, + allStartupArgs, }); - lastCommand = result; - return result; } export async function* runBazelStreaming( @@ -175,26 +290,28 @@ export async function* runBazelStreaming( ): AsyncGenerator { const config = getConfig(); assertBazelWorkspace(config.workspacePath); - const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS - ? process.env.BAZEL_IOS_STARTUP_ARGS.split(/\s+/).filter(Boolean) - : []; + const { all: allStartupArgs } = resolveStartupArgs(startupArgs); + const id = randomUUID().slice(0, 8); - let finalResult: CommandResult | undefined; for await (const chunk of runCommandStreaming( config.bazelPath, - [...envStartupArgs, ...startupArgs, ...args], + [...allStartupArgs, ...args], { cwd: config.workspacePath, timeoutSeconds, maxOutput: config.maxOutput, + id, }, )) { if ('stream' in chunk) { yield chunk; } else { - finalResult = chunk; - lastCommand = finalResult; - yield finalResult; + yield finalizeBazelResult(chunk, { + id, + bazelPath: config.bazelPath, + workspacePath: config.workspacePath, + allStartupArgs, + }); } } } diff --git a/src/core/command-log.test.ts b/src/core/command-log.test.ts new file mode 100644 index 0000000..9dcad10 --- /dev/null +++ b/src/core/command-log.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { commandLogPath, logCommand, readRecentCommands } from './command-log.js'; + +let dir: string; +const prevEnv = process.env.BAZEL_IOS_COMMAND_LOG; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cmdlog-')); + process.env.BAZEL_IOS_COMMAND_LOG = join(dir, 'commands.ndjson'); + delete process.env.BAZEL_IOS_COMMAND_LOG_DISABLE; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (prevEnv === undefined) delete process.env.BAZEL_IOS_COMMAND_LOG; + else process.env.BAZEL_IOS_COMMAND_LOG = prevEnv; +}); + +describe('command-log', () => { + it('uses the env override for the log path', () => { + expect(commandLogPath()).toBe(join(dir, 'commands.ndjson')); + }); + + it('appends NDJSON entries and reads them back', () => { + logCommand({ id: 'aaa', timestamp: 1, argv: ['bazel', 'build', '//a'], exitCode: 0, durationMs: 10 }); + logCommand({ id: 'bbb', timestamp: 2, argv: ['bazel', 'test', '//b'], exitCode: 1, durationMs: 20, failureKind: 'nonzero-exit' }); + + const raw = readFileSync(commandLogPath(), 'utf8').trim().split('\n'); + expect(raw).toHaveLength(2); + expect(JSON.parse(raw[0]).id).toBe('aaa'); + + const entries = readRecentCommands(); + expect(entries.map((e) => e.id)).toEqual(['aaa', 'bbb']); + expect(entries[1].failureKind).toBe('nonzero-exit'); + }); + + it('respects the limit (newest last)', () => { + for (let i = 0; i < 5; i++) { + logCommand({ timestamp: i, argv: ['bazel', String(i)], exitCode: 0, durationMs: 1 }); + } + const entries = readRecentCommands(2); + expect(entries.map((e) => e.argv[1])).toEqual(['3', '4']); + }); + + it('returns empty when no log exists', () => { + expect(readRecentCommands()).toEqual([]); + expect(existsSync(commandLogPath())).toBe(false); + }); + + it('does not write when disabled', () => { + process.env.BAZEL_IOS_COMMAND_LOG_DISABLE = '1'; + logCommand({ timestamp: 1, argv: ['bazel'], exitCode: 0, durationMs: 1 }); + expect(existsSync(commandLogPath())).toBe(false); + }); + + it('reads across the rotated .1 file so recent entries survive rotation', () => { + const path = commandLogPath(); + // Older generation lives in .1, newer in the active file. + writeFileSync(`${path}.1`, [ + JSON.stringify({ id: 'old1', timestamp: 1, argv: ['bazel', '1'], exitCode: 0, durationMs: 1 }), + JSON.stringify({ id: 'old2', timestamp: 2, argv: ['bazel', '2'], exitCode: 0, durationMs: 1 }), + ].join('\n') + '\n'); + writeFileSync(path, JSON.stringify({ id: 'new1', timestamp: 3, argv: ['bazel', '3'], exitCode: 0, durationMs: 1 }) + '\n'); + + expect(readRecentCommands().map((e) => e.id)).toEqual(['old1', 'old2', 'new1']); + // The limit applies across both files, keeping the newest. + expect(readRecentCommands(2).map((e) => e.id)).toEqual(['old2', 'new1']); + }); + + it('rotates to .1 once the size cap is exceeded (bounded growth)', () => { + process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES = '200'; + try { + for (let i = 0; i < 50; i++) { + logCommand({ id: `id-${i}`, timestamp: i, argv: ['bazel', 'build', `//pkg:t${i}`], exitCode: 0, durationMs: 1 }); + } + // The active log was rotated at least once, so a .1 sibling exists... + expect(existsSync(`${commandLogPath()}.1`)).toBe(true); + // ...and the active log stays bounded (not all 50 entries pile up). + expect(readRecentCommands(1000).length).toBeLessThan(50); + } finally { + delete process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES; + } + }); +}); diff --git a/src/core/command-log.ts b/src/core/command-log.ts new file mode 100644 index 0000000..3bd3175 --- /dev/null +++ b/src/core/command-log.ts @@ -0,0 +1,74 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +/** Rotate the log to `.1` once it grows past this size, keeping ≤ 2x on disk. */ +const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024; + +function maxLogBytes(): number { + const override = Number(process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES); + return Number.isFinite(override) && override > 0 ? override : DEFAULT_MAX_LOG_BYTES; +} + +export interface CommandLogEntry { + id?: string; + timestamp: number; + argv: string[]; + cwd?: string; + envOverrides?: string[]; + exitCode: number; + durationMs: number; + truncated?: boolean; + failureKind?: string; + timedOut?: boolean; + bytesDropped?: number; +} + +/** + * Path of the persistent NDJSON command log. Override with + * `BAZEL_IOS_COMMAND_LOG`. Defaults to `~/.xcodebazelmcp/commands.ndjson`. + */ +export function commandLogPath(): string { + return process.env.BAZEL_IOS_COMMAND_LOG || join(homedir(), '.xcodebazelmcp', 'commands.ndjson'); +} + +/** + * Append one command invocation to the persistent NDJSON log. Best-effort: a + * logging failure must never break the actual command. A postmortem is then a + * `tail` away, surviving restarts (unlike the in-memory lastCommand). + */ +export function logCommand(entry: CommandLogEntry): void { + if (process.env.BAZEL_IOS_COMMAND_LOG_DISABLE === '1') return; + try { + const path = commandLogPath(); + mkdirSync(dirname(path), { recursive: true }); + if (existsSync(path) && statSync(path).size >= maxLogBytes()) { + renameSync(path, `${path}.1`); // overwrites a previous .1; bounds disk to ~2x + } + appendFileSync(path, JSON.stringify(entry) + '\n'); + } catch { + /* best effort — never throw from logging */ + } +} + +/** Read the most recent command log entries (newest last). */ +export function readRecentCommands(limit = 20): CommandLogEntry[] { + const path = commandLogPath(); + const entries: CommandLogEntry[] = []; + // Read the rotated file first (older entries), then the active file, so the + // most-recent N span the rotation boundary instead of being truncated to + // whatever survived the last rename. + for (const p of [`${path}.1`, path]) { + if (!existsSync(p)) continue; + for (const raw of readFileSync(p, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line) continue; + try { + entries.push(JSON.parse(line) as CommandLogEntry); + } catch { + /* skip malformed line */ + } + } + } + return limit > 0 ? entries.slice(-limit) : entries; +} diff --git a/src/core/devices.ts b/src/core/devices.ts index 1cef45b..4a5be0a 100644 --- a/src/core/devices.ts +++ b/src/core/devices.ts @@ -1,9 +1,29 @@ -import { existsSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; import type { CommandResult } from '../types/index.js'; import { runCommand } from '../utils/process.js'; import { readBundleId } from './simulators.js'; +/** + * Create a JSON output path inside a fresh private temp directory (0700 via + * mkdtemp). Avoids predictable `/tmp/...-.json` names that are prone + * to symlink/TOCTOU races (CWE-377). + */ +function makeTempJsonPath(tag: string): string { + const dir = mkdtempSync(join(tmpdir(), 'xcodebazelmcp-')); + return join(dir, `${tag}.json`); +} + +function cleanupTempJson(path: string): void { + try { + rmSync(dirname(path), { recursive: true, force: true }); + } catch { + /* best effort */ + } +} + interface PhysicalDevice { udid: string; name: string; @@ -19,7 +39,7 @@ export async function listDevices(): Promise<{ command: CommandResult; devices: PhysicalDevice[]; }> { - const jsonOutputPath = `/tmp/xcodebazelmcp-devices-${Date.now()}.json`; + const jsonOutputPath = makeTempJsonPath('devices'); const command = await runCommand( 'xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonOutputPath], @@ -64,7 +84,7 @@ export async function listDevices(): Promise<{ } catch { // JSON parse or read failed — return empty list with the command result } finally { - try { const { unlinkSync } = await import('node:fs'); unlinkSync(jsonOutputPath); } catch { /* best effort */ } + cleanupTempJson(jsonOutputPath); } return { command, devices }; @@ -89,7 +109,7 @@ export async function resolveDevice(options: { if (options.deviceName) { const normalize = (s: string) => - s.toLowerCase().replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"'); + s.trim().toLowerCase().replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"'); const needle = normalize(options.deviceName); const match = connected.find( (d) => normalize(d.name) === needle, @@ -155,7 +175,7 @@ export async function terminateAppOnDevice( bundleId: string, ): Promise { // First try to find PID via `devicectl device info apps` - const appsJsonPath = `/tmp/xcodebazelmcp-apps-${Date.now()}.json`; + const appsJsonPath = makeTempJsonPath('apps'); const appsResult = await runCommand( 'xcrun', ['devicectl', 'device', 'info', 'apps', '--device', deviceId, '--json-output', appsJsonPath], @@ -164,7 +184,7 @@ export async function terminateAppOnDevice( let executableName: string | undefined; try { - const { readFileSync, unlinkSync } = await import('node:fs'); + const { readFileSync } = await import('node:fs'); if (appsResult.exitCode === 0) { const raw = readFileSync(appsJsonPath, 'utf8'); const parsed = JSON.parse(raw) as { @@ -187,13 +207,13 @@ export async function terminateAppOnDevice( } } } - try { unlinkSync(appsJsonPath); } catch { /* best effort */ } + cleanupTempJson(appsJsonPath); } catch { - try { const { unlinkSync } = await import('node:fs'); unlinkSync(appsJsonPath); } catch { /* best effort */ } + cleanupTempJson(appsJsonPath); } // Now get process list and match - const jsonPath = `/tmp/xcodebazelmcp-procs-${Date.now()}.json`; + const jsonPath = makeTempJsonPath('procs'); const listResult = await runCommand( 'xcrun', ['devicectl', 'device', 'info', 'processes', '--device', deviceId, '--json-output', jsonPath], @@ -206,9 +226,9 @@ export async function terminateAppOnDevice( let pid: number | undefined; try { - const { readFileSync, unlinkSync } = await import('node:fs'); + const { readFileSync } = await import('node:fs'); const raw = readFileSync(jsonPath, 'utf8'); - unlinkSync(jsonPath); + cleanupTempJson(jsonPath); const parsed = JSON.parse(raw) as { result?: { runningProcesses?: Array<{ @@ -238,7 +258,7 @@ export async function terminateAppOnDevice( pid = proc?.processIdentifier; } catch { - try { const { unlinkSync } = await import('node:fs'); unlinkSync(jsonPath); } catch { /* best effort */ } + cleanupTempJson(jsonPath); } if (!pid) { @@ -249,6 +269,7 @@ export async function terminateAppOnDevice( exitCode: 1, durationMs: 0, truncated: false, + failureKind: 'nonzero-exit', }; } @@ -259,6 +280,27 @@ export async function terminateAppOnDevice( ); } +export async function uninstallAppOnDevice( + deviceId: string, + bundleId: string, +): Promise { + return runCommand( + 'xcrun', + ['devicectl', 'device', 'uninstall', 'app', '--device', deviceId, bundleId], + { cwd: process.cwd(), timeoutSeconds: 120, maxOutput: 100_000 }, + ); +} + +export async function listAppsOnDevice( + deviceId: string, +): Promise { + return runCommand( + 'xcrun', + ['devicectl', 'device', 'info', 'apps', '--device', deviceId], + { cwd: process.cwd(), timeoutSeconds: 60, maxOutput: 500_000 }, + ); +} + export async function screenshotDevice( deviceId: string, outputPath: string, @@ -315,6 +357,9 @@ export async function screenshotDevice( return result; } +/** Keep at most this many chars of streamed device-log output (sliding tail). */ +const DEVICE_LOG_MAX = 500_000; + interface DeviceLogCapture { child: ChildProcess; getCaptured: () => string; @@ -338,11 +383,15 @@ export async function startDeviceLogCapture( let captured = ''; let stderrBuf = ''; - child.stdout?.on('data', (chunk: Buffer) => { captured += chunk.toString(); }); + const appendCapped = (text: string) => { + captured += text; + if (captured.length > DEVICE_LOG_MAX) captured = captured.slice(-DEVICE_LOG_MAX); + }; + child.stdout?.on('data', (chunk: Buffer) => appendCapped(chunk.toString())); child.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString(); stderrBuf += text; - captured += text; + appendCapped(text); }); // Wait briefly to detect fast failures (no tunneld, device not found, etc.) @@ -366,8 +415,12 @@ export async function startDeviceLogCapture( const child = spawn('idevicesyslog', logArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); let captured = ''; - child.stdout?.on('data', (chunk: Buffer) => { captured += chunk.toString(); }); - child.stderr?.on('data', (chunk: Buffer) => { captured += chunk.toString(); }); + const appendCapped = (text: string) => { + captured += text; + if (captured.length > DEVICE_LOG_MAX) captured = captured.slice(-DEVICE_LOG_MAX); + }; + child.stdout?.on('data', (chunk: Buffer) => appendCapped(chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => appendCapped(chunk.toString())); return { child, getCaptured: () => captured, tool: 'idevicesyslog' }; } @@ -448,7 +501,11 @@ export async function countSigningIdentities(): Promise<{ count: number; command { cwd: process.cwd(), timeoutSeconds: 15, maxOutput: 50_000 }, ); const match = command.output.match(/(\d+) valid identities found/); - return { count: match ? parseInt(match[1], 10) : 0, command }; + if (match) return { count: parseInt(match[1], 10), command }; + // Fallback for `security` phrasings without the summary line: count the + // enumerated ` 1) ""` identity lines. + const lineCount = (command.output.match(/^\s*\d+\)\s+[0-9A-F]{20,}/gim) || []).length; + return { count: lineCount, command }; } export { readBundleId }; diff --git a/src/core/diagnostics.test.ts b/src/core/diagnostics.test.ts new file mode 100644 index 0000000..4caca7f --- /dev/null +++ b/src/core/diagnostics.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { classifyFailure, extractInvocationUrl } from './diagnostics.js'; +import type { CommandResult } from '../types/index.js'; + +function res(partial: Partial): CommandResult { + return { + command: 'bazel', + args: ['build', '//x'], + exitCode: 1, + durationMs: 100, + output: '', + truncated: false, + ...partial, + }; +} + +describe('extractInvocationUrl', () => { + it('pulls a BuildBuddy invocation URL', () => { + const text = 'INFO: Streaming build results to: https://app.buildbuddy.io/invocation/abc-123\nLoading...'; + expect(extractInvocationUrl(text)).toBe('https://app.buildbuddy.io/invocation/abc-123'); + }); + + it('trims trailing punctuation', () => { + expect(extractInvocationUrl('see (https://x.buildbuddy.io/invocation/zzz).')).toBe( + 'https://x.buildbuddy.io/invocation/zzz', + ); + }); + + it('returns undefined when absent', () => { + expect(extractInvocationUrl('no url here')).toBeUndefined(); + }); +}); + +describe('classifyFailure', () => { + it('returns undefined for success', () => { + expect(classifyFailure(res({ exitCode: 0, failureKind: 'ok' }))).toBeUndefined(); + }); + + it('classifies timeouts', () => { + const c = classifyFailure(res({ timedOut: true, failureKind: 'timeout' })); + expect(c?.category).toBe('timeout'); + }); + + it('classifies spawn errors', () => { + const c = classifyFailure(res({ exitCode: -1, failureKind: 'spawn-error', spawnErrorCode: 'ENOENT', output: 'Error: spawn bazel ENOENT' })); + expect(c?.category).toBe('spawn_error'); + expect(c?.diagnostics[0]).toContain('ENOENT'); + }); + + it('classifies analysis errors', () => { + const c = classifyFailure(res({ stderr: 'ERROR: Analysis of target //a:b failed; build aborted' })); + expect(c?.category).toBe('analysis_error'); + }); + + it('classifies BUILD file errors', () => { + const c = classifyFailure(res({ stderr: "ERROR: no such package '//missing': BUILD file not found" })); + expect(c?.category).toBe('build_file_error'); + }); + + it('classifies missing dependency / visibility errors', () => { + const c = classifyFailure(res({ stderr: "ERROR: target '//a:b' is not visible to target '//c:d'" })); + expect(c?.category).toBe('missing_dependency'); + }); + + it('classifies link errors', () => { + const c = classifyFailure(res({ stderr: 'Undefined symbols for architecture arm64:\n "_foo"\nld: symbol(s) not found' })); + expect(c?.category).toBe('link_error'); + }); + + it('classifies compile errors', () => { + const c = classifyFailure(res({ stderr: '/path/Foo.swift:12:5: error: cannot find "bar" in scope' })); + expect(c?.category).toBe('compile_error'); + expect(c?.diagnostics.some((d) => d.includes('error:'))).toBe(true); + }); + + it('classifies test failures', () => { + const c = classifyFailure(res({ stderr: 'Test Suite "MyTests" failed\nExecuted 3 tests, with 1 failure' })); + expect(c?.category).toBe('test_failure'); + }); + + it('prefers stderr and surfaces the invocation URL', () => { + const c = classifyFailure(res({ + stderr: 'INFO: Streaming build results to: https://app.buildbuddy.io/invocation/xyz\n/a.swift:1:1: error: boom', + output: 'progress noise', + })); + expect(c?.category).toBe('compile_error'); + expect(c?.invocationUrl).toBe('https://app.buildbuddy.io/invocation/xyz'); + }); + + it('falls back to unknown for unrecognized failures', () => { + const c = classifyFailure(res({ stderr: 'something went sideways' })); + expect(c?.category).toBe('unknown'); + }); +}); diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts new file mode 100644 index 0000000..dccb679 --- /dev/null +++ b/src/core/diagnostics.ts @@ -0,0 +1,114 @@ +import type { CommandResult } from '../types/index.js'; + +export type FailureCategory = + | 'timeout' + | 'spawn_error' + | 'analysis_error' + | 'build_file_error' + | 'missing_dependency' + | 'link_error' + | 'compile_error' + | 'test_failure' + | 'unknown'; + +export interface FailureDiagnostics { + category: FailureCategory; + diagnostics: string[]; + invocationUrl?: string; +} + +const INVOCATION_RE = /https?:\/\/\S*invocation\/\S+/i; + +/** Pull a BuildBuddy / BES invocation URL out of Bazel output (one click to full remote logs). */ +export function extractInvocationUrl(text: string): string | undefined { + const match = text.match(INVOCATION_RE); + if (!match) return undefined; + // Trim trailing punctuation that often follows a URL in prose. + return match[0].replace(/[)\].,;'"]+$/, ''); +} + +const DIAGNOSTIC_LINE_RE = + /(ERROR:|error:|fatal error:|FAILED:|Undefined symbols?|duplicate symbol|ld: |linker command failed|no such target|no such package|not declared|is not visible|\*\* (BUILD|TEST) FAILED \*\*|Analysis (of|failed))/; + +function pickDiagnosticLines(text: string, limit = 12): string[] { + const seen = new Set(); + const lines: string[] = []; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || !DIAGNOSTIC_LINE_RE.test(line)) continue; + if (seen.has(line)) continue; + seen.add(line); + lines.push(line); + if (lines.length >= limit) break; + } + return lines; +} + +function categorize(text: string): FailureCategory { + if (/ERROR:.*[Aa]nalysis of target/.test(text) || /[Aa]nalysis failed/.test(text)) { + return 'analysis_error'; + } + if (/no such package/i.test(text) || /error loading package/i.test(text) || /\/BUILD(\.bazel)?:\d+/.test(text)) { + return 'build_file_error'; + } + if (/no such target/i.test(text) || /is not visible to target/i.test(text) || /which is not visible/i.test(text) || /missing dependency/i.test(text) || /undeclared inclusion/i.test(text)) { + return 'missing_dependency'; + } + if (/Undefined symbols?/i.test(text) || /duplicate symbol/i.test(text) || /\bld: /.test(text) || /linker command failed/i.test(text)) { + return 'link_error'; + } + if (/\.swift:\d+:\d+: error:/.test(text) || /\berror: /.test(text) || /fatal error:/.test(text)) { + return 'compile_error'; + } + if (/Executed \d+ tests?/i.test(text) || /Test Suite .* failed/i.test(text) || /\*\* TEST FAILED \*\*/.test(text) || /\d+ (?:test )?fails?\b/i.test(text) || /XCTAssert/.test(text)) { + return 'test_failure'; + } + return 'unknown'; +} + +type ClassifiableResult = Pick< + CommandResult, + 'exitCode' | 'stdout' | 'stderr' | 'output' | 'timedOut' | 'failureKind' | 'spawnErrorCode' +>; + +// formatCommandResult and structuredCommandResult are usually called on the same +// result object; cache by reference so the regex scan over (potentially large) +// output runs at most once per result. +const classifyCache = new WeakMap(); + +/** + * Classify a failed command and surface the top diagnostic lines so an agent + * can jump straight to the cause instead of re-reading a multi-thousand-line + * dump. Returns undefined for successful commands. + */ +export function classifyFailure(result: ClassifiableResult): FailureDiagnostics | undefined { + const cached = classifyCache.get(result); + if (cached !== undefined || classifyCache.has(result)) return cached; + const computed = computeClassification(result); + classifyCache.set(result, computed); + return computed; +} + +function computeClassification(result: ClassifiableResult): FailureDiagnostics | undefined { + if (result.failureKind === 'ok' || (result.exitCode === 0 && !result.timedOut)) { + return undefined; + } + + // Prefer stderr (where Bazel/Swift write diagnostics); fall back to combined. + const haystack = [result.stderr, result.output].filter((s): s is string => Boolean(s)).join('\n'); + const invocationUrl = extractInvocationUrl(haystack); + + if (result.timedOut || result.failureKind === 'timeout') { + return { category: 'timeout', diagnostics: pickDiagnosticLines(haystack, 6), invocationUrl }; + } + if (result.failureKind === 'spawn-error') { + const code = result.spawnErrorCode ? ` (${result.spawnErrorCode})` : ''; + return { category: 'spawn_error', diagnostics: [`Failed to spawn command${code}: ${result.output}`.trim()], invocationUrl }; + } + + return { + category: categorize(haystack), + diagnostics: pickDiagnosticLines(haystack), + invocationUrl, + }; +} diff --git a/src/core/lldb.test.ts b/src/core/lldb.test.ts index 882e675..5335c81 100644 --- a/src/core/lldb.test.ts +++ b/src/core/lldb.test.ts @@ -12,6 +12,7 @@ import { getSession, getThreadList, getVariables, + killAllSessions, listBreakpoints, listSessions, runLldbCommand, @@ -95,6 +96,20 @@ describe('listSessions', () => { }); }); +describe('killAllSessions', () => { + it('terminates and clears every live session', async () => { + const mockChild = new MockChildProcess(); + mockSpawn.mockReturnValue(mockChild as unknown as ChildProcess); + await attachToProcess(1234, 'MyApp'); + expect(listSessions().length).toBeGreaterThan(0); + + killAllSessions(); + + expect(mockChild.killed).toBe(true); + expect(listSessions()).toEqual([]); + }); +}); + describe('getSession', () => { it('throws when session not found', () => { expect(() => getSession('unknown-id')).toThrow('Unknown LLDB session'); @@ -232,6 +247,28 @@ describe('setBreakpoint', () => { expect(mockChild.stdin.write).toHaveBeenCalledWith('breakpoint set --file "main.swift" --line 42\n'); }); + it('evaluateExpression rejects a multi-line expression (injection guard)', async () => { + const mockChild = new MockChildProcess(); + mockSpawn.mockReturnValue(mockChild as unknown as ChildProcess); + const { sessionId } = await attachToProcess(1234, 'MyApp'); + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(evaluateExpression(sessionId, 'foo\nprocess kill')).rejects.toThrow('single line'); + }); + + it('rejects a file/symbol containing a newline or quote (injection guard)', async () => { + const mockChild = new MockChildProcess(); + mockSpawn.mockReturnValue(mockChild as unknown as ChildProcess); + const { sessionId } = await attachToProcess(1234, 'MyApp'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + await expect(setBreakpoint(sessionId, { file: 'a.swift"\nprocess kill' })).rejects.toThrow( + 'must not contain newlines or double quotes', + ); + await expect(setBreakpoint(sessionId, { symbol: 'foo\nquit' })).rejects.toThrow( + 'must not contain newlines or double quotes', + ); + }); + it('sends breakpoint set command with function name', async () => { const mockChild = new MockChildProcess(); mockSpawn.mockReturnValue(mockChild as unknown as ChildProcess); diff --git a/src/core/lldb.ts b/src/core/lldb.ts index 94b4835..076cbdf 100644 --- a/src/core/lldb.ts +++ b/src/core/lldb.ts @@ -11,6 +11,34 @@ export interface LldbSession { const MAX_OUTPUT = 500_000; const sessions = new Map(); let sessionCounter = 0; +let markerSeq = 0; + +/** + * Kill every live LLDB session. Called on server shutdown so attached `lldb` + * child processes aren't left running. + */ +export function killAllSessions(): void { + for (const [id, session] of sessions) { + try { session.child.kill('SIGTERM'); } catch { /* noop */ } + sessions.delete(id); + } +} + +/** Append to a session transcript, keeping only the most recent MAX_OUTPUT chars. */ +function appendSessionOutput(session: LldbSession, text: string): void { + session.output += text; + if (session.output.length > MAX_OUTPUT) { + session.output = session.output.slice(-MAX_OUTPUT); + } +} + +/** Reject values that would break out of a quoted LLDB argument or inject commands. */ +function assertLldbArg(value: string, field: string): string { + if (/[\n\r"]/.test(value)) { + throw new Error(`Invalid ${field}: must not contain newlines or double quotes.`); + } + return value; +} export function getSession(sessionId: string): LldbSession { const session = sessions.get(sessionId); @@ -41,6 +69,7 @@ export async function attachToProcess( deviceName?: string, ): Promise<{ sessionId: string; output: string }> { const sessionId = `lldb-${++sessionCounter}`; + if (deviceName) assertLldbArg(deviceName, 'deviceName'); const child = spawn('xcrun', ['lldb', '--no-use-colors'], { stdio: ['pipe', 'pipe', 'pipe'], @@ -49,16 +78,8 @@ export async function attachToProcess( const session: LldbSession = { child, output: '', pid, processName, target }; sessions.set(sessionId, session); - child.stdout!.on('data', (chunk: Buffer) => { - if (session.output.length < MAX_OUTPUT) { - session.output += chunk.toString(); - } - }); - child.stderr!.on('data', (chunk: Buffer) => { - if (session.output.length < MAX_OUTPUT) { - session.output += chunk.toString(); - } - }); + child.stdout!.on('data', (chunk: Buffer) => appendSessionOutput(session, chunk.toString())); + child.stderr!.on('data', (chunk: Buffer) => appendSessionOutput(session, chunk.toString())); child.on('close', () => { sessions.delete(sessionId); @@ -87,6 +108,8 @@ export async function attachByName( deviceName?: string, ): Promise<{ sessionId: string; output: string }> { const sessionId = `lldb-${++sessionCounter}`; + assertLldbArg(processName, 'processName'); + if (deviceName) assertLldbArg(deviceName, 'deviceName'); const child = spawn('xcrun', ['lldb', '--no-use-colors'], { stdio: ['pipe', 'pipe', 'pipe'], @@ -95,16 +118,8 @@ export async function attachByName( const session: LldbSession = { child, output: '', pid: 0, processName, target }; sessions.set(sessionId, session); - child.stdout!.on('data', (chunk: Buffer) => { - if (session.output.length < MAX_OUTPUT) { - session.output += chunk.toString(); - } - }); - child.stderr!.on('data', (chunk: Buffer) => { - if (session.output.length < MAX_OUTPUT) { - session.output += chunk.toString(); - } - }); + child.stdout!.on('data', (chunk: Buffer) => appendSessionOutput(session, chunk.toString())); + child.stderr!.on('data', (chunk: Buffer) => appendSessionOutput(session, chunk.toString())); child.on('close', () => { sessions.delete(sessionId); @@ -157,12 +172,26 @@ export async function setBreakpoint( }, ): Promise { const session = getSession(sessionId); + // A newline in any field would inject additional LLDB commands (each + // sendCommand writes `\n`), and a double-quote would break out of + // the quoted argument. Reject both in the structured fields. + const assertSafe = (value: string, field: string): string => { + if (/[\n\r"]/.test(value)) { + throw new Error(`Invalid ${field}: must not contain newlines or double quotes.`); + } + return value; + }; const parts = ['breakpoint set']; - if (options.file) parts.push(`--file "${options.file}"`); + if (options.file) parts.push(`--file "${assertSafe(options.file, 'file')}"`); if (options.line !== undefined) parts.push(`--line ${options.line}`); - if (options.symbol) parts.push(`--name "${options.symbol}"`); - if (options.module) parts.push(`--shlib "${options.module}"`); - if (options.condition) parts.push(`--condition '${options.condition}'`); + if (options.symbol) parts.push(`--name "${assertSafe(options.symbol, 'symbol')}"`); + if (options.module) parts.push(`--shlib "${assertSafe(options.module, 'module')}"`); + if (options.condition) { + if (/[\n\r]/.test(options.condition)) { + throw new Error('Invalid condition: must not contain newlines.'); + } + parts.push(`--condition '${options.condition}'`); + } if (options.oneShot) parts.push('--one-shot true'); return sendCommand(session, parts.join(' ')); } @@ -217,6 +246,11 @@ export async function getVariables(sessionId: string, scope: 'local' | 'args' | export async function evaluateExpression(sessionId: string, expression: string): Promise { const session = getSession(sessionId); + // A newline would be split into multiple LLDB commands. Use lldb_command for + // multi-statement input instead. + if (/[\n\r]/.test(expression)) { + throw new Error('Expression must be a single line. Use lldb_command for multi-line input.'); + } return sendCommand(session, `expression -- ${expression}`); } @@ -255,44 +289,42 @@ function cleanLldbOutput(raw: string): string { async function sendCommand(session: LldbSession, command: string, timeoutMs = 10_000): Promise { return new Promise((resolve, reject) => { - const markerBegin = `__XBMCP_BEGIN_${Date.now()}__`; - const markerEnd = `__XBMCP_END_${Date.now()}__`; - - const outputBefore = session.output.length; - let collected = ''; - let capturing = false; - - const onData = () => { - const newOutput = session.output.slice(outputBefore); - if (!capturing) { - const beginIdx = newOutput.indexOf(markerBegin); - if (beginIdx !== -1) { - capturing = true; - collected = newOutput.slice(beginIdx + markerBegin.length); - } - } else { - collected = session.output.slice(outputBefore); - const beginIdx = collected.indexOf(markerBegin); - if (beginIdx !== -1) { - collected = collected.slice(beginIdx + markerBegin.length); + const stamp = `${Date.now()}_${++markerSeq}`; + const markerBegin = `__XBMCP_BEGIN_${stamp}__`; + const markerEnd = `__XBMCP_END_${stamp}__`; + + // Accumulate this command's output independently of session.output, which + // is capped at MAX_OUTPUT — relying on the capped transcript would deadlock + // every command once a long-running session passes the cap. + let acc = ''; + let beginIdx = -1; + + const onData = (chunk: Buffer) => { + acc += chunk.toString(); + if (beginIdx === -1) { + const b = acc.indexOf(markerBegin); + if (b === -1) { + // Before the begin marker arrives, bound memory by keeping only a + // tail large enough to still contain a (possibly split) marker. + if (acc.length > MAX_OUTPUT) acc = acc.slice(-1024); + return; } + beginIdx = b + markerBegin.length; } - if (capturing) { - const endIdx = collected.indexOf(markerEnd); - if (endIdx !== -1) { - cleanup(); - resolve(cleanLldbOutput(collected.slice(0, endIdx))); - } + const endIdx = acc.indexOf(markerEnd, beginIdx); + if (endIdx !== -1) { + cleanup(); + resolve(cleanLldbOutput(acc.slice(beginIdx, endIdx))); } }; const timer = setTimeout(() => { cleanup(); - const allNew = session.output.slice(outputBefore).trim(); - resolve(cleanLldbOutput(allNew) || '(timeout waiting for LLDB response)'); + const captured = beginIdx >= 0 ? acc.slice(beginIdx) : acc; + resolve(cleanLldbOutput(captured.trim()) || '(timeout waiting for LLDB response)'); }, timeoutMs); - const dataHandler = () => onData(); + const dataHandler = (chunk: Buffer) => onData(chunk); session.child.stdout!.on('data', dataHandler); session.child.stderr!.on('data', dataHandler); @@ -330,7 +362,7 @@ async function waitForProcessStop(session: LldbSession, timeoutMs: number): Prom const check = () => { const newOut = session.output.slice(startLen); - if (newOut.includes('stopped') || newOut.includes('Process ')) { + if (/stop reason|stopped/.test(newOut)) { clearTimeout(timer); session.child.stdout!.removeListener('data', check); setTimeout(() => { diff --git a/src/core/scaffold.test.ts b/src/core/scaffold.test.ts index f4b6979..0ab3908 100644 --- a/src/core/scaffold.test.ts +++ b/src/core/scaffold.test.ts @@ -150,4 +150,73 @@ describe('scaffold guards', () => { 'already exists', ); }); + + it('throws on an unknown template instead of producing a partial project', () => { + const outDir = join(testDir, 'bad-template'); + expect(() => + // @ts-expect-error intentionally passing an invalid template + scaffold({ outputPath: outDir, name: 'Bad', template: 'ios_widget' }), + ).toThrow('Unknown template'); + expect(existsSync(join(outDir, 'MODULE.bazel'))).toBe(false); + }); + + it('rejects an invalid project name', () => { + const outDir = join(testDir, 'bad-name'); + expect(() => scaffold({ outputPath: outDir, name: '1Bad', template: 'ios_app' })).toThrow( + 'Invalid project name', + ); + }); + + it('writes a custom bazelVersion to .bazelversion and an absolute config path', () => { + const outDir = join(testDir, 'bazel-version'); + scaffold({ outputPath: outDir, name: 'App', template: 'ios_app', bazelVersion: '8.0.0' }); + expect(readFileSync(join(outDir, '.bazelversion'), 'utf8').trim()).toBe('8.0.0'); + expect(readFileSync(join(outDir, '.xcodebazelmcp', 'config.yaml'), 'utf8')).toContain(`workspacePath: ${outDir}`); + }); + + it('iOS Info.plist includes LSRequiresIPhoneOS / UILaunchScreen; macOS does not', () => { + const iosDir = join(testDir, 'ios-plist'); + scaffold({ outputPath: iosDir, name: 'IApp', template: 'ios_app' }); + const iosPlist = readFileSync(join(iosDir, 'IApp', 'Info.plist'), 'utf8'); + expect(iosPlist).toContain('LSRequiresIPhoneOS'); + expect(iosPlist).toContain('UILaunchScreen'); + + const macDir = join(testDir, 'mac-plist'); + scaffold({ outputPath: macDir, name: 'MApp', template: 'macos_app' }); + const macPlist = readFileSync(join(macDir, 'MApp', 'Info.plist'), 'utf8'); + expect(macPlist).not.toContain('LSRequiresIPhoneOS'); + }); + + it('rejects an injection-prone bundleId', () => { + const outDir = join(testDir, 'bad-bundle'); + expect(() => + scaffold({ outputPath: outDir, name: 'App', template: 'ios_app', bundleId: 'com.x"\n evil' }), + ).toThrow('Invalid bundleId'); + expect(existsSync(join(outDir, 'MODULE.bazel'))).toBe(false); + }); + + it('rejects a non-numeric minimumOs', () => { + const outDir = join(testDir, 'bad-os'); + expect(() => + scaffold({ outputPath: outDir, name: 'App', template: 'ios_app', minimumOs: 'latest' }), + ).toThrow('Invalid minimumOs'); + }); + + it('honors a custom families list and rejects invalid families', () => { + const outDir = join(testDir, 'families'); + scaffold({ outputPath: outDir, name: 'IPhoneOnly', template: 'ios_app', families: ['iphone'] }); + const build = readFileSync(join(outDir, 'IPhoneOnly', 'BUILD.bazel'), 'utf8'); + expect(build).toContain('families = ["iphone"]'); + + expect(() => + scaffold({ outputPath: join(testDir, 'bad-fam'), name: 'X', template: 'ios_app', families: ['tv'] }), + ).toThrow('Invalid family'); + }); + + it('rejects a malformed rulesVersion', () => { + const outDir = join(testDir, 'bad-rules'); + expect(() => + scaffold({ outputPath: outDir, name: 'App', template: 'ios_app', rulesVersion: 'HEAD' }), + ).toThrow('Invalid rulesVersion'); + }); }); diff --git a/src/core/scaffold.ts b/src/core/scaffold.ts index 656cd2c..6763871 100644 --- a/src/core/scaffold.ts +++ b/src/core/scaffold.ts @@ -1,5 +1,11 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; + +/** Default toolchain/dependency pins for generated projects (centralized so they're easy to bump). */ +const DEFAULT_BAZEL_VERSION = '7.6.1'; +const DEFAULT_RULES_APPLE_VERSION = '3.16.1'; +const RULES_SWIFT_VERSION = '2.6.0'; +const APPLE_SUPPORT_VERSION = '1.21.1'; export type ScaffoldTemplate = | 'ios_app' @@ -16,6 +22,9 @@ export interface ScaffoldOptions { bundleId?: string; minimumOs?: string; rulesVersion?: string; + bazelVersion?: string; + /** iOS device families for ios_application (default: iphone + ipad). */ + families?: string[]; } export interface ScaffoldResult { @@ -42,8 +51,31 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult { throw new Error(`Invalid project name "${name}". Use alphanumeric characters, hyphens, and underscores only.`); } + const validTemplates = getAvailableTemplates().map((t) => t.id); + if (!validTemplates.includes(template)) { + throw new Error(`Unknown template "${template}". Available: ${validTemplates.join(', ')}`); + } + const bundleId = options.bundleId || `com.example.${name}`; + if (!/^[A-Za-z0-9.-]+$/.test(bundleId)) { + throw new Error(`Invalid bundleId "${bundleId}". Use reverse-DNS style: letters, numbers, dots, and hyphens only.`); + } const minimumOs = options.minimumOs || (template.startsWith('macos') ? '14.0' : '17.0'); + if (!/^\d+(\.\d+){0,2}$/.test(minimumOs)) { + throw new Error(`Invalid minimumOs "${minimumOs}". Use a version like 17.0.`); + } + if (options.rulesVersion !== undefined && !/^\d+(\.\d+){0,2}$/.test(options.rulesVersion)) { + throw new Error(`Invalid rulesVersion "${options.rulesVersion}". Use a version like 3.16.1.`); + } + const bazelVersion = options.bazelVersion || DEFAULT_BAZEL_VERSION; + if (!/^\d+(\.\d+){0,2}$/.test(bazelVersion)) { + throw new Error(`Invalid bazelVersion "${bazelVersion}". Use a version like 7.6.1.`); + } + const families = options.families && options.families.length > 0 ? options.families : ['iphone', 'ipad']; + const validFamilies = ['iphone', 'ipad']; + for (const f of families) { + if (!validFamilies.includes(f)) throw new Error(`Invalid family "${f}". Allowed: ${validFamilies.join(', ')}.`); + } if (existsSync(join(outputPath, 'MODULE.bazel')) || existsSync(join(outputPath, 'WORKSPACE'))) { throw new Error(`A Bazel workspace already exists in ${outputPath}. Aborting to avoid overwriting.`); @@ -64,22 +96,22 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult { mkdirSync(outputPath, { recursive: true }); } - const rulesAppleVersion = options.rulesVersion || '3.16.1'; + const rulesAppleVersion = options.rulesVersion || DEFAULT_RULES_APPLE_VERSION; write('MODULE.bazel', moduleBazel(name, rulesAppleVersion)); write('.bazelrc', bazelrc(template)); - write('.bazelversion', '7.6.1\n'); + write('.bazelversion', `${bazelVersion}\n`); write('.gitignore', bazelGitignore()); switch (template) { case 'ios_app': - writeIosApp(write, name, bundleId, minimumOs); + writeIosApp(write, name, bundleId, minimumOs, families); break; case 'ios_test': writeIosTest(write, name, minimumOs); break; case 'ios_app_with_tests': - writeIosApp(write, name, bundleId, minimumOs); + writeIosApp(write, name, bundleId, minimumOs, families); writeIosAppTest(write, name, minimumOs); break; case 'macos_app': @@ -94,7 +126,9 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult { break; } - write('.xcodebazelmcp/config.yaml', `workspacePath: .\nbazelPath: bazel\n`); + // Absolute path so the config resolves correctly regardless of the cwd the + // server/CLI is later launched from. + write('.xcodebazelmcp/config.yaml', `workspacePath: ${resolve(outputPath)}\nbazelPath: bazel\n`); return { filesCreated, outputPath, template }; } @@ -109,8 +143,8 @@ function moduleBazel(name: string, rulesAppleVersion: string): string { if (needsAppleRules) { lines.push( `bazel_dep(name = "rules_apple", version = "${rulesAppleVersion}")`, - `bazel_dep(name = "rules_swift", version = "2.6.0")`, - `bazel_dep(name = "apple_support", version = "1.21.1")`, + `bazel_dep(name = "rules_swift", version = "${RULES_SWIFT_VERSION}")`, + `bazel_dep(name = "apple_support", version = "${APPLE_SUPPORT_VERSION}")`, '', ); } @@ -144,6 +178,10 @@ function bazelrc(template: ScaffoldTemplate): string { 'build:debug --compilation_mode=dbg', 'build:release --compilation_mode=opt --strip=always', '', + '# Caching (uncomment and configure to speed up rebuilds)', + '# build --disk_cache=~/.cache/bazel-disk', + '# build --remote_cache=grpc://your-cache:9092', + '', ); return lines.join('\n'); @@ -162,7 +200,8 @@ function bazelGitignore(): string { type WriteFunc = (relPath: string, content: string) => void; -function writeIosApp(write: WriteFunc, name: string, bundleId: string, minimumOs: string): void { +function writeIosApp(write: WriteFunc, name: string, bundleId: string, minimumOs: string, families: string[]): void { + const familiesLiteral = families.map((f) => `"${f}"`).join(', '); write(`${name}/BUILD.bazel`, `load("@rules_apple//apple:ios.bzl", "ios_application") load("@rules_swift//swift:swift.bzl", "swift_library") @@ -176,14 +215,14 @@ swift_library( ios_application( name = "${name}", bundle_id = "${bundleId}", - families = ["iphone", "ipad"], + families = [${familiesLiteral}], infoplists = ["Info.plist"], minimum_os_version = "${minimumOs}", deps = [":${name}Lib"], ) `); - write(`${name}/Info.plist`, infoPlist(name, bundleId)); + write(`${name}/Info.plist`, infoPlist(name, bundleId, true)); write(`${name}/Sources/${name}App.swift`, `import SwiftUI @@ -295,7 +334,7 @@ macos_application( ) `); - write(`${name}/Info.plist`, infoPlist(name, bundleId)); + write(`${name}/Info.plist`, infoPlist(name, bundleId, false)); write(`${name}/Sources/${name}App.swift`, `import SwiftUI @@ -387,7 +426,16 @@ final class ${testName}: XCTestCase { `); } -function infoPlist(name: string, bundleId: string): string { +function infoPlist(name: string, bundleId: string, isIos: boolean): string { + // iOS apps need LSRequiresIPhoneOS and a launch-screen key or the app warns / + // is rejected; macOS apps don't. + const iosKeys = isIos + ? ` LSRequiresIPhoneOS + + UILaunchScreen + +` + : ''; return ` @@ -402,7 +450,7 @@ function infoPlist(name: string, bundleId: string): string { 1.0 CFBundlePackageType APPL - +${iosKeys} `; } diff --git a/src/core/simulators.test.ts b/src/core/simulators.test.ts index 7a6cb5c..e1f3a76 100644 --- a/src/core/simulators.test.ts +++ b/src/core/simulators.test.ts @@ -4,6 +4,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import type { CommandResult } from '../types/index.js'; import { runCommand } from '../utils/process.js'; import { + assertLogFilter, + buildLogPredicate, bootSimulator, bootSimulatorIfNeeded, clearStatusBar, @@ -17,7 +19,9 @@ import { openSimulatorApp, openUrl, readBundleId, + readInfoPlistValue, resolveSimulator, + runtimeVersion, sendPushNotification, setPrivacy, setSimulatorAppearance, @@ -67,6 +71,22 @@ beforeAll(() => { `, ); + // macOS layout: Info.plist lives under Contents/ + mkdirSync(join(bazelBin, 'mac', 'MacApp.app', 'Contents'), { recursive: true }); + writeFileSync( + join(bazelBin, 'mac', 'MacApp.app', 'Contents', 'Info.plist'), + ` + + + + CFBundleIdentifier + com.example.MacApp + CFBundleExecutable + MacApp + +`, + ); + mkdirSync(join(bazelBin, 'app', 'app_archive-root', 'Payload', 'SwiftUIApp.app'), { recursive: true }); writeFileSync( join(bazelBin, 'app', 'app_archive-root', 'Payload', 'SwiftUIApp.app', 'Info.plist'), @@ -100,6 +120,34 @@ const mockSuccess: CommandResult = { truncated: false, }; +describe('assertLogFilter / buildLogPredicate', () => { + it('accepts dotted identifiers', () => { + expect(assertLogFilter('com.example.MyApp', 'subsystem')).toBe('com.example.MyApp'); + expect(assertLogFilter('MyApp', 'processName')).toBe('MyApp'); + }); + + it('rejects values with quotes, spaces, or predicate operators (security)', () => { + expect(() => assertLogFilter('com.x" OR 1==1', 'subsystem')).toThrow('Invalid subsystem'); + expect(() => assertLogFilter('My App', 'processName')).toThrow('Invalid processName'); + expect(() => assertLogFilter('*', 'subsystem')).toThrow('Invalid subsystem'); + expect(() => assertLogFilter('', 'processName')).toThrow('Invalid processName'); + }); + + it('builds an AND predicate from both filters', () => { + expect(buildLogPredicate({ processName: 'MyApp', subsystem: 'com.example.MyApp' })).toBe( + 'process == "MyApp" AND subsystem == "com.example.MyApp"', + ); + }); + + it('returns undefined with no filters', () => { + expect(buildLogPredicate({})).toBeUndefined(); + }); + + it('rejects an injection attempt in buildLogPredicate', () => { + expect(() => buildLogPredicate({ subsystem: 'a" OR process == "b' })).toThrow('Invalid subsystem'); + }); +}); + describe('findAppBundle', () => { it('finds an app at the root of bazel-bin for //:Target', () => { const result = findAppBundle(fakeWorkspace, '//:BazelApp'); @@ -143,11 +191,31 @@ describe('readBundleId', () => { expect(readBundleId(appPath)).toBe('com.example.MyApp'); }); + it('reads CFBundleIdentifier from a macOS Contents/Info.plist layout (regression)', () => { + const appPath = join(bazelBin, 'mac', 'MacApp.app'); + expect(readBundleId(appPath)).toBe('com.example.MacApp'); + }); + + it('reads arbitrary Info.plist keys via readInfoPlistValue', () => { + const appPath = join(bazelBin, 'mac', 'MacApp.app'); + expect(readInfoPlistValue(appPath, 'CFBundleExecutable')).toBe('MacApp'); + expect(readInfoPlistValue(appPath, 'NoSuchKey')).toBeUndefined(); + }); + it('throws when Info.plist is missing', () => { expect(() => readBundleId('/tmp/no-such-app.app')).toThrow('Info.plist not found'); }); }); +describe('runtimeVersion', () => { + it('parses iOS runtime ids into comparable numbers', () => { + expect(runtimeVersion('com.apple.CoreSimulator.SimRuntime.iOS-26-3')).toBeGreaterThan( + runtimeVersion('com.apple.CoreSimulator.SimRuntime.iOS-17-5'), + ); + expect(runtimeVersion('garbage')).toBe(0); + }); +}); + describe('listSimulators', () => { it('parses available simulators from xcrun simctl list', async () => { const jsonOutput = JSON.stringify({ @@ -188,6 +256,22 @@ describe('listSimulators', () => { expect(result.devices[0].name).toBe('iPhone 15'); }); + it('prefers stdout over combined output so stderr noise cannot break parsing (regression)', async () => { + const jsonOutput = JSON.stringify({ + devices: { 'iOS 17.0': [{ name: 'iPhone 15', udid: 'ABC-123', state: 'Booted', isAvailable: true }] }, + }); + mockRunCommand.mockResolvedValue({ + ...mockSuccess, + stdout: jsonOutput, + output: `WARNING: deprecated runtime\n${jsonOutput}`, + }); + + const result = await listSimulators(); + + expect(result.devices).toHaveLength(1); + expect(result.devices[0].udid).toBe('ABC-123'); + }); + it('returns empty array on parse error', async () => { mockRunCommand.mockResolvedValue({ ...mockSuccess, output: 'not json' }); const result = await listSimulators(); @@ -255,6 +339,24 @@ describe('resolveSimulator', () => { expect(result.device.name).toBe('iPhone 14'); }); + it('prefers the iPhone on the newest runtime when none booted', async () => { + mockRunCommand.mockResolvedValue({ + ...mockSuccess, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { name: 'iPhone 15', udid: 'OLD', state: 'Shutdown', isAvailable: true }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-26-3': [ + { name: 'iPhone 16', udid: 'NEW', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + const result = await resolveSimulator({}); + expect(result.device.udid).toBe('NEW'); + }); + it('returns first available device when no iPhones', async () => { mockRunCommand.mockResolvedValue({ ...mockSuccess, diff --git a/src/core/simulators.ts b/src/core/simulators.ts index 55a670a..3993775 100644 --- a/src/core/simulators.ts +++ b/src/core/simulators.ts @@ -12,6 +12,34 @@ export interface SimulatorDevice { isAvailable: boolean; } +/** + * Allowlist for os_log predicate filter values (process name / subsystem). + * Prevents a crafted value from breaking out of the quoted predicate and + * broadening the log stream to other apps or Apple system subsystems. + */ +const LOG_FILTER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function assertLogFilter(value: string, name: string): string { + if (!LOG_FILTER_RE.test(value)) { + throw new Error( + `Invalid ${name} "${value}". Use letters, numbers, dots, dashes, and underscores only (e.g. com.example.MyApp).`, + ); + } + return value; +} + +/** + * Build a safe `log stream --predicate` value from optional process/subsystem + * filters. Filters are validated against an allowlist and combined with AND so + * they narrow (intersect) the stream rather than broaden it. + */ +export function buildLogPredicate(opts: { processName?: string; subsystem?: string }): string | undefined { + const predicates: string[] = []; + if (opts.processName) predicates.push(`process == "${assertLogFilter(opts.processName, 'processName')}"`); + if (opts.subsystem) predicates.push(`subsystem == "${assertLogFilter(opts.subsystem, 'subsystem')}"`); + return predicates.length > 0 ? predicates.join(' AND ') : undefined; +} + export async function listSimulators(onlyBooted = false): Promise<{ command: CommandResult; devices: SimulatorDevice[]; @@ -28,7 +56,9 @@ export async function listSimulators(onlyBooted = false): Promise<{ let parsed: { devices?: Record>> }; try { - parsed = JSON.parse(command.output); + // simctl writes JSON to stdout; prefer it so stderr warnings (e.g. runtime + // deprecation notices merged into `output`) can't break JSON.parse. + parsed = JSON.parse(command.stdout || command.output); } catch { return { command, devices: [] }; } @@ -52,6 +82,17 @@ interface ResolvedSimulator { warning?: string; } +/** + * Turn a runtime id like `com.apple.CoreSimulator.SimRuntime.iOS-26-3` into a + * comparable number (26.3 → 26003) so the newest runtime sorts highest. + */ +export function runtimeVersion(runtime: string): number { + const match = runtime.match(/(\d+)-(\d+)(?:-(\d+))?$/); + if (!match) return 0; + const [, major, minor, patch] = match; + return Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch || 0); +} + export async function resolveSimulator(options: { simulatorId?: string; simulatorName?: string; @@ -86,8 +127,13 @@ export async function resolveSimulator(options: { if (bootedDevices.length === 1) return { device: bootedDevices[0] }; - const iphone = devices.find((d) => d.name.startsWith('iPhone') && d.isAvailable); - if (iphone) return { device: iphone }; + // No booted sim: pick a deterministic, sensible default — the available + // iPhone on the newest runtime (rather than whatever simctl happens to list + // first, which is often an old model). + const iphones = devices + .filter((d) => d.name.startsWith('iPhone') && d.isAvailable) + .sort((a, b) => runtimeVersion(b.runtime) - runtimeVersion(a.runtime) || a.name.localeCompare(b.name)); + if (iphones.length > 0) return { device: iphones[0] }; if (devices.length > 0) return { device: devices[0] }; @@ -119,6 +165,17 @@ export async function installApp( }); } +export async function uninstallApp( + simulatorUdid: string, + bundleId: string, +): Promise { + return runCommand('xcrun', ['simctl', 'uninstall', simulatorUdid, bundleId], { + cwd: process.cwd(), + timeoutSeconds: 30, + maxOutput: 50_000, + }); +} + export async function launchApp( simulatorUdid: string, bundleId: string, @@ -140,31 +197,45 @@ export async function launchApp( }); } -export function readBundleId(appPath: string): string { - const plistPath = join(appPath, 'Info.plist'); - if (!existsSync(plistPath)) { +/** + * Locate the Info.plist for an .app bundle, handling both the iOS layout + * (`/Info.plist`) and the macOS layout (`/Contents/Info.plist`). + */ +export function findInfoPlist(appPath: string): string | null { + const iosPlist = join(appPath, 'Info.plist'); + if (existsSync(iosPlist)) return iosPlist; + const macPlist = join(appPath, 'Contents', 'Info.plist'); + if (existsSync(macPlist)) return macPlist; + return null; +} + +/** Read a single string value out of an .app bundle's Info.plist. */ +export function readInfoPlistValue(appPath: string, key: string): string | undefined { + const plistPath = findInfoPlist(appPath); + if (!plistPath) { throw new Error(`Info.plist not found in ${appPath}`); } - const result = spawnSync('plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf-8', timeout: 5_000, }); - if (result.error) { throw result.error; } - if (result.status !== 0) { throw new Error(`plutil failed with exit code ${result.status}: ${result.stderr}`); } + const parsed = JSON.parse(result.stdout) as Record; + const value = parsed[key]; + return typeof value === 'string' ? value : undefined; +} - const parsed = JSON.parse(result.stdout) as { CFBundleIdentifier?: string }; - - if (!parsed.CFBundleIdentifier) { - throw new Error(`CFBundleIdentifier not found in ${plistPath}`); +export function readBundleId(appPath: string): string { + const bundleId = readInfoPlistValue(appPath, 'CFBundleIdentifier'); + if (!bundleId) { + throw new Error(`CFBundleIdentifier not found in ${appPath}`); } - return parsed.CFBundleIdentifier; + return bundleId; } export async function bootSimulator(udid: string): Promise { @@ -333,6 +404,29 @@ export async function openUrl( }); } +export async function addMedia( + simulatorUdid: string, + paths: string[], +): Promise { + return runCommand('xcrun', ['simctl', 'addmedia', simulatorUdid, ...paths], { + cwd: process.cwd(), + timeoutSeconds: 60, + maxOutput: 50_000, + }); +} + +export async function getAppContainer( + simulatorUdid: string, + bundleId: string, + kind: 'app' | 'data' | 'groups' = 'data', +): Promise { + return runCommand('xcrun', ['simctl', 'get_app_container', simulatorUdid, bundleId, kind], { + cwd: process.cwd(), + timeoutSeconds: 15, + maxOutput: 50_000, + }); +} + export async function getSimulatorUiState( simulatorUdid: string, ): Promise<{ appearance: CommandResult; increaseContrast: CommandResult }> { @@ -390,7 +484,7 @@ function findFirstApp(dir: string): string | null { return null; } -function searchForApp(dir: string, targetName: string): string | null { +function searchForApp(dir: string, targetName: string, depth = 8): string | null { const appName = `${targetName}.app`; try { @@ -400,9 +494,10 @@ function searchForApp(dir: string, targetName: string): string | null { return join(dir, entry.name); } } + if (depth <= 0) return null; // bound recursion on deep bazel-bin trees for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { - const found = searchForApp(join(dir, entry.name), targetName); + const found = searchForApp(join(dir, entry.name), targetName, depth - 1); if (found) return found; } } diff --git a/src/core/swift-package.test.ts b/src/core/swift-package.test.ts index f2f4ba2..32b86ac 100644 --- a/src/core/swift-package.test.ts +++ b/src/core/swift-package.test.ts @@ -340,6 +340,20 @@ describe('swiftPackageDump', () => { expect(result.manifest).toEqual({ name: 'MyPackage', platforms: [] }); }); + it('parses stdout JSON even when stderr warnings land in combined output (regression)', async () => { + const manifestJson = JSON.stringify({ name: 'MyPackage', platforms: [] }); + // `swift` may emit warnings to stderr which the combined `output` prepends. + mockRunCommand.mockResolvedValue({ + ...mockSuccess, + stdout: manifestJson, + output: `warning: 'foo' is deprecated\n${manifestJson}`, + }); + + const result = await swiftPackageDump({ packagePath: tempDir }); + + expect(result.manifest).toEqual({ name: 'MyPackage', platforms: [] }); + }); + it('returns undefined manifest on parse error', async () => { mockRunCommand.mockResolvedValue({ ...mockSuccess, output: 'not json' }); diff --git a/src/core/swift-package.ts b/src/core/swift-package.ts index a2d7f24..f7bf10b 100644 --- a/src/core/swift-package.ts +++ b/src/core/swift-package.ts @@ -157,6 +157,35 @@ export async function swiftRun(options: { }); } +export async function* swiftRunStreaming(options: { + packagePath: string; + executable?: string; + configuration?: SwiftBuildConfiguration; + extraArgs?: string[]; + runArgs?: string[]; + timeoutSeconds?: number; +}): AsyncGenerator { + const args = ['run']; + if (options.configuration) { + args.push('-c', options.configuration); + } + if (options.executable) { + args.push(options.executable); + } + if (options.extraArgs) { + args.push(...options.extraArgs); + } + if (options.runArgs && options.runArgs.length > 0) { + args.push('--', ...options.runArgs); + } + + yield* runCommandStreaming('swift', args, { + cwd: assertSwiftPackage(options.packagePath), + timeoutSeconds: options.timeoutSeconds || 300, + maxOutput: 500_000, + }); +} + export async function swiftPackageClean(options: { packagePath: string; }): Promise { @@ -190,7 +219,9 @@ export async function swiftPackageDump(options: { let manifest: Record | undefined; if (command.exitCode === 0) { try { - manifest = JSON.parse(command.output) as Record; + // dump-package writes JSON to stdout; swift may emit warnings to stderr + // which the combined `output` would prepend and break JSON.parse. + manifest = JSON.parse(command.stdout || command.output) as Record; } catch { // JSON parse failed — return raw output } diff --git a/src/core/test-simulator.ts b/src/core/test-simulator.ts index 8965637..3153206 100644 --- a/src/core/test-simulator.ts +++ b/src/core/test-simulator.ts @@ -26,8 +26,25 @@ export async function minimizeSimulatorWindows(): Promise { } export function startMinimizeSimulatorPoller(intervalMs = 2_000): () => void { + let consecutiveFailures = 0; + let hintShown = false; const timer = setInterval(() => { - void minimizeSimulatorWindows(); + void minimizeSimulatorWindows().then((result) => { + if (result.exitCode === 0) { + consecutiveFailures = 0; + return; + } + consecutiveFailures += 1; + if (!hintShown && /not authoriz|-1743|assistive|accessibility/i.test(result.output)) { + hintShown = true; + console.error( + 'Note: minimizing Simulator windows needs Automation/Accessibility permission ' + + '(System Settings → Privacy & Security → Automation). Continuing without minimize.', + ); + } + // Stop hammering osascript if Simulator isn't scriptable (e.g. not running). + if (consecutiveFailures >= 3) clearInterval(timer); + }); }, intervalMs); return () => clearInterval(timer); } @@ -46,8 +63,10 @@ export async function cleanupSimulatorsAfterTest( const deleted: string[] = []; for (const device of devices) { - const openedForTest = - !bootedBefore.has(device.udid) || device.name.startsWith(BAZEL_TEST_SIMULATOR_PREFIX); + // Only touch simulators that were not already booted when the test started + // (i.e. ones the test itself opened). A sim the user had running before — + // even a leftover BAZEL_TEST_* one — is left alone. + const openedForTest = !bootedBefore.has(device.udid); if (!openedForTest) continue; await shutdownSimulator(device.udid); diff --git a/src/core/ui-interaction.ts b/src/core/ui-interaction.ts index 2c29565..6933c23 100644 --- a/src/core/ui-interaction.ts +++ b/src/core/ui-interaction.ts @@ -320,7 +320,9 @@ export async function simulatorAccessibilitySnapshot( const command = await idbCommand(['ui', 'describe-all', '--udid', simulatorUdid], 15); return { command, - tree: command.exitCode === 0 ? command.output : undefined, + // idb writes the JSON tree to stdout; prefer it so idb stderr noise can't + // corrupt the tree passed back to the caller. + tree: command.exitCode === 0 ? (command.stdout || command.output) : undefined, }; } diff --git a/src/core/upgrade.test.ts b/src/core/upgrade.test.ts index 7d18510..72f390a 100644 --- a/src/core/upgrade.test.ts +++ b/src/core/upgrade.test.ts @@ -115,6 +115,22 @@ describe('compareVersions – edge cases', () => { expect(compareVersions('1.0.0.0.0', '1')).toBe(0); expect(compareVersions('1.0.0.0.1', '1')).toBe(1); }); + + it('treats a prerelease as lower precedence than the release', () => { + expect(compareVersions('2.5.0', '2.5.0-beta.1')).toBe(1); + expect(compareVersions('2.5.0-beta.1', '2.5.0')).toBe(-1); + }); + + it('orders prerelease identifiers', () => { + expect(compareVersions('2.5.0-beta.1', '2.5.0-beta.2')).toBe(-1); + expect(compareVersions('1.0.0-alpha', '1.0.0-beta')).toBe(-1); + expect(compareVersions('1.0.0-alpha.1', '1.0.0-alpha')).toBe(1); + expect(compareVersions('2.6.0-beta.1', '2.6.0-beta.1')).toBe(0); + }); + + it('strips build metadata', () => { + expect(compareVersions('1.0.0+build5', '1.0.0+build9')).toBe(0); + }); }); describe('detectInstallMethod – value check', () => { @@ -208,7 +224,7 @@ describe('performUpgrade', () => { await performUpgrade('source'); expect(mockRunCommand).toHaveBeenCalledTimes(3); - expect(mockRunCommand).toHaveBeenNthCalledWith(1, 'git', ['pull', '--rebase'], expect.any(Object)); + expect(mockRunCommand).toHaveBeenNthCalledWith(1, 'git', ['pull', '--rebase', '--autostash'], expect.any(Object)); expect(mockRunCommand).toHaveBeenNthCalledWith(2, 'npm', ['install'], expect.any(Object)); expect(mockRunCommand).toHaveBeenNthCalledWith(3, 'npm', ['run', 'build'], expect.any(Object)); }); diff --git a/src/core/upgrade.ts b/src/core/upgrade.ts index d515aa0..70f7286 100644 --- a/src/core/upgrade.ts +++ b/src/core/upgrade.ts @@ -20,6 +20,11 @@ export function detectInstallMethod(): InstallMethod { if (selfPath.includes('/Cellar/') || selfPath.includes('/homebrew/')) return 'homebrew'; if (selfPath.includes('/node_modules/.bin/')) return 'npm-local'; if (selfPath.includes('/lib/node_modules/') || selfPath.includes('/npm/') || selfPath.includes('/pnpm/')) return 'npm-global'; + // Node version managers (volta/asdf/fnm/nvm/n) install global packages under + // their own trees; a self path inside node_modules there is a global install. + if (/\/\.(volta|asdf|fnm|nvm)\/|\/n\/versions\//.test(selfPath) && selfPath.includes('/node_modules/')) { + return 'npm-global'; + } const pkgJson = findPackageJson(selfPath); if (pkgJson) return 'source'; @@ -92,7 +97,7 @@ export async function performUpgrade(method?: InstallMethod): Promise parseInt(s, 10) || 0); + return { core, pre: preStr ? preStr.split('.') : [] }; +} + +/** + * SemVer-ish comparison. Compares the numeric release core, then applies the + * SemVer rule that a version with a prerelease tag has lower precedence than the + * same version without one (e.g. 2.5.0-beta.1 < 2.5.0). + */ export function compareVersions(a: string, b: string): number { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; + const va = splitVersion(a); + const vb = splitVersion(b); + const len = Math.max(va.core.length, vb.core.length); + for (let i = 0; i < len; i++) { + const na = va.core[i] || 0; + const nb = vb.core[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } + if (va.pre.length === 0 && vb.pre.length === 0) return 0; + if (va.pre.length === 0) return 1; + if (vb.pre.length === 0) return -1; + const plen = Math.max(va.pre.length, vb.pre.length); + for (let i = 0; i < plen; i++) { + const ida = va.pre[i]; + const idb = vb.pre[i]; + if (ida === undefined) return -1; + if (idb === undefined) return 1; + const na = /^\d+$/.test(ida) ? Number(ida) : null; + const nb = /^\d+$/.test(idb) ? Number(idb) : null; + if (na !== null && nb !== null) { + if (na !== nb) return na > nb ? 1 : -1; + } else if (na !== null) { + return -1; + } else if (nb !== null) { + return 1; + } else if (ida !== idb) { + return ida > idb ? 1 : -1; + } + } return 0; } diff --git a/src/core/workflows.test.ts b/src/core/workflows.test.ts index d3af2df..504d37c 100644 --- a/src/core/workflows.test.ts +++ b/src/core/workflows.test.ts @@ -1,99 +1,71 @@ -import { describe, expect, it } from 'vitest'; -import { WORKFLOWS, getEnabledToolNames, validateWorkflowIds, compactToolSchema } from './workflows.js'; +import { describe, it, expect } from 'vitest'; +import { WORKFLOWS, DEFAULT_WORKFLOWS, validateWorkflowIds, getEnabledToolNames, compactToolSchema } from './workflows.js'; +import { bazelToolDefinitions } from '../tools/bazel-tools.js'; -describe('Workflows', () => { - it('defines at least 15 workflow categories', () => { - expect(WORKFLOWS.length).toBeGreaterThanOrEqual(15); - }); +// Tools intentionally always advertised regardless of workflow filtering. +const ALWAYS_ON = new Set(['bazel_list_workflows', 'bazel_toggle_workflow']); - it('every workflow has a unique id', () => { - const ids = WORKFLOWS.map((w) => w.id); - expect(new Set(ids).size).toBe(ids.length); - }); +describe('workflow registry integrity', () => { + const registered = new Set(bazelToolDefinitions.map((t) => t.name)); + const workflowTools = WORKFLOWS.flatMap((w) => w.tools); - it('every workflow has at least one tool', () => { - for (const wf of WORKFLOWS) { - expect(wf.tools.length, `${wf.id} should have tools`).toBeGreaterThan(0); - } + it('every workflow tool maps to a real registered tool', () => { + const unknown = workflowTools.filter((name) => !registered.has(name)); + expect(unknown, `workflow lists tools that don't exist: ${unknown.join(', ')}`).toEqual([]); }); - it('validates known workflow IDs', () => { - expect(() => validateWorkflowIds(['build', 'test'])).not.toThrow(); + it('every registered tool belongs to exactly one workflow (or is always-on)', () => { + const counts = new Map(); + for (const name of workflowTools) counts.set(name, (counts.get(name) ?? 0) + 1); + + const uncategorized = [...registered].filter((name) => !ALWAYS_ON.has(name) && !counts.has(name)); + expect(uncategorized, `tools not in any workflow (invisible to filtered clients): ${uncategorized.join(', ')}`).toEqual([]); + + const duplicated = [...counts.entries()].filter(([, c]) => c > 1).map(([n]) => n); + expect(duplicated, `tools listed in multiple workflows: ${duplicated.join(', ')}`).toEqual([]); }); - it('rejects unknown workflow IDs', () => { - expect(() => validateWorkflowIds(['build', 'nonexistent'])).toThrow('Unknown workflow IDs: nonexistent'); + it('workflow ids are unique', () => { + const ids = WORKFLOWS.map((w) => w.id); + expect(new Set(ids).size).toBe(ids.length); }); - it('returns null when no filter is active', () => { - expect(getEnabledToolNames(undefined)).toBeNull(); - expect(getEnabledToolNames([])).toBeNull(); + it('DEFAULT_WORKFLOWS reference real workflow ids', () => { + expect(() => validateWorkflowIds(DEFAULT_WORKFLOWS)).not.toThrow(); }); - it('returns only tools from enabled workflows', () => { + it('getEnabledToolNames keeps always-on meta tools even when filtered', () => { const enabled = getEnabledToolNames(['build']); expect(enabled).not.toBeNull(); - expect(enabled!.has('bazel_ios_build')).toBe(true); - expect(enabled!.has('bazel_ios_build_and_run')).toBe(true); - expect(enabled!.has('bazel_ios_test')).toBe(false); - // workflow discovery tools are always included - expect(enabled!.has('bazel_list_workflows')).toBe(true); - expect(enabled!.has('bazel_toggle_workflow')).toBe(true); + for (const name of ALWAYS_ON) expect(enabled!.has(name)).toBe(true); }); - it('includes multiple workflows', () => { - const enabled = getEnabledToolNames(['build', 'test', 'macos']); - expect(enabled).not.toBeNull(); - expect(enabled!.has('bazel_ios_build')).toBe(true); - expect(enabled!.has('bazel_ios_test')).toBe(true); - expect(enabled!.has('bazel_macos_build')).toBe(true); - expect(enabled!.has('bazel_ios_tap')).toBe(false); + it('getEnabledToolNames always exposes defaults management + health', () => { + const enabled = getEnabledToolNames(['build'])!; + expect(enabled.has('bazel_ios_set_defaults')).toBe(true); + expect(enabled.has('bazel_ios_show_defaults')).toBe(true); + expect(enabled.has('bazel_ios_health')).toBe(true); + expect(enabled.has('bazel_ios_set_workspace')).toBe(true); }); }); describe('compactToolSchema', () => { - it('strips description from properties while keeping other fields', () => { - const tool = { - name: 'my_tool', - description: 'Does stuff', + it('strips property descriptions but preserves enum and required', () => { + const compact = compactToolSchema({ + name: 'x', + description: 'desc', inputSchema: { type: 'object', properties: { - target: { type: 'string', description: 'The build target', default: '//...' }, - verbose: { type: 'boolean', description: 'Enable verbose output' }, + mode: { type: 'string', enum: ['a', 'b'], description: 'drop me' }, }, + required: ['mode'], }, - }; - const result = compactToolSchema(tool); - expect(result.inputSchema.properties).toEqual({ - target: { type: 'string', default: '//...' }, - verbose: { type: 'boolean' }, }); - }); - - it('preserves schema with no properties', () => { - const tool = { - name: 'no_props', - description: 'No props tool', - inputSchema: { type: 'object' }, - }; - const result = compactToolSchema(tool); - expect(result.inputSchema).toEqual({ type: 'object' }); - }); - - it('preserves name and description of the tool itself', () => { - const tool = { - name: 'keep_me', - description: 'Important tool description', - inputSchema: { - type: 'object', - properties: { - arg: { type: 'string', description: 'removed' }, - }, - }, - }; - const result = compactToolSchema(tool); - expect(result.name).toBe('keep_me'); - expect(result.description).toBe('Important tool description'); + const props = compact.inputSchema.properties as Record>; + expect(props.mode.description).toBeUndefined(); + expect(props.mode.enum).toEqual(['a', 'b']); + expect(compact.inputSchema.required).toEqual(['mode']); + expect(compact.description).toBe('desc'); }); }); diff --git a/src/core/workflows.ts b/src/core/workflows.ts index af60e77..43151ad 100644 --- a/src/core/workflows.ts +++ b/src/core/workflows.ts @@ -28,6 +28,7 @@ export const WORKFLOWS: WorkflowInfo[] = [ 'bazel_ios_set_simulator_location', 'bazel_ios_set_simulator_appearance', 'bazel_ios_open_simulator', 'bazel_ios_set_status_bar', 'bazel_ios_privacy', 'bazel_ios_ui_dump', + 'bazel_ios_add_media', 'bazel_ios_get_app_container', ], }, { @@ -36,7 +37,7 @@ export const WORKFLOWS: WorkflowInfo[] = [ description: 'Install, launch, and stop apps on simulator.', tools: [ 'bazel_ios_install_app', 'bazel_ios_launch_app', 'bazel_ios_stop_app', - 'bazel_ios_get_app_path', 'bazel_ios_get_bundle_id', + 'bazel_ios_uninstall_app', 'bazel_ios_get_app_path', 'bazel_ios_get_bundle_id', ], }, { @@ -84,7 +85,8 @@ export const WORKFLOWS: WorkflowInfo[] = [ 'bazel_ios_list_devices', 'bazel_ios_device_build_and_run', 'bazel_ios_device_get_app_path', 'bazel_ios_device_install_app', 'bazel_ios_device_launch_app', - 'bazel_ios_device_stop_app', 'bazel_ios_device_test', + 'bazel_ios_device_stop_app', 'bazel_ios_device_uninstall_app', + 'bazel_ios_device_list_apps', 'bazel_ios_device_test', 'bazel_ios_device_screenshot', 'bazel_ios_device_log_start', 'bazel_ios_device_log_stop', 'bazel_ios_device_info', 'bazel_ios_device_pair', 'bazel_ios_device_unpair', @@ -191,6 +193,16 @@ export const WORKFLOWS: WorkflowInfo[] = [ description: 'Check for and install updates.', tools: ['bazel_check_update', 'bazel_upgrade'], }, + { + id: 'xcode', + name: 'Xcode Native MCP (26.3+)', + description: "Detect and bridge Apple's native Xcode MCP integration, DeviceHub, and skill export.", + tools: [ + 'bazel_xcode_native_mcp_status', + 'bazel_xcode_open_device_hub', + 'bazel_xcode_export_skills', + ], + }, ]; const ALL_WORKFLOW_IDS = new Set(WORKFLOWS.map((w) => w.id)); @@ -220,6 +232,10 @@ export function getEnabledToolNames(enabledWorkflows?: string[]): Set | enabled.add('bazel_toggle_workflow'); enabled.add('bazel_ios_set_workspace'); enabled.add('bazel_ios_show_defaults'); + // Always expose defaults management + the health check so an agent on any + // filtered workflow set can configure the session and run diagnostics. + enabled.add('bazel_ios_set_defaults'); + enabled.add('bazel_ios_health'); return enabled; } diff --git a/src/core/xcode-mcp.test.ts b/src/core/xcode-mcp.test.ts new file mode 100644 index 0000000..c5443cd --- /dev/null +++ b/src/core/xcode-mcp.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + deviceHubPathForApp, + developerDirForApp, + findXcodeApps, + inspectXcodeApp, + mcpBridgeInvocation, + xcodeMcpClientConfig, +} from './xcode-mcp.js'; + +describe('mcpBridgeInvocation', () => { + it('uses xcrun mcpbridge without DEVELOPER_DIR by default', () => { + expect(mcpBridgeInvocation()).toEqual({ command: 'xcrun', args: ['mcpbridge'], env: undefined }); + }); + + it('pins DEVELOPER_DIR when a specific Xcode is requested', () => { + expect(mcpBridgeInvocation('/Applications/Xcode-beta.app/Contents/Developer')).toEqual({ + command: 'xcrun', + args: ['mcpbridge'], + env: { DEVELOPER_DIR: '/Applications/Xcode-beta.app/Contents/Developer' }, + }); + }); +}); + +describe('xcodeMcpClientConfig', () => { + it('produces a Cursor/Claude-style mcpServers snippet', () => { + expect(xcodeMcpClientConfig()).toEqual({ + mcpServers: { 'xcode-native': { command: 'xcrun', args: ['mcpbridge'] } }, + }); + }); + + it('includes env when a developer dir is given', () => { + const cfg = xcodeMcpClientConfig('/X/Contents/Developer') as { + mcpServers: { 'xcode-native': { env?: Record } }; + }; + expect(cfg.mcpServers['xcode-native'].env).toEqual({ DEVELOPER_DIR: '/X/Contents/Developer' }); + }); +}); + +describe('developerDirForApp', () => { + it('appends Contents/Developer', () => { + expect(developerDirForApp('/Applications/Xcode.app')).toBe('/Applications/Xcode.app/Contents/Developer'); + }); +}); + +describe('filesystem detection', () => { + let root: string; + + function makeApp(name: string, opts: { mcpbridge?: boolean; lldbMcp?: boolean; deviceHub?: boolean }): string { + const app = join(root, name); + const bin = join(app, 'Contents', 'Developer', 'usr', 'bin'); + mkdirSync(bin, { recursive: true }); + if (opts.mcpbridge) writeFileSync(join(bin, 'mcpbridge'), ''); + if (opts.lldbMcp) writeFileSync(join(bin, 'lldb-mcp'), ''); + if (opts.deviceHub) mkdirSync(join(app, 'Contents', 'Applications', 'DeviceHub.app'), { recursive: true }); + return app; + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'xcode-apps-')); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('finds only Xcode*.app entries', () => { + makeApp('Xcode.app', { mcpbridge: true }); + makeApp('Xcode-beta.app', { mcpbridge: true, lldbMcp: true, deviceHub: true }); + mkdirSync(join(root, 'NotXcode.app'), { recursive: true }); + const apps = findXcodeApps(root); + expect(apps).toEqual([join(root, 'Xcode-beta.app'), join(root, 'Xcode.app')]); + }); + + it('detects stable 26.3 (mcpbridge only)', () => { + const app = makeApp('Xcode.app', { mcpbridge: true }); + const info = inspectXcodeApp(app); + expect(info.hasMcpBridge).toBe(true); + expect(info.hasLldbMcp).toBe(false); + expect(info.deviceHubPath).toBeNull(); + expect(info.isBeta).toBe(false); + }); + + it('detects beta (lldb-mcp + DeviceHub)', () => { + const app = makeApp('Xcode-beta.app', { mcpbridge: true, lldbMcp: true, deviceHub: true }); + const info = inspectXcodeApp(app); + expect(info.isBeta).toBe(true); + expect(info.hasLldbMcp).toBe(true); + expect(info.deviceHubPath).toBe(join(app, 'Contents', 'Applications', 'DeviceHub.app')); + }); + + it('deviceHubPathForApp returns null when absent', () => { + const app = makeApp('Xcode.app', { mcpbridge: true }); + expect(deviceHubPathForApp(app)).toBeNull(); + }); + + it('returns [] for a missing apps root', () => { + expect(findXcodeApps(join(root, 'does-not-exist'))).toEqual([]); + }); +}); diff --git a/src/core/xcode-mcp.ts b/src/core/xcode-mcp.ts new file mode 100644 index 0000000..eab96bf --- /dev/null +++ b/src/core/xcode-mcp.ts @@ -0,0 +1,164 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { CommandResult } from '../types/index.js'; +import { runCommand } from '../utils/process.js'; + +/** + * Apple's native Xcode MCP integration (Xcode 26.3+): + * - `xcrun mcpbridge` STDIO MCP bridge to a running Xcode's tool service. + * - `xcrun mcpbridge run-agent` Launch a coding agent wired to Xcode tools. + * - `xcrun agent skills export` Export Xcode's globally available SKILL.md bundles. + * - `lldb-mcp` LLDB MCP server (Xcode-beta / Xcode 27 only). + * - DeviceHub.app GUI device manager (Xcode-beta / Xcode 27 only). + * + * This module only *detects and wraps* the Apple tooling. It never replaces the + * existing Bazel/simctl/devicectl flows, so older Xcodes keep working unchanged. + */ + +export interface XcodeInstall { + appPath: string; + developerDir: string; + isBeta: boolean; + /** `/usr/bin/mcpbridge` present (Xcode 26.3+). */ + hasMcpBridge: boolean; + /** `/usr/bin/lldb-mcp` present (Xcode-beta / 27+). */ + hasLldbMcp: boolean; + /** Absolute path to DeviceHub.app if bundled (Xcode-beta / 27+), else null. */ + deviceHubPath: string | null; +} + +export interface BridgeInvocation { + command: string; + args: string[]; + env?: Record; +} + +export function developerDirForApp(appPath: string): string { + return join(appPath, 'Contents', 'Developer'); +} + +export function deviceHubPathForApp(appPath: string): string | null { + const candidate = join(appPath, 'Contents', 'Applications', 'DeviceHub.app'); + return existsSync(candidate) ? candidate : null; +} + +export function inspectXcodeApp(appPath: string): XcodeInstall { + const developerDir = developerDirForApp(appPath); + const bin = join(developerDir, 'usr', 'bin'); + return { + appPath, + developerDir, + isBeta: /beta/i.test(appPath), + hasMcpBridge: existsSync(join(bin, 'mcpbridge')), + hasLldbMcp: existsSync(join(bin, 'lldb-mcp')), + deviceHubPath: deviceHubPathForApp(appPath), + }; +} + +/** Discover installed Xcode apps (e.g. /Applications/Xcode.app, Xcode-beta.app). */ +export function findXcodeApps(appsRoot = '/Applications'): string[] { + try { + return readdirSync(appsRoot) + // Match Xcode.app / Xcode-beta.app / Xcode-15.4.app, but not e.g. XcodeBackup.app. + .filter((name) => /^Xcode(-[A-Za-z0-9._]+)?\.app$/i.test(name)) + .map((name) => join(appsRoot, name)) + .sort(); + } catch { + return []; + } +} + +/** The MCP bridge invocation. Pins DEVELOPER_DIR when a specific Xcode is requested. */ +export function mcpBridgeInvocation(developerDir?: string): BridgeInvocation { + return { + command: 'xcrun', + args: ['mcpbridge'], + env: developerDir ? { DEVELOPER_DIR: developerDir } : undefined, + }; +} + +/** MCP client config snippet (Cursor/Claude/Codex) to expose Xcode's native tools. */ +export function xcodeMcpClientConfig(developerDir?: string): Record { + const { command, args, env } = mcpBridgeInvocation(developerDir); + const server: Record = { command, args }; + if (env) server.env = env; + return { mcpServers: { 'xcode-native': server } }; +} + +export async function activeDeveloperDir(): Promise { + if (process.env.DEVELOPER_DIR) return process.env.DEVELOPER_DIR; + const result = await runCommand('xcode-select', ['-p'], { + cwd: process.cwd(), + timeoutSeconds: 5, + maxOutput: 2_000, + }); + return result.exitCode === 0 && result.output.trim() ? result.output.trim() : null; +} + +export async function runningXcodeProcessIds(): Promise { + const result = await runCommand('pgrep', ['-x', 'Xcode'], { + cwd: process.cwd(), + timeoutSeconds: 5, + maxOutput: 5_000, + }); + if (result.exitCode !== 0) return []; + return result.output + .split('\n') + .map((line) => parseInt(line.trim(), 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0); +} + +export interface XcodeMcpStatus { + installs: XcodeInstall[]; + activeDeveloperDir: string | null; + runningXcodePids: number[]; + bridgeAvailable: boolean; + deviceHub: XcodeInstall | null; + lldbMcp: XcodeInstall | null; +} + +export async function detectXcodeNativeMcp(appsRoot = '/Applications'): Promise { + const installs = findXcodeApps(appsRoot).map(inspectXcodeApp); + const [active, pids] = await Promise.all([activeDeveloperDir(), runningXcodeProcessIds()]); + return { + installs, + activeDeveloperDir: active, + runningXcodePids: pids, + bridgeAvailable: installs.some((i) => i.hasMcpBridge), + deviceHub: installs.find((i) => i.deviceHubPath) ?? null, + lldbMcp: installs.find((i) => i.hasLldbMcp) ?? null, + }; +} + +/** Open the GUI DeviceHub.app (Xcode-beta / 27+) for direct device interaction. */ +export async function openDeviceHub(deviceHubPath: string): Promise { + return runCommand('open', [deviceHubPath], { + cwd: process.cwd(), + timeoutSeconds: 15, + maxOutput: 10_000, + }); +} + +export interface ExportSkillsOptions { + outputDir?: string; + replaceExisting?: boolean; + developerDir?: string; +} + +/** + * Export Xcode's globally available agent skills via + * `xcrun mcpbridge run-agent skills export`. Requires a running Xcode instance. + */ +export async function exportXcodeSkills(options: ExportSkillsOptions): Promise { + const args = ['mcpbridge', 'run-agent', 'skills', 'export']; + if (options.outputDir) args.push('--output-dir', options.outputDir); + if (options.replaceExisting) args.push('--replace-existing'); + const env = { ...process.env }; + if (options.developerDir) env.DEVELOPER_DIR = options.developerDir; + return runCommand('xcrun', args, { + cwd: process.cwd(), + timeoutSeconds: 60, + maxOutput: 200_000, + env, + }); +} diff --git a/src/doctor-cli.ts b/src/doctor-cli.ts index 3c895a4..611b421 100644 --- a/src/doctor-cli.ts +++ b/src/doctor-cli.ts @@ -1,4 +1,65 @@ import { callBazelTool } from './tools/index.js'; +import { getConfig } from './runtime/config.js'; +import { assertBazelWorkspace } from './core/workspace.js'; +import { runCommand } from './utils/process.js'; +import { listSimulators } from './core/simulators.js'; + +interface Check { + label: string; + ok: boolean; + detail: string; +} + +async function preflight(): Promise<{ checks: Check[]; hardFail: boolean }> { + const config = getConfig(); + const checks: Check[] = []; + + // 1. Bazel binary resolves and runs. + const version = await runCommand(config.bazelPath, ['--version'], { + cwd: config.workspacePath, + timeoutSeconds: 20, + maxOutput: 5_000, + }); + if (version.failureKind === 'spawn-error') { + checks.push({ + label: 'bazel binary', + ok: false, + detail: `cannot run "${config.bazelPath}" (${version.spawnErrorCode || 'spawn error'}). Set bazelPath in config or PATH.`, + }); + } else if (version.exitCode !== 0) { + checks.push({ label: 'bazel binary', ok: false, detail: `"${config.bazelPath} --version" exited ${version.exitCode}.` }); + } else { + checks.push({ label: 'bazel binary', ok: true, detail: `${config.bazelPath} — ${version.output.trim().split('\n')[0]}` }); + } + + // 2. Workspace is a real Bazel root. + try { + assertBazelWorkspace(config.workspacePath); + checks.push({ label: 'workspace', ok: true, detail: `${config.workspacePath} (Bazel root)` }); + } catch (err) { + checks.push({ label: 'workspace', ok: false, detail: (err as Error).message }); + } + + // 3. Simulator runtime availability. + const { command, devices } = await listSimulators(); + if (command.exitCode !== 0) { + checks.push({ label: 'simulators', ok: false, detail: 'xcrun simctl unavailable — is Xcode installed and selected?' }); + } else if (devices.length === 0) { + checks.push({ label: 'simulators', ok: false, detail: 'No simulators available. Install a runtime in Xcode.' }); + } else { + const booted = devices.filter((d) => d.state === 'Booted').length; + checks.push({ label: 'simulators', ok: true, detail: `${devices.length} available, ${booted} booted` }); + } + + return { checks, hardFail: checks.some((c) => !c.ok) }; +} + +const { checks, hardFail } = await preflight(); +console.log('⚙️ XcodeBazelMCP Doctor — preflight\n'); +for (const c of checks) { + console.log(` ${c.ok ? '✅' : '❌'} ${c.label}: ${c.detail}`); +} +console.log(''); const result = await callBazelTool('bazel_ios_health', {}); const text = result.content @@ -6,6 +67,7 @@ const text = result.content .map((item) => item.text) .join('\n'); console.log(text); -if (result.isError) { + +if (hardFail || result.isError) { process.exitCode = 1; } diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 647c47a..dfb52f4 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -77,7 +77,7 @@ describe('MCP server protocol', () => { const toolsResp = lines.map((l) => JSON.parse(l)).find((r) => r.id === 2); expect(toolsResp).toBeDefined(); - expect(toolsResp.result.tools).toHaveLength(52); + expect(toolsResp.result.tools).toHaveLength(57); const names = toolsResp.result.tools.map((t: { name: string }) => t.name); expect(names).toContain('bazel_ios_build'); expect(names).toContain('bazel_ios_test'); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2fd4a49..f333d6e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,8 +5,12 @@ import { DEFAULT_WORKFLOWS, compactToolSchema, getEnabledToolNames } from '../co import { getEnabledWorkflows } from '../runtime/config.js'; import type { JsonObject } from '../types/index.js'; import { formatCommandResult } from '../utils/output.js'; +import { disposeAllCaptures } from '../tools/helpers.js'; +import { killAllSessions } from '../core/lldb.js'; +import { getCurrentVersion } from '../core/upgrade.js'; -const SERVER_VERSION = '0.1.0'; +// Report the real installed version (was hardcoded '0.1.0', drifting from package.json). +const SERVER_VERSION = getCurrentVersion(); type RequestId = string | number | null; @@ -39,6 +43,13 @@ export async function startMcpServer(): Promise { } }); + // Reap any in-flight simulator log/video/device-log children and attached + // LLDB sessions so they aren't orphaned when the server exits. + const reap = () => { disposeAllCaptures(); killAllSessions(); }; + process.once('SIGINT', () => { reap(); process.exit(0); }); + process.once('SIGTERM', () => { reap(); process.exit(0); }); + process.once('exit', reap); + await new Promise((resolve) => rl.once('close', resolve)); } diff --git a/src/runtime/config.test.ts b/src/runtime/config.test.ts index 6b9eae2..9a265af 100644 --- a/src/runtime/config.test.ts +++ b/src/runtime/config.test.ts @@ -2,9 +2,11 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { homedir } from 'node:os'; import { activateProfile, clearDefaults, + expandTilde, getActiveProfile, getConfig, getDefaults, @@ -18,8 +20,14 @@ import { let tempDir: string; let configDir: string; +let savedWorkspaceEnv: string | undefined; beforeEach(() => { + // Config file workspacePath/bazelPath are only honored when the matching env + // var is unset; clear it so these tests are stable for users who export + // BAZEL_IOS_WORKSPACE in their shell. + savedWorkspaceEnv = process.env.BAZEL_IOS_WORKSPACE; + delete process.env.BAZEL_IOS_WORKSPACE; tempDir = join(tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); configDir = join(tempDir, '.xcodebazelmcp'); @@ -28,6 +36,11 @@ beforeEach(() => { }); afterEach(() => { + if (savedWorkspaceEnv === undefined) { + delete process.env.BAZEL_IOS_WORKSPACE; + } else { + process.env.BAZEL_IOS_WORKSPACE = savedWorkspaceEnv; + } if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } @@ -90,6 +103,71 @@ profiles: expect((config as unknown as Record).maxOutput).toBe(50000); expect((config as unknown as Record).ratio).toBe(3.14); }); + + it('strips surrounding double quotes from values (regression)', () => { + const yaml = `defaultDeviceName: "My iPhone"`.trim(); + const config = parseConfigYaml(yaml); + expect(config.defaultDeviceName).toBe('My iPhone'); + }); + + it('strips surrounding single quotes from values', () => { + const yaml = `defaultSimulatorName: 'iPhone 16 Pro'`.trim(); + const config = parseConfigYaml(yaml); + expect(config.defaultSimulatorName).toBe('iPhone 16 Pro'); + }); + + it('strips quotes from quoted profile values', () => { + const yaml = ` +profiles: + app-device: + defaultDeviceName: "My iPhone" + `.trim(); + const config = parseConfigYaml(yaml); + expect(config.profiles!['app-device'].defaultDeviceName).toBe('My iPhone'); + }); + + it('keeps a numeric-looking quoted string as a string', () => { + const yaml = `defaultSimulatorName: "12"`.trim(); + const config = parseConfigYaml(yaml); + expect(config.defaultSimulatorName).toBe('12'); + }); + + it('keeps string-valued keys as strings even when unquoted and numeric-looking', () => { + const config = parseConfigYaml(`defaultSimulatorName: 16\nmaxOutput: 50000`); + expect(config.defaultSimulatorName).toBe('16'); + expect((config as unknown as Record).maxOutput).toBe(50000); + }); + + it('keeps string-valued profile keys as strings', () => { + const config = parseConfigYaml(`profiles:\n p:\n defaultTarget: //a:b\n defaultSimulatorName: 16`); + expect(config.profiles!.p.defaultSimulatorName as unknown).toBe('16'); + }); +}); + +describe('expandTilde', () => { + it('expands a bare ~ to the home directory', () => { + expect(expandTilde('~')).toBe(homedir()); + }); + + it('expands ~/ prefixed paths', () => { + expect(expandTilde('~/projects/ios')).toBe(join(homedir(), 'projects/ios')); + }); + + it('leaves absolute and relative paths untouched', () => { + expect(expandTilde('/abs/path')).toBe('/abs/path'); + expect(expandTilde('rel/path')).toBe('rel/path'); + expect(expandTilde('/has~tilde/inside')).toBe('/has~tilde/inside'); + }); +}); + +describe('getConfig with tilde workspace path', () => { + it('resolves a ~ workspacePath from config under home (regression)', () => { + writeFileSync(join(configDir, 'config.yaml'), `workspacePath: ~/some-ios-workspace`); + setWorkspace(tempDir); + const config = getConfig(); + expect(config.workspacePath).toBe(join(homedir(), 'some-ios-workspace')); + expect(config.workspacePath).not.toContain('~'); + }); }); describe('getConfig', () => { diff --git a/src/runtime/config.ts b/src/runtime/config.ts index 20e2d39..8ec9bd2 100644 --- a/src/runtime/config.ts +++ b/src/runtime/config.ts @@ -3,6 +3,17 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import type { FileConfig, ProfileConfig, RuntimeConfig, SessionDefaults } from '../types/index.js'; +/** Expand a leading `~` or `~/` to the user's home directory before resolving. */ +export function expandTilde(p: string): string { + if (p === '~') return homedir(); + if (p.startsWith('~/')) return join(homedir(), p.slice(2)); + return p; +} + +function resolvePath(p: string): string { + return resolve(expandTilde(p)); +} + const DEFAULT_WORKSPACE = process.env.BAZEL_IOS_WORKSPACE || process.cwd(); const DEFAULT_BAZEL = process.env.BAZEL_PATH || process.env.MCP_BAZEL_PATH || 'bazel'; const DEFAULT_MAX_OUTPUT = Number(process.env.BAZEL_IOS_MCP_MAX_OUTPUT || 200_000); @@ -11,8 +22,8 @@ const CONFIG_FILE_NAMES = ['config.yaml', 'config.yml']; const CONFIG_DIR = '.xcodebazelmcp'; let config: RuntimeConfig = { - workspacePath: resolve(DEFAULT_WORKSPACE), - bazelPath: DEFAULT_BAZEL, + workspacePath: resolvePath(DEFAULT_WORKSPACE), + bazelPath: expandTilde(DEFAULT_BAZEL), maxOutput: DEFAULT_MAX_OUTPUT, defaults: {}, profiles: {}, @@ -35,8 +46,8 @@ export function getConfig(): RuntimeConfig { export function setWorkspace(workspacePath: string, bazelPath?: string): RuntimeConfig { config = { ...config, - workspacePath: resolve(workspacePath), - bazelPath: bazelPath || config.bazelPath, + workspacePath: resolvePath(workspacePath), + bazelPath: bazelPath ? expandTilde(bazelPath) : config.bazelPath, }; configLoaded = false; return getConfig(); @@ -124,10 +135,10 @@ function applyFileConfig(fileConfig: FileConfig, filePath: string): void { config.configFilePath = filePath; if (fileConfig.workspacePath && !process.env.BAZEL_IOS_WORKSPACE) { - config.workspacePath = resolve(fileConfig.workspacePath); + config.workspacePath = resolvePath(fileConfig.workspacePath); } if (fileConfig.bazelPath && !process.env.BAZEL_PATH && !process.env.MCP_BAZEL_PATH) { - config.bazelPath = fileConfig.bazelPath; + config.bazelPath = expandTilde(fileConfig.bazelPath); } if (fileConfig.maxOutput && !process.env.BAZEL_IOS_MCP_MAX_OUTPUT) { config.maxOutput = fileConfig.maxOutput; @@ -204,7 +215,7 @@ export function parseConfigYaml(content: string): FileConfig { if (key === 'enabledWorkflows' && rawValue) { result[key] = rawValue.split(',').map((s) => s.trim()).filter(Boolean); } else { - result[key] = parseValue(rawValue); + result[key] = parseValue(rawValue, key); } } else if (inProfiles && indent === 2 && !rawValue) { // profile name line like " myapp:" @@ -214,7 +225,7 @@ export function parseConfigYaml(content: string): FileConfig { currentProfileName = key; currentProfile = {}; } else if (inProfiles && indent >= 4 && currentProfileName) { - currentProfile[key] = parseValue(rawValue); + currentProfile[key] = parseValue(rawValue, key); } } @@ -229,9 +240,27 @@ export function parseConfigYaml(content: string): FileConfig { return result as unknown as FileConfig; } -function parseValue(raw: string): string | number | boolean { - if (raw === 'true') return true; - if (raw === 'false') return false; - if (/^\d+(\.\d+)?$/.test(raw)) return Number(raw); - return raw; +/** + * Config keys that are always strings. Without this, a value like + * `defaultSimulatorName: 16` would be coerced to the number 16 and fail the + * later string-typed lookups (e.g. simulator name match). + */ +const STRING_VALUED_KEYS = new Set([ + 'workspacePath', 'bazelPath', + 'defaultTarget', 'defaultSimulatorName', 'defaultSimulatorId', + 'defaultDeviceName', 'defaultDeviceId', 'defaultPlatform', 'defaultBuildMode', +]); + +function parseValue(raw: string, key?: string): string | number | boolean { + const unquoted = + (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) || + (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) + ? raw.slice(1, -1) + : raw; + if (key && STRING_VALUED_KEYS.has(key)) return unquoted; + if (unquoted === 'true') return true; + if (unquoted === 'false') return false; + // Only coerce to number when the original token was unquoted. + if (unquoted === raw && /^\d+(\.\d+)?$/.test(unquoted)) return Number(unquoted); + return unquoted; } diff --git a/src/tools/bazel-tools.test.ts b/src/tools/bazel-tools.test.ts index 541db08..a998b38 100644 --- a/src/tools/bazel-tools.test.ts +++ b/src/tools/bazel-tools.test.ts @@ -40,12 +40,15 @@ describe('Bazel MCP tool definitions', () => { 'bazel_ios_last_command', 'bazel_ios_bsp_status', 'bazel_ios_stop_app', + 'bazel_ios_uninstall_app', 'bazel_ios_get_app_path', 'bazel_ios_get_bundle_id', 'bazel_ios_screenshot', 'bazel_ios_video_record_start', 'bazel_ios_video_record_stop', 'bazel_ios_set_status_bar', + 'bazel_ios_add_media', + 'bazel_ios_get_app_container', 'bazel_ios_privacy', 'bazel_ios_push_notification', 'bazel_ios_open_url', @@ -56,6 +59,8 @@ describe('Bazel MCP tool definitions', () => { 'bazel_ios_device_install_app', 'bazel_ios_device_launch_app', 'bazel_ios_device_stop_app', + 'bazel_ios_device_uninstall_app', + 'bazel_ios_device_list_apps', 'bazel_ios_device_test', 'bazel_ios_device_screenshot', 'bazel_ios_device_log_start', @@ -124,10 +129,13 @@ describe('Bazel MCP tool definitions', () => { 'bazel_ios_key_press', 'bazel_ios_drag', 'bazel_ios_accessibility_snapshot', + 'bazel_xcode_native_mcp_status', + 'bazel_xcode_open_device_hub', + 'bazel_xcode_export_skills', ]; expect([...names].sort()).toEqual([...expected].sort()); expect(new Set(names).size).toBe(names.length); - expect(bazelToolDefinitions.length).toBe(117); + expect(bazelToolDefinitions.length).toBe(125); }); it('advertises startupArgs on every Bazel command tool that can need startup flags', () => { diff --git a/src/tools/bazel-tools.ts b/src/tools/bazel-tools.ts index 5b702bb..f0ab9a3 100644 --- a/src/tools/bazel-tools.ts +++ b/src/tools/bazel-tools.ts @@ -12,10 +12,11 @@ import * as spm from './handlers/spm.js'; import * as scaffold from './handlers/scaffold.js'; import * as uiAutomation from './handlers/ui-automation.js'; import * as agentDebug from './handlers/agent-debug.js'; +import * as xcode from './handlers/xcode.js'; const handlerModules = [ session, build, simulator, device, lldb, - macos, multiPlatform, spm, scaffold, uiAutomation, agentDebug, + macos, multiPlatform, spm, scaffold, uiAutomation, agentDebug, xcode, ]; export const bazelToolDefinitions: ToolDefinition[] = handlerModules.flatMap(m => m.definitions); diff --git a/src/tools/handlers/agent-debug.ts b/src/tools/handlers/agent-debug.ts index 7d51a2d..00bf735 100644 --- a/src/tools/handlers/agent-debug.ts +++ b/src/tools/handlers/agent-debug.ts @@ -109,7 +109,7 @@ export async function handle(name: string, args: JsonObject): Promise (c.type === 'text' ? c.text : '')) - .join('\n') - .match(/Capture ID:\s*(\S+)/); - captureId = match?.[1]; + // Prefer the structured captureId; fall back to parsing the text for + // older shapes. + const structuredId = cap.structuredContent?.captureId; + if (typeof structuredId === 'string') { + captureId = structuredId; + } else { + const match = cap.content + .map((c) => (c.type === 'text' ? c.text : '')) + .join('\n') + .match(/Capture ID:\s*(\S+)/); + captureId = match?.[1]; + } } const summary = { diff --git a/src/tools/handlers/build.ts b/src/tools/handlers/build.ts index 37752d6..c5a7625 100644 --- a/src/tools/handlers/build.ts +++ b/src/tools/handlers/build.ts @@ -5,6 +5,8 @@ import { buildCommandArgs, configArgs, discoverExpression, + iosSimulatorCpuArg, + parseTargetLabels, requireLabel, runBazel, sanitizeQueryExpression, @@ -400,7 +402,7 @@ export async function handle(name: string, args: JsonObject): Promise | undefined) || {}, ); - return toolText( - `Device: ${device.name} (${device.udid})\n${formatCommandResult(launchResult)}`, + const launchHint = launchResult.exitCode !== 0 ? formatDeviceError(launchResult.output) : undefined; + const launchLines = [`Device: ${device.name} (${device.udid})`, formatCommandResult(launchResult)]; + if (launchHint) launchLines.push('', launchHint); + return toolResult( + launchLines.join('\n'), + { ...structuredCommandResult(launchResult), device: device.name, bundleId: args.bundleId }, launchResult.exitCode !== 0, ); } + case 'bazel_ios_device_uninstall_app': { + if (typeof args.bundleId !== 'string' || !args.bundleId.trim()) throw new Error('bundleId is required.'); + const device = await resolveDevice({ + deviceId: stringOrUndefined(args.deviceId), + deviceName: stringOrUndefined(args.deviceName), + }); + const result = await uninstallAppOnDevice(device.udid, args.bundleId); + const hint = result.exitCode !== 0 ? formatDeviceError(result.output) : undefined; + const lines = [`Uninstalled ${args.bundleId} from ${device.name} (${device.udid})`, formatCommandResult(result)]; + if (hint) lines.push('', hint); + return toolResult( + lines.join('\n'), + { ...structuredCommandResult(result), device: device.name, bundleId: args.bundleId }, + result.exitCode !== 0, + ); + } + case 'bazel_ios_device_list_apps': { + const device = await resolveDevice({ + deviceId: stringOrUndefined(args.deviceId), + deviceName: stringOrUndefined(args.deviceName), + }); + const result = await listAppsOnDevice(device.udid); + return toolResult( + `Device: ${device.name} (${device.udid})\n${formatCommandResult(result)}`, + { ...structuredCommandResult(result), device: device.name }, + result.exitCode !== 0, + ); + } case 'bazel_ios_device_stop_app': { if (typeof args.bundleId !== 'string') throw new Error('bundleId is required.'); const device = await resolveDevice({ @@ -353,8 +411,12 @@ export async function handle(name: string, args: JsonObject): Promise setTimeout(r, 500)); + await new Promise((resolve) => { + let settled = false; + const done = () => { if (!settled) { settled = true; resolve(); } }; + entry.child.once('exit', done); + entry.child.kill('SIGINT'); + setTimeout(done, 1_000).unref(); + }); const output = entry.getCaptured(); deviceLogCaptures.delete(args.captureId); return toolText(`Log capture stopped.\nCapture ID: ${args.captureId}\n\n${output || '(no output captured)'}`); diff --git a/src/tools/handlers/handlers.test.ts b/src/tools/handlers/handlers.test.ts index 9642d66..d0c3dca 100644 --- a/src/tools/handlers/handlers.test.ts +++ b/src/tools/handlers/handlers.test.ts @@ -10,6 +10,7 @@ import * as spm from './spm.js'; import * as scaffold from './scaffold.js'; import * as uiAutomation from './ui-automation.js'; import * as agentDebug from './agent-debug.js'; +import * as xcode from './xcode.js'; const handlers = [ { label: 'session', mod: session }, @@ -23,6 +24,7 @@ const handlers = [ { label: 'scaffold', mod: scaffold }, { label: 'uiAutomation', mod: uiAutomation }, { label: 'agentDebug', mod: agentDebug }, + { label: 'xcode', mod: xcode }, ] as const; describe('handler modules', () => { @@ -59,8 +61,128 @@ describe('handler modules', () => { } }); - it('total definitions across all handlers is 117', () => { + it('total definitions across all handlers is 125', () => { const total = handlers.reduce((sum, { mod }) => sum + mod.definitions.length, 0); - expect(total).toBe(117); + expect(total).toBe(125); + }); +}); + +describe('ui-automation coordinate validation', () => { + it('tap rejects missing coordinates instead of sending NaN', async () => { + await expect(uiAutomation.handle('bazel_ios_tap', {})).rejects.toThrow('x must be a finite number'); + }); + + it('tap rejects a non-numeric y', async () => { + await expect(uiAutomation.handle('bazel_ios_tap', { x: 10, y: 'oops' })).rejects.toThrow( + 'y must be a finite number', + ); + }); + + it('pinch rejects missing scale', async () => { + await expect(uiAutomation.handle('bazel_ios_pinch', { x: 1, y: 2 })).rejects.toThrow( + 'scale must be a finite number', + ); + }); + + it('drag rejects missing toX/toY', async () => { + await expect(uiAutomation.handle('bazel_ios_drag', { fromX: 1, fromY: 2 })).rejects.toThrow( + 'toX must be a finite number', + ); + }); +}); + +describe('set_defaults validation', () => { + it('rejects an invalid buildMode', async () => { + await expect(session.handle('bazel_ios_set_defaults', { buildMode: 'fast' })).rejects.toThrow( + 'Invalid buildMode', + ); + }); + + it('rejects an invalid platform', async () => { + await expect(session.handle('bazel_ios_set_defaults', { platform: 'iphone' })).rejects.toThrow( + 'Invalid platform', + ); + }); +}); + +describe('spm/macos input validation', () => { + it('swift_package_init rejects an invalid type before spawning', async () => { + await expect(spm.handle('swift_package_init', { type: 'bogus' })).rejects.toThrow( + 'Invalid package type', + ); + }); + + it('bazel_macos_log rejects an invalid level', async () => { + await expect(macos.handle('bazel_macos_log', { level: 'verbose' })).rejects.toThrow( + 'Invalid level', + ); + }); + + it('bazel_macos_log rejects a processName with control characters', async () => { + await expect(macos.handle('bazel_macos_log', { processName: 'bad\nname' })).rejects.toThrow( + 'processName contains invalid characters', + ); + }); + + it('bazel_macos_bundle_id requires appPath (clean error, not a crash)', async () => { + await expect(macos.handle('bazel_macos_bundle_id', {})).rejects.toThrow('appPath is required'); + }); + + it('bazel_ios_open_url rejects a schemeless URL before touching a simulator', async () => { + await expect(simulator.handle('bazel_ios_open_url', { url: 'not a url' })).rejects.toThrow('no scheme'); + }); + + it('bazel_ios_open_url rejects an empty URL', async () => { + await expect(simulator.handle('bazel_ios_open_url', { url: ' ' })).rejects.toThrow('url is required'); + }); + + it('bazel_ios_set_simulator_location rejects out-of-range coordinates before touching a simulator', async () => { + await expect(simulator.handle('bazel_ios_set_simulator_location', { latitude: 200, longitude: 0 })).rejects.toThrow('latitude must be between'); + await expect(simulator.handle('bazel_ios_set_simulator_location', { latitude: 0, longitude: 999 })).rejects.toThrow('longitude must be between'); + }); + + it('uninstall tools require a bundleId before touching a device/simulator', async () => { + await expect(simulator.handle('bazel_ios_uninstall_app', {})).rejects.toThrow('bundleId is required'); + await expect(device.handle('bazel_ios_device_uninstall_app', {})).rejects.toThrow('bundleId is required'); + }); + + it('add_media rejects an empty paths list and missing files', async () => { + await expect(simulator.handle('bazel_ios_add_media', { paths: [] })).rejects.toThrow('paths is required'); + await expect(simulator.handle('bazel_ios_add_media', { paths: ['/no/such/file.png'] })).rejects.toThrow('not found'); + }); + + it('get_app_container requires a bundleId', async () => { + await expect(simulator.handle('bazel_ios_get_app_container', {})).rejects.toThrow('bundleId is required'); + }); +}); + +describe('xcode native mcp tools', () => { + it('status returns structured detection without throwing', async () => { + const result = await xcode.handle('bazel_xcode_native_mcp_status', {}); + expect(result?.structuredContent).toBeDefined(); + const sc = result!.structuredContent as Record; + expect(sc.bridge).toEqual({ command: 'xcrun', args: ['mcpbridge'], env: undefined }); + expect(typeof sc.bridgeAvailable).toBe('boolean'); + expect(Array.isArray(sc.installs)).toBe(true); + }); +}); + +describe('lldb attach validation', () => { + it('requires pid or processName', async () => { + await expect(lldb.handle('bazel_ios_lldb_attach', {})).rejects.toThrow( + 'Either pid or processName is required', + ); + }); + + it('rejects waitFor combined with pid', async () => { + await expect( + lldb.handle('bazel_ios_lldb_attach', { pid: 123, waitFor: true }), + ).rejects.toThrow('waitFor cannot be combined with pid'); + }); + + it('rejects a non-finite pid', async () => { + await expect(lldb.handle('bazel_ios_lldb_attach', { pid: NaN })).rejects.toThrow( + 'pid must be a finite number', + ); }); }); diff --git a/src/tools/handlers/lldb.ts b/src/tools/handlers/lldb.ts index dbbc58d..6467614 100644 --- a/src/tools/handlers/lldb.ts +++ b/src/tools/handlers/lldb.ts @@ -158,9 +158,16 @@ export function canHandle(name: string): boolean { export async function handle(name: string, args: JsonObject): Promise { switch (name) { case 'bazel_ios_lldb_attach': { - if (typeof args.pid !== 'number' && typeof args.processName !== 'string') { + const hasPid = typeof args.pid === 'number'; + if (!hasPid && typeof args.processName !== 'string') { throw new Error('Either pid or processName is required.'); } + if (hasPid && !Number.isFinite(args.pid as number)) { + throw new Error('pid must be a finite number.'); + } + if (hasPid && args.waitFor === true) { + throw new Error('waitFor cannot be combined with pid; it only applies when attaching by processName.'); + } const target = (args.target as 'simulator' | 'device') || 'simulator'; let deviceIdentifier: string | undefined; diff --git a/src/tools/handlers/macos.ts b/src/tools/handlers/macos.ts index 981b6d2..7cfb1aa 100644 --- a/src/tools/handlers/macos.ts +++ b/src/tools/handlers/macos.ts @@ -1,8 +1,9 @@ +import { join } from 'node:path'; import type { JsonObject, ToolCallResult, ToolDefinition, BuildArgs, TestArgs, TargetKind, BuildMode } from '../../types/index.js'; import { stringOrUndefined, numberOrUndefined } from '../helpers.js'; import { STREAMING_PROPERTY } from '../schema-constants.js'; -import { buildCommandArgs, configArgs, discoverExpression, modeArgs, requireLabel, runBazel, asStringArray, testFilterArgs } from '../../core/bazel.js'; -import { findAppBundle, readBundleId } from '../../core/simulators.js'; +import { buildCommandArgs, configArgs, discoverExpression, modeArgs, parseTargetLabels, requireLabel, runBazel, asStringArray, testFilterArgs } from '../../core/bazel.js'; +import { findAppBundle, readBundleId, readInfoPlistValue } from '../../core/simulators.js'; import { formatCommandResult, structuredCommandResult, toolResult, toolText } from '../../utils/output.js'; import { runCommand } from '../../utils/process.js'; import { getConfig } from '../../runtime/config.js'; @@ -207,6 +208,9 @@ export async function handle(name: string, args: JsonObject): Promise | undefined) || {}; + // `open` cannot inject per-launch environment variables, so when + // launchEnv is requested we exec the app's Mach-O binary directly. + if (Object.keys(launchEnv).length > 0) { + const exec = readInfoPlistValue(appPath, 'CFBundleExecutable'); + if (!exec) throw new Error(`Could not read CFBundleExecutable from ${appPath}.`); + const binary = join(appPath, 'Contents', 'MacOS', exec); + const result = await runCommand(binary, launchArgs, { + cwd: process.cwd(), timeoutSeconds: 30, maxOutput: 50_000, + env: { ...process.env, ...launchEnv }, + }); + return toolText(`Launched: ${binary}\n${formatCommandResult(result)}`, result.exitCode !== 0); + } const launchCmdArgs = ['open', appPath]; - if (args.launchArgs) { - launchCmdArgs.push('--args', ...asStringArray(args.launchArgs, 'launchArgs')); + if (launchArgs.length > 0) { + launchCmdArgs.push('--args', ...launchArgs); } const result = await runCommand(launchCmdArgs[0], launchCmdArgs.slice(1), { cwd: process.cwd(), timeoutSeconds: 30, maxOutput: 50_000, @@ -347,7 +373,10 @@ export async function handle(name: string, args: JsonObject): Promise { + const expression = discoverExpression(kind, stringOrUndefined(args.scope)); + const commandResult = await runBazel( + ['query', '--output=label', ...asStringArray(args.extraArgs, 'extraArgs'), expression], + numberOrUndefined(args.timeoutSeconds) || 600, + asStringArray(args.startupArgs, 'startupArgs'), + ); + if (commandResult.exitCode !== 0) { + return toolText(formatCommandResult(commandResult), true); + } + const targets = parseTargetLabels(commandResult.output); + const text = targets.length + ? `Found ${targets.length} ${kind} target(s):\n${targets.join('\n')}` + : `No ${kind} targets found in scope.`; + return toolResult(text, { count: targets.length, kind, targets }); +} + export async function handle(name: string, args: JsonObject): Promise { switch (name) { case 'bazel_tvos_build': @@ -280,36 +297,12 @@ export async function handle(name: string, args: JsonObject): Promise).' }, minimumOs: { type: 'string', description: 'Minimum OS version (default: 17.0 for iOS, 14.0 for macOS).' }, rulesVersion: { type: 'string', description: 'rules_apple version (default: 3.16.1).' }, + bazelVersion: { type: 'string', description: 'Pin written to .bazelversion (default: 7.6.1).' }, + families: { type: 'array', items: { type: 'string', enum: ['iphone', 'ipad'] }, description: 'iOS device families (default: iphone, ipad).' }, }, required: ['outputPath', 'name', 'template'], }, @@ -36,12 +38,12 @@ export const definitions: ToolDefinition[] = [ }, { name: 'bazel_daemon_start', - description: 'Start or ensure the per-workspace background daemon is running. The daemon keeps stateful operations (log captures, video recordings, LLDB sessions) alive across MCP reconnections.', + description: 'Start or ensure the per-workspace background daemon is running. The daemon exposes a coordination socket and tracks background-operation metadata for the workspace. Note: capture/recording/LLDB child processes currently run inside the MCP server process, not the daemon.', inputSchema: { type: 'object', properties: {} }, }, { name: 'bazel_daemon_stop', - description: 'Stop the per-workspace background daemon and clean up all active stateful operations.', + description: 'Stop the per-workspace background daemon and clear its tracked operation metadata.', inputSchema: { type: 'object', properties: {} }, }, { @@ -89,6 +91,8 @@ export async function handle(name: string, args: JsonObject): Promise d.name)); export function canHandle(name: string): boolean { @@ -125,7 +128,7 @@ export async function handle(name: string, args: JsonObject): Promise d.state === 'connected'); @@ -182,6 +189,8 @@ export async function handle(name: string, args: JsonObject): Promise ` ${k}: ${v}`); return toolText(`Profile "${args.profile}" activated.\n\nSession defaults:\n${lines.join('\n')}`); } + const buildMode = stringOrUndefined(args.buildMode); + if (buildMode !== undefined && !VALID_BUILD_MODES.includes(buildMode as BuildMode)) { + throw new Error(`Invalid buildMode "${buildMode}". Expected one of: ${VALID_BUILD_MODES.join(', ')}.`); + } + const platform = stringOrUndefined(args.platform); + if (platform !== undefined && !VALID_DEFAULT_PLATFORMS.includes(platform as BuildPlatform)) { + throw new Error(`Invalid platform "${platform}". Expected one of: ${VALID_DEFAULT_PLATFORMS.join(', ')}.`); + } const updated = setDefaults({ target: stringOrUndefined(args.target), simulatorName: stringOrUndefined(args.simulatorName), simulatorId: stringOrUndefined(args.simulatorId), deviceName: stringOrUndefined(args.deviceName), deviceId: stringOrUndefined(args.deviceId), - buildMode: stringOrUndefined(args.buildMode) as BuildMode | undefined, - platform: stringOrUndefined(args.platform) as BuildPlatform | undefined, + buildMode: buildMode as BuildMode | undefined, + platform: platform as BuildPlatform | undefined, streaming: booleanOrUndefined(args.streaming), }); const lines = Object.entries(updated) diff --git a/src/tools/handlers/simulator.ts b/src/tools/handlers/simulator.ts index 246b8d4..9a2021f 100644 --- a/src/tools/handlers/simulator.ts +++ b/src/tools/handlers/simulator.ts @@ -1,13 +1,17 @@ import { spawn } from 'node:child_process'; -import { join } from 'node:path'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import type { JsonObject, ToolCallResult, ToolDefinition, InstallAppArgs, LaunchAppArgs } from '../../types/index.js'; import { asStringArray, requireLabel } from '../../core/bazel.js'; import { + addMedia, bootSimulator, bootSimulatorIfNeeded, + buildLogPredicate, clearStatusBar, eraseSimulator, findAppBundle, + getAppContainer, getSimulatorUiState, installApp, launchApp, @@ -25,8 +29,9 @@ import { startVideoRecording, takeScreenshot, terminateApp, + uninstallApp, } from '../../core/simulators.js'; -import { formatCommandResult, toolText } from '../../utils/output.js'; +import { formatCommandResult, structuredCommandResult, toolResult, toolText } from '../../utils/output.js'; import { getConfig } from '../../runtime/config.js'; import { logCaptures, @@ -36,6 +41,7 @@ import { resolveSimulatorFromArgs, prependWarning, stringOrUndefined, + requireFiniteNumber, } from '../helpers.js'; export const definitions: ToolDefinition[] = [ @@ -116,6 +122,7 @@ export const definitions: ToolDefinition[] = [ type: 'object', properties: { simulatorId: { type: 'string', description: 'Simulator UDID to focus.' }, + simulatorName: { type: 'string', description: 'Simulator name to focus (alternative to simulatorId).' }, }, }, }, @@ -131,6 +138,7 @@ export const definitions: ToolDefinition[] = [ batteryLevel: { type: 'number', description: 'Battery level 0-100.' }, batteryState: { type: 'string', enum: ['charging', 'charged', 'discharging'], description: 'Battery state.' }, networkType: { type: 'string', enum: ['wifi', '3g', '4g', '5g', 'lte', 'lte-a', 'lte+'], description: 'Cellular data type.' }, + operatorName: { type: 'string', description: 'Carrier/operator name shown in the status bar.' }, wifiBars: { type: 'number', description: 'Wi-Fi signal bars 0-3.' }, cellularBars: { type: 'number', description: 'Cellular signal bars 0-4.' }, clear: { type: 'boolean', description: 'Clear all overrides instead of setting them.' }, @@ -187,6 +195,7 @@ export const definitions: ToolDefinition[] = [ properties: { bundleId: { type: 'string', description: 'App bundle identifier.' }, simulatorId: { type: 'string', description: 'Simulator UDID.' }, + simulatorName: { type: 'string', description: 'Simulator name (alternative to simulatorId).' }, launchArgs: { type: 'array', items: { type: 'string' }, @@ -214,6 +223,19 @@ export const definitions: ToolDefinition[] = [ required: ['bundleId'], }, }, + { + name: 'bazel_ios_uninstall_app', + description: 'Uninstall an app from a simulator by bundle identifier.', + inputSchema: { + type: 'object', + properties: { + bundleId: { type: 'string', description: 'App bundle identifier (e.g. com.example.MyApp).' }, + simulatorId: { type: 'string', description: 'Simulator UDID (default: first booted).' }, + simulatorName: { type: 'string', description: 'Simulator name (alternative to simulatorId).' }, + }, + required: ['bundleId'], + }, + }, { name: 'bazel_ios_get_app_path', description: 'Return the .app bundle path for a previously built Bazel target.', @@ -323,6 +345,33 @@ export const definitions: ToolDefinition[] = [ required: ['bundleId'], }, }, + { + name: 'bazel_ios_add_media', + description: 'Add photos/videos to a simulator photo library (simctl addmedia).', + inputSchema: { + type: 'object', + properties: { + paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths to image/video files to add.' }, + simulatorId: { type: 'string', description: 'Simulator UDID (default: first booted).' }, + simulatorName: { type: 'string', description: 'Simulator name (alternative to simulatorId).' }, + }, + required: ['paths'], + }, + }, + { + name: 'bazel_ios_get_app_container', + description: 'Get the on-disk container path for an installed app on a simulator (simctl get_app_container).', + inputSchema: { + type: 'object', + properties: { + bundleId: { type: 'string', description: 'App bundle identifier.' }, + kind: { type: 'string', enum: ['app', 'data', 'groups'], description: 'Container kind (default: data).' }, + simulatorId: { type: 'string', description: 'Simulator UDID (default: first booted).' }, + simulatorName: { type: 'string', description: 'Simulator name (alternative to simulatorId).' }, + }, + required: ['bundleId'], + }, + }, { name: 'bazel_ios_open_url', description: 'Open a URL on a booted simulator (deep links, universal links, web URLs).', @@ -395,11 +444,12 @@ export async function handle(name: string, args: JsonObject): Promise 90) throw new Error('latitude must be between -90 and 90.'); + if (longitude < -180 || longitude > 180) throw new Error('longitude must be between -180 and 180.'); const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); - const result = await setSimulatorLocation(sim.udid, args.latitude, args.longitude); + const result = await setSimulatorLocation(sim.udid, latitude, longitude); return toolText( prependWarning(`Location set on ${sim.name} (${sim.udid}) to ${args.latitude}, ${args.longitude}\n${formatCommandResult(result)}`, simWarning), result.exitCode !== 0, @@ -417,7 +467,12 @@ export async function handle(name: string, args: JsonObject): Promise = {}; if (typeof args.time === 'string') overrides['time'] = args.time; if (typeof args.batteryLevel === 'number') overrides['batteryLevel'] = String(args.batteryLevel); - if (typeof args.batteryState === 'string') overrides['batteryState'] = args.batteryState; - if (typeof args.networkType === 'string') overrides['dataNetwork'] = args.networkType; + if (typeof args.batteryState === 'string') { + const states = ['charging', 'charged', 'discharging']; + if (!states.includes(args.batteryState)) throw new Error(`Invalid batteryState "${args.batteryState}". Expected: ${states.join(', ')}.`); + overrides['batteryState'] = args.batteryState; + } + if (typeof args.networkType === 'string') { + const nets = ['wifi', '3g', '4g', '5g', 'lte', 'lte-a', 'lte+']; + if (!nets.includes(args.networkType)) throw new Error(`Invalid networkType "${args.networkType}". Expected: ${nets.join(', ')}.`); + overrides['dataNetwork'] = args.networkType; + } + if (typeof args.operatorName === 'string') overrides['operatorName'] = args.operatorName; if (typeof args.wifiBars === 'number') overrides['wifiBars'] = String(args.wifiBars); if (typeof args.cellularBars === 'number') overrides['cellularBars'] = String(args.cellularBars); if (Object.keys(overrides).length === 0) { - return toolText('No overrides specified. Pass time, batteryLevel, batteryState, networkType, wifiBars, or cellularBars.', true); + return toolText('No overrides specified. Pass time, batteryLevel, batteryState, networkType, operatorName, wifiBars, or cellularBars.', true); } const result = await setStatusBar(sim.udid, overrides); return toolText( @@ -494,7 +558,16 @@ export async function handle(name: string, args: JsonObject): Promise setTimeout(resolve, 500)); + // Wait for simctl to flush and finalize the container; a fixed sleep can + // truncate the file on slow encodes, so await the child's exit (capped). + await new Promise((resolve) => { + let settled = false; + const done = () => { if (!settled) { settled = true; resolve(); } }; + recording.child.once('exit', done); + recording.child.kill('SIGINT'); + setTimeout(done, 5_000).unref(); + }); return toolText(`Video recording stopped (${args.recordingId}).\nSaved to: ${recording.outputPath}`); } case 'bazel_ios_log_capture_start': { @@ -584,12 +675,11 @@ export async function handle(name: string, args: JsonObject): Promise 0) { - logArgs.push('--predicate', predicates.join(' OR ')); - } + const predicate = buildLogPredicate({ + processName: stringOrUndefined(args.processName), + subsystem: stringOrUndefined(args.subsystem), + }); + if (predicate) logArgs.push('--predicate', predicate); const child = spawn('xcrun', logArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); const maxLogSize = 500_000; @@ -599,6 +689,7 @@ export async function handle(name: string, args: JsonObject): Promise { @@ -608,7 +699,10 @@ export async function handle(name: string, args: JsonObject): Promise= 500_000 ? '\n[log output truncated at 500KB]' : ''; if (capture.jsonLinesOnly || capture.messageContains) { @@ -650,6 +748,9 @@ export async function handle(name: string, args: JsonObject): Promise = { aps: {} }; const aps = payload.aps as Record; @@ -675,8 +776,32 @@ export async function handle(name: string, args: JsonObject): Promise typeof p === 'string') : []; + if (paths.length === 0) throw new Error('paths is required (array of file paths).'); + const missing = paths.filter((p) => !existsSync(p)); + if (missing.length > 0) throw new Error(`File(s) not found: ${missing.join(', ')}`); + const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); + const result = await addMedia(sim.udid, paths); + return toolText( + prependWarning(`Added ${paths.length} item(s) to ${sim.name} (${sim.udid})\n${formatCommandResult(result)}`, simWarning), + result.exitCode !== 0, + ); + } + case 'bazel_ios_get_app_container': { + if (typeof args.bundleId !== 'string' || !args.bundleId.trim()) throw new Error('bundleId is required.'); + const kind = (args.kind as 'app' | 'data' | 'groups') || 'data'; + if (!['app', 'data', 'groups'].includes(kind)) throw new Error(`Invalid kind "${kind}". Expected app, data, or groups.`); + const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); + const result = await getAppContainer(sim.udid, args.bundleId, kind); + const body = result.exitCode === 0 ? result.output.trim() : formatCommandResult(result); + return toolText(prependWarning(body, simWarning), result.exitCode !== 0); + } case 'bazel_ios_open_url': { - if (typeof args.url !== 'string') throw new Error('url is required.'); + if (typeof args.url !== 'string' || !args.url.trim()) throw new Error('url is required.'); + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(args.url.trim())) { + throw new Error(`url "${args.url}" has no scheme. Use e.g. https://… or myapp://….`); + } const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); const result = await openUrl(sim.udid, args.url); return toolText( diff --git a/src/tools/handlers/spm.ts b/src/tools/handlers/spm.ts index c3224fa..e07263b 100644 --- a/src/tools/handlers/spm.ts +++ b/src/tools/handlers/spm.ts @@ -51,6 +51,7 @@ export const definitions: ToolDefinition[] = [ extraArgs: { type: 'array', items: { type: 'string' }, description: 'Additional arguments passed to swift run.' }, runArgs: { type: 'array', items: { type: 'string' }, description: 'Arguments passed to the executable after --.' }, timeoutSeconds: { type: 'number' }, + streaming: STREAMING_PROPERTY, }, }, }, @@ -195,7 +196,11 @@ export async function handle(name: string, args: JsonObject): Promise { switch (name) { case 'bazel_ios_tap': { + const x = requireFiniteNumber(args.x, 'x'); + const y = requireFiniteNumber(args.y, 'y'); const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); - const result = await simulatorTap({ simulatorUdid: sim.udid, x: args.x as number, y: args.y as number }); + const result = await simulatorTap({ simulatorUdid: sim.udid, x, y }); const msg = result.exitCode === 0 ? `Tapped at (${args.x}, ${args.y}) on ${sim.name}.` : formatCommandResult(result); return toolText(prependWarning(msg, simWarning), result.exitCode !== 0); } case 'bazel_ios_double_tap': { + const x = requireFiniteNumber(args.x, 'x'); + const y = requireFiniteNumber(args.y, 'y'); const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); - const result = await simulatorDoubleTap({ simulatorUdid: sim.udid, x: args.x as number, y: args.y as number }); + const result = await simulatorDoubleTap({ simulatorUdid: sim.udid, x, y }); const msg = result.exitCode === 0 ? `Double-tapped at (${args.x}, ${args.y}) on ${sim.name}.` : formatCommandResult(result); return toolText(prependWarning(msg, simWarning), result.exitCode !== 0); } case 'bazel_ios_long_press': { + const x = requireFiniteNumber(args.x, 'x'); + const y = requireFiniteNumber(args.y, 'y'); const { sim, warning: simWarning } = await resolveSimulatorFromArgs(args); const duration = numberOrUndefined(args.durationSeconds); - const result = await simulatorLongPress({ simulatorUdid: sim.udid, x: args.x as number, y: args.y as number, durationSeconds: duration }); + const result = await simulatorLongPress({ simulatorUdid: sim.udid, x, y, durationSeconds: duration }); const msg = result.exitCode === 0 ? `Long-pressed at (${args.x}, ${args.y}) for ${duration ?? 1.0}s on ${sim.name}.` : formatCommandResult(result); return toolText(prependWarning(msg, simWarning), result.exitCode !== 0); } @@ -183,15 +189,18 @@ export async function handle(name: string, args: JsonObject): Promise 1 ? 'zoom in' : 'zoom out'; + const label = scale > 1 ? 'zoom in' : 'zoom out'; const msg = result.exitCode === 0 ? `Pinch ${label} (scale=${args.scale}) at (${args.x}, ${args.y}) on ${sim.name}.` : formatCommandResult(result); return toolText(prependWarning(msg, simWarning), result.exitCode !== 0); } @@ -208,13 +217,17 @@ export async function handle(name: string, args: JsonObject): Promise d.name)); + +export function canHandle(name: string): boolean { + return HANDLED.has(name); +} + +export async function handle(name: string, args: JsonObject): Promise { + switch (name) { + case 'bazel_xcode_native_mcp_status': { + const status = await detectXcodeNativeMcp(); + const lines = ['Xcode native MCP integration', '']; + if (status.installs.length === 0) { + lines.push(' No Xcode installs found under /Applications.'); + } + for (const i of status.installs) { + const tags = [ + i.hasMcpBridge ? 'mcpbridge ✅' : 'mcpbridge ❌', + i.hasLldbMcp ? 'lldb-mcp ✅' : 'lldb-mcp ❌', + i.deviceHubPath ? 'DeviceHub ✅' : 'DeviceHub ❌', + ]; + const active = i.developerDir === status.activeDeveloperDir ? ' (active)' : ''; + lines.push(` ${i.appPath}${active}${i.isBeta ? ' [beta]' : ''}`); + lines.push(` ${tags.join(' ')}`); + } + lines.push(''); + lines.push(`Running Xcode PIDs: ${status.runningXcodePids.length ? status.runningXcodePids.join(', ') : '(none — mcpbridge needs a running Xcode)'}`); + lines.push(''); + lines.push(status.bridgeAvailable + ? 'Bridge available. Add Xcode\'s native tools to your MCP client with:' + : 'mcpbridge not found — Xcode 26.3 or later is required for native MCP.'); + if (status.bridgeAvailable) { + lines.push(JSON.stringify(xcodeMcpClientConfig(), null, 2)); + } + + return toolResult(lines.join('\n'), { + installs: status.installs, + activeDeveloperDir: status.activeDeveloperDir, + runningXcodePids: status.runningXcodePids, + bridgeAvailable: status.bridgeAvailable, + deviceHubAvailable: Boolean(status.deviceHub), + lldbMcpAvailable: Boolean(status.lldbMcp), + bridge: mcpBridgeInvocation(), + clientConfig: xcodeMcpClientConfig(), + }); + } + case 'bazel_xcode_open_device_hub': { + const status = await detectXcodeNativeMcp(); + const hub = status.deviceHub; + if (!hub || !hub.deviceHubPath) { + return toolText( + 'DeviceHub.app not found. It ships with Xcode-beta / Xcode 27. ' + + 'On Xcode 26.3 use the device tools (bazel_ios_list_devices, bazel_ios_device_info) or Xcode\u2019s Devices window.', + true, + ); + } + const result = await openDeviceHub(hub.deviceHubPath); + return toolText( + `Opening DeviceHub.app (${hub.appPath})\n${formatCommandResult(result)}`, + result.exitCode !== 0, + ); + } + case 'bazel_xcode_export_skills': { + const result = await exportXcodeSkills({ + outputDir: stringOrUndefined(args.outputDir), + replaceExisting: args.replaceExisting === true, + }); + const hint = result.exitCode !== 0 + ? '\n\nHint: `mcpbridge` needs a running Xcode 26.3+ instance. Open Xcode and retry.' + : ''; + return toolResult( + `${formatCommandResult(result)}${hint}`, + structuredCommandResult(result), + result.exitCode !== 0, + ); + } + default: + return undefined; + } +} diff --git a/src/tools/helpers.test.ts b/src/tools/helpers.test.ts index e4e837e..deb0dea 100644 --- a/src/tools/helpers.test.ts +++ b/src/tools/helpers.test.ts @@ -7,6 +7,10 @@ import { nextLogCaptureId, nextVideoRecordingId, resolveSimulatorFromArgs, + disposeAllCaptures, + logCaptures, + videoRecordings, + deviceLogCaptures, } from './helpers.js'; import { clearDefaults, setDefaults } from '../runtime/config.js'; @@ -203,3 +207,23 @@ describe('resolveSimulatorFromArgs', () => { expect(mockResolveSimulator).toHaveBeenCalledWith({ simulatorId: undefined, simulatorName: undefined }); }); }); + +describe('disposeAllCaptures', () => { + it('kills tracked log/video/device-log children and clears the maps', () => { + const logKill = vi.fn(); + const videoKill = vi.fn(); + const deviceKill = vi.fn(); + logCaptures.set('l1', { child: { kill: logKill } as never, output: '', simulatorId: 'u' }); + videoRecordings.set('v1', { child: { kill: videoKill } as never, outputPath: '/tmp/x.mp4', simulatorId: 'u' }); + deviceLogCaptures.set('d1', { child: { kill: deviceKill } as never, getCaptured: () => '' }); + + disposeAllCaptures(); + + expect(logKill).toHaveBeenCalled(); + expect(videoKill).toHaveBeenCalled(); + expect(deviceKill).toHaveBeenCalled(); + expect(logCaptures.size).toBe(0); + expect(videoRecordings.size).toBe(0); + expect(deviceLogCaptures.size).toBe(0); + }); +}); diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index a9b117d..fadef22 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -9,6 +9,7 @@ export const logCaptures = new Map(); export let logCaptureCounter = 0; export function nextLogCaptureId(): number { return ++logCaptureCounter; } @@ -19,6 +20,20 @@ export function nextVideoRecordingId(): number { return ++videoRecordingCounter; export const deviceLogCaptures = new Map; getCaptured: () => string; tool?: string }>(); +/** + * Kill any still-running capture/recording child processes. Registered on + * server shutdown so simctl log/video/device-log children aren't orphaned when + * the MCP server exits without an explicit stop call. + */ +export function disposeAllCaptures(): void { + for (const { child } of logCaptures.values()) { try { child.kill('SIGTERM'); } catch { /* noop */ } } + for (const { child } of videoRecordings.values()) { try { child.kill('SIGINT'); } catch { /* noop */ } } + for (const { child } of deviceLogCaptures.values()) { try { child.kill('SIGTERM'); } catch { /* noop */ } } + logCaptures.clear(); + videoRecordings.clear(); + deviceLogCaptures.clear(); +} + export function applyDefaults(args: JsonObject): JsonObject { const defaults = getDefaults(); const merged = { ...args }; @@ -50,7 +65,14 @@ export function stringOrUndefined(value: unknown): string | undefined { } export function numberOrUndefined(value: unknown): number | undefined { - return typeof value === 'number' ? value : undefined; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export function requireFiniteNumber(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${name} must be a finite number.`); + } + return value; } export function booleanOrUndefined(value: unknown): boolean | undefined { diff --git a/src/tools/streaming.ts b/src/tools/streaming.ts index 6c194e1..2cd0cc6 100644 --- a/src/tools/streaming.ts +++ b/src/tools/streaming.ts @@ -12,6 +12,7 @@ import { asStringArray, buildCommandArgs, configArgs, + iosSimulatorCpuArg, modeArgs, platformArgs, requireLabel, @@ -23,7 +24,7 @@ import { import { bootSimulatorIfNeeded, findAppBundle, installApp, launchApp, readBundleId, resolveSimulator } from '../core/simulators.js'; import { withTestSimulatorHooks } from '../core/test-simulator.js'; import { deployBuiltAppToDevice } from '../core/device-deploy.js'; -import { swiftBuildStreaming, swiftTestStreaming, type SwiftBuildConfiguration } from '../core/swift-package.js'; +import { swiftBuildStreaming, swiftRunStreaming, swiftTestStreaming, type SwiftBuildConfiguration } from '../core/swift-package.js'; import { getConfig } from '../runtime/config.js'; import { formatCommandResult, structuredCommandResult, toolResult, toolText } from '../utils/output.js'; import { applyDefaults, numberOrUndefined, prependWarning, stringOrUndefined } from './helpers.js'; @@ -52,6 +53,7 @@ const STREAMING_TOOLS = new Set([ 'bazel_visionos_test', 'swift_package_build', 'swift_package_test', + 'swift_package_run', ]); export async function callBazelToolStreaming( @@ -64,7 +66,7 @@ export async function callBazelToolStreaming( return callBazelTool(name, args); } - if (name === 'swift_package_build' || name === 'swift_package_test') { + if (name === 'swift_package_build' || name === 'swift_package_test' || name === 'swift_package_run') { return streamSwiftPackageTool(name, args, onProgress); } @@ -142,7 +144,13 @@ export async function callBazelToolStreaming( const output = cleanupSummary ? `${formatCommandResult(finalResult)}\n\n${cleanupSummary}` : formatCommandResult(finalResult); - return toolText(output, finalResult.exitCode !== 0); + // Return structured content so streaming results carry the same machine- + // readable failure data (category, diagnostics, exitCode) as the + // non-streaming handlers. + const structured: Record = { ...structuredCommandResult(finalResult) }; + if (typeof args.target === 'string') structured.target = args.target; + if (typeof args.testFilter === 'string') structured.testFilter = args.testFilter; + return toolResult(output, structured, finalResult.exitCode !== 0); } async function streamSwiftPackageTool( @@ -151,21 +159,24 @@ async function streamSwiftPackageTool( onProgress: (chunk: string) => void, ) { const pkgPath = stringOrUndefined(args.packagePath) || getConfig().workspacePath; - const generator = name === 'swift_package_build' - ? swiftBuildStreaming({ - packagePath: pkgPath, - configuration: stringOrUndefined(args.configuration) as SwiftBuildConfiguration | undefined, - target: stringOrUndefined(args.target), - extraArgs: asStringArray(args.extraArgs, 'extraArgs'), - timeoutSeconds: numberOrUndefined(args.timeoutSeconds), - }) - : swiftTestStreaming({ - packagePath: pkgPath, - filter: stringOrUndefined(args.filter), - configuration: stringOrUndefined(args.configuration) as SwiftBuildConfiguration | undefined, - extraArgs: asStringArray(args.extraArgs, 'extraArgs'), - timeoutSeconds: numberOrUndefined(args.timeoutSeconds), - }); + const configuration = stringOrUndefined(args.configuration) as SwiftBuildConfiguration | undefined; + const extraArgs = asStringArray(args.extraArgs, 'extraArgs'); + const timeoutSeconds = numberOrUndefined(args.timeoutSeconds); + let generator: AsyncGenerator; + if (name === 'swift_package_build') { + generator = swiftBuildStreaming({ packagePath: pkgPath, configuration, target: stringOrUndefined(args.target), extraArgs, timeoutSeconds }); + } else if (name === 'swift_package_run') { + generator = swiftRunStreaming({ + packagePath: pkgPath, + executable: stringOrUndefined(args.executable), + configuration, + extraArgs, + runArgs: asStringArray(args.runArgs, 'runArgs'), + timeoutSeconds, + }); + } else { + generator = swiftTestStreaming({ packagePath: pkgPath, filter: stringOrUndefined(args.filter), configuration, extraArgs, timeoutSeconds }); + } let finalResult: CommandResult | undefined; for await (const chunk of generator) { @@ -206,7 +217,7 @@ function buildStreamingBazelArgs(name: string, args: JsonObject): string[] { const bazelArgs = [ 'test', '--test_output=errors', - '--ios_multi_cpus=sim_arm64', + iosSimulatorCpuArg(), ...simulatorArgs(testArgs), ...configArgs(testArgs.configs), ...asStringArray(testArgs.extraArgs, 'extraArgs'), @@ -252,6 +263,7 @@ function buildStreamingBazelArgs(name: string, args: JsonObject): string[] { const target = requireLabel(testArgs.target); const bazelArgs = [ 'coverage', + '--combined_report=lcov', ...configArgs(testArgs.configs), ...asStringArray(testArgs.extraArgs, 'extraArgs'), ...testFilterArgs(testArgs.testFilter), diff --git a/src/types/index.ts b/src/types/index.ts index 685d207..f021868 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,14 +66,35 @@ export interface RuntimeConfig { enabledWorkflows?: string[]; } +export type FailureKind = 'ok' | 'spawn-error' | 'timeout' | 'signal' | 'nonzero-exit'; + export interface CommandResult { command: string; args: string[]; exitCode: number; signal?: NodeJS.Signals | null; durationMs: number; + /** Combined stdout+stderr in arrival order (head+tail truncated). */ output: string; truncated: boolean; + /** Separate stdout channel (head+tail truncated). */ + stdout?: string; + /** Separate stderr channel — where Bazel/Swift write real diagnostics. */ + stderr?: string; + /** Explicit failure classification so spawn errors != crashes != timeouts. */ + failureKind?: FailureKind; + /** True when the command was killed by the timeout watchdog. */ + timedOut?: boolean; + /** Configured timeout (seconds) for this invocation. */ + timeoutSeconds?: number; + /** Approximate characters dropped by head+tail truncation. */ + bytesDropped?: number; + /** For spawn errors: the underlying errno code (ENOENT, EACCES, ...). */ + spawnErrorCode?: string; + /** Correlation id linking the MCP request, the spawned command, and the log line. */ + id?: string; + /** Optional resolved-config context surfaced on failure (bazel path, workspace, startup args). */ + context?: Record; } export type TargetKind = diff --git a/src/utils/output.test.ts b/src/utils/output.test.ts index 20dc866..da831ce 100644 --- a/src/utils/output.test.ts +++ b/src/utils/output.test.ts @@ -39,10 +39,83 @@ describe('formatCommandResult', () => { expect(out).toContain('FAILED: //Tests'); }); + it('annotates known Bazel exit codes on failure', () => { + const out = formatCommandResult({ ...failResult, exitCode: 3, signal: null, truncated: false }); + expect(out).toContain('Bazel exit 3: tests failed or did not pass'); + }); + + it('does not annotate exit codes for non-bazel commands', () => { + const out = formatCommandResult({ ...failResult, command: 'xcrun', exitCode: 1, signal: null, truncated: false }); + expect(out).not.toContain('Bazel exit'); + }); + it('shows (no output) for empty output', () => { const out = formatCommandResult({ ...okResult, output: '' }); expect(out).toContain('(no output)'); }); + + it('surfaces a timeout explicitly', () => { + const out = formatCommandResult({ + ...failResult, + truncated: false, + timedOut: true, + timeoutSeconds: 30, + failureKind: 'timeout', + }); + expect(out).toContain('kind=timeout'); + expect(out).toContain('TIMED OUT after 30s'); + }); + + it('reports dropped characters on head+tail truncation', () => { + const out = formatCommandResult({ ...okResult, exitCode: 1, truncated: true, bytesDropped: 4096 }); + expect(out).toContain('[output truncated: ~4096 characters dropped (head + tail kept)]'); + }); + + it('surfaces failure category, diagnostics, and context for a build failure', () => { + const out = formatCommandResult({ + command: 'bazel', + args: ['build', '//a:b'], + exitCode: 1, + durationMs: 1000, + output: '', + stderr: "ERROR: no such target '//a:b'", + truncated: false, + failureKind: 'nonzero-exit', + context: { bazel: 'bazel', workspace: '/ws', startupArgs: '(none)' }, + }); + expect(out).toContain('Failure category: missing_dependency'); + expect(out).toContain('Top diagnostics:'); + expect(out).toContain('Context:'); + expect(out).toContain('workspace: /ws'); + }); + + it('does not print a bare "Failure category: unknown" for trivial failures', () => { + const out = formatCommandResult({ + command: 'xcrun', + args: ['simctl', 'boot', 'X'], + exitCode: 164, + durationMs: 50, + output: 'Unable to boot device in current state: Booted', + truncated: false, + failureKind: 'nonzero-exit', + }); + expect(out).not.toContain('Failure category'); + }); + + it('flags spawn errors distinctly in the header', () => { + const out = formatCommandResult({ + command: 'bazel', + args: ['build'], + exitCode: -1, + durationMs: 5, + output: 'Error: spawn bazel ENOENT', + truncated: false, + failureKind: 'spawn-error', + spawnErrorCode: 'ENOENT', + }); + expect(out).toContain('kind=spawn-error'); + expect(out).toContain('Failure category: spawn_error'); + }); }); describe('structuredCommandResult', () => { @@ -62,6 +135,31 @@ describe('structuredCommandResult', () => { expect(s.signal).toBe('SIGTERM'); expect(s.truncated).toBe(true); }); + + it('carries failureKind, stderr, and classification fields', () => { + const s = structuredCommandResult({ + command: 'bazel', + args: ['build', '//a:b'], + exitCode: 1, + durationMs: 10, + output: 'noise', + stderr: '/Foo.swift:1:1: error: boom', + truncated: false, + failureKind: 'nonzero-exit', + bytesDropped: 42, + }); + expect(s.failureKind).toBe('nonzero-exit'); + expect(s.stderr).toBe('/Foo.swift:1:1: error: boom'); + expect(s.category).toBe('compile_error'); + expect(s.bytesDropped).toBe(42); + }); + + it('treats a timed-out command as not ok', () => { + const s = structuredCommandResult({ ...okResult, timedOut: true, timeoutSeconds: 60, failureKind: 'timeout' }); + expect(s.ok).toBe(false); + expect(s.timedOut).toBe(true); + expect(s.timeoutSeconds).toBe(60); + }); }); describe('toolText', () => { diff --git a/src/utils/output.ts b/src/utils/output.ts index ce4d3da..4e9e71a 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,4 +1,23 @@ import type { CommandResult, ToolCallResult } from '../types/index.js'; +import { classifyFailure } from '../core/diagnostics.js'; + +/** Human-readable meanings for Bazel's documented exit codes. */ +const BAZEL_EXIT_CODES: Record = { + 1: 'build/command failed', + 2: 'command-line / usage error', + 3: 'tests failed or did not pass', + 4: 'no tests found for the given pattern', + 8: 'build interrupted', + 36: 'local environment issue', + 37: 'unhandled internal Bazel error', +}; + +function bazelExitHint(result: CommandResult): string | undefined { + const bin = result.command.split('/').pop() || result.command; + if (!/^bazel(isk)?$/.test(bin)) return undefined; + const meaning = BAZEL_EXIT_CODES[result.exitCode]; + return meaning ? `Bazel exit ${result.exitCode}: ${meaning}.` : undefined; +} export function toolText(text: string, isError = false): ToolCallResult { return { @@ -21,29 +40,75 @@ export function toolResult( export function formatCommandResult(commandResult: CommandResult): string { const commandLine = [commandResult.command, ...commandResult.args].join(' '); - const status = commandResult.exitCode === 0 ? 'OK' : 'FAILED'; + const failed = commandResult.exitCode !== 0 || commandResult.timedOut === true; + const status = failed ? 'FAILED' : 'OK'; const signal = commandResult.signal ? ` signal=${commandResult.signal}` : ''; - const truncated = commandResult.truncated ? '\n[output truncated]' : ''; + const kind = failed && commandResult.failureKind && commandResult.failureKind !== 'nonzero-exit' + ? ` kind=${commandResult.failureKind}` + : ''; - return [ - `${status} exit=${commandResult.exitCode}${signal} duration=${( + const lines = [ + `${status} exit=${commandResult.exitCode}${signal}${kind} duration=${( commandResult.durationMs / 1000 ).toFixed(1)}s`, `$ ${commandLine}`, '', commandResult.output.trim() || '(no output)', - truncated, - ].join('\n'); + ]; + + if (commandResult.timedOut) { + lines.push(`\n⏱ TIMED OUT after ${commandResult.timeoutSeconds ?? '?'}s — increase timeoutSeconds or narrow the target.`); + } + if (commandResult.truncated) { + const dropped = commandResult.bytesDropped + ? `: ~${commandResult.bytesDropped} characters dropped (head + tail kept)` + : ''; + lines.push(`[output truncated${dropped}]`); + } + + if (failed) { + const exitHint = bazelExitHint(commandResult); + if (exitHint) lines.push('', exitHint); + const failure = classifyFailure(commandResult); + // Only surface the diagnostics block when it adds signal — avoids a bare + // "Failure category: unknown" line on trivial (e.g. simctl) failures. + if (failure && (failure.category !== 'unknown' || failure.diagnostics.length > 0 || failure.invocationUrl)) { + lines.push('', `Failure category: ${failure.category}`); + if (failure.diagnostics.length > 0) { + lines.push('Top diagnostics:'); + for (const d of failure.diagnostics.slice(0, 8)) lines.push(` • ${d}`); + } + if (failure.invocationUrl) lines.push(`Remote logs: ${failure.invocationUrl}`); + } + if (commandResult.context) { + lines.push('', 'Context:'); + for (const [k, v] of Object.entries(commandResult.context)) lines.push(` ${k}: ${v}`); + } + } + + return lines.join('\n'); } export function structuredCommandResult(result: CommandResult): Record { + const failure = classifyFailure(result); return { - ok: result.exitCode === 0, + ok: result.exitCode === 0 && !result.timedOut, exitCode: result.exitCode, signal: result.signal || undefined, + failureKind: result.failureKind, + timedOut: result.timedOut || undefined, + timeoutSeconds: result.timedOut ? result.timeoutSeconds : undefined, + spawnErrorCode: result.spawnErrorCode, durationMs: result.durationMs, command: [result.command, ...result.args].join(' '), + id: result.id, output: result.output, + stderr: result.stderr, truncated: result.truncated, + bytesDropped: result.bytesDropped, + context: result.context, + category: failure?.category, + diagnostics: failure?.diagnostics, + invocationUrl: failure?.invocationUrl, }; } diff --git a/src/utils/process.test.ts b/src/utils/process.test.ts new file mode 100644 index 0000000..0215ad0 --- /dev/null +++ b/src/utils/process.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { runCommand, BoundedCapture } from './process.js'; + +const opts = { cwd: process.cwd(), maxOutput: 1_000_000 } as const; + +describe('runCommand', () => { + it('captures stdout and a zero exit code', async () => { + const result = await runCommand('node', ['-e', 'process.stdout.write("hello")'], opts); + expect(result.exitCode).toBe(0); + expect(result.output).toBe('hello'); + expect(result.truncated).toBe(false); + expect(result.failureKind).toBe('ok'); + expect(result.stdout).toBe('hello'); + expect(result.stderr).toBe(''); + }); + + it('keeps stdout and stderr as distinct channels', async () => { + const result = await runCommand( + 'node', + ['-e', 'process.stdout.write("OUT"); process.stderr.write("ERRLINE")'], + opts, + ); + expect(result.stdout).toBe('OUT'); + expect(result.stderr).toBe('ERRLINE'); + expect(result.output).toContain('OUT'); + expect(result.output).toContain('ERRLINE'); + }); + + it('reports a non-zero exit code with failureKind', async () => { + const result = await runCommand('node', ['-e', 'process.exit(3)'], opts); + expect(result.exitCode).toBe(3); + expect(result.failureKind).toBe('nonzero-exit'); + }); + + it('preserves multi-byte UTF-8 output without corruption', async () => { + const result = await runCommand('node', ['-e', 'process.stdout.write("café — 🚀")'], opts); + expect(result.output).toBe('café — 🚀'); + expect(result.output).not.toContain('\uFFFD'); + }); + + it('head+tail truncates and reports dropped characters', async () => { + const result = await runCommand( + 'node', + ['-e', 'process.stdout.write("H".repeat(2500) + "T".repeat(2500))'], + { cwd: process.cwd(), maxOutput: 100 }, + ); + expect(result.truncated).toBe(true); + expect(result.bytesDropped).toBeGreaterThan(0); + // Tail (the real error location) must survive. + expect(result.output).toContain('T'); + // Head must survive too. + expect(result.output).toContain('H'); + expect(result.output).toContain('characters dropped'); + }); + + it('flags spawn errors distinctly from crashes', async () => { + const result = await runCommand('definitely-not-a-real-binary-xyz', [], opts); + expect(result.exitCode).toBe(-1); + expect(result.failureKind).toBe('spawn-error'); + expect(result.spawnErrorCode).toBe('ENOENT'); + }); + + it('flags a timeout explicitly', async () => { + const result = await runCommand( + 'node', + ['-e', 'setTimeout(() => {}, 10000)'], + { cwd: process.cwd(), maxOutput: 1000, timeoutSeconds: 1 }, + ); + expect(result.timedOut).toBe(true); + expect(result.failureKind).toBe('timeout'); + expect(result.timeoutSeconds).toBe(1); + }); +}); + +describe('BoundedCapture', () => { + it('returns full text when under the limit', () => { + const cap = new BoundedCapture(1000); + cap.push('hello world'); + expect(cap.result()).toBe('hello world'); + expect(cap.truncated).toBe(false); + expect(cap.bytesDropped).toBe(0); + }); + + it('keeps head and tail and drops the middle', () => { + const cap = new BoundedCapture(10); // head=4, tail=6 + cap.push('AAAA' + 'x'.repeat(50) + 'ZZZZZZ'); + expect(cap.truncated).toBe(true); + expect(cap.bytesDropped).toBe(50); + expect(cap.result().startsWith('AAAA')).toBe(true); + expect(cap.result().endsWith('ZZZZZZ')).toBe(true); + expect(cap.result()).toContain('characters dropped'); + }); +}); diff --git a/src/utils/process.ts b/src/utils/process.ts index 9386c98..48b954e 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,11 +1,14 @@ import { spawn } from 'node:child_process'; -import type { CommandResult } from '../types/index.js'; +import { StringDecoder } from 'node:string_decoder'; +import type { CommandResult, FailureKind } from '../types/index.js'; export interface RunCommandOptions { cwd: string; timeoutSeconds?: number; maxOutput: number; env?: NodeJS.ProcessEnv; + /** Correlation id threaded into the result (and the command log). */ + id?: string; } export interface StreamChunk { @@ -13,12 +16,69 @@ export interface StreamChunk { data: string; } +/** + * Accumulates text while keeping only the first `head` and last `tail` portion + * of the stream. For Bazel/Swift the actionable diagnostic (`error:`, the + * `FAILED:`/`ERROR:` summary) lands at the END, so head-only truncation would + * discard it. This keeps both ends and reports how much was dropped. + */ +export class BoundedCapture { + private head = ''; + private tail = ''; + private readonly headLimit: number; + private readonly tailLimit: number; + private total = 0; + + constructor(maxOutput: number) { + const cap = Math.max(2, maxOutput); + // Bias toward the tail — that's where the real error usually is. + this.headLimit = Math.floor(cap * 0.4); + this.tailLimit = cap - this.headLimit; + } + + push(text: string): void { + if (!text) return; + this.total += text.length; + if (this.head.length < this.headLimit) { + const room = this.headLimit - this.head.length; + this.head += text.slice(0, room); + text = text.slice(room); + } + if (!text) return; + this.tail += text; + if (this.tail.length > this.tailLimit) { + this.tail = this.tail.slice(this.tail.length - this.tailLimit); + } + } + + get truncated(): boolean { + return this.total > this.headLimit + this.tailLimit; + } + + get bytesDropped(): number { + return Math.max(0, this.total - this.headLimit - this.tailLimit); + } + + result(): string { + if (!this.truncated) return this.head + this.tail; + return `${this.head}\n…[${this.bytesDropped} characters dropped — showing head + tail]…\n${this.tail}`; + } +} + +function classifyClose(timedOut: boolean, exitCode: number | null, signal: NodeJS.Signals | null): FailureKind { + if (timedOut) return 'timeout'; + if (exitCode === 0) return 'ok'; + if (exitCode === null && signal) return 'signal'; + return 'nonzero-exit'; +} + export function runCommand( command: string, args: string[], options: RunCommandOptions, ): Promise { - const timeoutMs = Math.max(1, Number(options.timeoutSeconds || 600)) * 1000; + const timeoutSeconds = Math.max(1, Number(options.timeoutSeconds || 600)); + const timeoutMs = timeoutSeconds * 1000; const started = Date.now(); return new Promise((resolve) => { @@ -28,48 +88,69 @@ export function runCommand( stdio: ['ignore', 'pipe', 'pipe'], }); - let output = ''; - let truncated = false; - const append = (chunk: Buffer) => { - if (output.length < options.maxOutput) { - output += chunk.toString(); - if (output.length > options.maxOutput) { - output = output.slice(0, options.maxOutput); - truncated = true; - } - } else { - truncated = true; - } - }; + const combined = new BoundedCapture(options.maxOutput); + const stdoutCap = new BoundedCapture(options.maxOutput); + const stderrCap = new BoundedCapture(options.maxOutput); + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + let timedOut = false; const timer = setTimeout(() => { + timedOut = true; child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 2_000).unref(); }, timeoutMs); - child.stdout.on('data', append); - child.stderr.on('data', append); + child.stdout.on('data', (chunk: Buffer) => { + const text = stdoutDecoder.write(chunk); + combined.push(text); + stdoutCap.push(text); + }); + child.stderr.on('data', (chunk: Buffer) => { + const text = stderrDecoder.write(chunk); + combined.push(text); + stderrCap.push(text); + }); + child.on('error', (err) => { clearTimeout(timer); + const code = (err as NodeJS.ErrnoException).code; resolve({ command, args, exitCode: -1, durationMs: Date.now() - started, output: String(err), - truncated, + truncated: false, + stdout: '', + stderr: String(err), + failureKind: 'spawn-error', + spawnErrorCode: code, + timeoutSeconds, + id: options.id, }); }); child.on('close', (exitCode, signal) => { clearTimeout(timer); + const flushOut = stdoutDecoder.end(); + const flushErr = stderrDecoder.end(); + if (flushOut) { combined.push(flushOut); stdoutCap.push(flushOut); } + if (flushErr) { combined.push(flushErr); stderrCap.push(flushErr); } resolve({ command, args, exitCode: exitCode ?? -1, signal, durationMs: Date.now() - started, - output, - truncated, + output: combined.result(), + truncated: combined.truncated, + stdout: stdoutCap.result(), + stderr: stderrCap.result(), + failureKind: classifyClose(timedOut, exitCode, signal), + timedOut: timedOut || undefined, + timeoutSeconds, + bytesDropped: combined.bytesDropped || undefined, + id: options.id, }); }); }); @@ -80,7 +161,8 @@ export async function* runCommandStreaming( args: string[], options: RunCommandOptions, ): AsyncGenerator { - const timeoutMs = Math.max(1, Number(options.timeoutSeconds || 600)) * 1000; + const timeoutSeconds = Math.max(1, Number(options.timeoutSeconds || 600)); + const timeoutMs = timeoutSeconds * 1000; const started = Date.now(); const child = spawn(command, args, { @@ -89,26 +171,20 @@ export async function* runCommandStreaming( stdio: ['ignore', 'pipe', 'pipe'], }); - let output = ''; - let truncated = false; - const appendAndTrack = (data: string) => { - if (output.length < options.maxOutput) { - output += data; - if (output.length > options.maxOutput) { - output = output.slice(0, options.maxOutput); - truncated = true; - } - } else { - truncated = true; - } - }; + const combined = new BoundedCapture(options.maxOutput); + const stdoutCap = new BoundedCapture(options.maxOutput); + const stderrCap = new BoundedCapture(options.maxOutput); + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + let timedOut = false; const timer = setTimeout(() => { + timedOut = true; child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 2_000).unref(); }, timeoutMs); - type QueueItem = StreamChunk | { type: 'error'; error: Error } | { type: 'close'; exitCode: number; signal?: NodeJS.Signals | null }; + type QueueItem = StreamChunk | { type: 'error'; error: Error } | { type: 'close'; exitCode: number | null; signal?: NodeJS.Signals | null }; const queue: QueueItem[] = []; let resolve: (() => void) | null = null; let done = false; @@ -122,13 +198,17 @@ export async function* runCommandStreaming( }; child.stdout.on('data', (chunk: Buffer) => { - const data = chunk.toString(); - appendAndTrack(data); + const data = stdoutDecoder.write(chunk); + if (!data) return; + combined.push(data); + stdoutCap.push(data); push({ stream: 'stdout', data }); }); child.stderr.on('data', (chunk: Buffer) => { - const data = chunk.toString(); - appendAndTrack(data); + const data = stderrDecoder.write(chunk); + if (!data) return; + combined.push(data); + stderrCap.push(data); push({ stream: 'stderr', data }); }); child.on('error', (err) => { @@ -138,8 +218,12 @@ export async function* runCommandStreaming( }); child.on('close', (exitCode, signal) => { clearTimeout(timer); + const flushOut = stdoutDecoder.end(); + const flushErr = stderrDecoder.end(); + if (flushOut) { combined.push(flushOut); stdoutCap.push(flushOut); } + if (flushErr) { combined.push(flushErr); stderrCap.push(flushErr); } done = true; - push({ type: 'close', exitCode: exitCode ?? -1, signal }); + push({ type: 'close', exitCode, signal }); }); while (true) { @@ -148,24 +232,38 @@ export async function* runCommandStreaming( if ('stream' in item) { yield item as StreamChunk; } else if ('type' in item && item.type === 'error') { + const code = (item.error as NodeJS.ErrnoException).code; yield { command, args, exitCode: -1, durationMs: Date.now() - started, output: String(item.error), - truncated, + truncated: false, + stdout: '', + stderr: String(item.error), + failureKind: 'spawn-error', + spawnErrorCode: code, + timeoutSeconds, + id: options.id, } satisfies CommandResult; return; } else if ('type' in item && item.type === 'close') { yield { command, args, - exitCode: item.exitCode, + exitCode: item.exitCode ?? -1, signal: item.signal, durationMs: Date.now() - started, - output, - truncated, + output: combined.result(), + truncated: combined.truncated, + stdout: stdoutCap.result(), + stderr: stderrCap.result(), + failureKind: classifyClose(timedOut, item.exitCode, item.signal ?? null), + timedOut: timedOut || undefined, + timeoutSeconds, + bytesDropped: combined.bytesDropped || undefined, + id: options.id, } satisfies CommandResult; return; } diff --git a/vitest.config.ts b/vitest.config.ts index fdcc47c..6a66f70 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['src/**/*.test.ts'], + // Don't write the persistent command log to the developer's home during tests. + env: { BAZEL_IOS_COMMAND_LOG_DISABLE: '1' }, coverage: { provider: 'v8', include: ['src/**/*.ts'],