xcode-mcli 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 +92 -0
- package/bin/xcode-mcli.ts +16 -0
- package/config/mcporter.json +11 -0
- package/package.json +57 -0
- package/skill/SKILL.md +70 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/references/api-reference.md +409 -0
- package/skill/references/apple-xcode-26.3.surface.json +1466 -0
- package/skill/references/compatibility.md +91 -0
- package/skill/references/setup.md +120 -0
- package/skill/references/troubleshooting.md +160 -0
- package/src/commands/daemon-restart.ts +22 -0
- package/src/commands/daemon-start.ts +22 -0
- package/src/commands/daemon-status.ts +29 -0
- package/src/commands/daemon-stop.ts +22 -0
- package/src/commands/index.ts +26 -0
- package/src/commands/project-build.ts +50 -0
- package/src/commands/setup.ts +26 -0
- package/src/commands/surface-snapshot.ts +45 -0
- package/src/commands/surface-verify.ts +46 -0
- package/src/commands/tool-command.ts +150 -0
- package/src/commands/windows-list.ts +43 -0
- package/src/commands/windows-use.ts +30 -0
- package/src/commands/xcode-tool-commands.ts +460 -0
- package/src/constants.ts +3 -0
- package/src/core/command-definition.ts +131 -0
- package/src/core/command-dispatch.ts +97 -0
- package/src/core/contracts.ts +25 -0
- package/src/core/errors.ts +52 -0
- package/src/core/package-version.ts +30 -0
- package/src/runtime/daemon-host-entry.ts +8 -0
- package/src/runtime/daemon-host.ts +455 -0
- package/src/runtime/daemon-state.ts +75 -0
- package/src/runtime/env.ts +37 -0
- package/src/runtime/mcp-jsonrpc.ts +114 -0
- package/src/runtime/output.ts +128 -0
- package/src/runtime/tab-resolver.ts +44 -0
- package/src/runtime/xcode-client.ts +192 -0
- package/src/runtime/xcode-surface.ts +166 -0
- package/src/runtime/xcode-tool-definition.ts +14 -0
- package/src/runtime/xcode-windows.ts +65 -0
- package/src/runtime/xcrun.ts +39 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { openSync } from "node:fs";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { runtimeError } from "../core/errors.ts";
|
|
9
|
+
import { setBridgeConnectionState, setCachedTools } from "./daemon-state.ts";
|
|
10
|
+
import {
|
|
11
|
+
resolveDaemonLogFilePath,
|
|
12
|
+
resolveDaemonPidFilePath,
|
|
13
|
+
resolveDaemonSocketPath,
|
|
14
|
+
resolveStateRoot,
|
|
15
|
+
} from "./env.ts";
|
|
16
|
+
import { createXcodeBridgeClient } from "./xcode-client.ts";
|
|
17
|
+
|
|
18
|
+
type DaemonRequest =
|
|
19
|
+
| {
|
|
20
|
+
id: number;
|
|
21
|
+
kind: "ping";
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
arguments: Record<string, unknown>;
|
|
25
|
+
id: number;
|
|
26
|
+
kind: "callTool";
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
id: number;
|
|
31
|
+
kind: "stop";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type DaemonResponse =
|
|
35
|
+
| {
|
|
36
|
+
id: number;
|
|
37
|
+
ok: true;
|
|
38
|
+
pid?: number;
|
|
39
|
+
result?: unknown;
|
|
40
|
+
running?: boolean;
|
|
41
|
+
stopped?: boolean;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
error: string;
|
|
45
|
+
id: number;
|
|
46
|
+
ok: false;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type DaemonStatus =
|
|
50
|
+
| {
|
|
51
|
+
running: false;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
pid: number;
|
|
55
|
+
running: true;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const daemonEntryPath = fileURLToPath(new URL("./daemon-host-entry.ts", import.meta.url));
|
|
59
|
+
const DAEMON_PING_CONNECT_TIMEOUT_MS = 100;
|
|
60
|
+
const DAEMON_TOOL_CONNECT_TIMEOUT_MS = 500;
|
|
61
|
+
const daemonRequestSchema = z.discriminatedUnion("kind", [
|
|
62
|
+
z.object({
|
|
63
|
+
id: z.number().int(),
|
|
64
|
+
kind: z.literal("ping"),
|
|
65
|
+
}),
|
|
66
|
+
z.object({
|
|
67
|
+
id: z.number().int(),
|
|
68
|
+
kind: z.literal("callTool"),
|
|
69
|
+
name: z.string().min(1),
|
|
70
|
+
arguments: z.record(z.string(), z.unknown()),
|
|
71
|
+
}),
|
|
72
|
+
z.object({
|
|
73
|
+
id: z.number().int(),
|
|
74
|
+
kind: z.literal("stop"),
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
const daemonResponseSchema = z.union([
|
|
78
|
+
z.object({
|
|
79
|
+
id: z.number().int(),
|
|
80
|
+
ok: z.literal(true),
|
|
81
|
+
running: z.boolean().optional(),
|
|
82
|
+
pid: z.number().int().optional(),
|
|
83
|
+
result: z.unknown().optional(),
|
|
84
|
+
stopped: z.boolean().optional(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
id: z.number().int(),
|
|
88
|
+
ok: z.literal(false),
|
|
89
|
+
error: z.string().min(1),
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
const daemonToolResultSchema = z.object({
|
|
93
|
+
isError: z.boolean().optional(),
|
|
94
|
+
structuredContent: z.unknown().optional(),
|
|
95
|
+
text: z.string().optional(),
|
|
96
|
+
});
|
|
97
|
+
const structuredToolErrorSchema = z.object({
|
|
98
|
+
type: z.literal("error"),
|
|
99
|
+
data: z.string().min(1),
|
|
100
|
+
});
|
|
101
|
+
let bridgeClient: Awaited<ReturnType<typeof createXcodeBridgeClient>> | null = null;
|
|
102
|
+
|
|
103
|
+
async function bindDaemonCleanup(server: net.Server): Promise<void> {
|
|
104
|
+
const cleanup = async () => {
|
|
105
|
+
server.close();
|
|
106
|
+
await Promise.all([
|
|
107
|
+
rm(resolveDaemonSocketPath(), { force: true }),
|
|
108
|
+
rm(resolveDaemonPidFilePath(), { force: true }),
|
|
109
|
+
]);
|
|
110
|
+
};
|
|
111
|
+
process.on("SIGTERM", () => {
|
|
112
|
+
void cleanup().finally(() => {
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
process.on("SIGINT", () => {
|
|
117
|
+
void cleanup().finally(() => {
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleDaemonRequest(
|
|
124
|
+
server: net.Server,
|
|
125
|
+
socket: net.Socket,
|
|
126
|
+
line: string,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const request = daemonRequestSchema.parse(JSON.parse(line));
|
|
129
|
+
if (request.kind === "ping") {
|
|
130
|
+
writeDaemonResponse(socket, {
|
|
131
|
+
id: request.id,
|
|
132
|
+
ok: true,
|
|
133
|
+
running: true,
|
|
134
|
+
pid: process.pid,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (request.kind === "callTool") {
|
|
139
|
+
const client = await readOrCreateBridgeClient();
|
|
140
|
+
const result = await client.callTool({
|
|
141
|
+
name: request.name,
|
|
142
|
+
arguments: request.arguments,
|
|
143
|
+
});
|
|
144
|
+
writeDaemonResponse(socket, {
|
|
145
|
+
id: request.id,
|
|
146
|
+
ok: true,
|
|
147
|
+
result,
|
|
148
|
+
running: true,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (request.kind === "stop") {
|
|
153
|
+
if (bridgeClient) {
|
|
154
|
+
await bridgeClient.close();
|
|
155
|
+
bridgeClient = null;
|
|
156
|
+
}
|
|
157
|
+
writeDaemonResponse(socket, {
|
|
158
|
+
id: request.id,
|
|
159
|
+
ok: true,
|
|
160
|
+
running: false,
|
|
161
|
+
stopped: true,
|
|
162
|
+
});
|
|
163
|
+
server.close(async () => {
|
|
164
|
+
await Promise.all([
|
|
165
|
+
rm(resolveDaemonSocketPath(), { force: true }),
|
|
166
|
+
rm(resolveDaemonPidFilePath(), { force: true }),
|
|
167
|
+
]);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readDaemonRequestId(line: string): number | null {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(line);
|
|
177
|
+
return typeof parsed.id === "number" ? parsed.id : null;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function toDaemonErrorMessage(error: unknown): string {
|
|
184
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
185
|
+
return error.message;
|
|
186
|
+
}
|
|
187
|
+
return String(error);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function handleDaemonRequestSafely(
|
|
191
|
+
server: net.Server,
|
|
192
|
+
socket: net.Socket,
|
|
193
|
+
line: string,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
try {
|
|
196
|
+
await handleDaemonRequest(server, socket, line);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const id = readDaemonRequestId(line);
|
|
199
|
+
if (id === null) {
|
|
200
|
+
socket.destroy();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
writeDaemonResponse(socket, {
|
|
204
|
+
id,
|
|
205
|
+
ok: false,
|
|
206
|
+
error: toDaemonErrorMessage(error),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function writeDaemonResponse(socket: net.Socket, response: DaemonResponse): void {
|
|
212
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function appendCompleteLines(input: {
|
|
216
|
+
buffer: string;
|
|
217
|
+
chunk: string;
|
|
218
|
+
onLine: (line: string) => void;
|
|
219
|
+
}): string {
|
|
220
|
+
const lines = `${input.buffer}${input.chunk}`.split("\n");
|
|
221
|
+
const nextBuffer = lines.pop() ?? "";
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
input.onLine(line);
|
|
224
|
+
}
|
|
225
|
+
return nextBuffer;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function sendDaemonRequest(request: DaemonRequest): Promise<DaemonResponse> {
|
|
229
|
+
const socketPath = resolveDaemonSocketPath();
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const socket = net.createConnection(socketPath);
|
|
232
|
+
let buffer = "";
|
|
233
|
+
let settled = false;
|
|
234
|
+
const connectTimeoutMs = request.kind === "callTool"
|
|
235
|
+
? DAEMON_TOOL_CONNECT_TIMEOUT_MS
|
|
236
|
+
: DAEMON_PING_CONNECT_TIMEOUT_MS;
|
|
237
|
+
const timeout = setTimeout(() => {
|
|
238
|
+
settle(() => {
|
|
239
|
+
socket.destroy();
|
|
240
|
+
reject(runtimeError("Timed out while contacting daemon."));
|
|
241
|
+
});
|
|
242
|
+
}, connectTimeoutMs);
|
|
243
|
+
const settle = (callback: () => void) => {
|
|
244
|
+
if (settled) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
settled = true;
|
|
248
|
+
clearTimeout(timeout);
|
|
249
|
+
callback();
|
|
250
|
+
};
|
|
251
|
+
socket.setEncoding("utf8");
|
|
252
|
+
socket.on("connect", () => {
|
|
253
|
+
clearTimeout(timeout);
|
|
254
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
255
|
+
});
|
|
256
|
+
socket.on("data", (chunk) => {
|
|
257
|
+
buffer = appendCompleteLines({
|
|
258
|
+
buffer,
|
|
259
|
+
chunk: String(chunk),
|
|
260
|
+
onLine: (line) => {
|
|
261
|
+
if (!line) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
socket.end();
|
|
265
|
+
settle(() => {
|
|
266
|
+
resolve(daemonResponseSchema.parse(JSON.parse(line)));
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
socket.on("end", () => {
|
|
272
|
+
settle(() => {
|
|
273
|
+
reject(runtimeError("Daemon closed the connection without replying."));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
socket.on("error", (error) => {
|
|
277
|
+
settle(() => {
|
|
278
|
+
reject(runtimeError(`Failed to contact daemon. ${error.message}`));
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function bindDaemonRequestServer(server: net.Server, socket: net.Socket): void {
|
|
285
|
+
let buffer = "";
|
|
286
|
+
socket.setEncoding("utf8");
|
|
287
|
+
socket.on("data", (chunk) => {
|
|
288
|
+
buffer = appendCompleteLines({
|
|
289
|
+
buffer,
|
|
290
|
+
chunk: String(chunk),
|
|
291
|
+
onLine: (line) => {
|
|
292
|
+
if (line.trim().length === 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
void handleDaemonRequestSafely(server, socket, line);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function waitForDaemonReady(): Promise<DaemonStatus> {
|
|
302
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
303
|
+
const status = await readDaemonStatus();
|
|
304
|
+
if (status.running) {
|
|
305
|
+
return status;
|
|
306
|
+
}
|
|
307
|
+
await delay(50);
|
|
308
|
+
}
|
|
309
|
+
throw runtimeError("Timed out while starting the daemon.");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function waitForProcessExit(pid: number): Promise<void> {
|
|
313
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
314
|
+
try {
|
|
315
|
+
process.kill(pid, 0);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
await delay(50);
|
|
323
|
+
}
|
|
324
|
+
throw runtimeError("Timed out while stopping the daemon.");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readOrCreateBridgeClient(): Promise<
|
|
328
|
+
Awaited<ReturnType<typeof createXcodeBridgeClient>>
|
|
329
|
+
> {
|
|
330
|
+
if (bridgeClient) {
|
|
331
|
+
return bridgeClient;
|
|
332
|
+
}
|
|
333
|
+
bridgeClient = await createXcodeBridgeClient();
|
|
334
|
+
await setBridgeConnectionState({
|
|
335
|
+
bridgeProcessId: bridgeClient.bridgeProcessId,
|
|
336
|
+
lastXcodeConnectionSucceeded: true,
|
|
337
|
+
});
|
|
338
|
+
await setCachedTools(await bridgeClient.listTools());
|
|
339
|
+
return bridgeClient;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function delay(ms: number): Promise<void> {
|
|
343
|
+
await new Promise((resolve) => {
|
|
344
|
+
setTimeout(resolve, ms);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readDaemonToolErrorMessage(result: unknown): string | null {
|
|
349
|
+
const parsed = daemonToolResultSchema.safeParse(result);
|
|
350
|
+
if (!parsed.success) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const structuredError = structuredToolErrorSchema.safeParse(parsed.data.structuredContent);
|
|
354
|
+
if (structuredError.success) {
|
|
355
|
+
return structuredError.data.data;
|
|
356
|
+
}
|
|
357
|
+
if (!parsed.data.isError) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const text = parsed.data.text?.trim();
|
|
361
|
+
if (text) {
|
|
362
|
+
return text;
|
|
363
|
+
}
|
|
364
|
+
return "Xcode MCP tool failed.";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function callDaemonTool(input: {
|
|
368
|
+
arguments: Record<string, unknown>;
|
|
369
|
+
name: string;
|
|
370
|
+
}): Promise<unknown> {
|
|
371
|
+
await startDaemon();
|
|
372
|
+
const response = await sendDaemonRequest({
|
|
373
|
+
id: 1,
|
|
374
|
+
kind: "callTool",
|
|
375
|
+
name: input.name,
|
|
376
|
+
arguments: input.arguments,
|
|
377
|
+
});
|
|
378
|
+
if (response.ok) {
|
|
379
|
+
const toolErrorMessage = readDaemonToolErrorMessage(response.result);
|
|
380
|
+
if (toolErrorMessage) {
|
|
381
|
+
throw runtimeError(toolErrorMessage);
|
|
382
|
+
}
|
|
383
|
+
return response.result;
|
|
384
|
+
}
|
|
385
|
+
throw runtimeError(response.error);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function readDaemonStatus(): Promise<DaemonStatus> {
|
|
389
|
+
try {
|
|
390
|
+
const response = await sendDaemonRequest({
|
|
391
|
+
id: 1,
|
|
392
|
+
kind: "ping",
|
|
393
|
+
});
|
|
394
|
+
if (response.ok && response.running && response.pid) {
|
|
395
|
+
return {
|
|
396
|
+
running: true,
|
|
397
|
+
pid: response.pid,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
running: false,
|
|
402
|
+
};
|
|
403
|
+
} catch {
|
|
404
|
+
return {
|
|
405
|
+
running: false,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function startDaemon(): Promise<DaemonStatus> {
|
|
411
|
+
const existingStatus = await readDaemonStatus();
|
|
412
|
+
if (existingStatus.running) {
|
|
413
|
+
return existingStatus;
|
|
414
|
+
}
|
|
415
|
+
await mkdir(resolveStateRoot(), { recursive: true });
|
|
416
|
+
const logFile = openSync(resolveDaemonLogFilePath(), "a");
|
|
417
|
+
const child = spawn(process.execPath, [daemonEntryPath], {
|
|
418
|
+
detached: true,
|
|
419
|
+
env: process.env,
|
|
420
|
+
stdio: ["ignore", logFile, logFile],
|
|
421
|
+
});
|
|
422
|
+
child.unref();
|
|
423
|
+
return waitForDaemonReady();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function stopDaemon(): Promise<void> {
|
|
427
|
+
const status = await readDaemonStatus();
|
|
428
|
+
if (!status.running) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
process.kill(status.pid, "SIGTERM");
|
|
432
|
+
await waitForProcessExit(status.pid);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function restartDaemon(): Promise<DaemonStatus> {
|
|
436
|
+
await stopDaemon();
|
|
437
|
+
return startDaemon();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function runDaemonHost(): Promise<void> {
|
|
441
|
+
await mkdir(resolveStateRoot(), { recursive: true });
|
|
442
|
+
await rm(resolveDaemonSocketPath(), { force: true });
|
|
443
|
+
await writeFile(resolveDaemonPidFilePath(), `${process.pid}\n`);
|
|
444
|
+
const server = net.createServer((socket) => {
|
|
445
|
+
bindDaemonRequestServer(server, socket);
|
|
446
|
+
});
|
|
447
|
+
await new Promise<void>((resolve, reject) => {
|
|
448
|
+
server.once("error", reject);
|
|
449
|
+
server.listen(resolveDaemonSocketPath(), () => {
|
|
450
|
+
server.off("error", reject);
|
|
451
|
+
resolve();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
void bindDaemonCleanup(server);
|
|
455
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { resolveStateFilePath, resolveStateRoot } from "./env.ts";
|
|
5
|
+
import {
|
|
6
|
+
type XcodeToolDefinition,
|
|
7
|
+
xcodeToolDefinitionSchema,
|
|
8
|
+
} from "./xcode-tool-definition.ts";
|
|
9
|
+
import { xcodeWindowSchema } from "./xcode-windows.ts";
|
|
10
|
+
|
|
11
|
+
const daemonStateSchema = z.object({
|
|
12
|
+
activeTabIdentifier: z.string().trim().min(1).optional(),
|
|
13
|
+
bridgeProcessId: z.number().int().positive().optional(),
|
|
14
|
+
cachedTools: z.array(xcodeToolDefinitionSchema).optional(),
|
|
15
|
+
lastSeenWindows: z.array(xcodeWindowSchema).optional(),
|
|
16
|
+
lastXcodeConnectionSucceeded: z.boolean().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type DaemonState = z.infer<typeof daemonStateSchema>;
|
|
20
|
+
|
|
21
|
+
export async function readDaemonState(): Promise<DaemonState> {
|
|
22
|
+
const stateFilePath = resolveStateFilePath();
|
|
23
|
+
try {
|
|
24
|
+
const content = await readFile(stateFilePath, "utf8");
|
|
25
|
+
return daemonStateSchema.parse(JSON.parse(content));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeDaemonState(state: DaemonState): Promise<void> {
|
|
35
|
+
await mkdir(resolveStateRoot(), { recursive: true });
|
|
36
|
+
await writeFile(resolveStateFilePath(), JSON.stringify(state, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function setActiveTabIdentifier(tabIdentifier: string): Promise<void> {
|
|
40
|
+
const currentState = await readDaemonState();
|
|
41
|
+
await writeDaemonState({
|
|
42
|
+
...currentState,
|
|
43
|
+
activeTabIdentifier: tabIdentifier,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function setLastSeenWindows(
|
|
48
|
+
lastSeenWindows: Array<z.infer<typeof xcodeWindowSchema>>,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const currentState = await readDaemonState();
|
|
51
|
+
await writeDaemonState({
|
|
52
|
+
...currentState,
|
|
53
|
+
lastSeenWindows,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function setCachedTools(cachedTools: XcodeToolDefinition[]): Promise<void> {
|
|
58
|
+
const currentState = await readDaemonState();
|
|
59
|
+
await writeDaemonState({
|
|
60
|
+
...currentState,
|
|
61
|
+
cachedTools,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function setBridgeConnectionState(input: {
|
|
66
|
+
bridgeProcessId?: number;
|
|
67
|
+
lastXcodeConnectionSucceeded: boolean;
|
|
68
|
+
}): Promise<void> {
|
|
69
|
+
const currentState = await readDaemonState();
|
|
70
|
+
await writeDaemonState({
|
|
71
|
+
...currentState,
|
|
72
|
+
bridgeProcessId: input.bridgeProcessId,
|
|
73
|
+
lastXcodeConnectionSucceeded: input.lastXcodeConnectionSucceeded,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const STATE_ROOT_ENV_NAME = "XCODE_MCLI_STATE_ROOT";
|
|
5
|
+
const XCRUN_PATH_ENV_NAME = "XCODE_MCLI_XCRUN_PATH";
|
|
6
|
+
|
|
7
|
+
export function resolveStateRoot(): string {
|
|
8
|
+
const override = process.env[STATE_ROOT_ENV_NAME]?.trim();
|
|
9
|
+
if (override) {
|
|
10
|
+
return override;
|
|
11
|
+
}
|
|
12
|
+
return join(homedir(), "Library", "Application Support", "xcode-mcli");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveStateFilePath(): string {
|
|
16
|
+
return join(resolveStateRoot(), "state.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveDaemonPidFilePath(): string {
|
|
20
|
+
return join(resolveStateRoot(), "daemon.pid");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveDaemonSocketPath(): string {
|
|
24
|
+
return join(resolveStateRoot(), "daemon.sock");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveDaemonLogFilePath(): string {
|
|
28
|
+
return join(resolveStateRoot(), "daemon.log");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveXcrunPath(): string {
|
|
32
|
+
const override = process.env[XCRUN_PATH_ENV_NAME]?.trim();
|
|
33
|
+
if (override) {
|
|
34
|
+
return override;
|
|
35
|
+
}
|
|
36
|
+
return "xcrun";
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { runtimeError } from "../core/errors.ts";
|
|
6
|
+
|
|
7
|
+
const jsonRpcMessageSchema = z.object({
|
|
8
|
+
id: z.number().int().optional(),
|
|
9
|
+
result: z.unknown().optional(),
|
|
10
|
+
error: z
|
|
11
|
+
.object({
|
|
12
|
+
message: z.string().optional(),
|
|
13
|
+
})
|
|
14
|
+
.optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
type PendingRequest = {
|
|
18
|
+
reject: (error: Error) => void;
|
|
19
|
+
resolve: (value: unknown) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class JsonRpcPeer {
|
|
23
|
+
readonly #child: ChildProcessWithoutNullStreams;
|
|
24
|
+
readonly #pendingRequests = new Map<number, PendingRequest>();
|
|
25
|
+
#nextRequestId = 1;
|
|
26
|
+
|
|
27
|
+
constructor(child: ChildProcessWithoutNullStreams) {
|
|
28
|
+
this.#child = child;
|
|
29
|
+
this.#bindLifecycle();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
33
|
+
const id = this.#nextRequestId++;
|
|
34
|
+
const payload = JSON.stringify({
|
|
35
|
+
jsonrpc: "2.0",
|
|
36
|
+
id,
|
|
37
|
+
method,
|
|
38
|
+
params,
|
|
39
|
+
});
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
this.#pendingRequests.set(id, { resolve, reject });
|
|
42
|
+
this.#child.stdin.write(`${payload}\n`, (error) => {
|
|
43
|
+
if (!error) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.#pendingRequests.delete(id);
|
|
47
|
+
reject(runtimeError(`Failed to write JSON-RPC request. ${error.message}`));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
notify(method: string, params: Record<string, unknown>): void {
|
|
53
|
+
const payload = JSON.stringify({
|
|
54
|
+
jsonrpc: "2.0",
|
|
55
|
+
method,
|
|
56
|
+
params,
|
|
57
|
+
});
|
|
58
|
+
this.#child.stdin.write(`${payload}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async close(): Promise<void> {
|
|
62
|
+
this.#child.kill();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#bindLifecycle(): void {
|
|
66
|
+
const rl = readline.createInterface({
|
|
67
|
+
input: this.#child.stdout,
|
|
68
|
+
crlfDelay: Infinity,
|
|
69
|
+
});
|
|
70
|
+
rl.on("line", (line) => {
|
|
71
|
+
this.#handleMessageLine(line);
|
|
72
|
+
});
|
|
73
|
+
this.#child.on("error", (error) => {
|
|
74
|
+
this.#rejectPending(runtimeError(`Bridge process error. ${error.message}`));
|
|
75
|
+
});
|
|
76
|
+
this.#child.on("exit", (code, signal) => {
|
|
77
|
+
if (code === 0 || signal === "SIGTERM") {
|
|
78
|
+
this.#rejectPending(runtimeError("Bridge process closed."));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.#rejectPending(
|
|
82
|
+
runtimeError(`Bridge process exited unexpectedly with code ${code ?? "null"}.`),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#handleMessageLine(line: string): void {
|
|
88
|
+
if (line.trim().length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const message = jsonRpcMessageSchema.parse(JSON.parse(line));
|
|
92
|
+
const id = typeof message.id === "number" ? message.id : undefined;
|
|
93
|
+
if (id === undefined) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const pending = this.#pendingRequests.get(id);
|
|
97
|
+
if (!pending) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.#pendingRequests.delete(id);
|
|
101
|
+
if ("error" in message && message.error) {
|
|
102
|
+
pending.reject(runtimeError(message.error.message ?? "JSON-RPC request failed."));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
pending.resolve(message.result);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#rejectPending(error: Error): void {
|
|
109
|
+
for (const [id, pending] of this.#pendingRequests) {
|
|
110
|
+
this.#pendingRequests.delete(id);
|
|
111
|
+
pending.reject(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|