@nomac/cli 0.14.0 → 0.15.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/README.md +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +4 -1
- package/src/mcp.mjs +2 -2
- package/src/phone-sync.mjs +314 -0
- package/src/phone-tools.json +13 -17
- package/src/phone-tools.mjs +34 -6
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ claude mcp add nomac -- npx @nomac/cli mcp
|
|
|
21
21
|
codex mcp add nomac -- npx @nomac/cli mcp
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Both MCP servers, `nomac mcp` here and the hosted `https://mcp.nomac.app/mcp` (no install), list the Mac and the cloud iPhone (the `phone` tools). App Store work (signing, TestFlight and App Store Connect) is `https://mcp.nomac.app/appstore` on the same sign-in, and `https://mcp.nomac.app/all` has everything. The cloud iPhone alone (NoMac Simulator): `https://mcp.nomac.app/simulator`, or `npx simulator-use cloud start` from any terminal.
|
|
24
|
+
Both MCP servers, `nomac mcp` here and the hosted `https://mcp.nomac.app/mcp` (no install), list the Mac and the cloud iPhone (the `phone` tools). App Store work (signing, TestFlight and App Store Connect) is `https://mcp.nomac.app/appstore` on the same sign-in, and `https://mcp.nomac.app/all` has everything. The cloud iPhone alone (NoMac Simulator): `https://mcp.nomac.app/simulator`, or `npx simulator-use cloud start` from any terminal. In `nomac mcp`, `phone_app_run` with `local_path` sends this machine's project to the phone, only the files that changed.
|
|
25
25
|
|
|
26
26
|
## What else
|
|
27
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "A real cloud Mac for AI agents: Xcode, the iOS Simulator, signing, TestFlight and App Store publishing, by the minute, over MCP, SSH and HTTPS.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/skill/SKILL.md
CHANGED
|
@@ -19,7 +19,10 @@ phone tools (`phone`, `phone_app_run`, `phone_ui_look`, `phone_ui_act` and the
|
|
|
19
19
|
other `phone_*`), which NoMac's MCP lists beside the Mac (`nomac mcp` and
|
|
20
20
|
https://mcp.nomac.app/mcp); https://mcp.nomac.app/simulator for the phone
|
|
21
21
|
alone; or `npx simulator-use cloud start` from a terminal
|
|
22
|
-
(https://nomac.app/simulator).
|
|
22
|
+
(https://nomac.app/simulator). `phone_app_run` builds from a git link, or from
|
|
23
|
+
the project here: in `nomac mcp`, `local_path` sends the folder (only what
|
|
24
|
+
changed); on the hosted server, `phone_upload_link` answers one command that
|
|
25
|
+
sends it.
|
|
23
26
|
|
|
24
27
|
## Cloud Mac setup and workflow
|
|
25
28
|
|
package/src/mcp.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const PROTOCOL_FALLBACK = "2025-06-18";
|
|
|
18
18
|
/** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text), within the 4,096 characters Claude Code keeps. */
|
|
19
19
|
const INSTRUCTIONS = [
|
|
20
20
|
"NoMac gives you a cloud iPhone and a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
|
|
21
|
-
"The cloud iPhone (the phone tools: phone and phone_*) builds, runs and tests an iOS app, ready in seconds, no shell: phone with action start (its answer has links.view for the person); phone_app_run with
|
|
21
|
+
"The cloud iPhone (the phone tools: phone and phone_*) builds, runs and tests an iOS app, ready in seconds, no shell: phone with action start (its answer has links.view for the person); phone_app_run with local_path (a folder here, only changes sent) or git (an https repository link) builds and runs the app, incrementally; phone_ui_look reads the screen as text, phone_ui_act taps and types by label; phone_app_logs, phone_tests_run; a long build answers with a job for phone_job_status; phone with action stop when done. The Mac (start_mac and the Mac tools) does the rest.",
|
|
22
22
|
"Use it when work needs macOS this machine lacks (Windows, Linux, a cloud sandbox): an iOS, iPadOS, watchOS, visionOS or macOS app (Swift, SwiftUI, React Native, Expo, Flutter, Capacitor), Xcode, signing, TestFlight or the App Store.",
|
|
23
23
|
"Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone into ~/drive/src, write_mac_file, fetch_to_mac, create_mac_upload_link), run exec_mac, get results with read_mac_file or create_mac_download_link, then stop_mac.",
|
|
24
24
|
"The account's drive at ~/drive (20 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; keep it under 10 GB (nomac-drive shows what takes the space, nomac-drive clean deletes stale build products).",
|
|
@@ -32,7 +32,7 @@ const INSTRUCTIONS = [
|
|
|
32
32
|
"The Mac is fully enabled for you: sudo needs no password, prompts and password dialogs are answered within seconds, the screen never locks, and the login password is in $NOMAC_MAC_PASSWORD on it. nomac-permit APP camera (with exec_mac) grants an app privacy permissions before it asks. To type or click as a person would, also on the lock screen, call mac_input; when screenshot_mac reports screen_locked or a dialog, follow its next_step.",
|
|
33
33
|
"A Mac stops at its time limit (2 hours unless start_mac set max_duration_seconds), its spend cap, its idle timeout or when credit runs out; everything outside ~/drive is deleted then. In its last 15 minutes answers carry stop_notice: extend_mac keeps it running (time limit or spend cap), or the human adds credit (reason credit). Before a long build, check that it fits.",
|
|
34
34
|
"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.",
|
|
35
|
-
"Always stop_mac when finished, after reading
|
|
35
|
+
"Always stop_mac when finished, after reading the output you need, so no credit goes to an idle Mac.",
|
|
36
36
|
].join(" ");
|
|
37
37
|
|
|
38
38
|
const text = (obj) => ({
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// A folder on this machine to the cloud iPhone's workspace, for local_path on the phone tools: simulator-use's own
|
|
2
|
+
// sync (its cloud mode, src/cloud/sync.ts), through the same REST API. The manifest (each file's path, size, mtime
|
|
3
|
+
// and SHA-256) goes in pages; the phone answers the paths it lacks; those go as one gzip tar, in 256 KiB chunks up to
|
|
4
|
+
// 8 MiB, else to an upload link; place writes them and every mtime (new mtimes alone make Xcode compile again), and
|
|
5
|
+
// removes what the folder no longer has. Build output and dependencies are left out, and left alone on the phone. A
|
|
6
|
+
// file's hash is kept in an index on this machine, so only files that changed since the last sync are read again.
|
|
7
|
+
|
|
8
|
+
import { execFile, spawn } from "node:child_process";
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { createReadStream, existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { mkdir, mkdtemp, readFile, readlink, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
import { api } from "./api.mjs";
|
|
15
|
+
|
|
16
|
+
/** Folders a build makes or a package manager fills: never sent, never removed on the phone. */
|
|
17
|
+
const SKIP_DIRS = new Set([".git", ".hg", ".svn", "DerivedData", "build", ".build", "node_modules", "Pods", "xcuserdata", ".swiftpm", ".expo", ".gradle"]);
|
|
18
|
+
/**
|
|
19
|
+
* Key and credential files, never sent even when git tracks them: environment files (but their examples), signing
|
|
20
|
+
* keys and certificates, provisioning profiles, keystores, SSH keys and .netrc. A Simulator build needs none of them.
|
|
21
|
+
*/
|
|
22
|
+
const secretFile = name => (/^\.env(\..+)?$/.test(name) && !/\.(example|sample|template)$/.test(name)) ||
|
|
23
|
+
/\.(p8|p12|pfx|pem|key|mobileprovision|provisionprofile|keystore|jks)$/i.test(name) ||
|
|
24
|
+
/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/.test(name) || name === ".netrc";
|
|
25
|
+
const SKIP_FILES = name => name === ".DS_Store" || name.endsWith(".xcuserstate") || secretFile(name);
|
|
26
|
+
// The protocol's limits (NoMac's mac-protocol, simManifestEntrySchema and the sim route's body).
|
|
27
|
+
const PAGE_ENTRIES = 1000;
|
|
28
|
+
const PAGE_BYTES = 192 * 1024;
|
|
29
|
+
const CHUNK = 256 * 1024;
|
|
30
|
+
const INLINE_MAX = 8 * 1024 * 1024;
|
|
31
|
+
const ARCHIVE_MAX = 2 * 1024 ** 3;
|
|
32
|
+
/** Files hashed at once. */
|
|
33
|
+
const HASHING = 8;
|
|
34
|
+
/** A file changed within this long of a scan is read again at the next one (coarse mtimes on some file systems). */
|
|
35
|
+
const RACY_MS = 2_000;
|
|
36
|
+
|
|
37
|
+
const sha256 = data => createHash("sha256").update(data).digest("hex");
|
|
38
|
+
const phones = "/api/v1/simulator/sessions";
|
|
39
|
+
|
|
40
|
+
/** Runs a program without a shell; rejects with its stderr when it fails or runs past the timeout. */
|
|
41
|
+
const run = (file, args, timeoutMs = 30_000) => new Promise((resolve_, reject) => {
|
|
42
|
+
execFile(file, args, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
43
|
+
if (!error) return resolve_({ stdout });
|
|
44
|
+
reject(new Error(`${file} ${args[0] ?? ""}: ${error.killed ? `timed out after ${Math.round(timeoutMs / 1000)} s` : (stderr || error.message).trim()}`));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The files git tracks in folder, or would (untracked but not ignored), from the folder, a submodule's too; undefined
|
|
50
|
+
* when folder is not a repository's top (or git is not there).
|
|
51
|
+
*/
|
|
52
|
+
async function filesInGit(folder) {
|
|
53
|
+
try {
|
|
54
|
+
const top = (await run("git", ["-C", folder, "rev-parse", "--show-toplevel"], 5_000)).stdout.trim();
|
|
55
|
+
if (realpathSync(top) !== realpathSync(folder)) return undefined;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const list = async (dir, prefix, depth) => {
|
|
60
|
+
const listed = (await run("git", ["-C", dir, "ls-files", "-co", "--exclude-standard", "-z"], 60_000)).stdout.split("\0").filter(Boolean);
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const rel of listed.map(path => path.replace(/\/+$/, ""))) {
|
|
63
|
+
// A submodule (or a repository inside) is listed as its folder: its own files, by its own rules.
|
|
64
|
+
if (depth < 4 && lstatSync(join(dir, rel), { throwIfNoEntry: false })?.isDirectory()) out.push(...await list(join(dir, rel), `${prefix}${rel}/`, depth + 1).catch(() => []));
|
|
65
|
+
else out.push(`${prefix}${rel}`);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
return await list(folder, "", 0);
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The files git tracks as executable (mode 100755) under folder, by their path from it; none outside a repository. */
|
|
77
|
+
async function executablesInGit(folder) {
|
|
78
|
+
try {
|
|
79
|
+
const listed = (await run("git", ["-C", folder, "ls-files", "-s", "-z"], 30_000)).stdout;
|
|
80
|
+
// Each entry: <mode> <object> <stage>\t<path>, NUL-separated; the paths are from the folder.
|
|
81
|
+
return new Set(listed.split("\0").filter(e => e.startsWith("100755 ")).map(e => e.slice(e.indexOf("\t") + 1)));
|
|
82
|
+
} catch {
|
|
83
|
+
return new Set();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const indexFile = (folder, only) => join(homedir(), ".nomac", "phone-sync-index", `${sha256(`${realpathSync(folder)}\0${only ?? ""}`).slice(0, 32)}.json`);
|
|
88
|
+
async function readIndex(file) {
|
|
89
|
+
try {
|
|
90
|
+
const index = JSON.parse(await readFile(file, "utf8"));
|
|
91
|
+
return typeof index.scanned_ms === "number" && index.files && typeof index.files === "object" ? index : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function writeIndex(file, index) {
|
|
97
|
+
await mkdir(dirname(file), { recursive: true });
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
await writeFile(tmp, JSON.stringify(index), { mode: 0o600 });
|
|
100
|
+
await rename(tmp, file);
|
|
101
|
+
}
|
|
102
|
+
/** A file's SHA-256: small files read at once, larger ones streamed. */
|
|
103
|
+
async function hashFile(path, size) {
|
|
104
|
+
if (size < 1024 * 1024) return sha256(await readFile(path));
|
|
105
|
+
const hash = createHash("sha256");
|
|
106
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
107
|
+
return hash.digest("hex");
|
|
108
|
+
}
|
|
109
|
+
/** Runs work on each item, at most limit at a time. */
|
|
110
|
+
async function eachLimit(items, limit, work) {
|
|
111
|
+
let next = 0;
|
|
112
|
+
const worker = async () => { while (next < items.length) { const i = next++; await work(items[i], i); } };
|
|
113
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The project's files as the phone is to have them: in a git repository, the files git tracks or would track
|
|
118
|
+
* (.gitignore's rules kept), else the folder's; build output, dependencies, editor state and key or credential files
|
|
119
|
+
* left out either way. With only (a .app beside others in its folder), that one alone and all of it: a bundle's own
|
|
120
|
+
* folders are part of the app. A file whose size, mtime, ctime and inode match the index is not read again.
|
|
121
|
+
*/
|
|
122
|
+
export async function listProject(folder, only) {
|
|
123
|
+
const found = [];
|
|
124
|
+
const walk = (dir, whole) => {
|
|
125
|
+
for (const name of readdirSync(dir).sort()) {
|
|
126
|
+
if (!whole && SKIP_FILES(name)) continue;
|
|
127
|
+
const path = join(dir, name);
|
|
128
|
+
const st = lstatSync(path);
|
|
129
|
+
if (st.isDirectory()) {
|
|
130
|
+
if (whole || !SKIP_DIRS.has(name)) walk(path, whole);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (st.isSymbolicLink() || st.isFile()) found.push({ rel: relative(folder, path).split(sep).join("/"), path, st });
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
if (only) {
|
|
137
|
+
const top = join(folder, only);
|
|
138
|
+
if (lstatSync(top, { throwIfNoEntry: false })?.isDirectory()) walk(top, true);
|
|
139
|
+
} else {
|
|
140
|
+
const tracked = await filesInGit(folder);
|
|
141
|
+
if (!tracked) walk(folder, false);
|
|
142
|
+
else {
|
|
143
|
+
for (const rel of tracked.sort()) {
|
|
144
|
+
const parts = rel.split("/");
|
|
145
|
+
if (SKIP_FILES(parts.at(-1)) || parts.slice(0, -1).some(part => SKIP_DIRS.has(part))) continue;
|
|
146
|
+
const path = join(folder, ...parts);
|
|
147
|
+
const st = lstatSync(path, { throwIfNoEntry: false });
|
|
148
|
+
if (st && (st.isSymbolicLink() || st.isFile())) found.push({ rel, path, st });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Windows keeps no executable bit: git's index has it for the files it tracks (a build phase's script).
|
|
153
|
+
const gitExecutable = process.platform === "win32" ? await executablesInGit(folder) : undefined;
|
|
154
|
+
const file = indexFile(folder, only);
|
|
155
|
+
const last = await readIndex(file);
|
|
156
|
+
const scanned = Date.now();
|
|
157
|
+
const next = {};
|
|
158
|
+
const out = new Array(found.length);
|
|
159
|
+
await eachLimit(found, HASHING, async ({ rel, path, st }, i) => {
|
|
160
|
+
if (st.isSymbolicLink()) {
|
|
161
|
+
const target = await readlink(path);
|
|
162
|
+
out[i] = { path: rel, size: Buffer.byteLength(target), mtime_ms: Math.floor(st.mtimeMs), sha256: sha256(target), kind: "symlink" };
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const known = last?.files[rel];
|
|
166
|
+
// Git's rule for a racy entry: a file changed within RACY_MS of the last scan may have changed again since,
|
|
167
|
+
// with the same mtime, so it is read again.
|
|
168
|
+
const same = known && known.size === st.size && known.mtime_ms === st.mtimeMs && known.ctime_ms === st.ctimeMs && known.ino === st.ino && st.mtimeMs < last.scanned_ms - RACY_MS;
|
|
169
|
+
const hash = same ? known.sha256 : await hashFile(path, st.size);
|
|
170
|
+
next[rel] = { size: st.size, mtime_ms: st.mtimeMs, ctime_ms: st.ctimeMs, ino: st.ino, sha256: hash };
|
|
171
|
+
const executable = gitExecutable ? gitExecutable.has(rel) : Boolean(st.mode & 0o111);
|
|
172
|
+
out[i] = { path: rel, size: st.size, mtime_ms: Math.floor(st.mtimeMs), sha256: hash, kind: executable ? "executable" : "file" };
|
|
173
|
+
});
|
|
174
|
+
await writeIndex(file, { scanned_ms: scanned, files: next }).catch(() => undefined);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Pages of at most 1000 entries and about 192 KiB each, in the manifest's order. */
|
|
179
|
+
export function pages(entries) {
|
|
180
|
+
const out = [[]];
|
|
181
|
+
let bytes = 0;
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const size = JSON.stringify(entry).length + 1;
|
|
184
|
+
if (out.at(-1).length >= PAGE_ENTRIES || (bytes + size > PAGE_BYTES && out.at(-1).length)) { out.push([]); bytes = 0; }
|
|
185
|
+
out.at(-1).push(entry);
|
|
186
|
+
bytes += size;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** git@github.com:a/b.git, https://user:token@github.com/a/b and https://github.com/a/b/ are one remote. */
|
|
192
|
+
export function normalizeRemote(remote) {
|
|
193
|
+
const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(remote);
|
|
194
|
+
const url = scp ? `ssh://${scp[1]}/${scp[2]}` : remote;
|
|
195
|
+
try {
|
|
196
|
+
const u = new URL(url);
|
|
197
|
+
return `${u.hostname.toLowerCase()}/${u.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`;
|
|
198
|
+
} catch {
|
|
199
|
+
return remote.replace(/\.git$/, "");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The project's id on NoMac: the hash of its git remote (and its folder inside the repository), or of the folder's
|
|
205
|
+
* path where it has none, so the same project lands in the same place in the account's workspace from any clone, and
|
|
206
|
+
* where simulator-use's cloud mode puts it too.
|
|
207
|
+
*/
|
|
208
|
+
export async function projectId(folder) {
|
|
209
|
+
let key = realpathSync(folder);
|
|
210
|
+
try {
|
|
211
|
+
const remote = (await run("git", ["-C", folder, "config", "--get", "remote.origin.url"], 5_000)).stdout.trim();
|
|
212
|
+
const top = (await run("git", ["-C", folder, "rev-parse", "--show-toplevel"], 5_000)).stdout.trim();
|
|
213
|
+
if (remote && top) key = `${normalizeRemote(remote)}#${relative(realpathSync(top), key).split(sep).join("/")}`;
|
|
214
|
+
} catch {
|
|
215
|
+
// Not a git repository, or no remote: the folder's own path.
|
|
216
|
+
}
|
|
217
|
+
return `prj_${sha256(key).slice(0, 32)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A gzip tar of these paths, without macOS's metadata files, paths read NUL-separated from a list. */
|
|
221
|
+
async function pack(folder, paths, file) {
|
|
222
|
+
const list = `${file}.list`;
|
|
223
|
+
await writeFile(list, `${paths.join("\0")}\0`);
|
|
224
|
+
const args = [...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), "--null", "-czf", file, "-C", folder, "-T", list];
|
|
225
|
+
await new Promise((resolve_, reject) => {
|
|
226
|
+
const child = spawn("tar", args, { env: { ...process.env, COPYFILE_DISABLE: "1" }, stdio: ["ignore", "ignore", "pipe"] });
|
|
227
|
+
let stderr = "";
|
|
228
|
+
child.stderr.on("data", d => (stderr += d.toString()));
|
|
229
|
+
child.on("error", reject);
|
|
230
|
+
child.on("close", code => (code === 0 ? resolve_() : reject(new Error(`tar: ${stderr.trim().slice(0, 300) || `exit ${code}`}`))));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Sends a folder to the phone's workspace. Answers where it is on the phone (the path the phone's tools take) and what went. */
|
|
235
|
+
export async function syncFolder(session, folder, options = {}) {
|
|
236
|
+
const { only } = options;
|
|
237
|
+
const started = Date.now();
|
|
238
|
+
const entries = await listProject(folder, only);
|
|
239
|
+
if (!entries.length) throw new Error(`${folder} has no files to send.`);
|
|
240
|
+
const project = options.project ?? await projectId(folder);
|
|
241
|
+
const sync = `cli-${randomBytes(16).toString("hex")}`;
|
|
242
|
+
const book = pages(entries);
|
|
243
|
+
if (book.length > 256) throw new Error(`${folder} has ${entries.length} files: more than NoMac's sync takes (256 pages).`);
|
|
244
|
+
// Pages go 4 at a time: the phone keeps each by its number, and places once it has them all.
|
|
245
|
+
const answered = new Array(book.length);
|
|
246
|
+
await eachLimit(book, 4, async (entriesOfPage, page) => {
|
|
247
|
+
const answer = await api("POST", `${phones}/${session}/sync`, { body: { op: "manifest", sync, project, page, pages: book.length, entries: entriesOfPage } });
|
|
248
|
+
// Only paths this page listed are packed: an answer naming any other file of the folder sends nothing more.
|
|
249
|
+
const listed = new Set(entriesOfPage.map(e => e.path));
|
|
250
|
+
answered[page] = (answer.missing ?? []).filter(path => typeof path === "string" && listed.has(path));
|
|
251
|
+
});
|
|
252
|
+
const missing = answered.flat();
|
|
253
|
+
let archive;
|
|
254
|
+
let bytes = 0;
|
|
255
|
+
if (missing.length) {
|
|
256
|
+
const dir = await mkdtemp(join(tmpdir(), "nomac-phone-sync-"));
|
|
257
|
+
try {
|
|
258
|
+
const file = join(dir, "archive.tgz");
|
|
259
|
+
await pack(folder, missing, file);
|
|
260
|
+
const data = await readFile(file);
|
|
261
|
+
bytes = data.length;
|
|
262
|
+
if (bytes > ARCHIVE_MAX) throw new Error(`The changed files pack to ${Math.round(bytes / 1024 ** 2)} MB: more than the 2 GB a sync takes.`);
|
|
263
|
+
const sum = sha256(data);
|
|
264
|
+
if (bytes <= INLINE_MAX) {
|
|
265
|
+
for (let offset = 0; offset < bytes; offset += CHUNK) {
|
|
266
|
+
await api("PUT", `${phones}/${session}/sync/blobs/${sum}?size=${bytes}&offset=${offset}`, { raw: data.subarray(offset, Math.min(offset + CHUNK, bytes)) });
|
|
267
|
+
}
|
|
268
|
+
archive = { blob: sum };
|
|
269
|
+
} else {
|
|
270
|
+
// Larger archives go straight to storage, and the phone fetches them from there.
|
|
271
|
+
const link = await api("POST", `${phones}/${session}/sync/upload`, { body: { size: bytes, sha256: sum } });
|
|
272
|
+
const put = await fetch(String(link.upload_url), { method: "PUT", body: data, headers: { "content-length": String(bytes) } });
|
|
273
|
+
if (!put.ok) throw new Error(`The upload to NoMac's storage failed: ${put.status}.`);
|
|
274
|
+
archive = link.archive;
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
await rm(dir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const deadline = Date.now() + 10 * 60_000; ;) {
|
|
281
|
+
const placed = await api("POST", `${phones}/${session}/sync`, { body: { op: "place", sync, ...(archive ? { archive } : {}) } });
|
|
282
|
+
if (placed.state === "done") return { root: String(placed.root), files: entries.length, sent: missing.length, bytes, ms: Date.now() - started };
|
|
283
|
+
if (placed.state !== "running") throw new Error(`The sync failed on the phone: ${String(placed.error ?? placed.state)}.`);
|
|
284
|
+
if (Date.now() > deadline) throw new Error("The sync was still being written after 10 minutes.");
|
|
285
|
+
await new Promise(r => setTimeout(r, 1_000));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Where a project is sent from: the repository it is in (so local packages beside it come along), else its own
|
|
291
|
+
* folder; and the path inside that the phone's tool takes.
|
|
292
|
+
*/
|
|
293
|
+
export async function projectRoot(path) {
|
|
294
|
+
const local = resolve(path);
|
|
295
|
+
if (!existsSync(local)) throw new Error(`No project at ${local}. Pass local_path: the folder with the .xcodeproj, .xcworkspace or Package.swift.`);
|
|
296
|
+
const isProject = [".xcodeproj", ".xcworkspace"].includes(extname(local));
|
|
297
|
+
const dir = isProject || !statSync(local).isDirectory() ? dirname(local) : local;
|
|
298
|
+
let folder = realpathSync(dir);
|
|
299
|
+
try {
|
|
300
|
+
folder = realpathSync((await run("git", ["-C", dir, "rev-parse", "--show-toplevel"], 5_000)).stdout.trim());
|
|
301
|
+
} catch {
|
|
302
|
+
// Not in a repository: the folder itself.
|
|
303
|
+
}
|
|
304
|
+
return { folder, inside: relative(folder, realpathSync(local)).split(sep).join("/") };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A .app built for the Simulator on this machine, sent as a project of its own, so it never touches the files of the project it was built from. */
|
|
308
|
+
export async function syncApp(session, path) {
|
|
309
|
+
const app = resolve(path);
|
|
310
|
+
if (extname(app) !== ".app" || !existsSync(app) || !statSync(app).isDirectory()) throw new Error(`${path} is not a .app built for the Simulator: build with xcodebuild -sdk iphonesimulator; the .app is under Build/Products/Debug-iphonesimulator.`);
|
|
311
|
+
const project = `prj_${sha256(`app:${realpathSync(app)}`).slice(0, 32)}`;
|
|
312
|
+
const synced = await syncFolder(session, dirname(app), { only: basename(app), project });
|
|
313
|
+
return { ...synced, app: `${synced.root}/${basename(app)}` };
|
|
314
|
+
}
|
package/src/phone-tools.json
CHANGED
|
@@ -120,9 +120,9 @@
|
|
|
120
120
|
"pattern": "^[A-Z_][A-Z0-9_]{0,63}$"
|
|
121
121
|
},
|
|
122
122
|
"upload": {
|
|
123
|
-
"description": "Instead of git: the
|
|
123
|
+
"description": "Instead of git: the upload phone_upload_link answered (a folder or archive sent to its link), unpacked into the phone's workspace (only what changed)",
|
|
124
124
|
"type": "string",
|
|
125
|
-
"pattern": "^[a-f0-9]{64}$"
|
|
125
|
+
"pattern": "^(up_[a-f0-9]{32}|[a-f0-9]{64})$"
|
|
126
126
|
},
|
|
127
127
|
"name": {
|
|
128
128
|
"description": "With upload: where it goes, the same name each time keeps builds incremental; default: upload",
|
|
@@ -251,12 +251,12 @@
|
|
|
251
251
|
"type": "string"
|
|
252
252
|
},
|
|
253
253
|
"upload": {
|
|
254
|
-
"description": "install: the
|
|
254
|
+
"description": "install: the upload phone_upload_link answered, a .zip or .tar.gz holding the .app",
|
|
255
255
|
"type": "string",
|
|
256
|
-
"pattern": "^[a-f0-9]{64}$"
|
|
256
|
+
"pattern": "^(up_[a-f0-9]{32}|[a-f0-9]{64})$"
|
|
257
257
|
},
|
|
258
258
|
"name": {
|
|
259
|
-
"description": "With upload: where it goes
|
|
259
|
+
"description": "With upload: where it goes; default: app",
|
|
260
260
|
"type": "string",
|
|
261
261
|
"pattern": "^[A-Za-z0-9._-]{1,80}$"
|
|
262
262
|
},
|
|
@@ -362,9 +362,9 @@
|
|
|
362
362
|
"pattern": "^[A-Z_][A-Z0-9_]{0,63}$"
|
|
363
363
|
},
|
|
364
364
|
"upload": {
|
|
365
|
-
"description": "Instead of git: the
|
|
365
|
+
"description": "Instead of git: the upload phone_upload_link answered (a folder or archive sent to its link), unpacked into the phone's workspace (only what changed)",
|
|
366
366
|
"type": "string",
|
|
367
|
-
"pattern": "^[a-f0-9]{64}$"
|
|
367
|
+
"pattern": "^(up_[a-f0-9]{32}|[a-f0-9]{64})$"
|
|
368
368
|
},
|
|
369
369
|
"name": {
|
|
370
370
|
"description": "With upload: where it goes, the same name each time keeps builds incremental; default: upload",
|
|
@@ -466,31 +466,27 @@
|
|
|
466
466
|
},
|
|
467
467
|
{
|
|
468
468
|
"name": "phone_upload_link",
|
|
469
|
-
"description": "Cloud iPhone: Answers a link
|
|
469
|
+
"description": "Cloud iPhone: Answers a link for the account's running phone that takes a project folder from this machine (a project without a git link) or a .app built elsewhere, up to 2 GB, and upload_command, which packs the folder it runs in and sends it in one step. Then pass upload to phone_app_run, phone_tests_run or phone_app_control install. size and sha256 together make a link for that one archive instead.",
|
|
470
470
|
"inputSchema": {
|
|
471
471
|
"type": "object",
|
|
472
472
|
"properties": {
|
|
473
473
|
"size": {
|
|
474
|
+
"description": "With sha256: the archive's exact size in bytes; the link then takes only that archive",
|
|
474
475
|
"type": "integer",
|
|
475
476
|
"minimum": 1,
|
|
476
|
-
"maximum": 9007199254740991
|
|
477
|
-
"description": "The archive's exact size in bytes: the link takes only that size"
|
|
477
|
+
"maximum": 9007199254740991
|
|
478
478
|
},
|
|
479
479
|
"sha256": {
|
|
480
|
+
"description": "With size: the archive's sha256, which upload is then",
|
|
480
481
|
"type": "string",
|
|
481
|
-
"pattern": "^[a-f0-9]{64}$"
|
|
482
|
-
"description": "The archive's sha256 (shasum -a 256)"
|
|
482
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
483
483
|
},
|
|
484
484
|
"session_id": {
|
|
485
485
|
"description": "The phone; default: the account's running phone",
|
|
486
486
|
"type": "string",
|
|
487
487
|
"pattern": "^ses_[A-Za-z0-9_-]{1,80}$"
|
|
488
488
|
}
|
|
489
|
-
}
|
|
490
|
-
"required": [
|
|
491
|
-
"size",
|
|
492
|
-
"sha256"
|
|
493
|
-
]
|
|
489
|
+
}
|
|
494
490
|
}
|
|
495
491
|
},
|
|
496
492
|
{
|
package/src/phone-tools.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
8
9
|
import { api } from "./api.mjs";
|
|
10
|
+
import { projectRoot, syncApp, syncFolder } from "./phone-sync.mjs";
|
|
9
11
|
|
|
10
12
|
const LISTED = JSON.parse(readFileSync(new URL("./phone-tools.json", import.meta.url), "utf8"));
|
|
11
13
|
/** A phone tool's simulator-use name for its listed one: phone is cloud, phone_ui_look is ui_look. */
|
|
@@ -55,7 +57,8 @@ async function place(phone, input, args, tool, key, fits) {
|
|
|
55
57
|
if (cloned.state !== "done") return asText(cloned.state === "running" ? { state: "running", next: `The repository is still being fetched. Call ${tool} again with the same request_key.`, request_key: key } : { error: { code: String(cloned.error ?? "clone_failed"), message: String(cloned.message ?? "The repository could not be fetched.") } });
|
|
56
58
|
args.path = typeof args.path === "string" && args.path ? `${cloned.root}/${args.path.replace(/^\/+/, "")}` : cloned.root;
|
|
57
59
|
} else if (upload) {
|
|
58
|
-
|
|
60
|
+
// A .app has a place of its own, so installing one never replaces an uploaded project's files.
|
|
61
|
+
const body = { ...(upload.startsWith("up_") ? { upload } : { sha256: upload }), name: name ?? (tool === "phone_app_control" ? "app" : "upload") };
|
|
59
62
|
let fetched = await api("POST", `${phones}/${phone}/sync/fetch`, { body, idempotencyKey: `${key}-fetch` });
|
|
60
63
|
for (let i = 0; fetched.state === "running" && i < 3 && fits(2); i++) fetched = await api("POST", `${phones}/${phone}/sync/fetch`, { body, idempotencyKey: `${key}-fetch` });
|
|
61
64
|
if (fetched.state !== "done") return asText(fetched.state === "running" ? { state: "running", next: `The upload is still being unpacked. Call ${tool} again with the same request_key.`, request_key: key } : { error: { code: String(fetched.error ?? "fetch_failed"), message: String(fetched.message ?? "The upload could not be unpacked.") } });
|
|
@@ -71,23 +74,40 @@ async function place(phone, input, args, tool, key, fits) {
|
|
|
71
74
|
|
|
72
75
|
/** One of simulator-use's tools on the account's phone. */
|
|
73
76
|
const simulatorTool = (listed, bare) => async input => {
|
|
74
|
-
const { session_id, request_key, git, ref, secret, upload, name, ...args } = input;
|
|
77
|
+
const { session_id, request_key, git, ref, secret, upload, name, local_path, ...args } = input;
|
|
78
|
+
if (local_path !== undefined && (git || upload)) return error("invalid_request", `local_path, git and upload each say where the project comes from: pass one. Nothing was sent; call ${listed} again.`);
|
|
79
|
+
if (local_path !== undefined && listed === "phone_app_control" && args.action !== "install") return error("invalid_request", "local_path is for install: the .app to send from this machine. Nothing was sent.");
|
|
75
80
|
const phone = session_id ?? await runningPhone();
|
|
76
81
|
if (typeof phone !== "string") return phone;
|
|
77
82
|
const key = request_key ?? newKey();
|
|
78
83
|
// An MCP client gives a call about two minutes: a fetch and the tool call each take up to 45 s.
|
|
79
84
|
const began = Date.now();
|
|
80
85
|
const fits = calls => Date.now() - began + calls * 47_000 < 112_000;
|
|
86
|
+
let sent;
|
|
87
|
+
if (local_path !== undefined) {
|
|
88
|
+
// Only what the phone lacks goes: a one-line change sends one file, and the build stays incremental.
|
|
89
|
+
const synced = listed === "phone_app_control" ? await syncApp(phone, local_path)
|
|
90
|
+
: await projectRoot(typeof args.path === "string" && args.path ? join(local_path, args.path) : local_path).then(async ({ folder, inside }) => ({ ...await syncFolder(phone, folder), inside }));
|
|
91
|
+
if (listed === "phone_app_control") args.app = synced.app;
|
|
92
|
+
else args.path = synced.inside ? `${synced.root}/${synced.inside}` : synced.root;
|
|
93
|
+
sent = synced.sent ? `Sent ${synced.sent} of ${synced.files} files (${Math.max(1, Math.round(synced.bytes / 1024))} KB) in ${(synced.ms / 1000).toFixed(1)} s.` : `Sent nothing: the phone has all ${synced.files} files.`;
|
|
94
|
+
}
|
|
81
95
|
const placed = await place(phone, { git, ref, secret, upload, name }, args, listed, key, fits);
|
|
82
96
|
if (placed) return placed;
|
|
83
|
-
if ((git || upload) && !fits(1)) return asText({ state: "running", request_key: key, next: `The project is on the phone. Call ${listed} again with the same arguments and request_key ${key} to run it.` });
|
|
84
|
-
|
|
97
|
+
if ((git || upload || sent) && !fits(1)) return asText({ state: "running", request_key: key, ...(sent ? { sent } : {}), next: `The project is on the phone. Call ${listed} again with the same arguments and request_key ${key} to run it${sent ? " (only what changed is sent again)" : ""}.` });
|
|
98
|
+
const reply = phoneReply(await api("POST", `${phones}/${phone}/tools/${bare}`, { body: args, idempotencyKey: key }), listed, key);
|
|
99
|
+
if (!sent) return reply;
|
|
100
|
+
// What was sent comes first, as the local tool's note does; after an error, which stays the answer's first part.
|
|
101
|
+
return { ...reply, content: reply.isError ? [...reply.content, { type: "text", text: sent }] : [{ type: "text", text: sent }, ...reply.content] };
|
|
85
102
|
};
|
|
86
103
|
|
|
87
104
|
async function uploadLink({ size, sha256, session_id }) {
|
|
105
|
+
if ((size === undefined) !== (sha256 === undefined)) return error("invalid_request", "Pass size and sha256 together, or neither for a link that takes any upload. Nothing was sent; call phone_upload_link again.");
|
|
88
106
|
const phone = session_id ?? await runningPhone();
|
|
89
107
|
if (typeof phone !== "string") return phone;
|
|
90
|
-
const link = await api("POST", `${phones}/${phone}/sync/upload`, { body: { size, sha256 } });
|
|
108
|
+
const link = await api("POST", `${phones}/${phone}/sync/upload`, { body: sha256 ? { size, sha256 } : {} });
|
|
109
|
+
if (!sha256) return asText({ upload: link.upload, upload_url: link.upload_url, upload_command: link.upload_command, expires_at: link.expires_at,
|
|
110
|
+
next: `Here, local_path does it in one call instead: phone_app_run or phone_tests_run with local_path (the project's folder) sends only what changed; phone_app_control install with local_path sends a .app. Otherwise run upload_command in the project's folder, then call phone_app_run or phone_tests_run with upload "${link.upload}"; the link takes a new upload until expires_at.` });
|
|
91
111
|
return asText({ upload_url: link.upload_url, expires_at: link.expires_at, upload_command: `curl -fsS -T PATH_TO_ARCHIVE '${String(link.upload_url)}'`, next: `Upload the archive to upload_url (an HTTP PUT), then pass upload: "${sha256}" to phone_app_run, phone_tests_run or phone_app_control install.` });
|
|
92
112
|
}
|
|
93
113
|
|
|
@@ -141,7 +161,15 @@ async function cloud(input) {
|
|
|
141
161
|
return asText(await api("GET", `${phones}/${phone}`));
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
/** This machine's files, which only the CLI reaches: local_path sends them to the phone first. */
|
|
165
|
+
const LOCAL_PATH = {
|
|
166
|
+
phone_app_run: "Instead of git or upload: the project's folder on this machine (or its .xcodeproj or .xcworkspace), sent to the phone first, only the files it lacks (from the git repository it is in, as git lists them; never build output, dependencies or key files); path is then a folder inside it",
|
|
167
|
+
phone_tests_run: "Instead of git or upload: the project's folder on this machine (or its .xcodeproj or .xcworkspace), sent to the phone first, only the files it lacks; path is then a folder inside it",
|
|
168
|
+
phone_app_control: "install: a .app built for the Simulator on this machine, sent to the phone first",
|
|
169
|
+
};
|
|
144
170
|
export const PHONE_TOOLS = LISTED.map(({ name, description, inputSchema }) => ({
|
|
145
|
-
name,
|
|
171
|
+
name,
|
|
172
|
+
description: LOCAL_PATH[name] ? `${description} Here, local_path sends ${name === "phone_app_control" ? "a .app" : "a project folder"} from this machine in the same call.` : description,
|
|
173
|
+
inputSchema: LOCAL_PATH[name] ? { ...inputSchema, properties: { ...inputSchema.properties, local_path: { type: "string", minLength: 1, maxLength: 4096, description: LOCAL_PATH[name] } } } : inputSchema,
|
|
146
174
|
handler: name === "phone" ? cloud : name === "phone_upload_link" ? uploadLink : simulatorTool(name, bareOf(name)),
|
|
147
175
|
}));
|