@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 +1 -1
- package/skill/SKILL.md +29 -3
- package/src/cli.mjs +2 -0
- package/src/hosted-sessions.mjs +30 -2
- package/src/mac-tools.mjs +2 -2
- package/src/mcp.mjs +11 -2
- package/src/ssh-config.mjs +102 -0
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -8,8 +8,8 @@ description: >
|
|
|
8
8
|
|
|
9
9
|
# nomac: a Mac for your agent
|
|
10
10
|
|
|
11
|
-
NoMac
|
|
12
|
-
commands and tools. Mac time is prepaid credit
|
|
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
|
|
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":
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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
|
+
}
|