@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 +1 -1
- package/src/cli.mjs +2 -0
- package/src/hosted-sessions.mjs +49 -10
- package/src/mac-tools.mjs +2 -2
- package/src/mcp.mjs +6 -3
- package/src/ssh-config.mjs +102 -0
- package/src/store-upload.mjs +6 -5
package/package.json
CHANGED
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":
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/store-upload.mjs
CHANGED
|
@@ -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
|
|
3
|
-
// app-scoped
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
}
|