@nomac/cli 0.13.3 → 0.13.4
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/package.json +1 -2
- package/skill/SKILL.md +0 -7
- package/src/cli.mjs +6 -163
- package/src/mcp.mjs +14 -121
- package/src/pack.mjs +0 -77
- package/src/upload.mjs +0 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"description": "A real cloud Mac for AI agents: Xcode, the iOS Simulator, signing, TestFlight and App Store publishing, by the minute, over MCP, SSH and HTTPS.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"https-proxy-agent": "^9.1.0",
|
|
23
23
|
"ignore": "^7.0.0",
|
|
24
24
|
"proxy-from-env": "^2.1.0",
|
|
25
|
-
"tar": "^7.4.0",
|
|
26
25
|
"ws": "^8.21.3"
|
|
27
26
|
},
|
|
28
27
|
"devDependencies": {
|
package/skill/SKILL.md
CHANGED
|
@@ -160,13 +160,6 @@ A step only a person can take (creating the app in App Store Connect,
|
|
|
160
160
|
agreements, an App Group) comes back as needs_human with its link: give it to
|
|
161
161
|
the human, then call again.
|
|
162
162
|
|
|
163
|
-
## Builder (existing subscribers only)
|
|
164
|
-
|
|
165
|
-
The separate iOS Builder publishing product is closed to new subscriptions.
|
|
166
|
-
If `get_billing_status` shows a Builder plan, its tools are listed and
|
|
167
|
-
https://nomac.app/llms-legacy.txt describes them. Otherwise use the cloud Mac
|
|
168
|
-
above.
|
|
169
|
-
|
|
170
163
|
## When stuck
|
|
171
164
|
|
|
172
165
|
`report_issue` with the failing `ref_id` files a support ticket with full
|
package/src/cli.mjs
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
1
|
import { api, CliError } from "./api.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import { packTarball } from "./pack.mjs";
|
|
5
|
-
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
2
|
+
import { saveApiKey } from "./config.mjs";
|
|
6
3
|
|
|
7
4
|
const HELP = `nomac: a cloud Mac for you and your agents ($0.80 an hour, by the minute)
|
|
8
5
|
|
|
@@ -46,27 +43,12 @@ Agents:
|
|
|
46
43
|
More: nomac start --help, nomac whoami, nomac --version, nomac update, nomac start --host user@mac (a Mac you own)
|
|
47
44
|
Environment: NOMAC_API_KEY, NOMAC_API_URL, NOMAC_NO_AUTO_UPDATE=1 (the CLI updates itself otherwise)`;
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
// so they take only their own flags and print usage for --help instead of running.
|
|
51
|
-
const LEGACY_HELP = {
|
|
52
|
-
push: "Usage: nomac push\n Upload this folder as a project snapshot (respects .gitignore; secrets are excluded).",
|
|
53
|
-
build: "Usage: nomac build [--smoke] [--testflight | --wait]\n Start a signed release build for TestFlight (uses one build from your plan).\n --smoke unsigned compile check instead\n --testflight wait until the build is on TestFlight (--wait for a smoke build)",
|
|
54
|
-
status: "Usage: nomac status [build-id]\n Show a build's state as JSON (the last build in this folder by default).",
|
|
55
|
-
logs: "Usage: nomac logs [build-id]\n Show a build's steps and failure excerpt (the last build in this folder by default).",
|
|
46
|
+
const COMMAND_HELP = {
|
|
56
47
|
mcp: "Usage: nomac mcp\n Run the NoMac MCP server on stdin/stdout. Add it to your agent's MCP settings as the command `nomac mcp`.",
|
|
57
48
|
};
|
|
58
49
|
const isHelp = (args) => args.some((a) => a === "--help" || a === "-h" || a === "help");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (unknown !== undefined) throw new CliError(`unknown option: ${unknown}`, `Unknown option "${unknown}" for nomac ${command}.\n\n${LEGACY_HELP[command]}`);
|
|
62
|
-
}
|
|
63
|
-
/** A build ID argument: bld_..., or none for the folder's last build. Never a flag. */
|
|
64
|
-
function buildIdArg(args, command) {
|
|
65
|
-
const [first, ...more] = args.filter((a) => a !== "--json");
|
|
66
|
-
if (first === undefined) return undefined;
|
|
67
|
-
if (!/^bld_[A-Za-z0-9]+$/.test(first) || more.length) throw new CliError(`not a build id: ${first}`, `"${[first, ...more].join(" ")}" is not a build ID (bld_...).\n\n${LEGACY_HELP[command]}`);
|
|
68
|
-
return first;
|
|
69
|
-
}
|
|
50
|
+
/** The Builder's commands, removed with it (October 2026). */
|
|
51
|
+
const RETIRED = "was removed with the Builder, retired in October 2026. Build on a NoMac Mac instead: nomac start, nomac sync, nomac exec -- xcodebuild ..., then nomac sign, nomac testflight App.ipa and nomac publish.";
|
|
70
52
|
|
|
71
53
|
export async function main(argv) {
|
|
72
54
|
const [cmd, ...rest] = argv;
|
|
@@ -99,12 +81,6 @@ export async function main(argv) {
|
|
|
99
81
|
return hostedSessionCommand(cmd, rest, { store: sessionStore() });
|
|
100
82
|
}
|
|
101
83
|
case "status":
|
|
102
|
-
// A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
|
|
103
|
-
if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) {
|
|
104
|
-
if (isHelp(rest)) return console.log(LEGACY_HELP.status);
|
|
105
|
-
return status(buildIdArg(rest, "status"));
|
|
106
|
-
}
|
|
107
|
-
// falls through: everyone else sees their Mac
|
|
108
84
|
case "start":
|
|
109
85
|
case "attach":
|
|
110
86
|
case "extend":
|
|
@@ -178,19 +154,12 @@ export async function main(argv) {
|
|
|
178
154
|
return deviceLogin();
|
|
179
155
|
}
|
|
180
156
|
case "push":
|
|
181
|
-
if (isHelp(rest)) return console.log(LEGACY_HELP.push);
|
|
182
|
-
onlyFlags(rest, [], "push");
|
|
183
|
-
return push();
|
|
184
157
|
case "build":
|
|
185
|
-
if (isHelp(rest)) return console.log(LEGACY_HELP.build);
|
|
186
|
-
onlyFlags(rest, ["--smoke", "--testflight", "--wait"], "build");
|
|
187
|
-
return build(rest);
|
|
188
158
|
case "logs":
|
|
189
|
-
|
|
190
|
-
return logs(buildIdArg(rest, "logs"));
|
|
159
|
+
throw new CliError("retired", `nomac ${cmd} ${RETIRED}`);
|
|
191
160
|
case "mcp": {
|
|
192
161
|
// Agents start this as a stdio server; --help must not start it and wait on stdin.
|
|
193
|
-
if (isHelp(rest)) return console.log(
|
|
162
|
+
if (isHelp(rest)) return console.log(COMMAND_HELP.mcp);
|
|
194
163
|
const { runMcpServer } = await import("./mcp.mjs");
|
|
195
164
|
return runMcpServer();
|
|
196
165
|
}
|
|
@@ -252,129 +221,3 @@ export function describeApple(view) {
|
|
|
252
221
|
return lines.join("\n");
|
|
253
222
|
}
|
|
254
223
|
|
|
255
|
-
async function ensureProject() {
|
|
256
|
-
const state = loadProjectState();
|
|
257
|
-
if (state.project_id) return state;
|
|
258
|
-
const name = basename(process.cwd());
|
|
259
|
-
const created = await api("POST", "/api/v1/projects", { body: { name } });
|
|
260
|
-
const next = { project_id: created.id, name };
|
|
261
|
-
saveProjectState(next);
|
|
262
|
-
console.log(`created project ${created.id} ("${name}")`);
|
|
263
|
-
return next;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async function push() {
|
|
267
|
-
const state = await ensureProject();
|
|
268
|
-
console.log("packing working tree (respecting .gitignore, secrets excluded)…");
|
|
269
|
-
const { tarball, files } = await packTarball(process.cwd());
|
|
270
|
-
console.log(`${files.length} files, ${(tarball.length / 1024).toFixed(0)} KB compressed`);
|
|
271
|
-
|
|
272
|
-
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
273
|
-
console.log(`✅ snapshot ${res.snapshot_id} (commit ${res.commit.slice(0, 8)})`);
|
|
274
|
-
const d = res.detected ?? {};
|
|
275
|
-
const framework = d.project_kind
|
|
276
|
-
? ` · ${d.project_kind}${d.flutter_sdk_version ? ` ${d.flutter_sdk_version}` : ""}`
|
|
277
|
-
: "";
|
|
278
|
-
console.log(
|
|
279
|
-
` detected: ${d.xcodeproj ?? "?"} · scheme ${d.scheme ?? "?"} · ${d.bundle_id ?? "no bundle id"}${d.marketing_version ? ` · v${d.marketing_version}` : ""}${framework}`,
|
|
280
|
-
);
|
|
281
|
-
if (res.lint) {
|
|
282
|
-
const icon = res.lint.grade === "green" ? "🟢" : res.lint.grade === "yellow" ? "🟡" : "🔴";
|
|
283
|
-
const n = res.lint.findings ?? 0;
|
|
284
|
-
console.log(
|
|
285
|
-
` review lint: ${icon} ${res.lint.grade}${n ? ` · ${n} finding${n === 1 ? "" : "s"}` : ""}`,
|
|
286
|
-
);
|
|
287
|
-
if (res.lint.grade === "red") {
|
|
288
|
-
console.log(" ⚠ red findings will block publish: fix them before `nomac build`/publish.");
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if (res.warning) console.log(`⚠ ${res.warning}`);
|
|
292
|
-
saveProjectState({ ...state, last_snapshot: res.snapshot_id });
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function build(rest) {
|
|
296
|
-
const state = loadProjectState();
|
|
297
|
-
if (!state.project_id) {
|
|
298
|
-
throw new CliError("no project", "No project here. Run `nomac push` first.");
|
|
299
|
-
}
|
|
300
|
-
const smoke = rest.includes("--smoke");
|
|
301
|
-
const wait = rest.includes("--testflight") || rest.includes("--wait");
|
|
302
|
-
const res = await api("POST", "/api/v1/builds", {
|
|
303
|
-
body: { project_id: state.project_id, workflow: smoke ? "smoke" : "release" },
|
|
304
|
-
idempotencyKey: `cli-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
305
|
-
});
|
|
306
|
-
console.log(`✅ build ${res.id} started (${res.workflow}, #${res.build_number})`);
|
|
307
|
-
saveProjectState({ ...state, last_build: res.id });
|
|
308
|
-
if (!wait) {
|
|
309
|
-
console.log(` follow with: nomac status ${res.id}`);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
await waitForBuild(res.id);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const SPINNER_STATES = {
|
|
316
|
-
queued: "queued",
|
|
317
|
-
mirrored: "mirrored",
|
|
318
|
-
dispatched: "runner starting",
|
|
319
|
-
building: "building on macOS",
|
|
320
|
-
uploading: "uploading to Apple",
|
|
321
|
-
processing: "Apple is processing",
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
async function waitForBuild(id) {
|
|
325
|
-
const started = Date.now();
|
|
326
|
-
let last = "";
|
|
327
|
-
let lastNotice = "";
|
|
328
|
-
for (;;) {
|
|
329
|
-
const b = await api("GET", `/api/v1/builds/${id}`);
|
|
330
|
-
if (b.state !== last) {
|
|
331
|
-
const t = Math.round((Date.now() - started) / 1000);
|
|
332
|
-
console.log(` [${t}s] ${SPINNER_STATES[b.state] ?? b.state}`);
|
|
333
|
-
last = b.state;
|
|
334
|
-
}
|
|
335
|
-
const notice = b.error?.stage === "testflight" ? b.error.raw : "";
|
|
336
|
-
if (notice && notice !== lastNotice) {
|
|
337
|
-
console.log(` ${notice}`);
|
|
338
|
-
if (b.error.url) console.log(` ${b.error.url}`);
|
|
339
|
-
}
|
|
340
|
-
lastNotice = notice;
|
|
341
|
-
if (b.state === "ready") {
|
|
342
|
-
console.log(
|
|
343
|
-
b.workflow === "smoke"
|
|
344
|
-
? "\n🎉 smoke build passed: the project compiles clean on macOS."
|
|
345
|
-
: "\n🎉 build ready: open TestFlight on your phone, the new build is live.",
|
|
346
|
-
);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
if (b.state === "failed") {
|
|
350
|
-
const err = b.error ?? {};
|
|
351
|
-
console.log(`\n❌ build failed (${err.code ?? "unknown"})`);
|
|
352
|
-
if (err.raw) console.log(` ${err.raw}`);
|
|
353
|
-
console.log(` details: nomac logs ${id}`);
|
|
354
|
-
const e = new CliError("build failed", "");
|
|
355
|
-
e.friendly = "";
|
|
356
|
-
throw e;
|
|
357
|
-
}
|
|
358
|
-
await new Promise((r) => setTimeout(r, 10_000));
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async function status(buildId) {
|
|
363
|
-
const state = loadProjectState();
|
|
364
|
-
const id = buildId ?? state.last_build;
|
|
365
|
-
if (!id) throw new CliError("no build", "No build id. Usage: nomac status <build-id>");
|
|
366
|
-
const b = await api("GET", `/api/v1/builds/${id}`);
|
|
367
|
-
console.log(JSON.stringify(b, null, 2));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function logs(buildId) {
|
|
371
|
-
const state = loadProjectState();
|
|
372
|
-
const id = buildId ?? state.last_build;
|
|
373
|
-
if (!id) throw new CliError("no build", "No build id. Usage: nomac logs <build-id>");
|
|
374
|
-
const res = await api("GET", `/api/v1/builds/${id}/logs`);
|
|
375
|
-
for (const step of res.steps ?? []) {
|
|
376
|
-
console.log(`\n═══ ${step.name} [${step.status ?? "?"}] ═══`);
|
|
377
|
-
if (step.log) console.log(step.log);
|
|
378
|
-
}
|
|
379
|
-
if (res.excerpt) console.log(`\n═══ failure excerpt ═══\n${res.excerpt}`);
|
|
380
|
-
}
|
package/src/mcp.mjs
CHANGED
|
@@ -9,9 +9,7 @@ import { createInterface } from "node:readline";
|
|
|
9
9
|
import { api } from "./api.mjs";
|
|
10
10
|
import { CLI_VERSION, mcpClientName, mcpContext, toolScope } from "./client-info.mjs";
|
|
11
11
|
import { argumentProblem } from "./mcp-args.mjs";
|
|
12
|
-
import { loadProjectState
|
|
13
|
-
import { packTarball } from "./pack.mjs";
|
|
14
|
-
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
12
|
+
import { loadProjectState } from "./config.mjs";
|
|
15
13
|
import { uploadStoreAsset } from "./store-upload.mjs";
|
|
16
14
|
import { MAC_TOOLS } from "./mac-tools.mjs";
|
|
17
15
|
|
|
@@ -44,26 +42,11 @@ async function resolveProjectId(args) {
|
|
|
44
42
|
if (args?.project_id) return args.project_id;
|
|
45
43
|
const state = loadProjectState(args?.directory ?? process.cwd());
|
|
46
44
|
if (state.project_id) return state.project_id;
|
|
47
|
-
throw new Error("no project here:
|
|
45
|
+
throw new Error("no project here: pass project_id (send_to_testflight answers with it, and connect_status lists the account's projects)");
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
export const TOOLS = [
|
|
51
49
|
...MAC_TOOLS,
|
|
52
|
-
{
|
|
53
|
-
name: "check_project", description: "Free source-readiness check of the latest uploaded snapshot. No Apple credentials or paid compute. Run push_project first or after code changes; this does not prove compilation or TestFlight delivery.",
|
|
54
|
-
inputSchema: { type: "object", properties: { project_id: { type: "string" } }, additionalProperties: false },
|
|
55
|
-
handler: async args => text(await api("POST", "/api/v1/check-project", { body: { project_id: await resolveProjectId(args) } })),
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: "get_billing_status", description: "Read confirmed payment, contracted prices, quota/reset, renewal and first-purchase refund eligibility. After payment, resume a blocked build once with a fresh request_key only if the earlier response said build_started:false. Reconcile unknown outcomes first.",
|
|
59
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
60
|
-
handler: async () => text(await api("GET", "/api/v1/billing/status")),
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: "get_refund_status", description: "Read the progress of a refund the account owner requested at nomac.app/usage. Agents cannot request refunds; point the human to that page. Does not submit a refund or cancel anything.",
|
|
64
|
-
inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
|
|
65
|
-
handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
|
|
66
|
-
},
|
|
67
50
|
{
|
|
68
51
|
name: "upload_store_asset",
|
|
69
52
|
description: "Upload a new Apple screenshot, preview, purchase image, supplemental reviewer attachment or encryption document from file_path (up to 500 MiB, subject to Apple limits) or data_base64 (2 MiB). Discover the createInstance operation/body and parent scope with get_store_capabilities/read_store. Preview by default; confirm:true reserves, uploads and commits. Preserve request_key and identical bytes/body to resume. ready:true requires Apple processing COMPLETE. Message attachments require App Store Connect.",
|
|
@@ -117,97 +100,6 @@ export const TOOLS = [
|
|
|
117
100
|
},
|
|
118
101
|
handler: async args => text(await api("PATCH", `/api/v1/projects/${await resolveProjectId(args)}`, { body: { connection_id: args.connection_id } })),
|
|
119
102
|
},
|
|
120
|
-
{
|
|
121
|
-
name: "push_project",
|
|
122
|
-
description:
|
|
123
|
-
"Pack the project directory (respects .gitignore; secrets never leave the machine) and upload a snapshot. Auto-detects the Xcode project, scheme and bundle id, runs source-level review lint, and links the App Store Connect app record. Run after every code change, before build.",
|
|
124
|
-
inputSchema: {
|
|
125
|
-
type: "object",
|
|
126
|
-
properties: {
|
|
127
|
-
directory: { type: "string", description: "project directory (default: cwd)" },
|
|
128
|
-
},
|
|
129
|
-
additionalProperties: false,
|
|
130
|
-
},
|
|
131
|
-
handler: async (args) => {
|
|
132
|
-
const dir = args?.directory ?? process.cwd();
|
|
133
|
-
let state = loadProjectState(dir);
|
|
134
|
-
if (!state.project_id) {
|
|
135
|
-
const name = dir.split("/").filter(Boolean).pop() ?? "app";
|
|
136
|
-
const created = await api("POST", "/api/v1/projects", { body: { name } });
|
|
137
|
-
state = { project_id: created.id, name };
|
|
138
|
-
saveProjectState(state, dir);
|
|
139
|
-
}
|
|
140
|
-
const { tarball, files } = await packTarball(dir);
|
|
141
|
-
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
142
|
-
saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
|
|
143
|
-
return text({ project_id: state.project_id, files_packed: files.length, ...res });
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
name: "build",
|
|
148
|
-
description:
|
|
149
|
-
"Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id; poll it with status.",
|
|
150
|
-
inputSchema: {
|
|
151
|
-
type: "object",
|
|
152
|
-
properties: {
|
|
153
|
-
project_id: { type: "string" },
|
|
154
|
-
workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
|
|
155
|
-
request_key: { type: "string", description: "Reuse this key to reconcile an unknown response. Use a fresh key after a confirmed build_started:false and payment/setup completion." },
|
|
156
|
-
},
|
|
157
|
-
additionalProperties: false,
|
|
158
|
-
},
|
|
159
|
-
handler: async (args) => {
|
|
160
|
-
const project_id = await resolveProjectId(args);
|
|
161
|
-
const res = await api("POST", "/api/v1/builds", {
|
|
162
|
-
body: { project_id, workflow: args?.workflow ?? "release" },
|
|
163
|
-
idempotencyKey: args?.request_key ?? `mcp-${randomUUID()}`,
|
|
164
|
-
});
|
|
165
|
-
let warning;
|
|
166
|
-
try {
|
|
167
|
-
const state = loadProjectState();
|
|
168
|
-
if (state?.project_id === project_id) {
|
|
169
|
-
saveProjectState({ ...state, last_build: res.id });
|
|
170
|
-
} else {
|
|
171
|
-
warning = "Build started for a project outside this directory's saved project. Pass build_id to status.";
|
|
172
|
-
}
|
|
173
|
-
} catch {
|
|
174
|
-
// The paid attempt already exists. Return its ID even when the local
|
|
175
|
-
// directory is read-only, so the agent can poll without starting again.
|
|
176
|
-
warning = "Build started, but its ID could not be saved locally. Pass build_id to status.";
|
|
177
|
-
}
|
|
178
|
-
return text({
|
|
179
|
-
...res,
|
|
180
|
-
...(warning ? { warning } : {}),
|
|
181
|
-
note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
|
|
182
|
-
});
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
name: "status",
|
|
187
|
-
description: "Build status (state machine position). States: queued→mirrored→dispatched→building→uploading→processing→ready|failed.",
|
|
188
|
-
inputSchema: {
|
|
189
|
-
type: "object",
|
|
190
|
-
properties: { build_id: { type: "string", description: "defaults to the last build started here" } },
|
|
191
|
-
additionalProperties: false,
|
|
192
|
-
},
|
|
193
|
-
handler: async (args) => {
|
|
194
|
-
const id = args?.build_id ?? loadProjectState().last_build;
|
|
195
|
-
if (!id) return errText("no build_id given and no build started from this directory");
|
|
196
|
-
return text(await api("GET", `/api/v1/builds/${id}`));
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
name: "get_failure",
|
|
201
|
-
description:
|
|
202
|
-
"Structured failure detail for a failed build: {stage, code, kind, error_lines, hints, raw_excerpt}. kind=project → you fix the source and re-push; kind=account → relay the deep link to your human.",
|
|
203
|
-
inputSchema: {
|
|
204
|
-
type: "object",
|
|
205
|
-
properties: { build_id: { type: "string" } },
|
|
206
|
-
required: ["build_id"],
|
|
207
|
-
additionalProperties: false,
|
|
208
|
-
},
|
|
209
|
-
handler: async (args) => text(await api("GET", `/api/v1/builds/${args.build_id}/failure`)),
|
|
210
|
-
},
|
|
211
103
|
{
|
|
212
104
|
name: "review_lint",
|
|
213
105
|
description:
|
|
@@ -403,18 +295,15 @@ export const TOOLS = [
|
|
|
403
295
|
},
|
|
404
296
|
];
|
|
405
297
|
|
|
406
|
-
/** The Builder's
|
|
407
|
-
const
|
|
408
|
-
|
|
298
|
+
/** The Builder's tools, removed with it (October 2026): a call to one says what does its job now. */
|
|
299
|
+
export const RETIRED_TOOLS = Object.fromEntries(["check_project", "push_project", "build", "status", "get_failure", "get_billing_status", "get_refund_status"].map(name => [name,
|
|
300
|
+
name === "get_billing_status" ? "get_billing_status was removed with the Builder, retired in October 2026: get_mac_credits shows the account's Mac credit."
|
|
301
|
+
: name === "get_refund_status" ? "get_refund_status was removed: refunds are the account owner's to ask, at support@nomac.app."
|
|
302
|
+
: `${name} was removed with the Builder, retired in October 2026: build and ship on a NoMac Mac instead. start_mac, build with exec_mac (xcodebuild), then set_up_signing, send_to_testflight and publish.`]));
|
|
409
303
|
|
|
410
|
-
/**
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
* Hidden tools still answer when called, so existing subscribers' agents keep working.
|
|
414
|
-
*/
|
|
415
|
-
export async function listedTools(isSubscriber = () => (subscriber ??= api("GET", "/api/v1/billing/status").then(s => Boolean(s?.tier) && s.tier !== "none", () => false))) {
|
|
416
|
-
const legacy = await isSubscriber();
|
|
417
|
-
return TOOLS.filter(t => legacy || !BUILDER_TOOLS.has(t.name));
|
|
304
|
+
/** Every tool is listed for every account. */
|
|
305
|
+
export async function listedTools() {
|
|
306
|
+
return TOOLS;
|
|
418
307
|
}
|
|
419
308
|
|
|
420
309
|
export async function runMcpServer() {
|
|
@@ -457,6 +346,10 @@ export async function runMcpServer() {
|
|
|
457
346
|
break;
|
|
458
347
|
case "tools/call": {
|
|
459
348
|
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
349
|
+
if (!tool && Object.hasOwn(RETIRED_TOOLS, params?.name ?? "")) {
|
|
350
|
+
reply({ content: [{ type: "text", text: JSON.stringify({ error: { code: "tool_moved", message: RETIRED_TOOLS[params.name] } }, null, 2) }], isError: true });
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
460
353
|
if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
|
|
461
354
|
const problem = argumentProblem(tool.inputSchema, params?.arguments ?? {});
|
|
462
355
|
if (problem) {
|
package/src/pack.mjs
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join, relative, sep } from "node:path";
|
|
3
|
-
import ignoreFactory from "ignore";
|
|
4
|
-
import * as tar from "tar";
|
|
5
|
-
|
|
6
|
-
// Pack the working tree: respect .gitignore, always exclude the deny-list
|
|
7
|
-
// (secrets never leave the machine), gzip in memory.
|
|
8
|
-
|
|
9
|
-
const ALWAYS_IGNORE = [
|
|
10
|
-
".git/",
|
|
11
|
-
"node_modules/",
|
|
12
|
-
".nomac.json",
|
|
13
|
-
"*.p8",
|
|
14
|
-
"*.p12",
|
|
15
|
-
"*.mobileprovision",
|
|
16
|
-
"*.pem",
|
|
17
|
-
".env",
|
|
18
|
-
".env.*",
|
|
19
|
-
".DS_Store",
|
|
20
|
-
"build/",
|
|
21
|
-
"DerivedData/",
|
|
22
|
-
"*.ipa",
|
|
23
|
-
"*.xcarchive",
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
export function collectFiles(dir) {
|
|
27
|
-
const denied = ignoreFactory().add(ALWAYS_IGNORE);
|
|
28
|
-
const relativePath = (base, path) => relative(base, path).split(sep).join("/");
|
|
29
|
-
const files = [];
|
|
30
|
-
const walk = (current, inherited = []) => {
|
|
31
|
-
let scopes = inherited;
|
|
32
|
-
try {
|
|
33
|
-
const rules = readFileSync(join(current, ".gitignore"), "utf8");
|
|
34
|
-
scopes = [...inherited, { base: current, ignore: ignoreFactory().add(rules) }];
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (error.code !== "ENOENT") throw error;
|
|
37
|
-
}
|
|
38
|
-
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
39
|
-
const full = join(current, entry.name);
|
|
40
|
-
const rel = relativePath(dir, full);
|
|
41
|
-
const relForIgnore = entry.isDirectory() ? `${rel}/` : rel;
|
|
42
|
-
if (denied.ignores(relForIgnore)) continue;
|
|
43
|
-
let ignored = false;
|
|
44
|
-
for (const scope of scopes) {
|
|
45
|
-
const scoped = relativePath(scope.base, full) + (entry.isDirectory() ? "/" : "");
|
|
46
|
-
const result = scope.ignore.test(scoped);
|
|
47
|
-
if (result.ignored) ignored = true;
|
|
48
|
-
else if (result.unignored) ignored = false;
|
|
49
|
-
}
|
|
50
|
-
if (ignored) continue;
|
|
51
|
-
if (entry.isDirectory()) walk(full, scopes);
|
|
52
|
-
else if (entry.isFile()) files.push(rel);
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
walk(dir);
|
|
56
|
-
return files.sort();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function packTarball(dir) {
|
|
60
|
-
const files = collectFiles(dir);
|
|
61
|
-
if (files.length === 0) {
|
|
62
|
-
throw new Error("nothing to pack (is this the right directory?)");
|
|
63
|
-
}
|
|
64
|
-
const chunks = [];
|
|
65
|
-
await new Promise((resolve, reject) => {
|
|
66
|
-
tar
|
|
67
|
-
.create({ gzip: true, cwd: dir, portable: true }, files)
|
|
68
|
-
.on("data", (c) => chunks.push(c))
|
|
69
|
-
.on("end", resolve)
|
|
70
|
-
.on("error", reject);
|
|
71
|
-
});
|
|
72
|
-
return { tarball: Buffer.concat(chunks), files };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function totalSize(dir, files) {
|
|
76
|
-
return files.reduce((n, f) => n + statSync(join(dir, f)).size, 0);
|
|
77
|
-
}
|
package/src/upload.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { api, CliError } from "./api.mjs";
|
|
2
|
-
|
|
3
|
-
/** Shared by the CLI and stdio MCP so both bypass the API's 4 MiB body limit. */
|
|
4
|
-
export async function uploadSourceSnapshot(projectId, tarball) {
|
|
5
|
-
const path = `/api/v1/projects/${projectId}/snapshots`;
|
|
6
|
-
let grant;
|
|
7
|
-
try {
|
|
8
|
-
grant = await api("POST", `${path}/uploads`);
|
|
9
|
-
} catch (error) {
|
|
10
|
-
// Older servers have no grant route. Outages and authorization failures
|
|
11
|
-
// should remain actionable, not silently retry through another transport.
|
|
12
|
-
if (!(error instanceof CliError) || error.status !== 404 || (error.code && error.code !== "bad_response")) throw error;
|
|
13
|
-
if (tarball.length > 4 * 1024 * 1024) {
|
|
14
|
-
throw new CliError("direct uploads unavailable", "This server does not support direct source uploads. Update the server or reduce the compressed archive to 4 MiB.");
|
|
15
|
-
}
|
|
16
|
-
return api("POST", path, { raw: tarball });
|
|
17
|
-
}
|
|
18
|
-
if (typeof grant.upload_url !== "string" || typeof grant.upload_key !== "string" ||
|
|
19
|
-
!Number.isSafeInteger(grant.max_bytes) || grant.max_bytes <= 0) {
|
|
20
|
-
throw new CliError("invalid source upload grant");
|
|
21
|
-
}
|
|
22
|
-
if (tarball.length > grant.max_bytes) {
|
|
23
|
-
throw new CliError(
|
|
24
|
-
"tarball too large",
|
|
25
|
-
`Packed source is ${(tarball.length / 1048576).toFixed(1)} MiB; the limit is ${(grant.max_bytes / 1048576).toFixed(0)} MiB. Check .gitignore: build artifacts are recreated on the build VM.`,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
const uploaded = await fetch(grant.upload_url, {
|
|
29
|
-
method: "PUT",
|
|
30
|
-
headers: { "content-type": "application/gzip", "content-length": String(tarball.length) },
|
|
31
|
-
body: tarball,
|
|
32
|
-
});
|
|
33
|
-
await uploaded.body?.cancel();
|
|
34
|
-
if (!uploaded.ok) throw new CliError(`upload failed (HTTP ${uploaded.status})`);
|
|
35
|
-
return api("POST", path, { body: { upload_key: grant.upload_key } });
|
|
36
|
-
}
|