@nomac/cli 0.11.11 → 0.12.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.11.11",
3
+ "version": "0.12.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/skill/SKILL.md CHANGED
@@ -8,8 +8,8 @@ description: >
8
8
 
9
9
  # nomac: a Mac for your agent
10
10
 
11
- NoMac 2.0 provides a full macOS VM with administrator access. You choose the
12
- commands and tools. Mac time is prepaid credit, separate from Builder.
11
+ NoMac provides a full macOS VM with administrator access, Xcode and the iOS
12
+ Simulator. You choose the commands and tools. Mac time is prepaid credit.
13
13
 
14
14
  ## Cloud Mac setup and workflow
15
15
 
@@ -20,6 +20,8 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
20
20
  give them the printed link, and ask them to pay. Never enter payment
21
21
  details yourself. Poll credits until the balance appears; a checkout URL is
22
22
  not a payment. No Apple Developer account is needed for an unsigned build.
23
+ Under an hour left, answers say `low_credit` with a `buy_url`: tell the
24
+ human before a long build (a refill of 3 packs, $30, earns bonus credit).
23
25
  2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
24
26
  the printed device URL and code. With a key the human made at
25
27
  https://nomac.app/keys: `nomac login --with-token < key.txt`, or set
@@ -50,6 +52,9 @@ the Mac, logs included. Download what you need from outside `~/drive` before
50
52
  stopping. SSH disconnects
51
53
  do not stop the session. Ready time includes idle time; queue/setup are free.
52
54
  One-minute minimum, then by the minute. One session at a time; no overage.
55
+ For work that needs two Macs at once, `nomac start --parallel` from another
56
+ folder (MCP `start_mac` with `parallel: true`) starts a second, for accounts
57
+ that bought credit while NoMac has a Mac to spare; it runs without `~/drive`.
53
58
  If start returns `insufficient_credit`, buy credit as in step 1, then retry
54
59
  with the same request key.
55
60
  Keep the same request key and arguments after uncertain responses; inspect
@@ -82,6 +87,18 @@ Mac, in the Simulator either: test it on a device.
82
87
  Report failures using `report_issue` with `session_id`, optional `job_id` and
83
88
  a description without secrets. Read https://nomac.app/llms.txt for full limits.
84
89
 
90
+ ## Your editor, an agent on the Mac, Mac-only MCP servers
91
+
92
+ After `nomac start`, `nomac ssh-config` (CLI 0.12+) names the folder's Mac
93
+ `nomac` in the human's SSH config, and each later start or attach points the
94
+ name at the new Mac. Then VS Code or Cursor Remote-SSH and Claude Code's SSH
95
+ sessions connect to `nomac`; `ssh -t nomac 'cd ~/drive/src/<folder> && npx -y
96
+ @anthropic-ai/claude-code'` runs an agent on the Mac itself (keep its API key
97
+ as a NoMac secret); and an MCP server that needs a Mac runs from Windows or
98
+ Linux over SSH: `claude mcp add xcode -- ssh nomac npx -y mobilebuildmcp@latest
99
+ mcp` (MobileBuildMCP, formerly XcodeBuildMCP). An open SSH connection keeps
100
+ the Mac running until its time or spend limit: `nomac stop` when done.
101
+
85
102
  ## A Mac fully enabled for you
86
103
 
87
104
  The Mac is yours to do anything on: the `agent` account is an administrator,
@@ -132,7 +149,8 @@ the human, then call again.
132
149
 
133
150
  The separate iOS Builder publishing product is closed to new subscriptions.
134
151
  If `get_billing_status` shows a Builder plan, its tools are listed and
135
- https://nomac.app/llms.txt describes them. Otherwise use the cloud Mac above.
152
+ https://nomac.app/llms-legacy.txt describes them. Otherwise use the cloud Mac
153
+ above.
136
154
 
137
155
  ## When stuck
138
156
 
@@ -143,3 +161,11 @@ tool returns a Private Relay warning, explain that Apple relay addresses may
143
161
  reject later mail and offer a non-relay address. Never show that warning for a
144
162
  normal address such as `@icloud.com`. Never retry-loop an identical failure
145
163
  more than twice.
164
+
165
+ ## Feedback
166
+
167
+ When you stop a Mac, its answer asks for feedback. If anything got in your
168
+ way, failed, was missing from the Mac or the tools, or would have saved you
169
+ time, send it in a sentence or two: `report_issue` with `kind: "feedback"` and
170
+ the `session_id`. Leave out code, secrets and personal data; skip it if nothing
171
+ did. Feedback needs no answer.
package/src/cli.mjs CHANGED
@@ -19,6 +19,7 @@ Use it:
19
19
  wait up to 10 minutes (the default is 30 seconds)
20
20
  nomac cancel JOB_ID stop a running command and everything it started
21
21
  nomac ssh open a shell (nomac ssh -- command runs one)
22
+ nomac ssh-config name this Mac "nomac" in your SSH config: VS Code, Cursor, Claude Code
22
23
  nomac sync . upload changed project files
23
24
  nomac pull ./build download a folder from the Mac
24
25
  nomac desktop the Mac's desktop in your browser (--app: Apple's Screen Sharing)
@@ -111,6 +112,7 @@ export async function main(argv) {
111
112
  case "cancel":
112
113
  case "sync":
113
114
  case "ssh":
115
+ case "ssh-config":
114
116
  case "desktop":
115
117
  case "screenshot":
116
118
  case "record":
@@ -9,6 +9,7 @@ import { describePull, describeSync, inSkippedFolder, pullProject, syncProject }
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
+ import { refreshSshConfig, sshConfigBlock, writeSshConfig } from "./ssh-config.mjs";
12
13
 
13
14
  const WORKSPACE = "/Users/agent/workspace";
14
15
 
@@ -43,6 +44,14 @@ const HELP = `Nomac cloud Mac sessions
43
44
  nomac drive [reset] the account's persistent drive at ~/drive, kept between sessions:
44
45
  space used and saves; reset empties it
45
46
  nomac start --drive none this Mac without the drive
47
+ nomac start --parallel a second Mac beside the account's running one, from another
48
+ folder (accounts that bought credit, while a Mac is spare);
49
+ it runs without ~/drive. Without it, start uses the running Mac.
50
+ nomac ssh-config [--alias nomac] [--print]
51
+ name this folder's Mac in your SSH config (Host nomac), for
52
+ VS Code or Cursor Remote-SSH, Claude Code SSH sessions, ssh nomac,
53
+ and MCP servers on the Mac (ssh nomac COMMAND); start and attach
54
+ keep it pointing at the folder's current Mac
46
55
  nomac start --permit APP=PERMISSIONS
47
56
  grant permissions on this Mac at start
48
57
  nomac exec [--wait 30] -- command args
@@ -87,6 +96,7 @@ const options = {
87
96
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
88
97
  right: { type: "boolean" }, double: { type: "boolean" }, "no-screenshot": { type: "boolean" },
89
98
  permit: { type: "string", multiple: true }, reset: { type: "boolean" }, drive: { type: "string" },
99
+ parallel: { type: "boolean" }, alias: { type: "string" }, print: { type: "boolean" },
90
100
  };
91
101
  /** --permit com.example.App=camera,microphone (or =all), as the permissions option. */
92
102
  export function permitOption(values) {
@@ -396,6 +406,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
396
406
  if (!local.workspaceDecided) await placeWorkspace(local);
397
407
  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") });
398
408
  writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
409
+ // An SSH config entry this folder made earlier (nomac ssh-config) follows it to this Mac.
410
+ refreshSshConfig(local, store.project);
399
411
  return local;
400
412
  }
401
413
  /**
@@ -632,6 +644,22 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
632
644
  print(values.json ? JSON.stringify(answer, null, 2) : inputSummary(answer, saved));
633
645
  return answer;
634
646
  }
647
+ if (command === "ssh-config") {
648
+ if (positionals.length || values.json) throw new CliError("Usage: nomac ssh-config [--alias NAME] [--print]");
649
+ const alias = values.alias ?? "nomac";
650
+ const local = await ready(localSession());
651
+ const block = sshConfigBlock(local, alias, store.project);
652
+ if (values.print) return print(block);
653
+ const written = writeSshConfig(block, alias);
654
+ print([`Host ${alias} is this folder's Mac (${local.id}), in ${written.file}${written.addedInclude ? `, now included from ${written.includedFrom}` : ""}.`,
655
+ `Project on the Mac: ${local.workspace}`,
656
+ ` ssh ${alias} a shell; VS Code and Cursor: Remote-SSH, connect to "${alias}"`,
657
+ ` ssh -t ${alias} 'cd ${local.workspace} && npx -y @anthropic-ai/claude-code' an agent on the Mac itself`,
658
+ ` claude mcp add xcode -- ssh ${alias} npx -y mobilebuildmcp@latest mcp a Mac-only MCP server, from here`,
659
+ "nomac start and nomac attach from this folder point it at the folder's next Mac.",
660
+ "An open SSH connection keeps the Mac running until its time or spend limit: nomac stop when done."].join("\n"));
661
+ return { alias, session: local.id, ...written };
662
+ }
635
663
  if (command === "ssh") {
636
664
  if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
637
665
  const local = await ready(localSession());
@@ -726,7 +754,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
726
754
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
727
755
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
728
756
  local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
729
- request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
757
+ request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}), ...(values.parallel ? { parallel: true } : {}) },
730
758
  ...previousDrive(store.load()) };
731
759
  store.save(local); store.saveBaseline({});
732
760
  }
@@ -737,7 +765,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
737
765
  try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
738
766
  catch (error) {
739
767
  // One Mac runs per account: when it was started elsewhere (an agent over MCP, the dashboard), use it from here.
740
- if (error.code === "session_limit_reached" && values["request-key"] === undefined) {
768
+ if (error.code === "session_limit_reached" && values["request-key"] === undefined && !values.parallel) {
741
769
  const [running] = await liveSessions();
742
770
  if (running) {
743
771
  local = await attachTo(running);
package/src/mac-tools.mjs CHANGED
@@ -14,13 +14,13 @@ const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
14
14
  const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
15
15
  const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
16
16
  export const MAC_TOOLS = [
17
- { name: "get_mac_checkout", description: "Get a payment link for Mac credit and open it in the human's browser on this machine. Credit is $0.80 an hour, billed by the minute, sold in $10 packs (12.5 hours each), 1 to 10 packs per purchase, and never expires. Bigger purchases earn bonus credit: +5% from 3 packs ($30), +10% from 5 ($50), +20% at 10 ($100 buys $120, 150 hours). For a first purchase (get_mac_credits shows first_purchase: true), recommend 1 pack: $10 (12.5 hours) is enough to get started. For returning buyers who expect ongoing use, offer a choice, for example $10 or $50 (+10%, about 69 hours), and let them decide. Call this when get_mac_credits shows too little credit or start_mac returns insufficient_credit. Then tell the human a payment page opened (or give them the returned url if opened_in_browser is false) and ask them to pay. Never enter payment details yourself. Poll get_mac_credits until the balance appears, then start the Mac. Reuse request_key after a lost response; a new key is a new purchase.", inputSchema: schema({ packs: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Number of $10 packs, 1-10. Pick from the human's budget or expected hours; 3+ packs earn bonus credit." }, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." }, request_key: key }, ["request_key"]), handler: async ({ packs = 1, open = true, request_key }) => {
17
+ { name: "get_mac_checkout", description: "Get a payment link for Mac credit and open it in the human's browser on this machine. Credit is $0.80 an hour, billed by the minute, sold in $10 packs (12.5 hours each), 1 to 10 packs per purchase, and never expires. Bigger purchases earn bonus credit: +5% from 3 packs ($30), +10% from 5 ($50), +20% at 10 ($100 buys $120, 150 hours). For a first purchase (get_mac_credits shows first_purchase: true), recommend 1 pack: $10 (12.5 hours) is enough to get started. For a refill, suggest 3 packs ($30, +5%) or more, and let them decide. Call this when get_mac_credits shows too little credit or start_mac returns insufficient_credit. Then tell the human a payment page opened (or give them the returned url if opened_in_browser is false) and ask them to pay. Never enter payment details yourself. Poll get_mac_credits until the balance appears, then start the Mac. Reuse request_key after a lost response; a new key is a new purchase.", inputSchema: schema({ packs: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Number of $10 packs, 1-10. Pick from the human's budget or expected hours; 3+ packs earn bonus credit." }, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." }, request_key: key }, ["request_key"]), handler: async ({ packs = 1, open = true, request_key }) => {
18
18
  const result = await api("POST", "/api/v1/mac/credits/checkout", { body: { packs }, idempotencyKey: request_key });
19
19
  const opened = open && result.action === "open_checkout_url" && result.url ? openUrl(result.url) : false;
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. 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 },
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 }, parallel: { type: "boolean", description: "true starts a second Mac beside the account's running one (accounts that bought credit, while NoMac has a Mac to spare); it runs without the account's drive. Without it, a start while a Mac runs answers session_limit_reached with that Mac's id, to use." },
24
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")) },
package/src/mcp.mjs CHANGED
@@ -19,7 +19,11 @@ const PROTOCOL_FALLBACK = "2025-06-18";
19
19
  /** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text). */
20
20
  const INSTRUCTIONS = [
21
21
  "NoMac gives you a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
22
- "Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone, write_mac_file, fetch_to_mac, create_mac_upload_link), run exec_mac, get results with read_mac_file or create_mac_download_link, then stop_mac.",
22
+ "Use it when work needs macOS that this machine does not have (Windows, Linux or a cloud sandbox): building, running or testing an iOS, iPadOS, watchOS, visionOS or macOS app (Swift, SwiftUI, React Native, Expo, Flutter, Capacitor), the iOS Simulator, Xcode, code signing, TestFlight or App Store submission.",
23
+ "Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone into ~/drive/src, write_mac_file, fetch_to_mac, create_mac_upload_link), run exec_mac, get results with read_mac_file or create_mac_download_link, then stop_mac.",
24
+ "The account's drive at ~/drive (50 GB) is on every Mac and kept between sessions; everything outside it is deleted at stop. Clone and build in ~/drive/src/<repo>, so the next session rebuilds only what changed.",
25
+ "When an answer says low_credit (under an hour of Mac time left), tell the human before a long build and offer its buy_url: a Mac stops when the credit runs out. For a refill, suggest 3 packs ($30, +5%).",
26
+ "One Mac runs per account at a time: start_mac while one runs answers session_limit_reached with its id, to use. For work that needs two at once, start_mac with parallel: true starts a second (accounts that bought credit, while NoMac has a Mac to spare); it runs without the drive.",
23
27
  "exec_mac returns a quick command's output in the same call; pass wait_seconds: 50 for builds and tests. A command still running comes back as a job: poll get_mac_job with its job_id, then read_mac_output.",
24
28
  "Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
25
29
  "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
@@ -367,7 +371,7 @@ export const TOOLS = [
367
371
  {
368
372
  name: "report_issue",
369
373
  description:
370
- "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description.",
374
+ "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description. kind feedback sends NoMac a note instead: what got in the way, failed or was missing.",
371
375
  inputSchema: {
372
376
  type: "object",
373
377
  properties: {
@@ -381,6 +385,11 @@ export const TOOLS = [
381
385
  maxLength: 320,
382
386
  description: "optional follow-up email for a direct resolution update",
383
387
  },
388
+ kind: {
389
+ type: "string",
390
+ enum: ["problem", "feedback"],
391
+ description: "problem (default): an error for support to answer. feedback: a note for NoMac about what got in the way, failed or was missing; it needs no answer. Leave out code, secrets and personal data.",
392
+ },
384
393
  },
385
394
  additionalProperties: false,
386
395
  },
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, 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 { CliError } from "./api.mjs";
7
+
8
+ /**
9
+ * nomac ssh-config: a Host entry for this folder's Mac in the person's own SSH config, so any
10
+ * SSH client reaches it by a name: VS Code and Cursor Remote-SSH, Claude Code's SSH sessions,
11
+ * plain ssh, and stdio MCP servers run on the Mac (ssh nomac COMMAND). The entries live in
12
+ * ~/.nomac/ssh_config, which ~/.ssh/config includes; each start or attach from the folder
13
+ * rewrites its entry for the new Mac, so the name keeps working across sessions.
14
+ */
15
+ const BEGIN = "# >>> nomac";
16
+ const END = "# <<< nomac";
17
+
18
+ export const validAlias = alias => typeof alias === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(alias);
19
+ const projectTag = project => createHash("sha256").update(project).digest("hex").slice(0, 16);
20
+ /** SSH config values are double-quoted; a path with a quote or a control character cannot be. */
21
+ const quoted = value => {
22
+ if (/["\x00-\x1f\x7f]/.test(value)) throw new CliError("This path cannot go in an SSH config.", `Path: ${value}`);
23
+ return `"${value}"`;
24
+ };
25
+
26
+ export function sshConfigPaths(home = homedir()) {
27
+ return { managed: join(home, ".nomac", "ssh_config"), user: join(home, ".ssh", "config"), sshDir: join(home, ".ssh") };
28
+ }
29
+
30
+ /** 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)) } = {}) {
32
+ if (!validAlias(alias)) throw new CliError("--alias takes letters, digits, dots, underscores and hyphens, starting with a letter or digit.");
33
+ 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
+ return [
35
+ `${BEGIN} ${alias} ${projectTag(project)} ${local.id}`,
36
+ `# ${project}`,
37
+ `Host ${alias}`,
38
+ ` HostName nomac-${local.id}`,
39
+ " User agent",
40
+ ` HostKeyAlias nomac-${local.id}`,
41
+ ` UserKnownHostsFile ${quoted(local.knownHosts)}`,
42
+ ...(platform === "win32" ? [] : [" GlobalKnownHostsFile /dev/null"]),
43
+ " StrictHostKeyChecking yes",
44
+ ` IdentityFile ${quoted(local.identity)}`,
45
+ " IdentitiesOnly yes",
46
+ ` ProxyCommand ${quoted(node)} ${quoted(bin)} tunnel ${local.id}`,
47
+ " ForwardAgent no",
48
+ " ServerAliveInterval 15",
49
+ " ServerAliveCountMax 3",
50
+ `${END} ${alias}`,
51
+ ].join("\n");
52
+ }
53
+
54
+ /** The managed file's entries, as [alias, text, project tag]. */
55
+ function entries(text) {
56
+ const found = [];
57
+ const pattern = new RegExp(`^${BEGIN} (\\S+) (\\S+)[^\\n]*\\n[\\s\\S]*?^${END} \\1$`, "gm");
58
+ for (const match of text.matchAll(pattern)) found.push({ alias: match[1], tag: match[2], text: match[0] });
59
+ return found;
60
+ }
61
+
62
+ function writeAtomic(file, text) {
63
+ const temporary = `${file}.${process.pid}.tmp`;
64
+ try { writeFileSync(temporary, text, { mode: 0o600 }); renameSync(temporary, file); }
65
+ finally { rmSync(temporary, { force: true }); }
66
+ chmodSync(file, 0o600);
67
+ }
68
+
69
+ /**
70
+ * Puts the entry in ~/.nomac/ssh_config (replacing one of the same name) and makes sure
71
+ * ~/.ssh/config includes that file. Include must come before any Host line to apply to
72
+ * every host, so it is added as the first line. Returns what changed.
73
+ */
74
+ export function writeSshConfig(block, alias, { home = homedir() } = {}) {
75
+ const paths = sshConfigPaths(home);
76
+ mkdirSync(join(home, ".nomac"), { recursive: true, mode: 0o700 });
77
+ const current = existsSync(paths.managed) ? readFileSync(paths.managed, "utf8") : "";
78
+ const kept = entries(current).filter(entry => entry.alias !== alias).map(entry => entry.text);
79
+ writeAtomic(paths.managed, `# Written by nomac ssh-config. Each start or attach updates its folder's entry.\n\n${[...kept, block].join("\n\n")}\n`);
80
+ mkdirSync(paths.sshDir, { recursive: true, mode: 0o700 });
81
+ const include = `Include ${paths.managed.includes(" ") ? quoted(paths.managed) : paths.managed}`;
82
+ const user = existsSync(paths.user) ? readFileSync(paths.user, "utf8") : "";
83
+ const included = user.split(/\r?\n/).some(line => line.trim() === include || line.trim() === `Include ${paths.managed}` || line.trim() === "Include ~/.nomac/ssh_config");
84
+ if (!included) writeAtomic(paths.user, `${include}\n${user.length && !user.startsWith("\n") ? "\n" : ""}${user}`);
85
+ return { file: paths.managed, includedFrom: paths.user, addedInclude: !included };
86
+ }
87
+
88
+ /**
89
+ * After a start or attach: if this folder has an entry, point it at the folder's new Mac.
90
+ * Never fails the command it follows.
91
+ */
92
+ export function refreshSshConfig(local, project, { home = homedir(), ...options } = {}) {
93
+ try {
94
+ const { managed } = sshConfigPaths(home);
95
+ if (!existsSync(managed)) return false;
96
+ const tag = projectTag(project);
97
+ const mine = entries(readFileSync(managed, "utf8")).filter(entry => entry.tag === tag);
98
+ if (!mine.length || mine.every(entry => entry.text.split("\n", 1)[0].endsWith(` ${local.id}`))) return false;
99
+ for (const entry of mine) writeSshConfig(sshConfigBlock(local, entry.alias, project, options), entry.alias, { home });
100
+ return true;
101
+ } catch { return false; }
102
+ }