@nomac/cli 0.11.11 → 0.12.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/package.json +1 -1
- package/src/cli.mjs +2 -0
- package/src/hosted-sessions.mjs +30 -2
- package/src/mac-tools.mjs +2 -2
- package/src/mcp.mjs +4 -1
- package/src/ssh-config.mjs +102 -0
package/package.json
CHANGED
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,10 @@ 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
|
+
"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.",
|
|
23
|
+
"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.",
|
|
24
|
+
"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%).",
|
|
25
|
+
"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
26
|
"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
27
|
"Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
|
|
25
28
|
"Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
|
|
@@ -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
|
+
}
|