@nomac/cli 0.13.3 → 0.13.5
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 +30 -0
- package/package.json +1 -2
- package/skill/SKILL.md +0 -7
- package/src/cli.mjs +6 -163
- package/src/mcp.mjs +15 -122
- package/src/pack.mjs +0 -77
- package/src/upload.mjs +0 -36
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @nomac/cli
|
|
2
|
+
|
|
3
|
+
A real cloud Mac for AI agents and for you: macOS on Apple silicon with Xcode and the iOS Simulator, administrator access and nothing blocked, billed from prepaid credit at $0.80 an hour by the minute. Build, run and test iOS and macOS apps from Linux, Windows or a cloud sandbox, then sign, send to TestFlight and publish.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @nomac/cli
|
|
7
|
+
nomac login # approve the code in your browser
|
|
8
|
+
nomac start # a Mac, usually ready within a minute
|
|
9
|
+
nomac sync . # upload this project (to ~/drive/src, kept between sessions)
|
|
10
|
+
nomac exec --wait 600 -- xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
|
|
11
|
+
nomac pull ./build # download results
|
|
12
|
+
nomac stop # the Mac and its files are deleted, except ~/drive; billing ends
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## For agents
|
|
16
|
+
|
|
17
|
+
`nomac mcp` is an MCP server over stdio for Claude Code, Codex, Cursor and others:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
claude mcp add nomac -- npx @nomac/cli mcp
|
|
21
|
+
codex mcp add nomac -- npx @nomac/cli mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Hosted MCP needs no install: `https://mcp.nomac.app/mcp` (the Mac) and `https://mcp.nomac.app/appstore` (signing, TestFlight and App Store Connect), one sign-in for both.
|
|
25
|
+
|
|
26
|
+
## What else
|
|
27
|
+
|
|
28
|
+
`nomac --help` lists every command: SSH (`nomac ssh`, `nomac ssh-config`), the desktop in your browser, screenshots and screen recordings, real keyboard and mouse input, secrets that reach every Mac, App Store signing (`nomac sign`), TestFlight (`nomac testflight`), App Review (`nomac publish`) and notarization (`nomac notarize`).
|
|
29
|
+
|
|
30
|
+
The full guide for agents is https://nomac.app/llms.txt; the CLI reference is https://nomac.app/docs/cli and the API https://nomac.app/docs/api. The CLI updates itself in the background (`NOMAC_NO_AUTO_UPDATE=1` turns that off).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
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
|
|
|
@@ -25,7 +23,7 @@ const INSTRUCTIONS = [
|
|
|
25
23
|
"For the iOS Simulator, simulator-use is on the Mac (run it with exec_mac): simulator-use look reads the screen as text, simulator-use act 'tap \"Sign In\"' taps and types by label, and simulator-use run builds, installs and launches the app; ~/NOMAC.md has the rest.",
|
|
26
24
|
"When an answer says low_credit (under an hour of Mac time left), tell the human before a long build and offer its buy_url: a Mac stops when the credit runs out. For a refill, suggest 3 packs ($30, +5%).",
|
|
27
25
|
"One Mac runs per account at a time: start_mac while one runs answers session_limit_reached with its id, to use. For work that needs two at once, start_mac with parallel: true starts a second (accounts that bought credit, while NoMac has a Mac to spare); it runs without the drive.",
|
|
28
|
-
"exec_mac returns a quick command's output in the same call; pass wait_seconds:
|
|
26
|
+
"exec_mac returns a quick command's output in the same call; pass wait_seconds: 40 for builds and tests (a call answers within 45 s). A command still running comes back as a job: poll get_mac_job with its job_id, then read_mac_output.",
|
|
29
27
|
"Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
|
|
30
28
|
"Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
|
|
31
29
|
"Shipping without the Apple key on the Mac: set_up_signing on the project returns the files and commands that sign a build with the agent's own tools; send_to_testflight sends the signed .ipa to TestFlight; publish submits it for App Review; notarize notarizes and staples a Developer ID-signed Mac app. Never ask for a secret in the chat: request_secret gives the human a link, and every Mac receives the account's secrets.",
|
|
@@ -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
|
-
}
|