@nomac/cli 0.7.0 → 0.9.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 +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +34 -3
- package/src/api.mjs +4 -2
- package/src/cli.mjs +70 -8
- package/src/hosted-sessions.mjs +39 -6
- package/src/mac-fetch.mjs +2 -0
- package/src/mac-tools.mjs +19 -6
- package/src/mcp-args.mjs +64 -0
- package/src/mcp.mjs +23 -6
- package/src/notarize.mjs +71 -0
- package/src/publish.mjs +76 -0
- package/src/secrets.mjs +136 -0
- package/src/session-ssh.mjs +3 -1
- package/src/session-transfer.mjs +117 -38
- package/src/sessions.mjs +2 -2
- package/src/sign.mjs +37 -0
- package/src/testflight.mjs +89 -0
package/bin/nomac.mjs
CHANGED
|
@@ -5,6 +5,7 @@ main(process.argv.slice(2)).catch((err) => {
|
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
6
|
if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
|
|
7
7
|
console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
|
|
8
|
-
|
|
8
|
+
// An empty friendly message means the command already printed why it failed.
|
|
9
|
+
} else if (err?.friendly !== "") console.error(err?.friendly ?? err?.message ?? String(err));
|
|
9
10
|
process.exit(err?.exitCode ?? 1);
|
|
10
11
|
});
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -6,7 +6,7 @@ description: >
|
|
|
6
6
|
stop.
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# nomac
|
|
9
|
+
# nomac: a Mac for your agent
|
|
10
10
|
|
|
11
11
|
NoMac 2.0 provides a full macOS VM with administrator access. You choose the
|
|
12
12
|
commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
@@ -44,8 +44,9 @@ with the same request key.
|
|
|
44
44
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
45
45
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
46
46
|
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
47
|
-
CLI sync
|
|
48
|
-
|
|
47
|
+
CLI sync sends every file (.env and keys included) and symlinks as links, and
|
|
48
|
+
skips dependency and build folders, .git and folders .gitignore lists; a `!`
|
|
49
|
+
line in .nomacignore sends any of them. It preserves remote extra files. See the screen with `nomac screenshot` (saves
|
|
49
50
|
`nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
|
|
50
51
|
`screenshot_mac`, which returns an image you can look at. Record a video for
|
|
51
52
|
the human with `nomac record`, do the work, then `nomac record stop` (saves the
|
|
@@ -58,6 +59,36 @@ Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered
|
|
|
58
59
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
59
60
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
60
61
|
|
|
62
|
+
## Secrets, signing and shipping
|
|
63
|
+
|
|
64
|
+
Never ask for a secret in the chat. `nomac secrets request NAME --description
|
|
65
|
+
TEXT` (MCP `request_secret`) gives the human a link to enter it; they can also
|
|
66
|
+
run `nomac secrets set NAME` themselves. Every Mac receives the account's
|
|
67
|
+
secrets as variables and files, masked in command output; `nomac secrets
|
|
68
|
+
install` refreshes a running Mac.
|
|
69
|
+
|
|
70
|
+
The human connects Apple once at https://nomac.app/apple; the App Store
|
|
71
|
+
Connect key stays on NoMac's server. Then, on the Mac:
|
|
72
|
+
|
|
73
|
+
1. `nomac sign [path]` (MCP `set_up_signing`) makes and installs the App Store
|
|
74
|
+
profiles the project needs and prints the files, the reserved build number
|
|
75
|
+
and the xcodebuild, Flutter and fastlane commands. The project is unchanged;
|
|
76
|
+
build with your own tools.
|
|
77
|
+
2. `nomac testflight path/to/App.ipa` (MCP `send_to_testflight`) puts it on
|
|
78
|
+
TestFlight. Relay the human's answer to Apple's encryption question (asked
|
|
79
|
+
once per app), never guess.
|
|
80
|
+
3. `nomac publish App.ipa --whats-new TEXT` stages the App Store version and
|
|
81
|
+
lists Apple's blockers; submit with the printed `--confirm` command only
|
|
82
|
+
after the human approves. MCP: `publish` with the send's project_id and
|
|
83
|
+
build_id.
|
|
84
|
+
4. Mac apps: sign with the Developer ID certificate, `--options runtime
|
|
85
|
+
--timestamp`, then `nomac notarize App.app` (MCP `notarize`) notarizes and
|
|
86
|
+
staples it.
|
|
87
|
+
|
|
88
|
+
A step only a person can take (creating the app in App Store Connect,
|
|
89
|
+
agreements, an App Group) comes back as needs_human with its link: give it to
|
|
90
|
+
the human, then call again.
|
|
91
|
+
|
|
61
92
|
## Builder (existing subscribers only)
|
|
62
93
|
|
|
63
94
|
The separate iOS Builder publishing product is closed to new subscriptions.
|
package/src/api.mjs
CHANGED
|
@@ -65,13 +65,15 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
65
65
|
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
66
|
}
|
|
67
67
|
if (!res.ok) {
|
|
68
|
-
|
|
68
|
+
// Some answers carry more beside the error, such as publish's blockers and findings.
|
|
69
|
+
const { error, ...beside } = json && typeof json === "object" && !Array.isArray(json) ? json : {};
|
|
70
|
+
const err = error && typeof error === "object" ? error : {};
|
|
69
71
|
throw new CliError(
|
|
70
72
|
`HTTP ${res.status} ${err.code ?? ""}`,
|
|
71
73
|
err.message ?? `API error (HTTP ${res.status})`,
|
|
72
74
|
res.status,
|
|
73
75
|
err.code,
|
|
74
|
-
{ ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
|
|
76
|
+
{ ...beside, ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
79
|
return json;
|
package/src/cli.mjs
CHANGED
|
@@ -23,6 +23,11 @@ Use it:
|
|
|
23
23
|
nomac screenshot save the Mac's screen as nomac-screenshot.jpg
|
|
24
24
|
nomac record record a video; nomac record stop saves it with a share link
|
|
25
25
|
nomac status state, cost so far and when it stops
|
|
26
|
+
nomac secrets variables and files every Mac receives (nomac secrets --help)
|
|
27
|
+
nomac sign [path] set up App Store signing for the project on the Mac
|
|
28
|
+
nomac testflight app.ipa send a signed .ipa on the Mac to TestFlight
|
|
29
|
+
nomac publish app.ipa submit it for App Review (--confirm sends it)
|
|
30
|
+
nomac notarize App.app notarize a Developer ID-signed Mac app and staple it
|
|
26
31
|
|
|
27
32
|
Agents:
|
|
28
33
|
nomac mcp MCP server for Claude Code, Codex, Cursor and others
|
|
@@ -30,6 +35,28 @@ Agents:
|
|
|
30
35
|
More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
|
|
31
36
|
Environment: NOMAC_API_KEY, NOMAC_API_URL`;
|
|
32
37
|
|
|
38
|
+
// The Builder commands act at once (a release build uses one of the plan's builds),
|
|
39
|
+
// so they take only their own flags and print usage for --help instead of running.
|
|
40
|
+
const LEGACY_HELP = {
|
|
41
|
+
push: "Usage: nomac push\n Upload this folder as a project snapshot (respects .gitignore; secrets are excluded).",
|
|
42
|
+
build: "Usage: nomac build [--smoke] [--testflight | --wait]\n Start a signed release build for TestFlight (uses one build from your plan).\n --smoke unsigned compile check instead\n --testflight wait until the build is on TestFlight (--wait for a smoke build)",
|
|
43
|
+
status: "Usage: nomac status [build-id]\n Show a build's state as JSON (the last build in this folder by default).",
|
|
44
|
+
logs: "Usage: nomac logs [build-id]\n Show a build's steps and failure excerpt (the last build in this folder by default).",
|
|
45
|
+
mcp: "Usage: nomac mcp\n Run the NoMac MCP server on stdin/stdout. Add it to your agent's MCP settings as the command `nomac mcp`.",
|
|
46
|
+
};
|
|
47
|
+
const isHelp = (args) => args.some((a) => a === "--help" || a === "-h" || a === "help");
|
|
48
|
+
function onlyFlags(args, allowed, command) {
|
|
49
|
+
const unknown = args.find((a) => !allowed.includes(a));
|
|
50
|
+
if (unknown !== undefined) throw new CliError(`unknown option: ${unknown}`, `Unknown option "${unknown}" for nomac ${command}.\n\n${LEGACY_HELP[command]}`);
|
|
51
|
+
}
|
|
52
|
+
/** A build ID argument: bld_..., or none for the folder's last build. Never a flag. */
|
|
53
|
+
function buildIdArg(args, command) {
|
|
54
|
+
const [first, ...more] = args.filter((a) => a !== "--json");
|
|
55
|
+
if (first === undefined) return undefined;
|
|
56
|
+
if (!/^bld_[A-Za-z0-9]+$/.test(first) || more.length) throw new CliError(`not a build id: ${first}`, `"${[first, ...more].join(" ")}" is not a build ID (bld_...).\n\n${LEGACY_HELP[command]}`);
|
|
57
|
+
return first;
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
export async function main(argv) {
|
|
34
61
|
const [cmd, ...rest] = argv;
|
|
35
62
|
switch (cmd) {
|
|
@@ -51,7 +78,10 @@ export async function main(argv) {
|
|
|
51
78
|
}
|
|
52
79
|
case "status":
|
|
53
80
|
// A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
|
|
54
|
-
if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load()))
|
|
81
|
+
if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) {
|
|
82
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.status);
|
|
83
|
+
return status(buildIdArg(rest, "status"));
|
|
84
|
+
}
|
|
55
85
|
// falls through: everyone else sees their Mac
|
|
56
86
|
case "start":
|
|
57
87
|
case "exec":
|
|
@@ -75,6 +105,31 @@ export async function main(argv) {
|
|
|
75
105
|
// A Mac you own reports through session status.
|
|
76
106
|
return cmd === "status" ? sessionCommand("session", ["status", ...rest]) : sessionCommand(cmd, rest);
|
|
77
107
|
}
|
|
108
|
+
case "sign": {
|
|
109
|
+
const { signCommand } = await import("./sign.mjs");
|
|
110
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
111
|
+
return signCommand(rest, { store: sessionStore() });
|
|
112
|
+
}
|
|
113
|
+
case "testflight": {
|
|
114
|
+
const { testflightCommand } = await import("./testflight.mjs");
|
|
115
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
116
|
+
return testflightCommand(rest, { store: sessionStore() });
|
|
117
|
+
}
|
|
118
|
+
case "publish": {
|
|
119
|
+
const { publishCommand } = await import("./publish.mjs");
|
|
120
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
121
|
+
return publishCommand(rest, { store: sessionStore() });
|
|
122
|
+
}
|
|
123
|
+
case "notarize": {
|
|
124
|
+
const { notarizeCommand } = await import("./notarize.mjs");
|
|
125
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
126
|
+
return notarizeCommand(rest, { store: sessionStore() });
|
|
127
|
+
}
|
|
128
|
+
case "secrets": {
|
|
129
|
+
const { secretsCommand } = await import("./secrets.mjs");
|
|
130
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
131
|
+
return secretsCommand(rest, { store: sessionStore() });
|
|
132
|
+
}
|
|
78
133
|
case "login": {
|
|
79
134
|
const key = rest[0];
|
|
80
135
|
if (key) {
|
|
@@ -84,19 +139,26 @@ export async function main(argv) {
|
|
|
84
139
|
}
|
|
85
140
|
saveApiKey(key);
|
|
86
141
|
const me = await api("GET", "/api/v1/me");
|
|
87
|
-
console.log(`✅ logged in
|
|
142
|
+
console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
|
|
88
143
|
return;
|
|
89
144
|
}
|
|
90
145
|
const { deviceLogin } = await import("./login.mjs");
|
|
91
146
|
return deviceLogin();
|
|
92
147
|
}
|
|
93
148
|
case "push":
|
|
149
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.push);
|
|
150
|
+
onlyFlags(rest, [], "push");
|
|
94
151
|
return push();
|
|
95
152
|
case "build":
|
|
153
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.build);
|
|
154
|
+
onlyFlags(rest, ["--smoke", "--testflight", "--wait"], "build");
|
|
96
155
|
return build(rest);
|
|
97
156
|
case "logs":
|
|
98
|
-
|
|
157
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.logs);
|
|
158
|
+
return logs(buildIdArg(rest, "logs"));
|
|
99
159
|
case "mcp": {
|
|
160
|
+
// Agents start this as a stdio server; --help must not start it and wait on stdin.
|
|
161
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.mcp);
|
|
100
162
|
const { runMcpServer } = await import("./mcp.mjs");
|
|
101
163
|
return runMcpServer();
|
|
102
164
|
}
|
|
@@ -149,10 +211,10 @@ async function push() {
|
|
|
149
211
|
const icon = res.lint.grade === "green" ? "🟢" : res.lint.grade === "yellow" ? "🟡" : "🔴";
|
|
150
212
|
const n = res.lint.findings ?? 0;
|
|
151
213
|
console.log(
|
|
152
|
-
` review lint: ${icon} ${res.lint.grade}${n ? `
|
|
214
|
+
` review lint: ${icon} ${res.lint.grade}${n ? ` · ${n} finding${n === 1 ? "" : "s"}` : ""}`,
|
|
153
215
|
);
|
|
154
216
|
if (res.lint.grade === "red") {
|
|
155
|
-
console.log(" ⚠ red findings will block publish
|
|
217
|
+
console.log(" ⚠ red findings will block publish: fix them before `nomac build`/publish.");
|
|
156
218
|
}
|
|
157
219
|
}
|
|
158
220
|
if (res.warning) console.log(`⚠ ${res.warning}`);
|
|
@@ -162,7 +224,7 @@ async function push() {
|
|
|
162
224
|
async function build(rest) {
|
|
163
225
|
const state = loadProjectState();
|
|
164
226
|
if (!state.project_id) {
|
|
165
|
-
throw new CliError("no project", "No project here
|
|
227
|
+
throw new CliError("no project", "No project here. Run `nomac push` first.");
|
|
166
228
|
}
|
|
167
229
|
const smoke = rest.includes("--smoke");
|
|
168
230
|
const wait = rest.includes("--testflight") || rest.includes("--wait");
|
|
@@ -208,8 +270,8 @@ async function waitForBuild(id) {
|
|
|
208
270
|
if (b.state === "ready") {
|
|
209
271
|
console.log(
|
|
210
272
|
b.workflow === "smoke"
|
|
211
|
-
? "\n🎉 smoke build passed
|
|
212
|
-
: "\n🎉 build ready
|
|
273
|
+
? "\n🎉 smoke build passed: the project compiles clean on macOS."
|
|
274
|
+
: "\n🎉 build ready: open TestFlight on your phone, the new build is live.",
|
|
213
275
|
);
|
|
214
276
|
return;
|
|
215
277
|
}
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -121,7 +121,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
121
121
|
for (let cursor = 0; ;) {
|
|
122
122
|
const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor}&stream=${stream}`, {}, { request, transport, cache });
|
|
123
123
|
parts.push(Buffer.from(part.data_base64 ?? "", "base64"));
|
|
124
|
-
if (part.eof && part.tail_base64) parts.push(
|
|
124
|
+
if (part.eof && part.tail_base64) parts.push(...tailAfter(part));
|
|
125
125
|
if (part.eof || part.next_cursor === cursor) break;
|
|
126
126
|
cursor = part.next_cursor;
|
|
127
127
|
}
|
|
@@ -135,6 +135,24 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
135
135
|
return job;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The 8 MiB output budget is shared by both streams, and once it runs out each
|
|
140
|
+
* stream's final 64 KiB comes back as a tail. Keep only what the retained prefix
|
|
141
|
+
* does not already hold: nothing for a stream that was not cut, the rest after a
|
|
142
|
+
* gap note for one that was.
|
|
143
|
+
*/
|
|
144
|
+
export function tailAfter(part) {
|
|
145
|
+
const tail = Buffer.from(part.tail_base64, "base64"), have = part.next_cursor;
|
|
146
|
+
const start = Number.isSafeInteger(part.tail_offset) ? part.tail_offset : have;
|
|
147
|
+
const total = Number.isSafeInteger(part.total_bytes) ? part.total_bytes : start + tail.length;
|
|
148
|
+
if (total <= have) return [];
|
|
149
|
+
if (start > have) return [Buffer.from(`\n[output over 8 MiB was not kept; its last part follows]\n`), tail];
|
|
150
|
+
return [tail.subarray(have - start)];
|
|
151
|
+
}
|
|
152
|
+
/** A command line the person can paste back: simple words as they are, the rest single-quoted. */
|
|
153
|
+
const shellWords = words => words.map(w => /^[A-Za-z0-9_\/.:=@%+,-]+$/.test(w) ? w : `'${w.replaceAll("'", "'\\''")}'`).join(" ");
|
|
154
|
+
/** Errors after which the command may or may not have started on the Mac. */
|
|
155
|
+
const uncertainOutcome = error => !Number.isInteger(error?.status) || error.status >= 500 || ["outcome_unknown", "operation_pending"].includes(error?.code);
|
|
138
156
|
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
139
157
|
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
140
158
|
/** One readable paragraph for nomac status. */
|
|
@@ -168,11 +186,16 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
168
186
|
const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
|
|
169
187
|
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
170
188
|
const output = result => {
|
|
189
|
+
// Sync, pull and the other commands answer with their own result, which has no session.
|
|
190
|
+
if (values.json || !["start", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
|
|
171
191
|
const billing = result.session?.billing;
|
|
172
192
|
const usage = billing?.mode === "weekly_included"
|
|
173
193
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
174
194
|
: `Cost so far: $${(Number(result.session?.cost?.accrued_microunits ?? 0) / 1_000_000).toFixed(2)} ($0.80/hour, billed by the minute while the Mac is ready). Credit left: nomac credits`;
|
|
175
|
-
|
|
195
|
+
const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
|
|
196
|
+
: result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
|
|
197
|
+
: result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
|
|
198
|
+
print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}`);
|
|
176
199
|
return result;
|
|
177
200
|
};
|
|
178
201
|
const saveView = (local, view) => {
|
|
@@ -217,9 +240,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
217
240
|
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
218
241
|
if (values["request-key"] !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"])) throw new CliError("A request key is 8 to 128 letters, digits, underscores, periods, colons or hyphens.");
|
|
219
242
|
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
220
|
-
//
|
|
243
|
+
// Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
|
|
244
|
+
// With a key the limit must not depend on --wait: a retry with another --wait is the same command.
|
|
221
245
|
const waitFor = integer(values.wait, 30, 0, 600);
|
|
222
|
-
|
|
246
|
+
const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 600);
|
|
247
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: timeout } };
|
|
223
248
|
} else {
|
|
224
249
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
225
250
|
path = `${base}/jobs/${positionals[0]}`;
|
|
@@ -233,6 +258,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
233
258
|
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache }).catch(error => {
|
|
234
259
|
// The job exists; a busy Mac only delays its report.
|
|
235
260
|
if (command === "job" && waiting && error?.code === "guest_busy") return { id: positionals[0], state: "running" };
|
|
261
|
+
// The key this CLI made up is the only safe way to retry: say it, with the exact same command.
|
|
262
|
+
if (command === "exec" && values["request-key"] === undefined && uncertainOutcome(error)) {
|
|
263
|
+
error.friendly = `${error.friendly ?? error.message}\nThe command may have started. To retry it without running it twice: nomac exec --request-key ${params.requestKey} --timeout ${params.body.timeout_seconds}${values.wait !== undefined ? ` --wait ${values.wait}` : ""} -- ${shellWords(positionals)}`;
|
|
264
|
+
}
|
|
236
265
|
throw error;
|
|
237
266
|
});
|
|
238
267
|
if (!waiting) return output(result);
|
|
@@ -248,8 +277,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
248
277
|
const local = localSession();
|
|
249
278
|
if (positionals[0] === "stop" && positionals.length === 1) {
|
|
250
279
|
const saved = await stopRecording(local.id, { saveTo: values.output ?? "nomac-recording.mp4" });
|
|
251
|
-
const r = saved.recording;
|
|
252
|
-
|
|
280
|
+
const r = saved.recording, link = saved.link;
|
|
281
|
+
// Only a ready link can be shared; a failed or unfinished copy says so instead.
|
|
282
|
+
const share = link?.state === "ready" ? `Share it: ${link.url} (works until ${link.expires_at.slice(11, 16)} UTC, also after the Mac stops)`
|
|
283
|
+
: link?.state === "failed" ? `No share link: ${link.error?.message ?? "the copy from the Mac failed"}${saved.saved_to ? "" : ` The video is still on the Mac at ${r.path} until the Mac stops.`}`
|
|
284
|
+
: `The share link is still being prepared: ${link?.url} (check it in a minute; it works until ${link?.expires_at?.slice(11, 16)} UTC).`;
|
|
285
|
+
print(values.json ? JSON.stringify(saved, null, 2) : `${saved.saved_to ? `Saved ${saved.saved_to}` : `Saved on the Mac at ${r.path}`} (${Number(r.seconds).toFixed(1)} s, ${(r.size / 1024 / 1024).toFixed(1)} MB${r.ended_by === "time limit" ? ", stopped at its time limit" : ""}).\n${share}`);
|
|
253
286
|
return saved;
|
|
254
287
|
}
|
|
255
288
|
if (positionals.length) throw new CliError("Usage: nomac record [--simulator] [--max SECONDS], then nomac record stop [-o FILE]");
|
package/src/mac-fetch.mjs
CHANGED
|
@@ -7,6 +7,8 @@ export const FETCH_SCRIPT = [
|
|
|
7
7
|
"set -euo pipefail",
|
|
8
8
|
'dest="$NOMAC_PATH"',
|
|
9
9
|
'case "$dest" in "~") dest="$HOME" ;; "~/"*) dest="$HOME/${dest:2}" ;; esac',
|
|
10
|
+
// A plain download moved onto a folder would land inside it under a temporary name.
|
|
11
|
+
'if [ -z "${NOMAC_EXTRACT:-}" ] && [ -d "$dest" ]; then echo "$dest is a folder. Give the path of the file to create, for example $dest/NAME, or use extract to unpack an archive into the folder." >&2; exit 2; fi',
|
|
10
12
|
'tmp="$(mktemp "${TMPDIR:-/tmp}/nomac-fetch.XXXXXX")"',
|
|
11
13
|
"trap 'rm -f \"$tmp\"' EXIT",
|
|
12
14
|
'curl -fsSL --retry 3 --retry-all-errors --max-time 590 -o "$tmp" -- "$NOMAC_URL"',
|
package/src/mac-tools.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, resolve } from "node:path";
|
|
|
6
6
|
import { FETCH_SCRIPT, imageType } from "./mac-fetch.mjs";
|
|
7
7
|
import { checkLink, createLink, saveFromLink, uploadThroughLink } from "./mac-links.mjs";
|
|
8
8
|
import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
|
|
9
|
-
const MAX_WRITE_BYTES = 3 * 1024 * 1024, MAX_READ_BYTES =
|
|
9
|
+
const MAX_WRITE_BYTES = 3 * 1024 * 1024, MAX_READ_BYTES = 3 * 1024 * 1024;
|
|
10
10
|
const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
11
11
|
const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
|
|
12
12
|
const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
|
|
@@ -22,7 +22,13 @@ export const MAC_TOOLS = [
|
|
|
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
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.", 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 } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
|
|
24
24
|
{ 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")) },
|
|
25
|
-
{ name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. 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))) },
|
|
25
|
+
{ 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). 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))) },
|
|
26
|
+
{ 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")) },
|
|
27
|
+
{ name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
|
|
28
|
+
{ name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
|
|
29
|
+
{ name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
|
|
30
|
+
{ name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
|
|
31
|
+
{ name: "install_secrets", description: "Install the account's secrets and signing identities on a running Mac now: every-Mac secrets, the on-request secrets named in names, and what the Mac already has; a deleted secret is removed. Macs receive every-Mac secrets on their own when they become ready, so call this after the person adds or changes one.", inputSchema: schema({ session_id: session, names: { type: "array", items: { type: "string", minLength: 1, maxLength: 128 }, maxItems: 100 } }, ["session_id"]), handler: async ({ session_id, names = [] }) => text(await api("POST", `${base({ session_id })}/secrets`, { body: { names } })) },
|
|
26
32
|
{ name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open \"$(xcode-select -p)/Applications/Simulator.app\" with exec_mac. macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
|
|
27
33
|
const view = await api("GET", base({ session_id }));
|
|
28
34
|
if (view?.session?.state !== "ready") return text({ error: { code: "session_not_ready", message: "The Mac is not ready. Wait until get_mac_session shows ready, then call open_mac_desktop again." } });
|
|
@@ -30,7 +36,7 @@ export const MAC_TOOLS = [
|
|
|
30
36
|
return text({ session_id, url, opened_in_browser: opened, message: opened ? "The desktop opened in the human's browser; they may need to sign in to nomac.app." : "Give the human this link; it opens after they sign in to nomac.app." });
|
|
31
37
|
} },
|
|
32
38
|
{ name: "stop_mac", description: "Stop billing by shutting down the VM and deleting its files. Retrieve files first. Poll get_mac_session until cleanup_confirmed; stopping only acknowledges the request.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("DELETE", base(args))) },
|
|
33
|
-
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command
|
|
39
|
+
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command can be at most 64 KiB in total: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
|
|
34
40
|
{ name: "write_mac_file", description: "Write a file on the Mac: local_path uploads a file from this machine, up to 2 GiB, without its content passing through you; or give content as text, or content_base64 for binary, up to 3 MiB. Relative Mac paths are in the session workspace; ~ is the home folder. Creates missing folders and replaces an existing file in one step; set executable for scripts. Safe to repeat. For a whole project use nomac sync; for larger files fetch_to_mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, content: { type: "string" }, content_base64: { type: "string" }, executable: { type: "boolean" } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, content, content_base64, executable }) => {
|
|
35
41
|
const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
|
|
36
42
|
if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
|
|
@@ -42,7 +48,7 @@ export const MAC_TOOLS = [
|
|
|
42
48
|
}
|
|
43
49
|
return text(await api("POST", `${base({ session_id })}/files`, { body: { path, ...(content !== undefined ? { content } : { content_base64 }), executable: Boolean(executable) } }));
|
|
44
50
|
} },
|
|
45
|
-
{ name: "read_mac_file", description: "Read a file on the Mac, up to max_bytes (default 1 MiB, max
|
|
51
|
+
{ name: "read_mac_file", description: "Read a file on the Mac, up to max_bytes (default 1 MiB, max 3 MiB). save_to writes the whole file to this machine instead of returning it (larger files come through a download link). Otherwise text comes back as text, binary as base64, and PNG, JPEG, GIF and WebP files as an image you can see, read whole up to 3 MiB. To see the screen or the Simulator, use screenshot_mac. Relative Mac paths are in the session workspace. Read-only on the Mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, max_bytes: { type: "integer", minimum: 1, maximum: MAX_READ_BYTES }, save_to: { type: "string" } }, ["session_id", "path"]), handler: async ({ session_id, path, max_bytes, save_to }) => {
|
|
46
52
|
const read = max => api("GET", `${base({ session_id })}/files?${new URLSearchParams({ path, ...(max ? { max_bytes: String(max) } : {}) })}`);
|
|
47
53
|
const head = file => file.encoding === "base64" ? imageType(Buffer.from(String(file.content_base64 ?? "").slice(0, 32), "base64")) : undefined;
|
|
48
54
|
// An image file, or one being saved here, is read whole in one go; others start with the default 1 MiB.
|
|
@@ -52,6 +58,13 @@ export const MAC_TOOLS = [
|
|
|
52
58
|
const bytes = file.encoding === "utf8" ? Buffer.from(file.content ?? "", "utf8") : Buffer.from(file.content_base64 ?? "", "base64");
|
|
53
59
|
const meta = Object.fromEntries(Object.entries(file).filter(([k]) => !["content", "content_base64"].includes(k)));
|
|
54
60
|
if (save_to) {
|
|
61
|
+
// Never save part of a file as if it were whole: a file over one read comes through a download link.
|
|
62
|
+
if (file.truncated && max_bytes === undefined) {
|
|
63
|
+
let link = await createLink(session_id, { direction: "download", path, wait_seconds: 30 });
|
|
64
|
+
for (let checks = 0; link.state === "copying" && checks < 12; checks++) link = await checkLink(link.url, 50);
|
|
65
|
+
if (link.state !== "ready") return text({ ...link, next_step: link.state === "copying" ? "The file is still being copied from the Mac. Call get_mac_file_link with this url and save_to to finish." : "The file could not be copied from the Mac; see error." });
|
|
66
|
+
return text({ ...meta, truncated: false, ...await saveFromLink(link, save_to) });
|
|
67
|
+
}
|
|
55
68
|
const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
|
|
56
69
|
return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
|
|
57
70
|
}
|
|
@@ -59,7 +72,7 @@ export const MAC_TOOLS = [
|
|
|
59
72
|
if (mimeType) return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
|
|
60
73
|
// Part of a binary file as base64 would only fill the context; say how to get it instead.
|
|
61
74
|
if (file.truncated && max_bytes === undefined && file.encoding === "base64") return text({ ...meta, next_step: head(file)
|
|
62
|
-
? "This image is over
|
|
75
|
+
? "This image is over 3 MiB. Make a smaller copy on the Mac and read that: exec_mac [\"sips\", \"-Z\", \"1568\", \"-s\", \"format\", \"jpeg\", PATH, \"--out\", \"small.jpg\"]. save_to or create_mac_download_link fetches the original."
|
|
63
76
|
: "This binary file is over 1 MiB. Pass save_to to download it to this machine, or max_bytes to read its first bytes as base64." });
|
|
64
77
|
return text(file);
|
|
65
78
|
} },
|
|
@@ -69,7 +82,7 @@ export const MAC_TOOLS = [
|
|
|
69
82
|
const { image_base64: data, ...meta } = shot;
|
|
70
83
|
return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
|
|
71
84
|
} },
|
|
72
|
-
{ name: "start_mac_recording", description: "Start recording a video of the Mac. target screen (default) records the whole desktop; target simulator records the
|
|
85
|
+
{ name: "start_mac_recording", description: "Start recording a video of the Mac. target screen (default) records the whole desktop; target simulator records the iOS Simulator's window. Returns at once while it records: then do what should be shown (with exec_mac: launch the app, run UI tests, open a URL) and call stop_mac_recording, which saves the MP4 and can download it here. Stops by itself after max_seconds (default 300, max 1800). One recording at a time per session; a stopped Mac deletes it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, max_seconds: { type: "integer", minimum: 1, maximum: 1800 }, path: { type: "string", minLength: 1, maxLength: 1024 } }, ["session_id"]), handler: async ({ session_id, target, max_seconds, path }) => text(await startRecording(session_id, { target, maxSeconds: max_seconds, path })) },
|
|
73
86
|
{ name: "stop_mac_recording", description: "Stop the recording started with start_mac_recording and save it as an MP4. Returns its path, length in seconds and size, and a download link for the human (url). save_to downloads the video to this machine. If the recording already reached its max_seconds, returns that video. Safe to repeat.", inputSchema: schema({ session_id: session, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, save_to }) => text(await stopRecording(session_id, { saveTo: save_to })) },
|
|
74
87
|
{ name: "fetch_to_mac", description: "Download a URL straight onto the Mac: any size, as fast as the Mac's connection. path is the file to create, or with extract (zip or tar, including .tar.gz) the folder to unpack into. Relative paths are in the session workspace. Waits up to wait_seconds (default 30, max 50) like exec_mac; a longer download keeps running as a job. For a git repository, run git clone with exec_mac instead.", inputSchema: schema({ session_id: session, request_key: key, url: { type: "string", pattern: "^https?://", maxLength: 4096 }, path: { type: "string", minLength: 1, maxLength: 1024 }, extract: { type: "string", enum: ["zip", "tar"] }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "url", "path"]), handler: async ({ session_id, request_key, url, path, extract, wait_seconds }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 30}`, { body: { argv: ["/bin/bash", "-c", FETCH_SCRIPT], env: { NOMAC_URL: url, NOMAC_PATH: path, NOMAC_EXTRACT: extract ?? "" }, timeout_seconds: 600 }, idempotencyKey: request_key })) },
|
|
75
88
|
{ name: "create_mac_upload_link", description: "Get a link that puts one file of up to 2 GiB on the Mac at path. With local_path, the file is uploaded from this machine now and placed on the Mac. Otherwise give url to the human (open: true opens it in this machine's browser): they choose a file in the browser and it lands on the Mac, no account needed. Then call get_mac_file_link until state is placed. With extract (zip or tar), path is the folder to unpack into. The link takes one file and expires after expires_in_minutes (default 60, max 1440).", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, executable: { type: "boolean" }, extract: { type: "string", enum: ["zip", "tar"] }, expires_in_minutes: { type: "integer", minimum: 1, maximum: 1440 }, open: { type: "boolean", default: false, description: "Open the link in this machine's browser for the human." } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, executable, extract, expires_in_minutes, open }) => {
|
package/src/mcp-args.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks MCP tool arguments against the tool's JSON Schema before calling the
|
|
3
|
+
* API, so a missing or misnamed argument (job_id sent as id) is named to the
|
|
4
|
+
* agent instead of becoming a request for /jobs/undefined.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately lenient where being strict could break a call that works today:
|
|
7
|
+
* unknown extra keys are ignored and a number may arrive as a numeric string.
|
|
8
|
+
* Returns the first problem as a sentence, or undefined when the arguments fit.
|
|
9
|
+
*/
|
|
10
|
+
export function argumentProblem(schema, value, path = "") {
|
|
11
|
+
if (!schema || typeof schema !== "object") return undefined;
|
|
12
|
+
const name = path || "arguments";
|
|
13
|
+
if (Array.isArray(schema.anyOf) && !schema.anyOf.some(option => argumentProblem(option, value, path) === undefined)) {
|
|
14
|
+
return argumentProblem(schema.anyOf[0], value, path) ?? `${name} does not match any allowed form.`;
|
|
15
|
+
}
|
|
16
|
+
if (value === undefined) return undefined;
|
|
17
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return `${name} must be one of ${schema.enum.map(v => JSON.stringify(v)).join(", ")}.`;
|
|
18
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
19
|
+
if (types.length && !types.some(type => fits(type, value))) return `${name} must be ${types.map(article).join(" or ")}.`;
|
|
20
|
+
if (typeof value === "string" || (typeof value === "number" && types.some(t => t === "integer" || t === "number"))) {
|
|
21
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
22
|
+
if (types.some(t => t === "integer" || t === "number")) {
|
|
23
|
+
if (typeof schema.minimum === "number" && number < schema.minimum) return `${name} must be at least ${schema.minimum}.`;
|
|
24
|
+
if (typeof schema.maximum === "number" && number > schema.maximum) return `${name} must be at most ${schema.maximum}.`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === "string" && types.includes("string")) {
|
|
28
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) return `${name} must be at least ${schema.minLength} characters.`;
|
|
29
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return `${name} must be at most ${schema.maxLength} characters.`;
|
|
30
|
+
if (typeof schema.pattern === "string" && !matches(schema.pattern, value)) return `${name} has the wrong format (expected ${schema.pattern}).`;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) return `${name} needs at least ${schema.minItems} item${schema.minItems === 1 ? "" : "s"}.`;
|
|
34
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return `${name} can have at most ${schema.maxItems} items.`;
|
|
35
|
+
if (schema.items) for (let i = 0; i < value.length; i++) { const problem = argumentProblem(schema.items, value[i], `${name}[${i}]`); if (problem) return problem; }
|
|
36
|
+
}
|
|
37
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
38
|
+
for (const required of schema.required ?? []) if (value[required] === undefined) return `${path ? `${path}.` : ""}${required} is required.`;
|
|
39
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
40
|
+
const problem = argumentProblem(property, value[key], path ? `${path}.${key}` : key);
|
|
41
|
+
if (problem) return problem;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A pattern this checker cannot compile is the server's to enforce, never a reason to refuse a call. */
|
|
48
|
+
function matches(pattern, value) {
|
|
49
|
+
try { return new RegExp(pattern).test(value); } catch { return true; }
|
|
50
|
+
}
|
|
51
|
+
const numeric = value => typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value);
|
|
52
|
+
function fits(type, value) {
|
|
53
|
+
switch (type) {
|
|
54
|
+
case "string": return typeof value === "string";
|
|
55
|
+
case "integer": return (typeof value === "number" && Number.isInteger(value)) || (numeric(value) && Number.isInteger(Number(value)));
|
|
56
|
+
case "number": return (typeof value === "number" && Number.isFinite(value)) || numeric(value);
|
|
57
|
+
case "boolean": return typeof value === "boolean";
|
|
58
|
+
case "array": return Array.isArray(value);
|
|
59
|
+
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
case "null": return value === null;
|
|
61
|
+
default: return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const article = type => ({ integer: "an integer", object: "an object", array: "an array", null: "null" })[type] ?? `a ${type}`;
|
package/src/mcp.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
const lifecycleSchemas = JSON.parse(readFileSync(new URL("./lifecycle-schemas.json", import.meta.url), "utf8"));
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { api } from "./api.mjs";
|
|
10
|
-
import { mcpClientName, mcpContext, toolScope } from "./client-info.mjs";
|
|
10
|
+
import { CLI_VERSION, mcpClientName, mcpContext, toolScope } from "./client-info.mjs";
|
|
11
|
+
import { argumentProblem } from "./mcp-args.mjs";
|
|
11
12
|
import { loadProjectState, saveProjectState } from "./config.mjs";
|
|
12
13
|
import { packTarball } from "./pack.mjs";
|
|
13
14
|
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
@@ -15,6 +16,16 @@ import { uploadStoreAsset } from "./store-upload.mjs";
|
|
|
15
16
|
import { MAC_TOOLS } from "./mac-tools.mjs";
|
|
16
17
|
|
|
17
18
|
const PROTOCOL_FALLBACK = "2025-06-18";
|
|
19
|
+
/** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text). */
|
|
20
|
+
const INSTRUCTIONS = [
|
|
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.",
|
|
23
|
+
"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
|
+
"Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
|
|
25
|
+
"Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
|
|
26
|
+
"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.",
|
|
27
|
+
"Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
|
|
28
|
+
].join(" ");
|
|
18
29
|
|
|
19
30
|
const text = (obj) => ({
|
|
20
31
|
content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }],
|
|
@@ -25,7 +36,7 @@ async function resolveProjectId(args) {
|
|
|
25
36
|
if (args?.project_id) return args.project_id;
|
|
26
37
|
const state = loadProjectState(args?.directory ?? process.cwd());
|
|
27
38
|
if (state.project_id) return state.project_id;
|
|
28
|
-
throw new Error("no project here
|
|
39
|
+
throw new Error("no project here: run push_project first (or pass project_id)");
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
export const TOOLS = [
|
|
@@ -126,7 +137,7 @@ export const TOOLS = [
|
|
|
126
137
|
{
|
|
127
138
|
name: "build",
|
|
128
139
|
description:
|
|
129
|
-
"Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id
|
|
140
|
+
"Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id; poll it with status.",
|
|
130
141
|
inputSchema: {
|
|
131
142
|
type: "object",
|
|
132
143
|
properties: {
|
|
@@ -288,12 +299,12 @@ export const TOOLS = [
|
|
|
288
299
|
{
|
|
289
300
|
name: "publish",
|
|
290
301
|
description:
|
|
291
|
-
"Submit for App Store review (3-step reviewSubmissions). Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
302
|
+
"Submit for App Store review (3-step reviewSubmissions). Takes the app's newest build on TestFlight, or build_id: a Builder build or one sent with send_to_testflight, whose answer gives project_id and build_id. Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
292
303
|
inputSchema: {
|
|
293
304
|
type: "object",
|
|
294
305
|
properties: {
|
|
295
306
|
project_id: { type: "string" },
|
|
296
|
-
build_id: { type: "string", description: "Exact
|
|
307
|
+
build_id: { type: "string", description: "Exact NoMac build (bld_…) to attach, from send_to_testflight or build" },
|
|
297
308
|
asc_submission_id: { type: "string", description: "Exact Apple submission from get_review" },
|
|
298
309
|
resolve_rejection: { type: "boolean", default: false, description: "Assert this version rejection was fixed; mark its item ready for review" },
|
|
299
310
|
confirm: { type: "boolean", default: false },
|
|
@@ -415,7 +426,8 @@ export async function runMcpServer() {
|
|
|
415
426
|
reply({
|
|
416
427
|
protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
|
|
417
428
|
capabilities: { tools: {} },
|
|
418
|
-
serverInfo: { name: "nomac", version:
|
|
429
|
+
serverInfo: { name: "nomac", version: CLI_VERSION },
|
|
430
|
+
instructions: INSTRUCTIONS,
|
|
419
431
|
});
|
|
420
432
|
break;
|
|
421
433
|
case "notifications/initialized":
|
|
@@ -432,6 +444,11 @@ export async function runMcpServer() {
|
|
|
432
444
|
case "tools/call": {
|
|
433
445
|
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
434
446
|
if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
|
|
447
|
+
const problem = argumentProblem(tool.inputSchema, params?.arguments ?? {});
|
|
448
|
+
if (problem) {
|
|
449
|
+
reply({ content: [{ type: "text", text: JSON.stringify({ error: { code: "invalid_arguments", message: `${problem} Nothing was sent to the Mac; fix the arguments and call ${tool.name} again.` } }, null, 2) }], isError: true });
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
435
452
|
try {
|
|
436
453
|
reply(await toolScope.run({ tool: tool.name }, () => tool.handler(params?.arguments ?? {})));
|
|
437
454
|
} catch (err) {
|