@nomac/cli 0.12.1 → 0.13.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/bin/nomac.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "../src/cli.mjs";
3
+ import { runNewerIfInstalled } from "../src/self-update.mjs";
4
+
5
+ // A newer CLI this one installed in the background runs the command instead (self-update.mjs).
6
+ const delegated = await runNewerIfInstalled(process.argv.slice(2)).catch(() => undefined);
7
+ if (delegated !== undefined) process.exit(delegated);
3
8
 
4
9
  main(process.argv.slice(2)).catch((err) => {
5
10
  const args = process.argv.slice(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.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/skill/SKILL.md CHANGED
@@ -51,6 +51,13 @@ name>`, which is kept between sessions; stopping deletes everything else on
51
51
  the Mac, logs included. Download what you need from outside `~/drive` before
52
52
  stopping. SSH disconnects
53
53
  do not stop the session. Ready time includes idle time; queue/setup are free.
54
+ A Mac stops at its time limit (2 hours unless `nomac start --max-duration`
55
+ set another), its spend cap, when credit runs out, or when idle; a build left
56
+ running in the background keeps it awake while it uses the CPU. In its last
57
+ 15 minutes answers carry `stop_notice` (when and why) and the CLI prints it:
58
+ `nomac extend 60` (MCP `extend_mac`) adds an hour, up to 8 hours in all; for
59
+ `reason: credit`, the human adds credit. The CLI updates itself in the
60
+ background (`NOMAC_NO_AUTO_UPDATE=1` turns it off; `nomac update` now).
54
61
  One-minute minimum, then by the minute. One session at a time; no overage.
55
62
  For work that needs two Macs at once, `nomac start --parallel` from another
56
63
  folder (MCP `start_mac` with `parallel: true`) starts a second, for accounts
package/src/api.mjs CHANGED
@@ -1,5 +1,25 @@
1
1
  import { loadApiKey, loadApiUrl } from "./config.mjs";
2
2
  import { clientHeaders } from "./client-info.mjs";
3
+ import { rememberLatest, updateNotice } from "./self-update.mjs";
4
+
5
+ /** Lines for the person at the terminal, on stderr (never stdout, which commands' output owns), once each. */
6
+ const said = new Set();
7
+ function say(key, text) {
8
+ if (said.has(key)) return;
9
+ said.add(key);
10
+ process.stderr.write(`${text}\n`);
11
+ }
12
+ /** What every answer may carry besides its own result: the latest CLI, and a Mac about to stop. */
13
+ function noteAnswer(res, json) {
14
+ const latest = res.headers.get("x-nomac-cli-latest");
15
+ if (latest) {
16
+ rememberLatest(latest);
17
+ const notice = updateNotice();
18
+ if (notice) say("update", notice);
19
+ }
20
+ const stop = json && typeof json === "object" ? json.stop_notice : undefined;
21
+ if (stop && typeof stop.message === "string") say(`stop:${stop.stops_at}:${stop.minutes_left <= 2 ? "last" : "soon"}`, `NoMac: ${stop.message.replace(/call extend_mac \(CLI: (nomac extend)\)/, "run $1")}`);
22
+ }
3
23
 
4
24
  export class CliError extends Error {
5
25
  constructor(message, friendly, status, code, details) {
@@ -27,6 +47,7 @@ export async function apiPublic(method, path, body) {
27
47
  } catch {
28
48
  throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
29
49
  }
50
+ noteAnswer(res, json);
30
51
  return { status: res.status, ok: res.ok, json };
31
52
  }
32
53
 
@@ -64,6 +85,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
64
85
  } catch {
65
86
  throw new CliError(`API returned non-JSON (HTTP ${res.status})`, "The response could not be read. Reconcile the operation before retrying; reuse its request_key.", res.status, "bad_response", { code: "bad_response", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
66
87
  }
88
+ noteAnswer(res, json);
67
89
  if (!res.ok) {
68
90
  // Some answers carry more beside the error, such as publish's blockers and findings.
69
91
  const { error, ...beside } = json && typeof json === "object" && !Array.isArray(json) ? json : {};
package/src/cli.mjs CHANGED
@@ -32,6 +32,7 @@ Use it:
32
32
  nomac drive the account's persistent drive at ~/drive, kept between sessions
33
33
  nomac record record a video; nomac record stop saves it with a share link
34
34
  nomac status state, cost so far and when it stops
35
+ nomac extend 60 keep the Mac running another hour (8 hours in all)
35
36
  nomac secrets variables and files every Mac receives (nomac secrets --help)
36
37
  nomac apple whether Apple is connected: team key, certificates, profiles
37
38
  nomac sign [path] set up App Store signing for the project on the Mac
@@ -42,8 +43,8 @@ Use it:
42
43
  Agents:
43
44
  nomac mcp MCP server for Claude Code, Codex, Cursor and others
44
45
 
45
- More: nomac start --help, nomac whoami, nomac --version, nomac start --host user@mac (a Mac you own)
46
- Environment: NOMAC_API_KEY, NOMAC_API_URL`;
46
+ More: nomac start --help, nomac whoami, nomac --version, nomac update, nomac start --host user@mac (a Mac you own)
47
+ Environment: NOMAC_API_KEY, NOMAC_API_URL, NOMAC_NO_AUTO_UPDATE=1 (the CLI updates itself otherwise)`;
47
48
 
48
49
  // The Builder commands act at once (a release build uses one of the plan's builds),
49
50
  // so they take only their own flags and print usage for --help instead of running.
@@ -106,6 +107,7 @@ export async function main(argv) {
106
107
  // falls through: everyone else sees their Mac
107
108
  case "start":
108
109
  case "attach":
110
+ case "extend":
109
111
  case "exec":
110
112
  case "job":
111
113
  case "output":
@@ -208,6 +210,10 @@ export async function main(argv) {
208
210
  console.log(`auth: ${me.auth?.via ?? "?"}${me.auth?.key_id ? ` (${me.auth.key_id})` : ""}`);
209
211
  return;
210
212
  }
213
+ case "update": {
214
+ const { updateCommand } = await import("./update-command.mjs");
215
+ return updateCommand(rest);
216
+ }
211
217
  case "--version":
212
218
  case "-v":
213
219
  case "version": {
@@ -12,6 +12,8 @@ import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
12
12
  import { refreshSshConfig, sshConfigBlock, writeSshConfig } from "./ssh-config.mjs";
13
13
 
14
14
  const WORKSPACE = "/Users/agent/workspace";
15
+ /** How long the CLI keeps trying to put a project on the drive before it gives up for now. */
16
+ const PLACE_BUDGET_MS = 120_000;
15
17
 
16
18
  const HELP = `Nomac cloud Mac sessions
17
19
  nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
@@ -74,6 +76,8 @@ const HELP = `Nomac cloud Mac sessions
74
76
  start a screen video; nomac record stop saves it
75
77
  as nomac-recording.mp4 (or -o) with a share link
76
78
  nomac status [--json] state, cost so far and when it stops
79
+ nomac extend [MINUTES] [--json] keep the Mac running longer: its time limit goes up by
80
+ MINUTES (60 by default; 8 hours in all), its spend cap with it
77
81
  nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
78
82
  agent started it elsewhere (MCP, the dashboard); start does it too
79
83
  nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
@@ -333,7 +337,7 @@ export function statusSummary(session, image) {
333
337
  const a = session.activity ?? {};
334
338
  if (session.state === "ready") {
335
339
  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);
336
- if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
340
+ if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Longer: nomac extend 60. Stop now: nomac stop`);
337
341
  }
338
342
  const d = session.drive;
339
343
  if (d && ["mounted", "saving", "saved", "unavailable", "not_saved"].includes(d.state)) {
@@ -424,18 +428,42 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
424
428
  // A folder another local folder claimed, or one made on the Mac some other way, is left alone.
425
429
  + '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; '
426
430
  + 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
427
- const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
428
- body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
431
+ const job = await placeOnDrive(local, script, { NAME: name, ORIGIN: origin }, `cli-place-${local.id.slice(4, 24)}-${origin}`);
429
432
  const path = String(job?.stdout ?? "").trim();
430
433
  if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
431
434
  // The same folder on the drive as last time: what was synced there then is still there.
432
435
  if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
433
436
  Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
437
+ } else {
438
+ // The drive itself refused (full, or not writable): this session works in ~/workspace, and says so.
439
+ const why = !job ? "the Mac refused the request" : job.exit_code === 0 ? "the Mac named another folder" : `exit code ${job.exit_code}${String(job.stderr ?? "").trim() ? `: ${String(job.stderr).trim().split("\n").pop().slice(0, 120)}` : ""}`;
440
+ console.error(`NoMac: this project could not be put on the drive (${why}), so this session works in ${WORKSPACE}, which is deleted when the Mac stops. Copy anything to keep into ~/drive, or check the drive: nomac drive.`);
434
441
  }
435
442
  }
436
443
  delete local.previousDriveWorkspace;
437
444
  local.workspaceDecided = true; store.save(local);
438
445
  }
446
+ /**
447
+ * Runs the placement script until it answers, retrying what is passing with the same request
448
+ * key: a Mac is often slow in its first minutes, and a lost or busy answer put whole sessions'
449
+ * work in ~/workspace, deleted at stop (October 2026). An answer that never comes within the
450
+ * budget fails the command, with the project still unplaced, so the next command tries again.
451
+ */
452
+ async function placeOnDrive(local, script, env, key) {
453
+ const started = now(), base = `/api/v1/mac/sessions/${sessionId(local.id)}/jobs`;
454
+ const passing = error => ["guest_busy", "host_unavailable", "outcome_unknown", "operation_unconfirmed", "bad_response", "operation_pending"].includes(error?.code) || error?.status === 429 || error?.status >= 500;
455
+ for (;;) {
456
+ try {
457
+ let job = await request("POST", `${base}?wait=30`, { idempotencyKey: key, body: { argv: ["/bin/bash", "-c", script], env, timeout_seconds: 30 } });
458
+ while (job?.state !== "exited" && job?.id && now() - started < PLACE_BUDGET_MS) { await sleep(2000); job = await request("GET", `${base}/${job.id}`); }
459
+ if (job?.state === "exited") return job;
460
+ } catch (error) {
461
+ if (!passing(error)) return null;
462
+ }
463
+ if (now() - started + 5000 > PLACE_BUDGET_MS) throw new CliError("The Mac did not answer in time.", `The Mac did not answer while this project was being put on its drive (~/drive/src); nothing was uploaded. It is often slow in its first minutes: run the same command again.`, undefined, "drive_placement_pending");
464
+ await sleep(5000);
465
+ }
466
+ }
439
467
  /**
440
468
  * A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
441
469
  * from this folder: add this folder's own to its authorized keys with an ordinary command, so
@@ -482,6 +510,15 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
482
510
  if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac status [--json]");
483
511
  const local = localSession(); return output({ session: await refresh(local) });
484
512
  }
513
+ if (command === "extend") {
514
+ if (positionals.length > 1) throw new CliError("Usage: nomac extend [MINUTES] [--json]");
515
+ const minutes = integer(positionals[0], 60, 1, 480);
516
+ const local = localSession();
517
+ const result = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/extend`, { body: { minutes } });
518
+ saveView(local, result.session);
519
+ print(values.json ? JSON.stringify(result, null, 2) : result.message);
520
+ return result;
521
+ }
485
522
  if (command === "status") {
486
523
  if (positionals.length) throw new CliError("Usage: nomac status [--json]");
487
524
  const loaded = store.load();
@@ -616,7 +653,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
616
653
  if (command === "permit") {
617
654
  if (positionals.length < 2) throw new CliError("Usage: nomac permit APP PERMISSION... (or all), for example nomac permit com.example.App camera microphone");
618
655
  const id = sessionId(localSession().id);
619
- const job = await request("POST", `/api/v1/mac/sessions/${id}/jobs?wait=30`, { idempotencyKey: `cli-${randomUUID()}`, body: { argv: ["/opt/homebrew/bin/node", "/usr/local/bin/nomac-permit", ...positionals], timeout_seconds: 60 } });
656
+ const job = await request("POST", `/api/v1/mac/sessions/${id}/jobs?wait=30`, { idempotencyKey: `cli-${randomUUID()}`, body: { argv: ["/usr/local/bin/nomac-permit", ...positionals], timeout_seconds: 60 } });
620
657
  let answer;
621
658
  try { answer = JSON.parse(String(job.stdout ?? "").trim().split("\n").pop()); } catch { throw new CliError("The Mac gave no answer for the grant.", "Check that the Mac is ready (nomac status), then try again."); }
622
659
  if (!answer.ok) throw new CliError(answer.message, undefined, undefined, answer.error);
package/src/mac-tools.mjs CHANGED
@@ -25,6 +25,7 @@ export const MAC_TOOLS = [
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: "extend_mac", description: "Keep a running Mac going longer: raises its time limit by minutes (default 60), up to 8 hours since it started, and its spend cap with it. Call it when an answer's stop_notice says the Mac stops soon at its time limit or spend cap, or before a build or test that would not finish in time. It adds no credit: when stop_notice's reason is credit, the human adds some (get_mac_checkout). The Mac still bills $0.80 an hour while it runs.", inputSchema: schema({ session_id: session, minutes: { type: "integer", minimum: 1, maximum: 480, default: 60, description: "Minutes to add to the time limit, 1-480. Default 60." } }, ["session_id"]), handler: async ({ session_id, minutes = 60 }) => text(await api("POST", `${base({ session_id })}/extend`, { body: { minutes } })) },
28
29
  { 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
30
  { 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
31
  { 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")) },
package/src/mcp.mjs CHANGED
@@ -29,6 +29,8 @@ const INSTRUCTIONS = [
29
29
  "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
30
30
  "Shipping without the Apple key on the Mac: set_up_signing on the project returns the files and commands that sign a build with the agent's own tools; send_to_testflight sends the signed .ipa to TestFlight; publish submits it for App Review; notarize notarizes and staples a Developer ID-signed Mac app. Never ask for a secret in the chat: request_secret gives the human a link, and every Mac receives the account's secrets.",
31
31
  "The Mac is fully enabled for you: sudo needs no password, macOS asks for no administrator password, the privacy prompts and password dialogs it still shows are answered on the Mac within seconds, the screen never locks, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it (for the login keychain or any prompt). nomac-permit APP camera (run with exec_mac) grants an app privacy permissions before it asks. To type or click as a person would, also on the lock screen and in password dialogs, call mac_input: its answer has a screenshot. When screenshot_mac reports screen_locked or a dialog, follow its next_step.",
32
+ "A Mac stops at its time limit (2 hours unless start_mac set max_duration_seconds), its spend cap, when the account's credit runs out, or after its idle timeout; everything outside ~/drive is deleted then. In its last 15 minutes every answer about it carries stop_notice, with when and why: call extend_mac to keep it running (time limit or spend cap), or ask the human to add credit (reason credit). Before a long build, check that it fits or extend first.",
33
+ "When an answer carries cli_update, this NoMac CLI is out of date: tell the human, who updates it with npm install -g @nomac/cli@latest (or nomac update) and restarts this MCP server.",
32
34
  "Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
33
35
  ].join(" ");
34
36
 
@@ -0,0 +1,25 @@
1
+ // Installs one NoMac CLI version into its own folder, in the background (self-update.mjs):
2
+ // node self-update-worker.mjs <version> <dir>. It is ready only once it answers --version
3
+ // with that number; until then the running CLI keeps handling commands itself.
4
+ import { spawnSync } from "node:child_process";
5
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { installedBin, pruneInstalls } from "./self-update.mjs";
8
+
9
+ const [version, dir] = process.argv.slice(2);
10
+ if (!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(version ?? "") || !dir) process.exit(2);
11
+ const windows = process.platform === "win32";
12
+ try {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ mkdirSync(dir, { recursive: true });
15
+ const npm = spawnSync(windows ? "npm.cmd" : "npm", ["install", "--prefix", dir, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", `@nomac/cli@${version}`],
16
+ { stdio: "ignore", timeout: 10 * 60_000, shell: windows, windowsHide: true });
17
+ if (npm.status !== 0) throw new Error("npm install failed");
18
+ const check = spawnSync(process.execPath, [installedBin(version), "--version"], { encoding: "utf8", timeout: 30_000, env: { ...process.env, NOMAC_CLI_DELEGATED: "check", NOMAC_NO_AUTO_UPDATE: "1" }, windowsHide: true });
19
+ if (check.status !== 0 || !check.stdout.includes(version)) throw new Error("the installed CLI did not run");
20
+ writeFileSync(join(dir, ".nomac-ready"), `${new Date().toISOString()}\n`);
21
+ pruneInstalls(version);
22
+ } catch {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ process.exit(1);
25
+ }
@@ -0,0 +1,112 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CLI_VERSION } from "./client-info.mjs";
7
+
8
+ /**
9
+ * Keeping the CLI current. nomac.app names the latest CLI on every answer (x-nomac-cli-latest);
10
+ * this CLI remembers it. When a newer one is out, it installs it in the background into
11
+ * ~/.nomac/cli/<version> (never over the running files) and, from the next command, hands
12
+ * every command to it. A global npm install or an npx cache is never touched, so it works the
13
+ * same for both, and a failed install leaves this version running. On October 15, 2026 most
14
+ * accounts using the CLI ran 0.5 to 0.11, missing months of fixes, and nothing told them.
15
+ * NOMAC_NO_AUTO_UPDATE=1 turns it off; then it only says an update is out.
16
+ */
17
+ const DIR = () => join(homedir(), ".nomac");
18
+ const STATE = () => join(DIR(), "cli-update.json");
19
+ export const installDir = (version) => join(DIR(), "cli", version);
20
+ export const installedBin = (version) => join(installDir(version), "node_modules", "@nomac", "cli", "bin", "nomac.mjs");
21
+ /** Written once the version installed and answered --version with its own number. */
22
+ const marker = (version) => join(installDir(version), ".nomac-ready");
23
+ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
24
+ /** How long a failed or unfinished background install waits before it is tried again. */
25
+ const RETRY_MS = 6 * 3600_000;
26
+ /** How often a person is told about an update this CLI cannot install itself. */
27
+ const NOTICE_MS = 24 * 3600_000;
28
+
29
+ /** Whether version a is newer than b (x.y.z). */
30
+ export function newer(a, b) {
31
+ const pa = String(a).split(".").map(Number), pb = String(b).split(".").map(Number);
32
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
33
+ return false;
34
+ }
35
+ export const autoUpdateOff = () => process.env.NOMAC_NO_AUTO_UPDATE === "1";
36
+
37
+ export function readState() {
38
+ try { return JSON.parse(readFileSync(STATE(), "utf8")); } catch { return {}; }
39
+ }
40
+ function writeState(state) {
41
+ try { mkdirSync(DIR(), { recursive: true }); writeFileSync(STATE(), JSON.stringify(state, null, 2) + "\n"); } catch { /* best effort */ }
42
+ }
43
+
44
+ /** Records the latest version nomac.app named (from a response header). Never throws. */
45
+ export function rememberLatest(version) {
46
+ if (typeof version !== "string" || !VERSION.test(version)) return;
47
+ const state = readState();
48
+ if (state.latest !== version) writeState({ ...state, latest: version, seen_at: new Date().toISOString() });
49
+ }
50
+
51
+ /** The newest installed version this CLI should hand commands to, if any. */
52
+ export function delegateTarget(state = readState(), own = CLI_VERSION) {
53
+ if (autoUpdateOff() || process.env.NOMAC_CLI_DELEGATED) return undefined;
54
+ const ready = installedVersions().filter(v => newer(v, own) && existsSync(installedBin(v)));
55
+ return ready.sort((a, b) => (newer(a, b) ? -1 : 1))[0];
56
+ }
57
+ export function installedVersions() {
58
+ try { return readdirSync(join(DIR(), "cli")).filter(v => VERSION.test(v) && existsSync(marker(v))); } catch { return []; }
59
+ }
60
+
61
+ /**
62
+ * At startup: runs the command with a newer installed CLI when there is one (and returns its
63
+ * exit code), else starts the background install of a newer one nomac.app named. Returns
64
+ * undefined when this CLI should run the command itself.
65
+ */
66
+ export async function runNewerIfInstalled(argv) {
67
+ const state = readState(), target = delegateTarget(state);
68
+ if (target) {
69
+ // The original entry point: ssh-config's ProxyCommand and the like keep pointing at it,
70
+ // so they keep working after this installed version is replaced by a newer one.
71
+ const entry = process.env.NOMAC_CLI_ENTRY ?? process.argv[1];
72
+ const child = spawn(process.execPath, [installedBin(target), ...argv], { stdio: "inherit",
73
+ env: { ...process.env, NOMAC_CLI_DELEGATED: CLI_VERSION, ...(entry ? { NOMAC_CLI_ENTRY: entry } : {}) } });
74
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
75
+ return new Promise(resolve => {
76
+ child.on("error", () => resolve(undefined));
77
+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
78
+ });
79
+ }
80
+ if (!autoUpdateOff() && !process.env.NOMAC_CLI_DELEGATED && state.latest && newer(state.latest, CLI_VERSION)) startInstall(state.latest, state);
81
+ return undefined;
82
+ }
83
+
84
+ /** Installs version in the background, at most once per RETRY_MS. */
85
+ export function startInstall(version, state = readState()) {
86
+ if (!VERSION.test(version) || existsSync(marker(version))) return false;
87
+ if (state.attempted === version && Date.now() - Date.parse(state.attempted_at ?? 0) < RETRY_MS) return false;
88
+ writeState({ ...state, attempted: version, attempted_at: new Date().toISOString() });
89
+ try {
90
+ const worker = fileURLToPath(new URL("./self-update-worker.mjs", import.meta.url));
91
+ spawn(process.execPath, [worker, version, installDir(version)], { detached: true, stdio: "ignore", windowsHide: true }).unref();
92
+ return true;
93
+ } catch { return false; }
94
+ }
95
+
96
+ /**
97
+ * One line for a person (never on stdout, never in `nomac mcp`): a newer CLI is out. Said when
98
+ * this CLI will not install it itself (NOMAC_NO_AUTO_UPDATE=1), at most once a day.
99
+ */
100
+ export function updateNotice(state = readState(), now = Date.now()) {
101
+ if (!state.latest || !newer(state.latest, CLI_VERSION)) return undefined;
102
+ if (!autoUpdateOff()) return undefined;
103
+ if (state.noticed_at && now - Date.parse(state.noticed_at) < NOTICE_MS) return undefined;
104
+ writeState({ ...state, noticed_at: new Date(now).toISOString() });
105
+ return `NoMac CLI ${state.latest} is out (this is ${CLI_VERSION}): npm install -g @nomac/cli@latest, or run nomac update.`;
106
+ }
107
+
108
+ /** Removes installed versions older than keep's predecessor (one older version stays for running commands). */
109
+ export function pruneInstalls(keep) {
110
+ const versions = installedVersions().filter(v => v !== keep).sort((a, b) => (newer(a, b) ? -1 : 1));
111
+ for (const old of versions.slice(1)) { try { rmSync(installDir(old), { recursive: true, force: true }); } catch { /* in use: next time */ } }
112
+ }
@@ -3,8 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { chmodSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { isAbsolute, join } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
6
  import { CliError } from "./api.mjs";
7
+ import { cliEntry } from "./ssh-config.mjs";
8
8
 
9
9
  export const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
10
10
 
@@ -51,7 +51,7 @@ export function sshArgs(session, { batch = true } = {}) {
51
51
  }
52
52
  return [
53
53
  ...(session.mode === "hosted" ? ["-F", "/dev/null", "-o", `UserKnownHostsFile=${session.knownHosts}`, "-o", "GlobalKnownHostsFile=/dev/null",
54
- "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
54
+ "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, cliEntry(), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
55
55
  "-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no",
56
56
  "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
57
57
  ...multiplexArgs(session),
@@ -5,6 +5,12 @@ import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CliError } from "./api.mjs";
7
7
 
8
+ /**
9
+ * The nomac this person installed: a CLI that a newer installed one runs on its behalf names the
10
+ * original (self-update.mjs), so a ProxyCommand outlives the next update.
11
+ */
12
+ export const cliEntry = () => process.env.NOMAC_CLI_ENTRY ?? fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url));
13
+
8
14
  /**
9
15
  * nomac ssh-config: a Host entry for this folder's Mac in the person's own SSH config, so any
10
16
  * SSH client reaches it by a name: VS Code and Cursor Remote-SSH, Claude Code's SSH sessions,
@@ -28,7 +34,7 @@ export function sshConfigPaths(home = homedir()) {
28
34
  }
29
35
 
30
36
  /** The Host entry for a ready hosted session (target, identity and known_hosts set by nomac's ready step). */
31
- export function sshConfigBlock(local, alias, project, { platform = process.platform, node = process.execPath, bin = fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)) } = {}) {
37
+ export function sshConfigBlock(local, alias, project, { platform = process.platform, node = process.execPath, bin = cliEntry() } = {}) {
32
38
  if (!validAlias(alias)) throw new CliError("--alias takes letters, digits, dots, underscores and hyphens, starting with a letter or digit.");
33
39
  if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(local.id ?? "") || !local.identity || !local.knownHosts) throw new CliError("This folder's Mac is not ready for SSH yet.", "Run nomac start (or nomac attach), then nomac ssh-config.");
34
40
  return [
@@ -0,0 +1,35 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { CliError } from "./api.mjs";
4
+ import { CLI_VERSION } from "./client-info.mjs";
5
+ import { installDir, installedVersions, newer, rememberLatest } from "./self-update.mjs";
6
+
7
+ const HELP = `Usage: nomac update [--check]
8
+ Install the newest NoMac CLI now. nomac also does this by itself in the background
9
+ (NOMAC_NO_AUTO_UPDATE=1 turns that off) and uses it from the next command.
10
+ --check only say whether a newer one is out`;
11
+
12
+ /** The newest @nomac/cli on npm. */
13
+ async function latestOnNpm() {
14
+ const response = await fetch("https://registry.npmjs.org/@nomac%2fcli/latest", { headers: { accept: "application/json" } }).catch(() => undefined);
15
+ const version = response?.ok ? (await response.json().catch(() => ({}))).version : undefined;
16
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) throw new CliError("npm unreachable", "Could not reach npm to look up the newest NoMac CLI. Try again, or run npm install -g @nomac/cli@latest.");
17
+ return version;
18
+ }
19
+
20
+ export async function updateCommand(args) {
21
+ if (args.some(arg => arg === "--help" || arg === "-h")) return console.log(HELP);
22
+ if (args.some(arg => arg !== "--check")) throw new CliError("update usage", HELP);
23
+ const latest = await latestOnNpm();
24
+ rememberLatest(latest);
25
+ const running = process.env.NOMAC_CLI_DELEGATED ? `${CLI_VERSION} (installed by nomac; your own install is ${process.env.NOMAC_CLI_DELEGATED})` : CLI_VERSION;
26
+ if (!newer(latest, CLI_VERSION)) return console.log(`NoMac CLI ${running} is the newest.`);
27
+ if (args.includes("--check")) return console.log(`NoMac CLI ${latest} is out (this is ${running}). Install it: nomac update`);
28
+ if (!installedVersions().includes(latest)) {
29
+ console.error(`Installing NoMac CLI ${latest}…`);
30
+ const worker = fileURLToPath(new URL("./self-update-worker.mjs", import.meta.url));
31
+ const run = spawnSync(process.execPath, [worker, latest, installDir(latest)], { stdio: "inherit" });
32
+ if (run.status !== 0) throw new CliError("update failed", `Could not install NoMac CLI ${latest} with npm. Run npm install -g @nomac/cli@latest instead.`);
33
+ }
34
+ console.log(`NoMac CLI ${latest} is installed; nomac uses it from your next command. To update your own install too: npm install -g @nomac/cli@latest`);
35
+ }