xcode-build-queue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +266 -0
- package/dist/backends/mcp.d.ts +5 -0
- package/dist/backends/mcp.js +224 -0
- package/dist/backends/xcodebuild.d.ts +5 -0
- package/dist/backends/xcodebuild.js +83 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +244 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +114 -0
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +186 -0
- package/dist/enqueue.d.ts +14 -0
- package/dist/enqueue.js +133 -0
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +134 -0
- package/dist/setup-claude.d.ts +10 -0
- package/dist/setup-claude.js +100 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.js +99 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +13 -0
- package/dist/utils.d.ts +41 -0
- package/dist/utils.js +118 -0
- package/dist/worktree.d.ts +17 -0
- package/dist/worktree.js +239 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# xbq — Xcode Build Queue
|
|
2
|
+
|
|
3
|
+
Serial build queue for Xcode projects with git worktrees.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Running multiple Claude Code sessions on the same Xcode project requires separate repos, each needing its own SPM resolution and DerivedData (~8GB+ per copy). This tool eliminates that duplication by routing all builds through a single "main" repo.
|
|
8
|
+
|
|
9
|
+
## How It Works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
13
|
+
│ Claude Code │ │ Claude Code │ │ Claude Code │
|
|
14
|
+
│ (worktree A) │ │ (worktree B) │ │ (worktree C) │
|
|
15
|
+
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
|
16
|
+
│ snapshot │ snapshot │ snapshot
|
|
17
|
+
└──────────────────┼──────────────────┘
|
|
18
|
+
▼
|
|
19
|
+
┌────────────────────┐
|
|
20
|
+
│ xbq (serial queue) │
|
|
21
|
+
│ │
|
|
22
|
+
│ checkout snapshot │
|
|
23
|
+
│ (detached HEAD) │
|
|
24
|
+
│ build/test via │
|
|
25
|
+
│ Xcode MCP or │
|
|
26
|
+
│ xcodebuild │
|
|
27
|
+
└────────┬───────────┘
|
|
28
|
+
▼
|
|
29
|
+
┌───────────┐
|
|
30
|
+
│ Main Repo │ ← single DerivedData
|
|
31
|
+
│ (warm) │ ← SPM already resolved
|
|
32
|
+
└───────────┘
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Claude Code sessions edit code in lightweight git worktrees (code only, no builds)
|
|
36
|
+
- When they need to build/test, they run `xbq build` or `xbq test`
|
|
37
|
+
- `xbq` creates a snapshot commit of all changes and checks it out via detached HEAD in the main repo
|
|
38
|
+
- Jobs run serially to prevent DerivedData corruption
|
|
39
|
+
- Results are returned to the requesting session
|
|
40
|
+
- Each worktree gets a `CLAUDE.md` that tells Claude Code to use `xbq`
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
### npm (recommended)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install -g xbq
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### From GitHub
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g github:a-ulkhan/xbq
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### From source
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/a-ulkhan/xbq.git
|
|
60
|
+
cd xbq
|
|
61
|
+
make install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Setup
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Configure (point to your main Xcode repo)
|
|
68
|
+
xbq init ~/path/to/your/project
|
|
69
|
+
|
|
70
|
+
# Start the daemon
|
|
71
|
+
xbq daemon start
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Optional: `git-restore-mtime`
|
|
75
|
+
|
|
76
|
+
For best incremental build performance after branch switches:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip3 install git-restore-mtime
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick Start
|
|
83
|
+
|
|
84
|
+
### Start a new parallel session
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Create a worktree + launch Claude Code (all-in-one)
|
|
88
|
+
xbq session my-feature
|
|
89
|
+
|
|
90
|
+
# Or create worktree only (e.g. to open in a new terminal)
|
|
91
|
+
xbq worktree new my-feature
|
|
92
|
+
|
|
93
|
+
# Auto-named session (timestamp-based)
|
|
94
|
+
xbq session
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`xbq session` / `xbq worktree new`:
|
|
98
|
+
1. Creates a git worktree branching from the default branch
|
|
99
|
+
2. Auto-injects `xbq` instructions into the worktree's `CLAUDE.md`
|
|
100
|
+
3. Optionally launches Claude Code in the worktree
|
|
101
|
+
|
|
102
|
+
### Build and test from a worktree
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Build (changes are captured automatically via diff — no commit needed)
|
|
106
|
+
xbq build
|
|
107
|
+
|
|
108
|
+
# Run tests
|
|
109
|
+
xbq test
|
|
110
|
+
|
|
111
|
+
# With specific destination
|
|
112
|
+
xbq build --destination "platform=iOS Simulator,name=iPhone 16,OS=26.2"
|
|
113
|
+
|
|
114
|
+
# Specific scheme/test plan
|
|
115
|
+
xbq test --scheme MyScheme --test-plan All
|
|
116
|
+
|
|
117
|
+
# Force xcodebuild backend
|
|
118
|
+
xbq build --backend xcodebuild
|
|
119
|
+
|
|
120
|
+
# Legacy branch mode (for pushed branches)
|
|
121
|
+
xbq build --branch feature/my-branch
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Worktree Management
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# List all worktrees
|
|
128
|
+
xbq worktree list
|
|
129
|
+
|
|
130
|
+
# Clean up merged and stale worktrees (>7 days, no uncommitted changes)
|
|
131
|
+
xbq worktree clean
|
|
132
|
+
|
|
133
|
+
# Force remove all non-main worktrees
|
|
134
|
+
xbq worktree clean --force
|
|
135
|
+
|
|
136
|
+
# Custom age threshold
|
|
137
|
+
xbq worktree clean --days 3
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Cleanup rules
|
|
141
|
+
|
|
142
|
+
| Condition | Action |
|
|
143
|
+
|-----------|--------|
|
|
144
|
+
| Branch merged into default branch | Removed automatically |
|
|
145
|
+
| Stale (>7d) with no uncommitted changes | Removed automatically |
|
|
146
|
+
| Active with uncommitted work | Kept (unless `--force`) |
|
|
147
|
+
|
|
148
|
+
Tip: add to crontab for automatic cleanup:
|
|
149
|
+
```bash
|
|
150
|
+
0 9 * * * xbq worktree clean
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Claude Code Integration
|
|
154
|
+
|
|
155
|
+
Every worktree created by `xbq` gets a `CLAUDE.md` that instructs Claude Code to:
|
|
156
|
+
|
|
157
|
+
- **NEVER** run `xcodebuild` directly in the worktree
|
|
158
|
+
- **NEVER** use Xcode MCP build/test tools directly
|
|
159
|
+
- **ALWAYS** use `xbq build` / `xbq test` (changes are captured automatically)
|
|
160
|
+
|
|
161
|
+
This is automatic — no manual setup needed per session.
|
|
162
|
+
|
|
163
|
+
To manually manage the Claude integration:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# Inject xbq instructions into an existing project's CLAUDE.md
|
|
167
|
+
xbq setup-claude /path/to/worktree
|
|
168
|
+
|
|
169
|
+
# Remove xbq instructions
|
|
170
|
+
xbq setup-claude --remove /path/to/worktree
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The injection is idempotent (safe to run multiple times) and uses HTML markers to update in place.
|
|
174
|
+
|
|
175
|
+
## Queue Management
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Check daemon and queue status
|
|
179
|
+
xbq status
|
|
180
|
+
|
|
181
|
+
# View recent job results
|
|
182
|
+
xbq logs
|
|
183
|
+
|
|
184
|
+
# View a specific job's full log
|
|
185
|
+
xbq logs 20260323-101530-abc123
|
|
186
|
+
|
|
187
|
+
# Clean old results and logs (default: 7 days)
|
|
188
|
+
xbq clean
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Daemon
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
xbq daemon start # Start in background
|
|
195
|
+
xbq daemon stop # Stop
|
|
196
|
+
xbq daemon status # Check status + queue info
|
|
197
|
+
xbq daemon start -f # Foreground (for debugging)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The daemon auto-starts when you enqueue a job if it's not already running.
|
|
201
|
+
|
|
202
|
+
## Backends
|
|
203
|
+
|
|
204
|
+
### Xcode MCP (default)
|
|
205
|
+
|
|
206
|
+
Uses Xcode 26.3+'s native MCP server (`xcrun mcpbridge`). Requires Xcode to be running with the project open. Provides richer diagnostics.
|
|
207
|
+
|
|
208
|
+
### xcodebuild (fallback)
|
|
209
|
+
|
|
210
|
+
Standard CLI build tool. Works headless, no Xcode GUI needed. Auto-selected when mcpbridge is unavailable.
|
|
211
|
+
|
|
212
|
+
If MCP fails, `xbq` automatically falls back to xcodebuild (configurable).
|
|
213
|
+
|
|
214
|
+
## Configuration
|
|
215
|
+
|
|
216
|
+
Stored at `~/.bq/config.json`:
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"main_repo": "~/path/to/your/project",
|
|
221
|
+
"workspace": "MyApp.xcworkspace",
|
|
222
|
+
"default_scheme": "MyApp",
|
|
223
|
+
"default_test_plan": "",
|
|
224
|
+
"default_destination": "platform=iOS Simulator,name=iPhone 16",
|
|
225
|
+
"backend": "mcp",
|
|
226
|
+
"xcodebuild_fallback": true,
|
|
227
|
+
"git_restore_mtime": true
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Workspace and scheme are auto-detected during `xbq init`.
|
|
232
|
+
|
|
233
|
+
## All Commands
|
|
234
|
+
|
|
235
|
+
| Command | Description |
|
|
236
|
+
|---------|-------------|
|
|
237
|
+
| `xbq init [path]` | First-time setup — set main repo path |
|
|
238
|
+
| `xbq session [name]` | Create worktree + start Claude Code |
|
|
239
|
+
| `xbq worktree new [name]` | Create a new worktree |
|
|
240
|
+
| `xbq worktree list` | List all worktrees |
|
|
241
|
+
| `xbq worktree clean` | Remove merged/stale worktrees |
|
|
242
|
+
| `xbq build` | Enqueue a build job |
|
|
243
|
+
| `xbq test` | Enqueue a test job |
|
|
244
|
+
| `xbq status` | Show daemon + queue status |
|
|
245
|
+
| `xbq logs [job-id]` | View build logs / recent results |
|
|
246
|
+
| `xbq daemon start\|stop\|status` | Manage the queue daemon |
|
|
247
|
+
| `xbq setup-claude [dir]` | Inject/update xbq instructions in CLAUDE.md |
|
|
248
|
+
| `xbq clean` | Clean old results and logs |
|
|
249
|
+
|
|
250
|
+
### Common flags
|
|
251
|
+
|
|
252
|
+
| Flag | Commands | Description |
|
|
253
|
+
|------|----------|-------------|
|
|
254
|
+
| `-b, --branch <branch>` | build, test | Branch to build (optional in worktree) |
|
|
255
|
+
| `-s, --scheme <scheme>` | build, test | Xcode scheme override |
|
|
256
|
+
| `-d, --destination <dest>` | build, test | Simulator destination |
|
|
257
|
+
| `-t, --test-plan <plan>` | test | Test plan name |
|
|
258
|
+
| `--backend <backend>` | build, test | Force mcp or xcodebuild |
|
|
259
|
+
| `--timeout <seconds>` | build, test | Job timeout (default: 1800) |
|
|
260
|
+
|
|
261
|
+
## Requirements
|
|
262
|
+
|
|
263
|
+
- Node.js >= 18
|
|
264
|
+
- Xcode 26.3+ (for MCP backend) or any Xcode (for xcodebuild backend)
|
|
265
|
+
- git
|
|
266
|
+
- Optional: `git-restore-mtime` (`pip3 install git-restore-mtime`)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWithMCP = executeWithMCP;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const utils_js_1 = require("../utils.js");
|
|
8
|
+
/**
|
|
9
|
+
* Minimal MCP client that talks to Xcode's mcpbridge via JSON-RPC over stdio.
|
|
10
|
+
*/
|
|
11
|
+
class MCPClient {
|
|
12
|
+
process = null;
|
|
13
|
+
requestId = 0;
|
|
14
|
+
pending = new Map();
|
|
15
|
+
buffer = "";
|
|
16
|
+
async connect() {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
try {
|
|
19
|
+
this.process = (0, node_child_process_1.spawn)("xcrun", ["mcpbridge"], {
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
reject(new Error(`Failed to spawn mcpbridge: ${err}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.process.stdout?.on("data", (data) => {
|
|
28
|
+
this.buffer += data.toString();
|
|
29
|
+
this.processBuffer();
|
|
30
|
+
});
|
|
31
|
+
this.process.stderr?.on("data", (data) => {
|
|
32
|
+
const msg = data.toString().trim();
|
|
33
|
+
if (msg)
|
|
34
|
+
utils_js_1.log.dim(`mcpbridge: ${msg}`);
|
|
35
|
+
});
|
|
36
|
+
this.process.on("error", (err) => {
|
|
37
|
+
reject(new Error(`mcpbridge error: ${err.message}`));
|
|
38
|
+
});
|
|
39
|
+
this.process.on("close", (code) => {
|
|
40
|
+
for (const [, { reject: r }] of this.pending) {
|
|
41
|
+
r(new Error(`mcpbridge exited with code ${code}`));
|
|
42
|
+
}
|
|
43
|
+
this.pending.clear();
|
|
44
|
+
});
|
|
45
|
+
// Initialize MCP session
|
|
46
|
+
this.sendRequest("initialize", {
|
|
47
|
+
protocolVersion: "2024-11-05",
|
|
48
|
+
capabilities: {},
|
|
49
|
+
clientInfo: { name: "xbq", version: "0.1.0" },
|
|
50
|
+
}).then(() => {
|
|
51
|
+
this.sendNotification("notifications/initialized", {});
|
|
52
|
+
resolve();
|
|
53
|
+
}).catch(reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
processBuffer() {
|
|
57
|
+
// MCP uses Content-Length framed messages
|
|
58
|
+
while (true) {
|
|
59
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
60
|
+
if (headerEnd === -1)
|
|
61
|
+
break;
|
|
62
|
+
const header = this.buffer.slice(0, headerEnd);
|
|
63
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
64
|
+
if (!match) {
|
|
65
|
+
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const contentLength = parseInt(match[1]);
|
|
69
|
+
const bodyStart = headerEnd + 4;
|
|
70
|
+
if (this.buffer.length < bodyStart + contentLength)
|
|
71
|
+
break;
|
|
72
|
+
const body = this.buffer.slice(bodyStart, bodyStart + contentLength);
|
|
73
|
+
this.buffer = this.buffer.slice(bodyStart + contentLength);
|
|
74
|
+
try {
|
|
75
|
+
const msg = JSON.parse(body);
|
|
76
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
77
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
78
|
+
this.pending.delete(msg.id);
|
|
79
|
+
if (msg.error) {
|
|
80
|
+
reject(new Error(`MCP error: ${msg.error.message || JSON.stringify(msg.error)}`));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
resolve(msg.result);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Skip malformed messages
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
sendRaw(msg) {
|
|
93
|
+
const body = JSON.stringify(msg);
|
|
94
|
+
const frame = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
|
95
|
+
this.process?.stdin?.write(frame);
|
|
96
|
+
}
|
|
97
|
+
sendRequest(method, params) {
|
|
98
|
+
const id = ++this.requestId;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const timeout = setTimeout(() => {
|
|
101
|
+
this.pending.delete(id);
|
|
102
|
+
reject(new Error(`MCP request timed out: ${method}`));
|
|
103
|
+
}, 300_000); // 5 min timeout for builds
|
|
104
|
+
this.pending.set(id, {
|
|
105
|
+
resolve: (v) => { clearTimeout(timeout); resolve(v); },
|
|
106
|
+
reject: (e) => { clearTimeout(timeout); reject(e); },
|
|
107
|
+
});
|
|
108
|
+
this.sendRaw({ jsonrpc: "2.0", id, method, params });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
sendNotification(method, params) {
|
|
112
|
+
this.sendRaw({ jsonrpc: "2.0", method, params });
|
|
113
|
+
}
|
|
114
|
+
async callTool(name, args) {
|
|
115
|
+
return this.sendRequest("tools/call", { name, arguments: args });
|
|
116
|
+
}
|
|
117
|
+
disconnect() {
|
|
118
|
+
this.process?.kill();
|
|
119
|
+
this.process = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Execute a build/test job using Xcode MCP.
|
|
124
|
+
*/
|
|
125
|
+
async function executeWithMCP(job, repoPath, workspace) {
|
|
126
|
+
const logPath = (0, node_path_1.join)(utils_js_1.BQ_LOGS_DIR, `${job.id}.log`);
|
|
127
|
+
const logStream = (0, node_fs_1.createWriteStream)(logPath, { flags: "a" });
|
|
128
|
+
const startTime = Date.now();
|
|
129
|
+
const appendLog = (msg) => {
|
|
130
|
+
logStream.write(msg + "\n");
|
|
131
|
+
};
|
|
132
|
+
const client = new MCPClient();
|
|
133
|
+
try {
|
|
134
|
+
utils_js_1.log.info("Connecting to Xcode MCP...");
|
|
135
|
+
await client.connect();
|
|
136
|
+
utils_js_1.log.ok("Connected to Xcode MCP");
|
|
137
|
+
let result;
|
|
138
|
+
if (job.action === "build") {
|
|
139
|
+
utils_js_1.log.info(`Building scheme: ${job.scheme}`);
|
|
140
|
+
appendLog(`[bq] Building scheme: ${job.scheme}`);
|
|
141
|
+
result = await client.callTool("build", {
|
|
142
|
+
scheme: job.scheme,
|
|
143
|
+
workspace: (0, node_path_1.join)(repoPath, workspace),
|
|
144
|
+
...(job.destination ? { destination: job.destination } : {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
utils_js_1.log.info(`Testing scheme: ${job.scheme}, plan: ${job.test_plan || "default"}`);
|
|
149
|
+
appendLog(`[bq] Testing scheme: ${job.scheme}`);
|
|
150
|
+
result = await client.callTool("run_tests", {
|
|
151
|
+
scheme: job.scheme,
|
|
152
|
+
workspace: (0, node_path_1.join)(repoPath, workspace),
|
|
153
|
+
...(job.test_plan ? { testPlan: job.test_plan } : {}),
|
|
154
|
+
...(job.destination ? { destination: job.destination } : {}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
158
|
+
appendLog(`[bq] Completed in ${duration}s`);
|
|
159
|
+
appendLog(JSON.stringify(result, null, 2));
|
|
160
|
+
// Parse MCP response
|
|
161
|
+
return parseMCPResult(job.id, result, duration, logPath);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
165
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
166
|
+
appendLog(`[bq] Error: ${message}`);
|
|
167
|
+
return {
|
|
168
|
+
id: job.id,
|
|
169
|
+
status: "error",
|
|
170
|
+
duration_seconds: duration,
|
|
171
|
+
summary: `MCP error: ${message}`,
|
|
172
|
+
failures: [],
|
|
173
|
+
build_errors: [message],
|
|
174
|
+
warnings: [],
|
|
175
|
+
log_path: logPath,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
client.disconnect();
|
|
180
|
+
logStream.end();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function parseMCPResult(jobId, raw, duration, logPath) {
|
|
184
|
+
// MCP tool results come as { content: [{ type: "text", text: "..." }] }
|
|
185
|
+
const r = raw;
|
|
186
|
+
const text = r?.content?.map((c) => c.text).join("\n") || JSON.stringify(raw);
|
|
187
|
+
const failures = [];
|
|
188
|
+
const buildErrors = [];
|
|
189
|
+
const warnings = [];
|
|
190
|
+
for (const line of text.split("\n")) {
|
|
191
|
+
if (line.match(/error:/i))
|
|
192
|
+
buildErrors.push(line.trim());
|
|
193
|
+
else if (line.match(/Test Case .* failed/))
|
|
194
|
+
failures.push(line.trim());
|
|
195
|
+
else if (line.match(/warning:/i) && warnings.length < 20)
|
|
196
|
+
warnings.push(line.trim());
|
|
197
|
+
}
|
|
198
|
+
const hasError = buildErrors.length > 0 || failures.length > 0;
|
|
199
|
+
const testMatch = text.match(/Executed (\d+) tests?, with (\d+) failures?/);
|
|
200
|
+
let summary = "";
|
|
201
|
+
if (testMatch) {
|
|
202
|
+
const [, total, failed] = testMatch;
|
|
203
|
+
summary = `${parseInt(total) - parseInt(failed)}/${total} tests passed`;
|
|
204
|
+
}
|
|
205
|
+
else if (text.includes("BUILD SUCCEEDED") || text.includes("TEST SUCCEEDED")) {
|
|
206
|
+
summary = "Succeeded";
|
|
207
|
+
}
|
|
208
|
+
else if (hasError) {
|
|
209
|
+
summary = `${buildErrors.length} errors, ${failures.length} test failures`;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
summary = "Completed (check logs for details)";
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id: jobId,
|
|
216
|
+
status: hasError ? "failed" : "passed",
|
|
217
|
+
duration_seconds: duration,
|
|
218
|
+
summary,
|
|
219
|
+
failures,
|
|
220
|
+
build_errors: buildErrors,
|
|
221
|
+
warnings,
|
|
222
|
+
log_path: logPath,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWithXcodebuild = executeWithXcodebuild;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const utils_js_1 = require("../utils.js");
|
|
6
|
+
/**
|
|
7
|
+
* Execute a build/test job using xcodebuild.
|
|
8
|
+
*/
|
|
9
|
+
async function executeWithXcodebuild(job, repoPath, workspace) {
|
|
10
|
+
const logPath = (0, node_path_1.join)(utils_js_1.BQ_LOGS_DIR, `${job.id}.log`);
|
|
11
|
+
const workspacePath = (0, node_path_1.join)(repoPath, workspace);
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
const destination = job.destination || "platform=iOS Simulator,name=iPhone 16";
|
|
14
|
+
const args = [];
|
|
15
|
+
if (job.action === "test") {
|
|
16
|
+
args.push("test", "-workspace", workspacePath, "-scheme", job.scheme, "-destination", destination, "-resultBundlePath", (0, node_path_1.join)(utils_js_1.BQ_LOGS_DIR, `${job.id}.xcresult`));
|
|
17
|
+
if (job.test_plan) {
|
|
18
|
+
args.push("-testPlan", job.test_plan);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
args.push("build", "-workspace", workspacePath, "-scheme", job.scheme, "-destination", destination);
|
|
23
|
+
}
|
|
24
|
+
utils_js_1.log.info(`Running: xcodebuild ${args.slice(0, 3).join(" ")} ...`);
|
|
25
|
+
const failures = [];
|
|
26
|
+
const buildErrors = [];
|
|
27
|
+
const warnings = [];
|
|
28
|
+
const { exitCode, output } = await (0, utils_js_1.spawnWithLog)("xcodebuild", args, {
|
|
29
|
+
cwd: repoPath,
|
|
30
|
+
logPath,
|
|
31
|
+
onLine: (line) => {
|
|
32
|
+
if (line.includes("** BUILD FAILED **")) {
|
|
33
|
+
buildErrors.push("Build failed");
|
|
34
|
+
}
|
|
35
|
+
else if (line.includes("** TEST FAILED **")) {
|
|
36
|
+
// Will parse individual failures below
|
|
37
|
+
}
|
|
38
|
+
else if (line.includes("** BUILD SUCCEEDED **")) {
|
|
39
|
+
utils_js_1.log.ok("Build succeeded");
|
|
40
|
+
}
|
|
41
|
+
else if (line.includes("** TEST SUCCEEDED **")) {
|
|
42
|
+
utils_js_1.log.ok("Tests succeeded");
|
|
43
|
+
}
|
|
44
|
+
else if (line.match(/error:/i)) {
|
|
45
|
+
buildErrors.push(line.trim());
|
|
46
|
+
}
|
|
47
|
+
else if (line.match(/Test Case .* failed/)) {
|
|
48
|
+
failures.push(line.trim());
|
|
49
|
+
}
|
|
50
|
+
else if (line.match(/warning:/i) && warnings.length < 20) {
|
|
51
|
+
warnings.push(line.trim());
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
56
|
+
// Parse test count from output
|
|
57
|
+
let summary = "";
|
|
58
|
+
const testSummary = output.match(/Executed (\d+) tests?, with (\d+) failures?/);
|
|
59
|
+
if (testSummary) {
|
|
60
|
+
const [, total, failed] = testSummary;
|
|
61
|
+
const passed = parseInt(total) - parseInt(failed);
|
|
62
|
+
summary = `${passed}/${total} tests passed`;
|
|
63
|
+
if (parseInt(failed) > 0) {
|
|
64
|
+
summary += ` (${failed} failed)`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (exitCode === 0) {
|
|
68
|
+
summary = job.action === "test" ? "All tests passed" : "Build succeeded";
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
summary = job.action === "test" ? "Tests failed" : "Build failed";
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
id: job.id,
|
|
75
|
+
status: exitCode === 0 ? "passed" : "failed",
|
|
76
|
+
duration_seconds: duration,
|
|
77
|
+
summary,
|
|
78
|
+
failures,
|
|
79
|
+
build_errors: buildErrors,
|
|
80
|
+
warnings,
|
|
81
|
+
log_path: logPath,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/dist/cli.d.ts
ADDED