@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.13.2",
4
- "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
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
- Use a full cloud Mac over SSH, HTTPS or MCP for macOS tools, Xcode and iOS
5
- development. Start sessions, sync files, run commands, retrieve work and
6
- stop.
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 provides a full macOS VM with administrator access, Xcode and the iOS
12
- Simulator. You choose the commands and tools. Mac time is prepaid credit.
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. SSH disconnects
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 simctl,
87
- and give the human `open_mac_desktop` when they want to see the screen.
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, gh, Git LFS, jq.
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 { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
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
- // The Builder commands act at once (a release build uses one of the plan's builds),
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
- function onlyFlags(args, allowed, command) {
60
- const unknown = args.find((a) => !allowed.includes(a));
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
- if (isHelp(rest)) return console.log(LEGACY_HELP.logs);
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(LEGACY_HELP.mcp);
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, saveProjectState } from "./config.mjs";
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: run push_project first (or pass project_id)");
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
- /** Shown to everyone: the Mac tools and support. The rest serve Builder subscribers. */
406
- const PUBLIC_TOOLS = new Set([...MAC_TOOLS.map(t => t.name), "report_issue"]);
407
- let subscriber;
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
- * The Builder (publishing) plans are closed to new subscriptions, so their tools
411
- * are listed only for accounts that still have one. Hidden tools still answer
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
- }