@nomac/cli 0.11.0 → 0.11.2

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.0",
3
+ "version": "0.11.2",
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,8 @@ 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 attach use from this folder the Mac your agent started elsewhere (MCP, dashboard)
14
+ nomac stop stop the Mac: deletes it and its files (not ~/drive), billing ends
14
15
 
15
16
  Use it:
16
17
  nomac exec -- ls run a command; prints its output, keeps its exit code
@@ -38,7 +39,7 @@ Use it:
38
39
  Agents:
39
40
  nomac mcp MCP server for Claude Code, Codex, Cursor and others
40
41
 
41
- More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
42
+ More: nomac start --help, nomac whoami, nomac --version, nomac start --host user@mac (a Mac you own)
42
43
  Environment: NOMAC_API_KEY, NOMAC_API_URL`;
43
44
 
44
45
  // The Builder commands act at once (a release build uses one of the plan's builds),
@@ -101,6 +102,7 @@ export async function main(argv) {
101
102
  }
102
103
  // falls through: everyone else sees their Mac
103
104
  case "start":
105
+ case "attach":
104
106
  case "exec":
105
107
  case "job":
106
108
  case "output":
@@ -188,6 +190,13 @@ export async function main(argv) {
188
190
  console.log(`auth: ${me.auth?.via ?? "?"}${me.auth?.key_id ? ` (${me.auth.key_id})` : ""}`);
189
191
  return;
190
192
  }
193
+ case "--version":
194
+ case "-v":
195
+ case "version": {
196
+ const { CLI_VERSION } = await import("./client-info.mjs");
197
+ console.log(CLI_VERSION);
198
+ return;
199
+ }
191
200
  case undefined:
192
201
  case "help":
193
202
  case "--help":
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
@@ -52,7 +52,9 @@ const HELP = `Nomac cloud Mac sessions
52
52
  start a screen video; nomac record stop saves it
53
53
  as nomac-recording.mp4 (or -o) with a share link
54
54
  nomac status [--json] state, cost so far and when it stops
55
- nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files
55
+ nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
56
+ 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)
56
58
 
57
59
  Login once with nomac login. The CLI creates a local SSH key; it sends only the
58
60
  public key. Sessions use the port-443 gateway and verified guest host keys.
@@ -196,10 +198,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
196
198
  const unknown = job.state === "unknown";
197
199
  const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
198
200
  const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
199
- : `${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 10 minutes: nomac ssh -- command. 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.`;
200
202
  if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
201
203
  else if (unknown) stderr(`nomac: ${next}\n`);
202
- 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 10 minutes: nomac ssh -- command\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`);
203
205
  exitWith(unknown ? 1 : STILL_RUNNING);
204
206
  return job;
205
207
  }
@@ -286,11 +288,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
286
288
  catch (error) { throw new CliError(error.message); }
287
289
  const { values, positionals } = parsed;
288
290
  if (values.help) return print(command === "desktop" ? DESKTOP_HELP : HELP);
289
- const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
291
+ const wait = ["start", "attach", "stop"].includes(command) ? integer(values.wait, command === "stop" ? 60 : 300, 0, command === "stop" ? 300 : 600) : 0;
290
292
  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.");
291
293
  const output = result => {
292
294
  // Sync, pull and the other commands answer with their own result, which has no session.
293
- if (values.json || !["start", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
295
+ if (values.json || !["start", "attach", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
294
296
  const billing = result.session?.billing;
295
297
  const usage = billing?.mode === "weekly_included"
296
298
  ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
@@ -298,7 +300,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
298
300
  const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
299
301
  : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
300
302
  : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
301
- print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
303
+ print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
302
304
  return result;
303
305
  };
304
306
  const saveView = (local, view) => {
@@ -313,10 +315,44 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
313
315
  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");
314
316
  const access = await sessionAccess(local.id, request);
315
317
  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
+ if (local.attached && !local.keyAdded) await addKey(local);
316
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") });
317
320
  writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
318
321
  return local;
319
322
  }
323
+ /**
324
+ * A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
325
+ * from this folder: add this folder's own to its authorized keys with an ordinary command, so
326
+ * ssh, sync and pull work here too.
327
+ */
328
+ async function addKey(local) {
329
+ const key = readFileSync(`${local.identity}.pub`, "utf8").trim();
330
+ const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-key-${createHash("sha256").update(key).digest("hex").slice(0, 32)}`,
331
+ body: { argv: ["/bin/bash", "-c", 'umask 077; mkdir -p ~/.ssh && grep -qxF "$NOMAC_KEY" ~/.ssh/authorized_keys 2>/dev/null || printf "%s\n" "$NOMAC_KEY" >> ~/.ssh/authorized_keys'], env: { NOMAC_KEY: key }, timeout_seconds: 30 } });
332
+ 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
+ local.keyAdded = true; store.save(local);
334
+ }
335
+ /** The account's running Macs, newest first: queued, starting or ready. */
336
+ async function liveSessions() {
337
+ const answer = await request("GET", "/api/v1/mac/sessions");
338
+ return (Array.isArray(answer?.sessions) ? answer.sessions : []).filter(s => ["queued", "provisioning", "ready"].includes(s.state));
339
+ }
340
+ /** This folder now uses that Mac: a fresh key of its own, then ready() adds it once the Mac is ready. */
341
+ async function attachTo(view) {
342
+ const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
343
+ await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
344
+ 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 } : {}) } };
346
+ store.save(local); store.saveBaseline({});
347
+ const deadline = now() + wait * 1000;
348
+ for (;;) {
349
+ await refresh(local);
350
+ if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
351
+ await sleep(2000);
352
+ }
353
+ if (local.state === "ready") await ready(local);
354
+ return local;
355
+ }
320
356
  const localSession = () => {
321
357
  const local = store.load();
322
358
  if (local?.mode !== "hosted" || !local.id) throw new CliError("No attached cloud session.", "Run nomac start to allocate or reconcile your Mac.");
@@ -332,8 +368,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
332
368
  if (!loaded) { print(values.json ? JSON.stringify({ session: null }) : "No Mac in this folder. Start one: nomac start"); return null; }
333
369
  const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
334
370
  const session = saveView(local, result.session);
335
- if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}) }, null, 2)); return session; }
336
- print(statusSummary(session, result.image));
371
+ // This folder's Mac has stopped, but the account may run another, started elsewhere.
372
+ 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` : ""));
337
375
  return session;
338
376
  }
339
377
  if (["exec", "job", "output"].includes(command)) {
@@ -345,8 +383,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
345
383
  // A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
346
384
  // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
347
385
  // With a key the limit must not depend on --wait: a retry with another --wait is the same command.
348
- const waitFor = integer(values.wait, 30, 0, 600);
349
- const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 600);
386
+ // A command may run an hour (the gateway's limit); waiting can cover all of it.
387
+ const waitFor = integer(values.wait, 30, 0, 3600);
388
+ const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
350
389
  method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: timeout } };
351
390
  } else {
352
391
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
@@ -368,7 +407,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
368
407
  throw error;
369
408
  });
370
409
  if (!waiting) return output(result);
371
- return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
410
+ return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 3600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
372
411
  }
373
412
  if (command === "screenshot") {
374
413
  if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
@@ -462,6 +501,20 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
462
501
  return run("ssh", [...sshArgs(local, { batch: false }), ...(!positionals.length && process.stdin.isTTY ? ["-t"] : []), ...(values.forward ?? []).flatMap(v => ["-L", forwardingArg(v)]), "-o", "ExitOnForwardFailure=yes", local.target, workspaceCommand(local, positionals)], { inherit: true });
463
502
  }
464
503
  return store.locked(async () => {
504
+ if (command === "attach") {
505
+ if (positionals.length > 1) throw new CliError("Usage: nomac attach [SESSION_ID] [--wait SECONDS] [--json]");
506
+ const live = await liveSessions(), wanted = positionals[0] && sessionId(positionals[0]);
507
+ const current = store.load();
508
+ if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
509
+ await refresh(current);
510
+ if (current.state === "ready") await ready(current);
511
+ return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.` });
512
+ }
513
+ const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
514
+ 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
+ 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.` });
517
+ }
465
518
  if (command === "start") {
466
519
  if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
467
520
  let local = store.load();
@@ -493,6 +546,14 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
493
546
  let result;
494
547
  try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
495
548
  catch (error) {
549
+ // One Mac runs per account: when it was started elsewhere (an agent over MCP, the dashboard), use it from here.
550
+ if (error.code === "session_limit_reached" && values["request-key"] === undefined) {
551
+ const [running] = await liveSessions();
552
+ if (running) {
553
+ 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.` });
555
+ }
556
+ }
496
557
  if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
497
558
  local.state = "failed"; local.rejection = error.code; store.save(local);
498
559
  }
@@ -44,7 +44,7 @@ export async function transportCommand(args, { request = macRequest, print = con
44
44
  const options = parsed(rest.slice(0, separator), ["--request-key", "--timeout"]);
45
45
  if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(options["--request-key"] ?? "")) throw new CliError("Supply a stable --request-key of 8 to 128 safe characters.");
46
46
  const timeout = Number(options["--timeout"] ?? 300);
47
- if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new CliError("--timeout must be 1 to 600 seconds.");
47
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600) throw new CliError("--timeout must be 1 to 3600 seconds.");
48
48
  result = await request("POST", `${base}/jobs`, { requestKey: options["--request-key"], body: { argv: rest.slice(separator + 1), timeout_seconds: timeout } });
49
49
  } else if (command === "job" || command === "output") {
50
50
  const [job, ...flags] = rest;