@nomac/cli 0.13.2 → 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 +2 -3
- package/skill/SKILL.md +17 -16
- package/src/cli.mjs +6 -163
- package/src/mcp.mjs +16 -122
- package/src/pack.mjs +0 -77
- package/src/upload.mjs +0 -36
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.13.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.13.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",
|
|
7
7
|
"bin": {
|
|
@@ -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
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nomac
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
A real cloud Mac for agents on Linux, Windows or a sandbox: build, run and
|
|
5
|
+
test iOS and macOS apps with Xcode and the iOS Simulator (simulator-use),
|
|
6
|
+
sign, send to TestFlight and publish. Fully enabled for the agent, $0.80 an
|
|
7
|
+
hour by the minute, over SSH, HTTPS or MCP.
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# nomac: a Mac for your agent
|
|
10
11
|
|
|
11
|
-
NoMac
|
|
12
|
-
|
|
12
|
+
NoMac gives your agent a full macOS VM that is entirely its own: administrator,
|
|
13
|
+
no prompts, nothing blocked, with Xcode and the iOS Simulator. You choose the
|
|
14
|
+
commands and tools. Mac time is prepaid credit.
|
|
13
15
|
|
|
14
16
|
## Cloud Mac setup and workflow
|
|
15
17
|
|
|
@@ -49,7 +51,9 @@ Simulator. You choose the commands and tools. Mac time is prepaid credit.
|
|
|
49
51
|
The CLI keeps the project on the account's drive, in `~/drive/src/<folder
|
|
50
52
|
name>`, which is kept between sessions; stopping deletes everything else on
|
|
51
53
|
the Mac, logs included. Download what you need from outside `~/drive` before
|
|
52
|
-
stopping.
|
|
54
|
+
stopping. Keep the drive under 10 GB: a Mac that does not hold it yet downloads
|
|
55
|
+
all of it before it boots; `nomac-drive` on the Mac shows what takes the space,
|
|
56
|
+
and `nomac-drive clean` deletes stale build products. SSH disconnects
|
|
53
57
|
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
54
58
|
A Mac stops at its time limit (2 hours unless `nomac start --max-duration`
|
|
55
59
|
set another), its spend cap, when credit runs out, or when idle; a build left
|
|
@@ -83,10 +87,14 @@ MP4 and prints a share link); MCP `start_mac_recording`, `stop_mac_recording`.
|
|
|
83
87
|
Commands drive any app with AppleScript without a permission prompt. For real
|
|
84
88
|
keyboard and mouse, also on the lock screen and in password dialogs, use
|
|
85
89
|
`nomac input type TEXT`, `nomac input click X Y`, `nomac unlock` (MCP
|
|
86
|
-
`mac_input`): each answers with a screenshot. Drive the Simulator with
|
|
87
|
-
|
|
90
|
+
`mac_input`): each answers with a screenshot. Drive the Simulator with
|
|
91
|
+
simulator-use, preinstalled: `simulator-use look` reads the screen as text,
|
|
92
|
+
`simulator-use act 'tap "Sign In"'` taps and types by label, `simulator-use run`
|
|
93
|
+
builds, installs and launches (simctl works too). Give the human
|
|
94
|
+
`open_mac_desktop` when they want to see the screen.
|
|
88
95
|
`get_mac_session` (and `nomac status --json`) lists what
|
|
89
|
-
is installed: Ruby with CocoaPods, xcbeautify, xcodegen,
|
|
96
|
+
is installed: Node, Python, uv, nvm, Ruby with CocoaPods, xcbeautify, xcodegen,
|
|
97
|
+
gh, Git LFS, jq and simulator-use.
|
|
90
98
|
Pipe xcodebuild through `xcbeautify`. Simulator devices share names across iOS
|
|
91
99
|
runtimes: pick one by ID (`xcrun simctl list devices available`) or with
|
|
92
100
|
`OS=27.0` in `-destination`. Apple Intelligence cannot generate on a virtual
|
|
@@ -152,13 +160,6 @@ A step only a person can take (creating the app in App Store Connect,
|
|
|
152
160
|
agreements, an App Group) comes back as needs_human with its link: give it to
|
|
153
161
|
the human, then call again.
|
|
154
162
|
|
|
155
|
-
## Builder (existing subscribers only)
|
|
156
|
-
|
|
157
|
-
The separate iOS Builder publishing product is closed to new subscriptions.
|
|
158
|
-
If `get_billing_status` shows a Builder plan, its tools are listed and
|
|
159
|
-
https://nomac.app/llms-legacy.txt describes them. Otherwise use the cloud Mac
|
|
160
|
-
above.
|
|
161
|
-
|
|
162
163
|
## When stuck
|
|
163
164
|
|
|
164
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
|
|
|
@@ -21,7 +19,8 @@ const INSTRUCTIONS = [
|
|
|
21
19
|
"NoMac gives you a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
|
|
22
20
|
"Use it when work needs macOS that this machine does not have (Windows, Linux or a cloud sandbox): building, running or testing an iOS, iPadOS, watchOS, visionOS or macOS app (Swift, SwiftUI, React Native, Expo, Flutter, Capacitor), the iOS Simulator, Xcode, code signing, TestFlight or App Store submission.",
|
|
23
21
|
"Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone into ~/drive/src, write_mac_file, fetch_to_mac, create_mac_upload_link), run exec_mac, get results with read_mac_file or create_mac_download_link, then stop_mac.",
|
|
24
|
-
"The account's drive at ~/drive (20 GB) is on every Mac and kept between sessions; everything outside it is deleted at stop. Clone and build in ~/drive/src/<repo>, so the next session rebuilds only what changed.",
|
|
22
|
+
"The account's drive at ~/drive (20 GB) is on every Mac and kept between sessions; everything outside it is deleted at stop. Clone and build in ~/drive/src/<repo>, so the next session rebuilds only what changed. Keep it under 10 GB: a Mac that does not hold it yet downloads all of it before it boots; nomac-drive on the Mac shows what takes the space, and nomac-drive clean deletes stale build products.",
|
|
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.",
|
|
25
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%).",
|
|
26
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.",
|
|
27
26
|
"exec_mac returns a quick command's output in the same call; pass wait_seconds: 50 for builds and tests. A command still running comes back as a job: poll get_mac_job with its job_id, then read_mac_output.",
|
|
@@ -43,26 +42,11 @@ async function resolveProjectId(args) {
|
|
|
43
42
|
if (args?.project_id) return args.project_id;
|
|
44
43
|
const state = loadProjectState(args?.directory ?? process.cwd());
|
|
45
44
|
if (state.project_id) return state.project_id;
|
|
46
|
-
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)");
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
export const TOOLS = [
|
|
50
49
|
...MAC_TOOLS,
|
|
51
|
-
{
|
|
52
|
-
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.",
|
|
53
|
-
inputSchema: { type: "object", properties: { project_id: { type: "string" } }, additionalProperties: false },
|
|
54
|
-
handler: async args => text(await api("POST", "/api/v1/check-project", { body: { project_id: await resolveProjectId(args) } })),
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
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.",
|
|
58
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
59
|
-
handler: async () => text(await api("GET", "/api/v1/billing/status")),
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
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.",
|
|
63
|
-
inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
|
|
64
|
-
handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
|
|
65
|
-
},
|
|
66
50
|
{
|
|
67
51
|
name: "upload_store_asset",
|
|
68
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.",
|
|
@@ -116,97 +100,6 @@ export const TOOLS = [
|
|
|
116
100
|
},
|
|
117
101
|
handler: async args => text(await api("PATCH", `/api/v1/projects/${await resolveProjectId(args)}`, { body: { connection_id: args.connection_id } })),
|
|
118
102
|
},
|
|
119
|
-
{
|
|
120
|
-
name: "push_project",
|
|
121
|
-
description:
|
|
122
|
-
"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.",
|
|
123
|
-
inputSchema: {
|
|
124
|
-
type: "object",
|
|
125
|
-
properties: {
|
|
126
|
-
directory: { type: "string", description: "project directory (default: cwd)" },
|
|
127
|
-
},
|
|
128
|
-
additionalProperties: false,
|
|
129
|
-
},
|
|
130
|
-
handler: async (args) => {
|
|
131
|
-
const dir = args?.directory ?? process.cwd();
|
|
132
|
-
let state = loadProjectState(dir);
|
|
133
|
-
if (!state.project_id) {
|
|
134
|
-
const name = dir.split("/").filter(Boolean).pop() ?? "app";
|
|
135
|
-
const created = await api("POST", "/api/v1/projects", { body: { name } });
|
|
136
|
-
state = { project_id: created.id, name };
|
|
137
|
-
saveProjectState(state, dir);
|
|
138
|
-
}
|
|
139
|
-
const { tarball, files } = await packTarball(dir);
|
|
140
|
-
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
141
|
-
saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
|
|
142
|
-
return text({ project_id: state.project_id, files_packed: files.length, ...res });
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
name: "build",
|
|
147
|
-
description:
|
|
148
|
-
"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.",
|
|
149
|
-
inputSchema: {
|
|
150
|
-
type: "object",
|
|
151
|
-
properties: {
|
|
152
|
-
project_id: { type: "string" },
|
|
153
|
-
workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
|
|
154
|
-
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." },
|
|
155
|
-
},
|
|
156
|
-
additionalProperties: false,
|
|
157
|
-
},
|
|
158
|
-
handler: async (args) => {
|
|
159
|
-
const project_id = await resolveProjectId(args);
|
|
160
|
-
const res = await api("POST", "/api/v1/builds", {
|
|
161
|
-
body: { project_id, workflow: args?.workflow ?? "release" },
|
|
162
|
-
idempotencyKey: args?.request_key ?? `mcp-${randomUUID()}`,
|
|
163
|
-
});
|
|
164
|
-
let warning;
|
|
165
|
-
try {
|
|
166
|
-
const state = loadProjectState();
|
|
167
|
-
if (state?.project_id === project_id) {
|
|
168
|
-
saveProjectState({ ...state, last_build: res.id });
|
|
169
|
-
} else {
|
|
170
|
-
warning = "Build started for a project outside this directory's saved project. Pass build_id to status.";
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
173
|
-
// The paid attempt already exists. Return its ID even when the local
|
|
174
|
-
// directory is read-only, so the agent can poll without starting again.
|
|
175
|
-
warning = "Build started, but its ID could not be saved locally. Pass build_id to status.";
|
|
176
|
-
}
|
|
177
|
-
return text({
|
|
178
|
-
...res,
|
|
179
|
-
...(warning ? { warning } : {}),
|
|
180
|
-
note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
|
|
181
|
-
});
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
name: "status",
|
|
186
|
-
description: "Build status (state machine position). States: queued→mirrored→dispatched→building→uploading→processing→ready|failed.",
|
|
187
|
-
inputSchema: {
|
|
188
|
-
type: "object",
|
|
189
|
-
properties: { build_id: { type: "string", description: "defaults to the last build started here" } },
|
|
190
|
-
additionalProperties: false,
|
|
191
|
-
},
|
|
192
|
-
handler: async (args) => {
|
|
193
|
-
const id = args?.build_id ?? loadProjectState().last_build;
|
|
194
|
-
if (!id) return errText("no build_id given and no build started from this directory");
|
|
195
|
-
return text(await api("GET", `/api/v1/builds/${id}`));
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
{
|
|
199
|
-
name: "get_failure",
|
|
200
|
-
description:
|
|
201
|
-
"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.",
|
|
202
|
-
inputSchema: {
|
|
203
|
-
type: "object",
|
|
204
|
-
properties: { build_id: { type: "string" } },
|
|
205
|
-
required: ["build_id"],
|
|
206
|
-
additionalProperties: false,
|
|
207
|
-
},
|
|
208
|
-
handler: async (args) => text(await api("GET", `/api/v1/builds/${args.build_id}/failure`)),
|
|
209
|
-
},
|
|
210
103
|
{
|
|
211
104
|
name: "review_lint",
|
|
212
105
|
description:
|
|
@@ -402,18 +295,15 @@ export const TOOLS = [
|
|
|
402
295
|
},
|
|
403
296
|
];
|
|
404
297
|
|
|
405
|
-
/**
|
|
406
|
-
const
|
|
407
|
-
|
|
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.`]));
|
|
408
303
|
|
|
409
|
-
/**
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
* when called, so existing subscribers' agents keep working.
|
|
413
|
-
*/
|
|
414
|
-
export async function listedTools(isSubscriber = () => (subscriber ??= api("GET", "/api/v1/billing/status").then(s => Boolean(s?.tier) && s.tier !== "none", () => false))) {
|
|
415
|
-
const legacy = await isSubscriber();
|
|
416
|
-
return TOOLS.filter(t => legacy || PUBLIC_TOOLS.has(t.name));
|
|
304
|
+
/** Every tool is listed for every account. */
|
|
305
|
+
export async function listedTools() {
|
|
306
|
+
return TOOLS;
|
|
417
307
|
}
|
|
418
308
|
|
|
419
309
|
export async function runMcpServer() {
|
|
@@ -456,6 +346,10 @@ export async function runMcpServer() {
|
|
|
456
346
|
break;
|
|
457
347
|
case "tools/call": {
|
|
458
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
|
+
}
|
|
459
353
|
if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
|
|
460
354
|
const problem = argumentProblem(tool.inputSchema, params?.arguments ?? {});
|
|
461
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
|
-
}
|