@nomac/cli 0.11.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.11.10",
3
+ "version": "0.12.0",
4
4
  "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -19,6 +19,7 @@ Use it:
19
19
  wait up to 10 minutes (the default is 30 seconds)
20
20
  nomac cancel JOB_ID stop a running command and everything it started
21
21
  nomac ssh open a shell (nomac ssh -- command runs one)
22
+ nomac ssh-config name this Mac "nomac" in your SSH config: VS Code, Cursor, Claude Code
22
23
  nomac sync . upload changed project files
23
24
  nomac pull ./build download a folder from the Mac
24
25
  nomac desktop the Mac's desktop in your browser (--app: Apple's Screen Sharing)
@@ -111,6 +112,7 @@ export async function main(argv) {
111
112
  case "cancel":
112
113
  case "sync":
113
114
  case "ssh":
115
+ case "ssh-config":
114
116
  case "desktop":
115
117
  case "screenshot":
116
118
  case "record":
@@ -9,6 +9,7 @@ import { describePull, describeSync, inSkippedFolder, pullProject, syncProject }
9
9
  import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
10
10
  import { copyToClipboard, desktop, DESKTOP_HELP, screenSharing } from "./desktop.mjs";
11
11
  import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
12
+ import { refreshSshConfig, sshConfigBlock, writeSshConfig } from "./ssh-config.mjs";
12
13
 
13
14
  const WORKSPACE = "/Users/agent/workspace";
14
15
 
@@ -43,6 +44,14 @@ const HELP = `Nomac cloud Mac sessions
43
44
  nomac drive [reset] the account's persistent drive at ~/drive, kept between sessions:
44
45
  space used and saves; reset empties it
45
46
  nomac start --drive none this Mac without the drive
47
+ nomac start --parallel a second Mac beside the account's running one, from another
48
+ folder (accounts that bought credit, while a Mac is spare);
49
+ it runs without ~/drive. Without it, start uses the running Mac.
50
+ nomac ssh-config [--alias nomac] [--print]
51
+ name this folder's Mac in your SSH config (Host nomac), for
52
+ VS Code or Cursor Remote-SSH, Claude Code SSH sessions, ssh nomac,
53
+ and MCP servers on the Mac (ssh nomac COMMAND); start and attach
54
+ keep it pointing at the folder's current Mac
46
55
  nomac start --permit APP=PERMISSIONS
47
56
  grant permissions on this Mac at start
48
57
  nomac exec [--wait 30] -- command args
@@ -87,6 +96,7 @@ const options = {
87
96
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
88
97
  right: { type: "boolean" }, double: { type: "boolean" }, "no-screenshot": { type: "boolean" },
89
98
  permit: { type: "string", multiple: true }, reset: { type: "boolean" }, drive: { type: "string" },
99
+ parallel: { type: "boolean" }, alias: { type: "string" }, print: { type: "boolean" },
90
100
  };
91
101
  /** --permit com.example.App=camera,microphone (or =all), as the permissions option. */
92
102
  export function permitOption(values) {
@@ -308,14 +318,17 @@ export function projectLine({ path, on_drive }) {
308
318
  return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
309
319
  : `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
310
320
  }
321
+ /** "Mac: macOS 26.6.2, Xcode 27.0 (default) and Xcode 26.6 (/Applications/Xcode-26.6.app), iOS 27.0 Simulator", or "". */
322
+ export function macLine(image) {
323
+ if (!image?.macos) return "";
324
+ // An image with a second Xcode names it and its path, as the API's image line does.
325
+ const others = (image.xcodes ?? []).filter(x => !x.default);
326
+ const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
327
+ return `Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`;
328
+ }
311
329
  export function statusSummary(session, image) {
312
330
  const lines = [`${session.id}: ${session.state}`];
313
- if (image?.macos) {
314
- // An image with a second Xcode names it and its path, as the API's image line does.
315
- const others = (image.xcodes ?? []).filter(x => !x.default);
316
- const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
317
- lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
318
- }
331
+ if (macLine(image)) lines.push(macLine(image));
319
332
  if (["ready", "provisioning", "queued"].includes(session.state)) lines.push(`Cost so far: ${usd(session.cost?.accrued_microunits)} ($0.80/hour, billed by the minute while ready). Credit left: nomac credits`);
320
333
  const a = session.activity ?? {};
321
334
  if (session.state === "ready") {
@@ -351,6 +364,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
351
364
  if (values.help) return print(command === "desktop" ? DESKTOP_HELP : HELP);
352
365
  const wait = ["start", "attach", "stop"].includes(command) ? integer(values.wait, command === "stop" ? 60 : 300, 0, command === "stop" ? 300 : 600) : 0;
353
366
  if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
367
+ // The image the last read reported (macOS, Xcodes, Simulators), for start's summary.
368
+ let lastImage;
354
369
  const output = result => {
355
370
  // Sync, pull and the other commands answer with their own result, which has no session.
356
371
  if (values.json || !["start", "attach", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
@@ -364,7 +379,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
364
379
  : ["stopped", "failed"].includes(result.session.state) ? `${result.session.drive ? "The drive saves in the background (nomac drive shows it). " : ""}Start another: nomac start`
365
380
  : result.session.state === "stopping" ? "Stopping. Check: nomac status"
366
381
  : "Check: nomac status";
367
- print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
382
+ // A ready Mac says what is on it, as nomac status does.
383
+ const mac = result.session.state === "ready" && macLine(lastImage) ? `${macLine(lastImage)}\n` : "";
384
+ print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${mac}${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
368
385
  return result;
369
386
  };
370
387
  const saveView = (local, view) => {
@@ -373,7 +390,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
373
390
  Object.assign(local, { id: view.id, state: view.state, snapshot: view }); store.save(local);
374
391
  return view;
375
392
  };
376
- const refresh = async local => saveView(local, (await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
393
+ const refresh = async local => {
394
+ const answer = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
395
+ if (answer.image) lastImage = answer.image;
396
+ return saveView(local, answer.session);
397
+ };
377
398
  async function ready(local) {
378
399
  await refresh(local);
379
400
  if (local.state !== "ready") throw new CliError("Mac is not ready.", `Session ${local.id} is ${local.state}. Run nomac session status --json.`, undefined, "session_not_ready");
@@ -385,6 +406,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
385
406
  if (!local.workspaceDecided) await placeWorkspace(local);
386
407
  Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: local.workspace ?? WORKSPACE, knownHosts: join(store.directory, "known_hosts") });
387
408
  writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
409
+ // An SSH config entry this folder made earlier (nomac ssh-config) follows it to this Mac.
410
+ refreshSshConfig(local, store.project);
388
411
  return local;
389
412
  }
390
413
  /**
@@ -621,6 +644,22 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
621
644
  print(values.json ? JSON.stringify(answer, null, 2) : inputSummary(answer, saved));
622
645
  return answer;
623
646
  }
647
+ if (command === "ssh-config") {
648
+ if (positionals.length || values.json) throw new CliError("Usage: nomac ssh-config [--alias NAME] [--print]");
649
+ const alias = values.alias ?? "nomac";
650
+ const local = await ready(localSession());
651
+ const block = sshConfigBlock(local, alias, store.project);
652
+ if (values.print) return print(block);
653
+ const written = writeSshConfig(block, alias);
654
+ print([`Host ${alias} is this folder's Mac (${local.id}), in ${written.file}${written.addedInclude ? `, now included from ${written.includedFrom}` : ""}.`,
655
+ `Project on the Mac: ${local.workspace}`,
656
+ ` ssh ${alias} a shell; VS Code and Cursor: Remote-SSH, connect to "${alias}"`,
657
+ ` ssh -t ${alias} 'cd ${local.workspace} && npx -y @anthropic-ai/claude-code' an agent on the Mac itself`,
658
+ ` claude mcp add xcode -- ssh ${alias} npx -y mobilebuildmcp@latest mcp a Mac-only MCP server, from here`,
659
+ "nomac start and nomac attach from this folder point it at the folder's next Mac.",
660
+ "An open SSH connection keeps the Mac running until its time or spend limit: nomac stop when done."].join("\n"));
661
+ return { alias, session: local.id, ...written };
662
+ }
624
663
  if (command === "ssh") {
625
664
  if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
626
665
  const local = await ready(localSession());
@@ -715,7 +754,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
715
754
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
716
755
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
717
756
  local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
718
- request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
757
+ request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}), ...(values.parallel ? { parallel: true } : {}) },
719
758
  ...previousDrive(store.load()) };
720
759
  store.save(local); store.saveBaseline({});
721
760
  }
@@ -726,7 +765,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
726
765
  try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
727
766
  catch (error) {
728
767
  // One Mac runs per account: when it was started elsewhere (an agent over MCP, the dashboard), use it from here.
729
- if (error.code === "session_limit_reached" && values["request-key"] === undefined) {
768
+ if (error.code === "session_limit_reached" && values["request-key"] === undefined && !values.parallel) {
730
769
  const [running] = await liveSessions();
731
770
  if (running) {
732
771
  local = await attachTo(running);
package/src/mac-tools.mjs CHANGED
@@ -14,13 +14,13 @@ const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
14
14
  const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
15
15
  const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
16
16
  export const MAC_TOOLS = [
17
- { name: "get_mac_checkout", description: "Get a payment link for Mac credit and open it in the human's browser on this machine. Credit is $0.80 an hour, billed by the minute, sold in $10 packs (12.5 hours each), 1 to 10 packs per purchase, and never expires. Bigger purchases earn bonus credit: +5% from 3 packs ($30), +10% from 5 ($50), +20% at 10 ($100 buys $120, 150 hours). For a first purchase (get_mac_credits shows first_purchase: true), recommend 1 pack: $10 (12.5 hours) is enough to get started. For returning buyers who expect ongoing use, offer a choice, for example $10 or $50 (+10%, about 69 hours), and let them decide. Call this when get_mac_credits shows too little credit or start_mac returns insufficient_credit. Then tell the human a payment page opened (or give them the returned url if opened_in_browser is false) and ask them to pay. Never enter payment details yourself. Poll get_mac_credits until the balance appears, then start the Mac. Reuse request_key after a lost response; a new key is a new purchase.", inputSchema: schema({ packs: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Number of $10 packs, 1-10. Pick from the human's budget or expected hours; 3+ packs earn bonus credit." }, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." }, request_key: key }, ["request_key"]), handler: async ({ packs = 1, open = true, request_key }) => {
17
+ { name: "get_mac_checkout", description: "Get a payment link for Mac credit and open it in the human's browser on this machine. Credit is $0.80 an hour, billed by the minute, sold in $10 packs (12.5 hours each), 1 to 10 packs per purchase, and never expires. Bigger purchases earn bonus credit: +5% from 3 packs ($30), +10% from 5 ($50), +20% at 10 ($100 buys $120, 150 hours). For a first purchase (get_mac_credits shows first_purchase: true), recommend 1 pack: $10 (12.5 hours) is enough to get started. For a refill, suggest 3 packs ($30, +5%) or more, and let them decide. Call this when get_mac_credits shows too little credit or start_mac returns insufficient_credit. Then tell the human a payment page opened (or give them the returned url if opened_in_browser is false) and ask them to pay. Never enter payment details yourself. Poll get_mac_credits until the balance appears, then start the Mac. Reuse request_key after a lost response; a new key is a new purchase.", inputSchema: schema({ packs: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Number of $10 packs, 1-10. Pick from the human's budget or expected hours; 3+ packs earn bonus credit." }, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." }, request_key: key }, ["request_key"]), handler: async ({ packs = 1, open = true, request_key }) => {
18
18
  const result = await api("POST", "/api/v1/mac/credits/checkout", { body: { packs }, idempotencyKey: request_key });
19
19
  const opened = open && result.action === "open_checkout_url" && result.url ? openUrl(result.url) : false;
20
20
  return text({ ...result, opened_in_browser: opened });
21
21
  } },
22
22
  { name: "get_mac_credits", description: "Read the prepaid Mac credit balance: spendable dollars, spendable_seconds (how long a Mac can run on it), amounts held by a running session, pricing (including bonus_tiers for bigger purchases), recent purchases and first_purchase (true until the first paid top-up). Credit never expires. If spendable_seconds is below what the task needs, call get_mac_checkout.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
23
- { name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files. The Mac is fully enabled for you: macOS asks for no administrator password, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it; the answer's warning says so. Every Mac has the account's persistent drive at ~/drive, kept between sessions (get_mac_drive). access opts out: prompts ask keeps macOS's usual prompts; drive none starts without the drive.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false },
23
+ { name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files. The Mac is fully enabled for you: macOS asks for no administrator password, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it; the answer's warning says so. Every Mac has the account's persistent drive at ~/drive, kept between sessions (get_mac_drive). access opts out: prompts ask keeps macOS's usual prompts; drive none starts without the drive.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false }, parallel: { type: "boolean", description: "true starts a second Mac beside the account's running one (accounts that bought credit, while NoMac has a Mac to spare); it runs without the account's drive. Without it, a start while a Mac runs answers session_limit_reached with that Mac's id, to use." },
24
24
  access: { type: "object", properties: { prompts: { type: "string", enum: ["auto", "ask"] }, gatekeeper: { type: "string", enum: ["off", "on"] }, drive: { type: "string", enum: ["account", "none"] },
25
25
  permissions: { type: "object", description: "By bundle ID: a list of permissions (camera, microphone, contacts, calendars, reminders, photos, desktop, documents, downloads and more) or \"all\"", additionalProperties: { anyOf: [{ type: "string", enum: ["all"] }, { type: "array", items: { type: "string" }, minItems: 1 }] } } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
26
26
  { name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
package/src/mcp.mjs CHANGED
@@ -19,7 +19,10 @@ const PROTOCOL_FALLBACK = "2025-06-18";
19
19
  /** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text). */
20
20
  const INSTRUCTIONS = [
21
21
  "NoMac gives you a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
22
- "Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone, 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.",
22
+ "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.",
23
+ "The account's drive at ~/drive (50 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.",
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%).",
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.",
23
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.",
24
27
  "Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
25
28
  "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
@@ -70,7 +73,7 @@ export const TOOLS = [
70
73
  handler: async args => text(await api("GET", `/api/v1/store-capabilities?${new URLSearchParams(Object.entries(args ?? {}).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]))}`)),
71
74
  },
72
75
  ...[
73
- ["read_store", "read", "Read a discovered Apple GET operation. Start with an apps_* relationship and scope:[]; reuse the returned resource scopes. Paginate with next_cursor and unchanged query. Raw IDs are accepted only for global lookups. Reads live Apple state, file upload operations and analytics report URLs."],
76
+ ["read_store", "read", "Read a discovered Apple GET operation. Start with an apps_* relationship and scope:[]; reuse the returned resource scopes. Paginate with next_cursor and unchanged query. Raw IDs are accepted only for global lookups. Reads live Apple state and analytics report URLs; a file's upload ranges come only in the answer to its reservation, so use upload_store_asset."],
74
77
  ["set_subscription_prices", "subscription-prices", "Sets a subscription's price in every territory from one base price point, using Apple's equalized prices (what App Store Connect's all-territories pricing does): territories already at that price are skipped, the others are set one by one, about 40 per call within 35 seconds. Call again (a new request_key, or none) until remaining is 0; a repeat changes nothing. Apple's refusal for a territory is listed in failed (UNSUPPORTED_TERRITORY: set the subscription's availability there first). Without confirm it returns a preview with the counts. Prices come from the human."],
75
78
  ["update_store", "write", "Execute a discovered Apple write using its exact JSON:API body. Copy scope and relationship references from read_store. Preview by default; confirm:true changes real Apple resources. Use owner-authorized prices and attestations. On pending, retry identical arguments with request_key; get_store_operation recovers lost keys. Use publish/update_review/manage_testflight for their dedicated lifecycle actions."],
76
79
  ].map(([name, route, description]) => ({
@@ -260,7 +263,7 @@ export const TOOLS = [
260
263
  {
261
264
  name: "upload_screenshots",
262
265
  description:
263
- "Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, data: base64 PNG}].",
266
+ "Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, link}] with a create_mac_download_link url for each PNG on the Mac (any size; preferred), or [{filename, data: base64 PNG}] for small images (a request is capped near 4.5 MB in all).",
264
267
  inputSchema: {
265
268
  type: "object",
266
269
  properties: {
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CliError } from "./api.mjs";
7
+
8
+ /**
9
+ * nomac ssh-config: a Host entry for this folder's Mac in the person's own SSH config, so any
10
+ * SSH client reaches it by a name: VS Code and Cursor Remote-SSH, Claude Code's SSH sessions,
11
+ * plain ssh, and stdio MCP servers run on the Mac (ssh nomac COMMAND). The entries live in
12
+ * ~/.nomac/ssh_config, which ~/.ssh/config includes; each start or attach from the folder
13
+ * rewrites its entry for the new Mac, so the name keeps working across sessions.
14
+ */
15
+ const BEGIN = "# >>> nomac";
16
+ const END = "# <<< nomac";
17
+
18
+ export const validAlias = alias => typeof alias === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(alias);
19
+ const projectTag = project => createHash("sha256").update(project).digest("hex").slice(0, 16);
20
+ /** SSH config values are double-quoted; a path with a quote or a control character cannot be. */
21
+ const quoted = value => {
22
+ if (/["\x00-\x1f\x7f]/.test(value)) throw new CliError("This path cannot go in an SSH config.", `Path: ${value}`);
23
+ return `"${value}"`;
24
+ };
25
+
26
+ export function sshConfigPaths(home = homedir()) {
27
+ return { managed: join(home, ".nomac", "ssh_config"), user: join(home, ".ssh", "config"), sshDir: join(home, ".ssh") };
28
+ }
29
+
30
+ /** The Host entry for a ready hosted session (target, identity and known_hosts set by nomac's ready step). */
31
+ export function sshConfigBlock(local, alias, project, { platform = process.platform, node = process.execPath, bin = fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)) } = {}) {
32
+ if (!validAlias(alias)) throw new CliError("--alias takes letters, digits, dots, underscores and hyphens, starting with a letter or digit.");
33
+ if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(local.id ?? "") || !local.identity || !local.knownHosts) throw new CliError("This folder's Mac is not ready for SSH yet.", "Run nomac start (or nomac attach), then nomac ssh-config.");
34
+ return [
35
+ `${BEGIN} ${alias} ${projectTag(project)} ${local.id}`,
36
+ `# ${project}`,
37
+ `Host ${alias}`,
38
+ ` HostName nomac-${local.id}`,
39
+ " User agent",
40
+ ` HostKeyAlias nomac-${local.id}`,
41
+ ` UserKnownHostsFile ${quoted(local.knownHosts)}`,
42
+ ...(platform === "win32" ? [] : [" GlobalKnownHostsFile /dev/null"]),
43
+ " StrictHostKeyChecking yes",
44
+ ` IdentityFile ${quoted(local.identity)}`,
45
+ " IdentitiesOnly yes",
46
+ ` ProxyCommand ${quoted(node)} ${quoted(bin)} tunnel ${local.id}`,
47
+ " ForwardAgent no",
48
+ " ServerAliveInterval 15",
49
+ " ServerAliveCountMax 3",
50
+ `${END} ${alias}`,
51
+ ].join("\n");
52
+ }
53
+
54
+ /** The managed file's entries, as [alias, text, project tag]. */
55
+ function entries(text) {
56
+ const found = [];
57
+ const pattern = new RegExp(`^${BEGIN} (\\S+) (\\S+)[^\\n]*\\n[\\s\\S]*?^${END} \\1$`, "gm");
58
+ for (const match of text.matchAll(pattern)) found.push({ alias: match[1], tag: match[2], text: match[0] });
59
+ return found;
60
+ }
61
+
62
+ function writeAtomic(file, text) {
63
+ const temporary = `${file}.${process.pid}.tmp`;
64
+ try { writeFileSync(temporary, text, { mode: 0o600 }); renameSync(temporary, file); }
65
+ finally { rmSync(temporary, { force: true }); }
66
+ chmodSync(file, 0o600);
67
+ }
68
+
69
+ /**
70
+ * Puts the entry in ~/.nomac/ssh_config (replacing one of the same name) and makes sure
71
+ * ~/.ssh/config includes that file. Include must come before any Host line to apply to
72
+ * every host, so it is added as the first line. Returns what changed.
73
+ */
74
+ export function writeSshConfig(block, alias, { home = homedir() } = {}) {
75
+ const paths = sshConfigPaths(home);
76
+ mkdirSync(join(home, ".nomac"), { recursive: true, mode: 0o700 });
77
+ const current = existsSync(paths.managed) ? readFileSync(paths.managed, "utf8") : "";
78
+ const kept = entries(current).filter(entry => entry.alias !== alias).map(entry => entry.text);
79
+ writeAtomic(paths.managed, `# Written by nomac ssh-config. Each start or attach updates its folder's entry.\n\n${[...kept, block].join("\n\n")}\n`);
80
+ mkdirSync(paths.sshDir, { recursive: true, mode: 0o700 });
81
+ const include = `Include ${paths.managed.includes(" ") ? quoted(paths.managed) : paths.managed}`;
82
+ const user = existsSync(paths.user) ? readFileSync(paths.user, "utf8") : "";
83
+ const included = user.split(/\r?\n/).some(line => line.trim() === include || line.trim() === `Include ${paths.managed}` || line.trim() === "Include ~/.nomac/ssh_config");
84
+ if (!included) writeAtomic(paths.user, `${include}\n${user.length && !user.startsWith("\n") ? "\n" : ""}${user}`);
85
+ return { file: paths.managed, includedFrom: paths.user, addedInclude: !included };
86
+ }
87
+
88
+ /**
89
+ * After a start or attach: if this folder has an entry, point it at the folder's new Mac.
90
+ * Never fails the command it follows.
91
+ */
92
+ export function refreshSshConfig(local, project, { home = homedir(), ...options } = {}) {
93
+ try {
94
+ const { managed } = sshConfigPaths(home);
95
+ if (!existsSync(managed)) return false;
96
+ const tag = projectTag(project);
97
+ const mine = entries(readFileSync(managed, "utf8")).filter(entry => entry.tag === tag);
98
+ if (!mine.length || mine.every(entry => entry.text.split("\n", 1)[0].endsWith(` ${local.id}`))) return false;
99
+ for (const entry of mine) writeSshConfig(sshConfigBlock(local, entry.alias, project, options), entry.alias, { home });
100
+ return true;
101
+ } catch { return false; }
102
+ }
@@ -1,6 +1,6 @@
1
1
  // Shared by the hosted upload endpoint and the published CLI (copied by the
2
- // schema generator). Provider upload URLs come only from an authenticated,
3
- // app-scoped read_store response; Nomac credentials never go to those URLs.
2
+ // schema generator). Provider upload URLs come only from the authenticated,
3
+ // app-scoped answer to the reservation; Nomac credentials never go to those URLs.
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { open } from 'node:fs/promises';
6
6
  import { basename } from 'node:path';
@@ -82,8 +82,9 @@ export async function uploadStoreAsset(args, call) {
82
82
  stage = 'upload';
83
83
  const completed = ['UPLOAD_COMPLETE', 'COMPLETE', 'FAILED'].includes(attributes.assetDeliveryState?.state);
84
84
  if (!completed) {
85
- const operations = attributes.uploadOperations;
86
- if (!Array.isArray(operations) || !operations.length) throw new Error('Apple has not exposed upload operations yet. Retry this same upload.');
85
+ // Apple lists the upload ranges in its answer to the reservation only (a later read has none).
86
+ const operations = reservation.upload_operations?.length ? reservation.upload_operations : attributes.uploadOperations;
87
+ if (!Array.isArray(operations) || !operations.length) throw new Error(`Apple gives a file's upload links only in its answer to the reservation, and this request has none (that answer was lost). Delete the reservation (update_store with ${args.operation.replace(/_createInstance$/, '_deleteInstance')} and this scope), then upload again with a new request_key.`);
87
88
  let end = 0;
88
89
  for (const op of [...operations].sort((a, b) => a.offset - b.offset)) {
89
90
  if (op.method !== 'PUT' || !Number.isSafeInteger(op.offset) || !Number.isSafeInteger(op.length) || op.length < 1 || op.offset !== end || op.offset + op.length > size) throw new Error('Apple returned invalid or incomplete upload byte ranges.');
@@ -105,7 +106,7 @@ export async function uploadStoreAsset(args, call) {
105
106
  const part = await source(op.offset, op.length);
106
107
  try {
107
108
  const response = await fetch(op.url, { method: 'PUT', headers, body: part, duplex: 'half', redirect: 'error', signal: AbortSignal.timeout(120_000) });
108
- if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key to refresh its upload URLs.`);
109
+ if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key; if it fails again, its upload links have expired: delete the reservation (update_store with ${args.operation.replace(/_createInstance$/, '_deleteInstance')} and this scope) and upload again with a new request_key.`);
109
110
  await response.body?.cancel();
110
111
  } finally { if (typeof part.destroy === 'function') part.destroy(); }
111
112
  }