@nomac/cli 0.10.1 → 0.11.1

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.1",
3
+ "version": "0.11.1",
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
@@ -10,7 +10,7 @@ Start:
10
10
  nomac login connect this device (approve in the browser)
11
11
  nomac credits credit left; nomac credits buy --packs N adds more
12
12
  nomac start start a Mac, or reconnect to yours
13
- nomac stop stop the Mac: deletes it and its files, billing ends
13
+ nomac stop stop the Mac: deletes it and its files (not ~/drive), billing ends
14
14
 
15
15
  Use it:
16
16
  nomac exec -- ls run a command; prints its output, keeps its exit code
@@ -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)
@@ -37,7 +38,7 @@ Use it:
37
38
  Agents:
38
39
  nomac mcp MCP server for Claude Code, Codex, Cursor and others
39
40
 
40
- More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
41
+ More: nomac start --help, nomac whoami, nomac --version, nomac start --host user@mac (a Mac you own)
41
42
  Environment: NOMAC_API_KEY, NOMAC_API_URL`;
42
43
 
43
44
  // The Builder commands act at once (a release build uses one of the plan's builds),
@@ -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");
@@ -186,6 +188,13 @@ export async function main(argv) {
186
188
  console.log(`auth: ${me.auth?.via ?? "?"}${me.auth?.key_id ? ` (${me.auth.key_id})` : ""}`);
187
189
  return;
188
190
  }
191
+ case "--version":
192
+ case "-v":
193
+ case "version": {
194
+ const { CLI_VERSION } = await import("./client-info.mjs");
195
+ console.log(CLI_VERSION);
196
+ return;
197
+ }
189
198
  case undefined:
190
199
  case "help":
191
200
  case "--help":
@@ -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
@@ -49,7 +52,7 @@ const HELP = `Nomac cloud Mac sessions
49
52
  start a screen video; nomac record stop saves it
50
53
  as nomac-recording.mp4 (or -o) with a share link
51
54
  nomac status [--json] state, cost so far and when it stops
52
- nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files
55
+ nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files (not ~/drive)
53
56
 
54
57
  Login once with nomac login. The CLI creates a local SSH key; it sends only the
55
58
  public key. Sessions use the port-443 gateway and verified guest host keys.
@@ -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
  }
@@ -255,6 +259,12 @@ export function statusSummary(session, image) {
255
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);
256
260
  if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
257
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
+ }
258
268
  if (session.state === "ready" && session.access) {
259
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).`);
260
270
  }
@@ -389,8 +399,16 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
389
399
  if (values.app) return screenSharing(local, { request, print });
390
400
  return desktop(local, { values, print });
391
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
+ }
392
410
  if (command === "defaults") {
393
- 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]");
394
412
  let answer;
395
413
  if (values.reset) answer = await request("DELETE", "/api/v1/mac/defaults");
396
414
  else {
@@ -403,7 +421,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
403
421
  const e = answer.effective, apps = Object.entries(e.permissions ?? {});
404
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"}.`,
405
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).",
406
- "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"));
407
426
  return answer;
408
427
  }
409
428
  if (command === "permit") {
package/src/mac-tools.mjs CHANGED
@@ -20,11 +20,13 @@ 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 })) },