@nomac/cli 0.12.0 → 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 +36 -3
- 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 +9 -1
- 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
|
@@ -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
|
|
@@ -49,7 +51,17 @@ name>`, which is kept between sessions; stopping deletes everything else on
|
|
|
49
51
|
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.
|
|
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).
|
|
52
61
|
One-minute minimum, then by the minute. One session at a time; no overage.
|
|
62
|
+
For work that needs two Macs at once, `nomac start --parallel` from another
|
|
63
|
+
folder (MCP `start_mac` with `parallel: true`) starts a second, for accounts
|
|
64
|
+
that bought credit while NoMac has a Mac to spare; it runs without `~/drive`.
|
|
53
65
|
If start returns `insufficient_credit`, buy credit as in step 1, then retry
|
|
54
66
|
with the same request key.
|
|
55
67
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
@@ -82,6 +94,18 @@ Mac, in the Simulator either: test it on a device.
|
|
|
82
94
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
83
95
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
84
96
|
|
|
97
|
+
## Your editor, an agent on the Mac, Mac-only MCP servers
|
|
98
|
+
|
|
99
|
+
After `nomac start`, `nomac ssh-config` (CLI 0.12+) names the folder's Mac
|
|
100
|
+
`nomac` in the human's SSH config, and each later start or attach points the
|
|
101
|
+
name at the new Mac. Then VS Code or Cursor Remote-SSH and Claude Code's SSH
|
|
102
|
+
sessions connect to `nomac`; `ssh -t nomac 'cd ~/drive/src/<folder> && npx -y
|
|
103
|
+
@anthropic-ai/claude-code'` runs an agent on the Mac itself (keep its API key
|
|
104
|
+
as a NoMac secret); and an MCP server that needs a Mac runs from Windows or
|
|
105
|
+
Linux over SSH: `claude mcp add xcode -- ssh nomac npx -y mobilebuildmcp@latest
|
|
106
|
+
mcp` (MobileBuildMCP, formerly XcodeBuildMCP). An open SSH connection keeps
|
|
107
|
+
the Mac running until its time or spend limit: `nomac stop` when done.
|
|
108
|
+
|
|
85
109
|
## A Mac fully enabled for you
|
|
86
110
|
|
|
87
111
|
The Mac is yours to do anything on: the `agent` account is an administrator,
|
|
@@ -132,7 +156,8 @@ the human, then call again.
|
|
|
132
156
|
|
|
133
157
|
The separate iOS Builder publishing product is closed to new subscriptions.
|
|
134
158
|
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
|
|
159
|
+
https://nomac.app/llms-legacy.txt describes them. Otherwise use the cloud Mac
|
|
160
|
+
above.
|
|
136
161
|
|
|
137
162
|
## When stuck
|
|
138
163
|
|
|
@@ -143,3 +168,11 @@ tool returns a Private Relay warning, explain that Apple relay addresses may
|
|
|
143
168
|
reject later mail and offer a non-relay address. Never show that warning for a
|
|
144
169
|
normal address such as `@icloud.com`. Never retry-loop an identical failure
|
|
145
170
|
more than twice.
|
|
171
|
+
|
|
172
|
+
## Feedback
|
|
173
|
+
|
|
174
|
+
When you stop a Mac, its answer asks for feedback. If anything got in your
|
|
175
|
+
way, failed, was missing from the Mac or the tools, or would have saved you
|
|
176
|
+
time, send it in a sentence or two: `report_issue` with `kind: "feedback"` and
|
|
177
|
+
the `session_id`. Leave out code, secrets and personal data; skip it if nothing
|
|
178
|
+
did. Feedback needs no answer.
|
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
|
@@ -19,6 +19,7 @@ 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
|
+
"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.",
|
|
22
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.",
|
|
23
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.",
|
|
24
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%).",
|
|
@@ -28,6 +29,8 @@ const INSTRUCTIONS = [
|
|
|
28
29
|
"Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
|
|
29
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.",
|
|
30
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.",
|
|
31
34
|
"Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
|
|
32
35
|
].join(" ");
|
|
33
36
|
|
|
@@ -370,7 +373,7 @@ export const TOOLS = [
|
|
|
370
373
|
{
|
|
371
374
|
name: "report_issue",
|
|
372
375
|
description:
|
|
373
|
-
"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.",
|
|
376
|
+
"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.",
|
|
374
377
|
inputSchema: {
|
|
375
378
|
type: "object",
|
|
376
379
|
properties: {
|
|
@@ -384,6 +387,11 @@ export const TOOLS = [
|
|
|
384
387
|
maxLength: 320,
|
|
385
388
|
description: "optional follow-up email for a direct resolution update",
|
|
386
389
|
},
|
|
390
|
+
kind: {
|
|
391
|
+
type: "string",
|
|
392
|
+
enum: ["problem", "feedback"],
|
|
393
|
+
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.",
|
|
394
|
+
},
|
|
387
395
|
},
|
|
388
396
|
additionalProperties: false,
|
|
389
397
|
},
|
|
@@ -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
|
+
}
|