@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 +5 -0
- package/package.json +1 -1
- package/skill/SKILL.md +7 -0
- package/src/api.mjs +22 -0
- package/src/cli.mjs +8 -2
- package/src/hosted-sessions.mjs +41 -4
- package/src/mac-tools.mjs +1 -0
- package/src/mcp.mjs +2 -0
- package/src/self-update-worker.mjs +25 -0
- package/src/self-update.mjs +112 -0
- package/src/session-ssh.mjs +2 -2
- package/src/ssh-config.mjs +7 -1
- package/src/update-command.mjs +35 -0
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
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": {
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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
|
|
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: ["/
|
|
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
|
+
}
|
package/src/session-ssh.mjs
CHANGED
|
@@ -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,
|
|
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),
|
package/src/ssh-config.mjs
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|