@nomac/cli 0.11.4 → 0.11.6

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.4",
3
+ "version": "0.11.6",
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",
@@ -19,11 +19,11 @@
19
19
  "test": "vitest run"
20
20
  },
21
21
  "dependencies": {
22
+ "https-proxy-agent": "^9.1.0",
22
23
  "ignore": "^7.0.0",
24
+ "proxy-from-env": "^2.1.0",
23
25
  "tar": "^7.4.0",
24
- "ws": "^8.21.3",
25
- "https-proxy-agent": "^9.1.0",
26
- "proxy-from-env": "^2.1.0"
26
+ "ws": "^8.21.3"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.1.1",
package/src/cli.mjs CHANGED
@@ -106,6 +106,7 @@ export async function main(argv) {
106
106
  case "exec":
107
107
  case "job":
108
108
  case "output":
109
+ case "cancel":
109
110
  case "sync":
110
111
  case "ssh":
111
112
  case "desktop":
@@ -1,15 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { api, CliError } from "./api.mjs";
6
6
  import { sessionStore } from "./session-store.mjs";
7
- import { forwardingArg, isShellLine, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
7
+ import { forwardingArg, isShellLine, runProcess, shellQuote, sshArgs, validateWorkspace, workspaceCommand } from "./session-ssh.mjs";
8
8
  import { describePull, describeSync, pullProject, syncProject } from "./session-transfer.mjs";
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
12
 
13
+ const WORKSPACE = "/Users/agent/workspace";
14
+
13
15
  const HELP = `Nomac cloud Mac sessions
14
16
  nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
15
17
  [--prompts ask]
@@ -17,7 +19,9 @@ const HELP = `Nomac cloud Mac sessions
17
19
  password prompts, its login password in
18
20
  $NOMAC_MAC_PASSWORD. --prompts ask keeps macOS's
19
21
  usual prompts.
20
- nomac sync . [--dry-run] upload changed files
22
+ nomac sync . [--dry-run] upload changed files to the project folder on the Mac:
23
+ ~/drive/src/<this folder's name>, kept between sessions
24
+ (~/workspace with --drive none)
21
25
  nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
22
26
  nomac ssh -- command args run any command over SSH
23
27
  nomac desktop [--no-open] [--json] the full macOS desktop in your browser
@@ -44,6 +48,7 @@ const HELP = `Nomac cloud Mac sessions
44
48
  (--request-key KEY makes a scripted retry safe)
45
49
  nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
46
50
  nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
51
+ nomac cancel JOB_ID stop a running command and everything it started
47
52
  nomac pull ./output [--from PATH] retrieve files before stopping
48
53
  nomac screenshot [--simulator] [-o FILE]
49
54
  save the screen (or the Simulator) as a JPEG,
@@ -54,13 +59,14 @@ const HELP = `Nomac cloud Mac sessions
54
59
  nomac status [--json] state, cost so far and when it stops
55
60
  nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
56
61
  agent started it elsewhere (MCP, the dashboard); start does it too
57
- nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files (not ~/drive)
62
+ nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
63
+ (your synced project stays there)
58
64
 
59
65
  Login once with nomac login. The CLI creates a local SSH key; it sends only the
60
66
  public key. Sessions use the port-443 gateway and verified guest host keys.
61
67
  Credit is used at $0.80 an hour, billed by the minute, while the VM is ready,
62
68
  including between commands. Sessions run 2 hours by default (--max-duration,
63
- up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and its files; pull first.
69
+ up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and every file outside ~/drive.
64
70
  A wait timeout leaves the session attached: inspect status or run stop.
65
71
  Use nomac start --host user@mac to connect an existing Mac instead.`;
66
72
  const options = {
@@ -198,10 +204,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
198
204
  const unknown = job.state === "unknown";
199
205
  const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
200
206
  const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
201
- : `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac. Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over an hour: nomac ssh -- command. Do not rerun it.`;
207
+ : `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac. Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over an hour: nomac ssh -- command. Do not rerun it; to stop it: nomac cancel ${job.id}.`;
202
208
  if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
203
209
  else if (unknown) stderr(`nomac: ${next}\n`);
204
- else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.\n wait for it: nomac job ${job.id} --wait 600\n next time, wait longer: nomac exec --wait 600 -- command\n over an hour: nomac ssh -- command\n`);
210
+ else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.\n wait for it: nomac job ${job.id} --wait 600\n stop it: nomac cancel ${job.id}\n next time, wait longer: nomac exec --wait 600 -- command\n over an hour: nomac ssh -- command\n`);
205
211
  exitWith(unknown ? 1 : STILL_RUNNING);
206
212
  return job;
207
213
  }
@@ -247,6 +253,12 @@ const uncertainOutcome = error => !Number.isInteger(error?.status) || error.stat
247
253
  const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
248
254
  const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
249
255
  /** One readable paragraph for nomac status. */
256
+ /** "Project: ~/drive/src/app, on your drive: the next session continues from it." */
257
+ export function projectLine({ path, on_drive }) {
258
+ const short = String(path).replace(/^\/Users\/agent(?=\/)/, "~");
259
+ return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
260
+ : `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
261
+ }
250
262
  export function statusSummary(session, image) {
251
263
  const lines = [`${session.id}: ${session.state}`];
252
264
  if (image?.macos) {
@@ -297,9 +309,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
297
309
  const usage = billing?.mode === "weekly_included"
298
310
  ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
299
311
  : `Cost so far: $${(Number(result.session?.cost?.accrued_microunits ?? 0) / 1_000_000).toFixed(2)} ($0.80/hour, billed by the minute while the Mac is ready). Credit left: nomac credits`;
300
- const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
312
+ const next = result.session.state === "ready" ? `${result.project ? `${projectLine(result.project)}\n` : ""}Next: nomac sync . → nomac ssh`
301
313
  : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
302
- : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
314
+ : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start"
315
+ : ["stopped", "failed"].includes(result.session.state) ? `${result.session.drive ? "The drive saves in the background (nomac drive shows it). " : ""}Start another: nomac start`
316
+ : result.session.state === "stopping" ? "Stopping. Check: nomac status"
317
+ : "Check: nomac status";
303
318
  print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
304
319
  return result;
305
320
  };
@@ -316,10 +331,39 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
316
331
  const access = await sessionAccess(local.id, request);
317
332
  if (local.generation && (local.generation !== access.generation || local.hostKey !== access.ssh.host_key)) throw new CliError("The guest identity changed. Stop this session and start a fresh one.", undefined, undefined, "host_key_changed");
318
333
  if (local.attached && !local.keyAdded) await addKey(local);
319
- Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: "/Users/agent/workspace", knownHosts: join(store.directory, "known_hosts") });
334
+ // A session an older CLI began in ~/workspace stays there, so nothing moves mid-session.
335
+ if (local.workspace && !local.workspaceDecided) local.workspaceDecided = true;
336
+ if (!local.workspaceDecided) await placeWorkspace(local);
337
+ 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") });
320
338
  writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
321
339
  return local;
322
340
  }
341
+ /**
342
+ * With the account's drive, this folder's project lives on it, at ~/drive/src/<folder name>,
343
+ * so the next session finds it and builds only what changed (~/workspace is emptied every
344
+ * session). A name another local folder took gets a suffix; the claims are kept in
345
+ * ~/drive/.nomac/projects. Without a drive, the workspace is ~/workspace as before.
346
+ */
347
+ async function placeWorkspace(local) {
348
+ if (local.snapshot?.drive?.state === "mounted") {
349
+ const name = (basename(store.project).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "") || "project").slice(0, 64);
350
+ const origin = createHash("sha256").update(store.project).digest("hex").slice(0, 12);
351
+ const script = 'set -e; claims=~/drive/.nomac/projects; mkdir -p "$claims" ~/drive/src; d="$NAME"; '
352
+ // A folder another local folder claimed, or one made on the Mac some other way, is left alone.
353
+ + 'if { [ -e "$claims/$d" ] && [ "$(cat "$claims/$d")" != "$ORIGIN" ]; } || { [ ! -e "$claims/$d" ] && [ -n "$(ls -A ~/drive/src/"$d" 2>/dev/null)" ]; }; then d="$NAME-$ORIGIN"; fi; '
354
+ + 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
355
+ const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
356
+ body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
357
+ const path = String(job?.stdout ?? "").trim();
358
+ if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
359
+ // The same folder on the drive as last time: what was synced there then is still there.
360
+ if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
361
+ Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
362
+ }
363
+ }
364
+ delete local.previousDriveWorkspace;
365
+ local.workspaceDecided = true; store.save(local);
366
+ }
323
367
  /**
324
368
  * A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
325
369
  * from this folder: add this folder's own to its authorized keys with an ordinary command, so
@@ -332,6 +376,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
332
376
  if (job.exit_code !== 0) throw new CliError("Could not add this folder's SSH key to the Mac.", `Adding the key ended with ${job.state === "exited" ? `exit code ${job.exit_code}` : `the command ${job.state}`}. Try again: nomac attach ${local.id}`);
333
377
  local.keyAdded = true; store.save(local);
334
378
  }
379
+ /** Where this folder's project is on the Mac, once a ready Mac has decided it. */
380
+ const project = local => local.state === "ready" && local.workspaceDecided ? { project: { path: local.workspace, on_drive: Boolean(local.workspaceOnDrive) } } : {};
381
+ /** The last session's folder on the drive and what was synced to it, for the next session to pick up. */
382
+ const previousDrive = old => old?.workspaceOnDrive ? { previousDriveWorkspace: { path: old.workspace, baseline: store.baseline() } } : {};
335
383
  /** The account's running Macs, newest first: queued, starting or ready. */
336
384
  async function liveSessions() {
337
385
  const answer = await request("GET", "/api/v1/mac/sessions");
@@ -342,7 +390,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
342
390
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
343
391
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
344
392
  const local = { mode: "hosted", state: view.state, id: sessionId(view.id), identity, attached: true, outcomeUnknown: false, requestKey: `attach-${view.id}`,
345
- request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) } };
393
+ request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) }, ...previousDrive(store.load()) };
346
394
  store.save(local); store.saveBaseline({});
347
395
  const deadline = now() + wait * 1000;
348
396
  for (;;) {
@@ -370,15 +418,25 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
370
418
  const session = saveView(local, result.session);
371
419
  // This folder's Mac has stopped, but the account may run another, started elsewhere.
372
420
  const elsewhere = ["stopped", "failed"].includes(session.state) ? (await liveSessions().catch(() => [])).find(s => s.id !== session.id) : undefined;
373
- if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
374
- print(statusSummary(session, result.image) + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
421
+ if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...project(local), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
422
+ print(statusSummary(session, result.image) + (project(local).project ? `\n${projectLine(project(local).project)}` : "") + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
375
423
  return session;
376
424
  }
425
+ if (command === "cancel") {
426
+ if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Usage: nomac cancel JOB_ID");
427
+ const local = localSession(), job = await hostedRequest(local.id, "POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs/${positionals[0]}/cancel`, {}, { request, transport });
428
+ if (values.json) { print(JSON.stringify(job, null, 2)); return job; }
429
+ const ended = job.exit_code === null || job.exit_code === undefined ? (job.signal ? `signal ${job.signal}` : job.state) : `exit code ${job.exit_code}`;
430
+ print(job.cancelled ? `Cancelled ${job.id}: it and everything it started were stopped (${ended}). Its output so far: nomac output ${job.id}`
431
+ : job.state === "exited" ? `${job.id} had already finished (${job.reason === "cancelled" ? "cancelled" : ended}).` : `${job.id} is ${job.state}.`);
432
+ return job;
433
+ }
377
434
  if (["exec", "job", "output"].includes(command)) {
378
435
  const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
379
436
  let path, method = "GET", params = {};
380
437
  if (command === "exec") {
381
438
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
439
+ if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
382
440
  if (values["request-key"] !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"])) throw new CliError("A request key is 8 to 128 letters, digits, underscores, periods, colons or hyphens.");
383
441
  // A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
384
442
  // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
@@ -386,7 +444,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
386
444
  // A command may run an hour (the gateway's limit); waiting can cover all of it.
387
445
  const waitFor = integer(values.wait, 30, 0, 3600);
388
446
  const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
389
- method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: isShellLine(positionals) ? ["/bin/bash", "-lc", positionals[0]] : positionals, timeout_seconds: timeout } };
447
+ const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
448
+ const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
449
+ : line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
450
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: remote, timeout_seconds: timeout } };
390
451
  } else {
391
452
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
392
453
  path = `${base}/jobs/${positionals[0]}`;
@@ -508,12 +569,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
508
569
  if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
509
570
  await refresh(current);
510
571
  if (current.state === "ready") await ready(current);
511
- return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.` });
572
+ return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.`, ...project(current) });
512
573
  }
513
574
  const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
514
575
  if (!target) throw new CliError(wanted ? `No running Mac ${wanted} in this account.` : live.length ? `This account has ${live.length} running Macs: name one, nomac attach ${live.map(s => s.id).join(" | ")}` : "This account has no running Mac. Start one: nomac start");
515
576
  const local = await attachTo(target);
516
- return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.` });
577
+ return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.`, ...project(local) });
517
578
  }
518
579
  if (command === "start") {
519
580
  if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
@@ -537,7 +598,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
537
598
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
538
599
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
539
600
  local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
540
- request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) } };
601
+ request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
602
+ ...previousDrive(store.load()) };
541
603
  store.save(local); store.saveBaseline({});
542
604
  }
543
605
  if (!local.id) {
@@ -551,7 +613,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
551
613
  const [running] = await liveSessions();
552
614
  if (running) {
553
615
  local = await attachTo(running);
554
- return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.` });
616
+ return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.`, ...project(local) });
555
617
  }
556
618
  }
557
619
  if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
@@ -570,7 +632,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
570
632
  await sleep(2000);
571
633
  }
572
634
  if (local.state === "ready") await ready(local);
573
- return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
635
+ return output({ session: local.snapshot, request_key: local.requestKey, ...project(local), ...(local.warning ? { warning: local.warning } : {}) });
574
636
  }
575
637
  const local = localSession();
576
638
  if (command === "stop") {
package/src/mac-tools.mjs CHANGED
@@ -28,7 +28,7 @@ export const MAC_TOOLS = [
28
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
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")) },
30
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")) },
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 })) },
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 url to the person who holds the value; they need no NoMac account, and it works once, for 24 hours. 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 })) },
32
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 })) },
33
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 })) },
34
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 })) },
@@ -40,7 +40,7 @@ export const MAC_TOOLS = [
40
40
  return text({ session_id, url, opened_in_browser: opened, message: opened ? "The desktop opened in the human's browser; they may need to sign in to nomac.app." : "Give the human this link; it opens after they sign in to nomac.app." });
41
41
  } },
42
42
  { name: "stop_mac", description: "Stop billing by shutting down the VM and deleting its files. Retrieve files first. Poll get_mac_session until cleanup_confirmed; stopping only acknowledges the request.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("DELETE", base(args))) },
43
- { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command can be at most 64 KiB in total: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
43
+ { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. cwd runs it in another folder, such as ~/drive/src/app. timeout_seconds defaults to 300 and caps at 3600 (an hour) for builds and test runs; a first simulator boot takes about 90 seconds. cancel_mac_job stops a command. A command can be at most 64 KiB in total: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, cwd: { type: "string", minLength: 1, maxLength: 1024, description: "The folder to run in: relative to the workspace, or ~/… and absolute paths anywhere on the Mac" }, timeout_seconds: { type: "integer", minimum: 1, maximum: 3600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
44
44
  { name: "write_mac_file", description: "Write a file on the Mac: local_path uploads a file from this machine, up to 2 GiB, without its content passing through you; or give content as text, or content_base64 for binary, up to 3 MiB. Relative Mac paths are in the session workspace; ~ is the home folder. Creates missing folders and replaces an existing file in one step; set executable for scripts. Safe to repeat. For a whole project use nomac sync; for larger files fetch_to_mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, content: { type: "string" }, content_base64: { type: "string" }, executable: { type: "boolean" } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, content, content_base64, executable }) => {
45
45
  const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
46
46
  if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
@@ -115,5 +115,6 @@ export const MAC_TOOLS = [
115
115
  return text(save_to && view.state === "ready" ? { ...view, ...await saveFromLink(view, save_to) } : view);
116
116
  } },
117
117
  { name: "get_mac_job", description: "Inspect the existing command without rerunning it. Retain its job ID until completion and retrieve output before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}`)) },
118
+ { name: "cancel_mac_job", description: "Stop a running command and everything it started (its whole process group): SIGTERM, a second to finish, then SIGKILL. It comes back exited with reason cancelled; its output so far stays readable. A command that already finished is returned unchanged (cancelled: false).", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("POST", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/cancel`)) },
118
119
  { name: "read_mac_output", description: "Read stdout/stderr as base64 chunks; resume from next_cursor. If truncated, the final response includes tail_base64 and its absolute tail_offset. Retrieve promptly: only recent completed jobs retain output. output_expired preserves the job receipt and never means rerun. Retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
119
120
  ];
package/src/secrets.mjs CHANGED
@@ -14,7 +14,8 @@ come back from NoMac, and are never taken as command-line arguments.
14
14
  nomac secrets rm NAME delete it
15
15
  nomac secrets install [NAME...] put them on this folder's Mac now [--session ses_…]
16
16
  nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]
17
- a link where someone enters it in the browser
17
+ a link for whoever holds it to enter it once,
18
+ in the browser, with no NoMac account
18
19
  Add --json for JSON.`;
19
20
 
20
21
  /**
@@ -130,7 +131,7 @@ export async function secretsCommand(args, { api = defaultApi, print = console.l
130
131
  if (names.length !== 1 || !values.description || !["variable", "file"].includes(kind)) throw new CliError("request usage", "Usage: nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]");
131
132
  const result = await api("POST", "/api/v1/secrets/requests", { body: { name: names[0], kind, description: values.description,
132
133
  ...(values.destination ? { destination: values.destination } : {}), delivery } });
133
- return out(result, `Open this link to enter ${result.name} (expires ${result.expires_at}):\n${result.url}`);
134
+ return out(result, `Send this link to whoever holds ${result.name}; they need no NoMac account. It works once, until ${result.expires_at.slice(0, 16).replace("T", " ")} UTC:\n${result.url}`);
134
135
  }
135
136
  throw new CliError("unknown secrets command", SECRETS_HELP);
136
137
  }