@nomac/cli 0.10.0 → 0.11.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.10.0",
3
+ "version": "0.11.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
@@ -26,6 +26,7 @@ Use it:
26
26
  nomac password the Mac's login password (also $NOMAC_MAC_PASSWORD on it)
27
27
  nomac permit APP camera grant an app privacy permissions, so it never asks
28
28
  nomac defaults options every new Mac starts with (nomac defaults --help)
29
+ nomac drive the account's persistent drive at ~/drive, kept between sessions
29
30
  nomac record record a video; nomac record stop saves it with a share link
30
31
  nomac status state, cost so far and when it stops
31
32
  nomac secrets variables and files every Mac receives (nomac secrets --help)
@@ -86,6 +87,7 @@ export async function main(argv) {
86
87
  case "input":
87
88
  case "unlock":
88
89
  case "permit":
90
+ case "drive":
89
91
  case "defaults": {
90
92
  const { sessionStore } = await import("./session-store.mjs");
91
93
  const { hostedSessionCommand } = await import("./hosted-sessions.mjs");
@@ -30,8 +30,11 @@ const HELP = `Nomac cloud Mac sessions
30
30
  nomac unlock unlock the Mac's lock screen
31
31
  nomac permit APP PERMISSION... grant camera, microphone, contacts, photos... (or all)
32
32
  to an app by bundle ID, so it never asks
33
- nomac defaults [--prompts ask] [--permit APP=camera,microphone] [--reset]
33
+ nomac defaults [--prompts ask] [--permit APP=camera,microphone] [--drive none] [--reset]
34
34
  what every new Mac starts with (shows them with no options)
35
+ nomac drive [reset] the account's persistent drive at ~/drive, kept between sessions:
36
+ space used and saves; reset empties it
37
+ nomac start --drive none this Mac without the drive
35
38
  nomac start --permit APP=PERMISSIONS
36
39
  grant permissions on this Mac at start
37
40
  nomac exec [--wait 30] -- command args
@@ -67,7 +70,7 @@ const options = {
67
70
  "no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
68
71
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
69
72
  right: { type: "boolean" }, double: { type: "boolean" }, "no-screenshot": { type: "boolean" },
70
- permit: { type: "string", multiple: true }, reset: { type: "boolean" },
73
+ permit: { type: "string", multiple: true }, reset: { type: "boolean" }, drive: { type: "string" },
71
74
  };
72
75
  /** --permit com.example.App=camera,microphone (or =all), as the permissions option. */
73
76
  export function permitOption(values) {
@@ -87,10 +90,11 @@ const oneOf = (value, allowed, flag) => {
87
90
  /** The access the person chose at start, or none: the server's defaults then apply (fully open). */
88
91
  function accessOption(values) {
89
92
  const permissions = permitOption(values.permit);
90
- if (values.prompts === undefined && values.gatekeeper === undefined && !permissions) return undefined;
93
+ if (values.prompts === undefined && values.gatekeeper === undefined && values.drive === undefined && !permissions) return undefined;
91
94
  return {
92
95
  ...(values.prompts !== undefined ? { prompts: oneOf(values.prompts, ["auto", "ask"], "--prompts") } : {}),
93
96
  ...(values.gatekeeper !== undefined ? { gatekeeper: oneOf(values.gatekeeper, ["off", "on"], "--gatekeeper") } : {}),
97
+ ...(values.drive !== undefined ? { drive: oneOf(values.drive, ["account", "none"], "--drive") } : {}),
94
98
  ...(permissions ? { permissions } : {}),
95
99
  };
96
100
  }
@@ -243,13 +247,24 @@ const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
243
247
  /** One readable paragraph for nomac status. */
244
248
  export function statusSummary(session, image) {
245
249
  const lines = [`${session.id}: ${session.state}`];
246
- if (image?.macos) lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, Xcode ${image.xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
250
+ if (image?.macos) {
251
+ // An image with a second Xcode names it and its path, as the API's image line does.
252
+ const others = (image.xcodes ?? []).filter(x => !x.default);
253
+ const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
254
+ lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
255
+ }
247
256
  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`);
248
257
  const a = session.activity ?? {};
249
258
  if (session.state === "ready") {
250
259
  const stops = [a.idle_deadline_at ? `when idle, at ${clockUtc(a.idle_deadline_at)}` : null, a.hard_deadline_at ? `at ${clockUtc(a.hard_deadline_at)} at the latest` : null].filter(Boolean);
251
260
  if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
252
261
  }
262
+ const d = session.drive;
263
+ if (d && ["mounted", "saving", "saved", "unavailable", "not_saved"].includes(d.state)) {
264
+ lines.push(d.state === "mounted" ? `Drive: ${d.path}, kept between sessions${d.source === "r2" ? " (downloaded to this Mac before boot)" : ""}.`
265
+ : d.state === "saving" ? "Drive: saving this session's changes." : d.state === "saved" ? `Drive: saved (version ${d.saved_version}).`
266
+ : d.state === "not_saved" ? `Drive: this session's changes were not saved. ${d.message ?? ""}`.trim() : `Drive: not attached. ${d.message ?? ""}`.trim());
267
+ }
253
268
  if (session.state === "ready" && session.access) {
254
269
  lines.push(`Open to your agent: ${session.access.prompts === "auto" ? "no administrator password prompts" : "macOS's usual prompts"}. Login password: nomac password ($NOMAC_MAC_PASSWORD on the Mac).`);
255
270
  }
@@ -384,8 +399,16 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
384
399
  if (values.app) return screenSharing(local, { request, print });
385
400
  return desktop(local, { values, print });
386
401
  }
402
+ if (command === "drive") {
403
+ if (positionals.length > 1 || (positionals.length === 1 && positionals[0] !== "reset")) throw new CliError("Usage: nomac drive [reset] [--json]");
404
+ const answer = positionals[0] === "reset" ? await request("DELETE", "/api/v1/mac/drive") : await request("GET", "/api/v1/mac/drive");
405
+ if (values.json) { print(JSON.stringify(answer, null, 2)); return answer; }
406
+ const d = answer.drive;
407
+ print(d ? [`Drive: ${d.path}, ${(d.used_bytes / 1e9).toFixed(1)} GB of ${Math.round(d.size_bytes / 2 ** 30)} GB used, version ${d.version}.`, answer.message, "Empty it: nomac drive reset"].join("\n") : answer.message);
408
+ return answer;
409
+ }
387
410
  if (command === "defaults") {
388
- if (positionals.length) throw new CliError("Usage: nomac defaults [--prompts auto|ask] [--permit APP=PERMISSIONS]... [--reset] [--json]");
411
+ if (positionals.length) throw new CliError("Usage: nomac defaults [--prompts auto|ask] [--permit APP=PERMISSIONS]... [--drive account|none] [--reset] [--json]");
389
412
  let answer;
390
413
  if (values.reset) answer = await request("DELETE", "/api/v1/mac/defaults");
391
414
  else {
@@ -398,7 +421,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
398
421
  const e = answer.effective, apps = Object.entries(e.permissions ?? {});
399
422
  print([`Every new Mac starts with: ${e.prompts === "auto" ? "no administrator password prompts, privacy prompts and password dialogs answered on the Mac" : "macOS's usual prompts"}.`,
400
423
  apps.length ? `Permissions granted at start: ${apps.map(([app, list]) => `${app} (${list === "all" ? "all" : list.join(", ")})`).join("; ")}.` : "No app is granted permissions at start (nomac defaults --permit APP=camera,microphone).",
401
- "A start's own --prompts or --permit wins for that Mac. nomac defaults --reset goes back to fully open."].join("\n"));
424
+ e.drive === "none" ? "Macs start without the account's drive (nomac defaults --drive account turns it back on)." : "Every Mac has the account's drive at ~/drive (nomac drive).",
425
+ "A start's own --prompts, --permit or --drive wins for that Mac. nomac defaults --reset goes back to fully open."].join("\n"));
402
426
  return answer;
403
427
  }
404
428
  if (command === "permit") {
package/src/mac-tools.mjs CHANGED
@@ -20,18 +20,20 @@ export const MAC_TOOLS = [
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. access opts out: prompts ask keeps macOS's usual prompts.", 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 },
24
- access: { type: "object", properties: { prompts: { type: "string", enum: ["auto", "ask"] }, gatekeeper: { type: "string", enum: ["off", "on"] },
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 },
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")) },
27
27
  { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation, and once ready the credentials installed on it (secrets, signing identities and profiles, by name and path, never values) and account: the Mac's user, whether its login password is set (in $NOMAC_MAC_PASSWORD on the Mac), the access choices, and how to type and click (mac_input). queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
28
+ { name: "get_mac_drive", description: "Read the account's persistent drive: a disk kept between sessions and mounted at ~/drive on every Mac (50 GB). What is there stays for the next session: keep projects in ~/drive/src for incremental builds; package and build caches (DerivedData, Swift packages, CocoaPods, npm) are kept there already. NoMac saves it when a Mac stops, with nothing to call. Shows the space used, the newest saved version, a save still in progress, recent versions, and the date it is deleted after 90 days without a Mac.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/drive")) },
29
+ { name: "reset_mac_drive", description: "Empty the account's persistent drive: the next Mac starts with a new, empty ~/drive, and everything on the old drive is deleted, with no way back. Stop any running session first (stop_mac). For a single Mac without the drive, start_mac with access drive none instead.", inputSchema: schema({}), handler: async () => text(await api("DELETE", "/api/v1/mac/drive")) },
28
30
  { name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
29
31
  { name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
30
32
  { name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
31
33
  { name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
32
34
  { name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
33
35
  { name: "install_secrets", description: "Install the account's secrets and signing identities on a running Mac now: every-Mac secrets, the on-request secrets named in names, and what the Mac already has; a deleted secret is removed. Macs receive every-Mac secrets on their own when they become ready, so call this after the person adds or changes one.", inputSchema: schema({ session_id: session, names: { type: "array", items: { type: "string", minLength: 1, maxLength: 128 }, maxItems: 100 } }, ["session_id"]), handler: async ({ session_id, names = [] }) => text(await api("POST", `${base({ session_id })}/secrets`, { body: { names } })) },
34
- { name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open \"$(xcode-select -p)/Applications/Simulator.app\" with exec_mac. macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
36
+ { name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open -a Simulator || open -a DeviceHub in a shell with exec_mac (Xcode 27 replaced Simulator.app with Device Hub; Xcode 26's Simulator.app, kept beside it, shows iOS 27 devices too). macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
35
37
  const view = await api("GET", base({ session_id }));
36
38
  if (view?.session?.state !== "ready") return text({ error: { code: "session_not_ready", message: "The Mac is not ready. Wait until get_mac_session shows ready, then call open_mac_desktop again." } });
37
39
  const url = desktopUrl(session_id), opened = open ? openUrl(url) : false;