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 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,5 @@
1
+ import { type Job, type JobResult } from "../types.js";
2
+ /**
3
+ * Execute a build/test job using Xcode MCP.
4
+ */
5
+ export declare function executeWithMCP(job: Job, repoPath: string, workspace: string): Promise<JobResult>;
@@ -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,5 @@
1
+ import { type Job, type JobResult } from "../types.js";
2
+ /**
3
+ * Execute a build/test job using xcodebuild.
4
+ */
5
+ export declare function executeWithXcodebuild(job: Job, repoPath: string, workspace: string): Promise<JobResult>;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};