@nomac/cli 0.11.6 → 0.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skill/SKILL.md +31 -10
- package/src/cli.mjs +41 -5
- package/src/hosted-sessions.mjs +34 -15
- package/src/mac-tools.mjs +1 -1
- package/src/session-store.mjs +4 -0
- package/src/session-transfer.mjs +124 -30
- package/src/sessions.mjs +3 -3
- package/src/testflight.mjs +12 -5
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -21,32 +21,47 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
|
21
21
|
details yourself. Poll credits until the balance appears; a checkout URL is
|
|
22
22
|
not a payment. No Apple Developer account is needed for an unsigned build.
|
|
23
23
|
2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
|
|
24
|
-
the printed device URL and code.
|
|
24
|
+
the printed device URL and code. With a key the human made at
|
|
25
|
+
https://nomac.app/keys: `nomac login --with-token < key.txt`, or set
|
|
26
|
+
`NOMAC_API_KEY`. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
25
27
|
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
26
28
|
3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
|
|
27
29
|
Run commands with `nomac exec -- COMMAND`: it prints the output and exits
|
|
28
30
|
with the command's code, waiting at most 30 s. For builds and tests use
|
|
29
31
|
`nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
|
|
30
32
|
with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
|
|
31
|
-
`--request-key stable-key-0001`.
|
|
33
|
+
`--request-key stable-key-0001`. A command may run an hour (`--timeout
|
|
34
|
+
3600`); longer, use `nomac ssh -- COMMAND`. A command ends with everything
|
|
35
|
+
it started, so keep a dev server running with `nomac exec -- nomac-service
|
|
36
|
+
start api -- npx wrangler dev` (`nomac-service logs api`, `stop api`).
|
|
37
|
+
`nomac cancel JOB_ID` stops a command. MCP equivalents are `start_mac`, `exec_mac` (also waits),
|
|
32
38
|
`write_mac_file`, `read_mac_file`, `fetch_to_mac`, `stop_mac`. For a file
|
|
33
39
|
up to 2 GiB from the human, or to hand them a build, use
|
|
34
40
|
`create_mac_upload_link` and `create_mac_download_link`: each returns a url
|
|
35
41
|
they open in a browser.
|
|
36
|
-
4. Download work with `nomac pull ./build
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
4. Download work with `nomac pull ./build`, or to any local path with the
|
|
43
|
+
folder on the Mac named: `nomac pull /tmp/out --from build/export`. Then
|
|
44
|
+
`nomac stop --json` and confirm `cleanup_confirmed`.
|
|
45
|
+
|
|
46
|
+
The CLI keeps the project on the account's drive, in `~/drive/src/<folder
|
|
47
|
+
name>`, which is kept between sessions; stopping deletes everything else on
|
|
48
|
+
the Mac, logs included. Download what you need from outside `~/drive` before
|
|
49
|
+
stopping. SSH disconnects
|
|
40
50
|
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
41
51
|
One-minute minimum, then by the minute. One session at a time; no overage.
|
|
42
52
|
If start returns `insufficient_credit`, buy credit as in step 1, then retry
|
|
43
53
|
with the same request key.
|
|
44
54
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
45
55
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
46
|
-
|
|
56
|
+
A command runs up to its `timeout_seconds` (300 by default, 3600 at most) and
|
|
57
|
+
says so when that limit stops it; SSH can run longer within session limits.
|
|
47
58
|
CLI sync sends every file (.env and keys included) and symlinks as links, and
|
|
48
59
|
skips dependency and build folders, .git and folders .gitignore lists; a `!`
|
|
49
|
-
line in .nomacignore sends any of them.
|
|
60
|
+
line in .nomacignore sends any of them. A file changed on the Mac since the
|
|
61
|
+
last sync is left alone and named (the rest is sent, and sync exits 1): `nomac
|
|
62
|
+
pull` it, or `nomac sync . --overwrite`. Sync deletes nothing on the Mac
|
|
63
|
+
unless `--delete`, which removes what it sent earlier and you deleted here.
|
|
64
|
+
See the screen with `nomac screenshot` (saves
|
|
50
65
|
`nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
|
|
51
66
|
`screenshot_mac`, which returns an image you can look at. Record a video for
|
|
52
67
|
the human with `nomac record`, do the work, then `nomac record stop` (saves the
|
|
@@ -58,7 +73,10 @@ keyboard and mouse, also on the lock screen and in password dialogs, use
|
|
|
58
73
|
and give the human `open_mac_desktop` when they want to see the screen.
|
|
59
74
|
`get_mac_session` (and `nomac status --json`) lists what
|
|
60
75
|
is installed: Ruby with CocoaPods, xcbeautify, xcodegen, gh, Git LFS, jq.
|
|
61
|
-
Pipe xcodebuild through `xcbeautify`.
|
|
76
|
+
Pipe xcodebuild through `xcbeautify`. Simulator devices share names across iOS
|
|
77
|
+
runtimes: pick one by ID (`xcrun simctl list devices available`) or with
|
|
78
|
+
`OS=27.0` in `-destination`. Apple Intelligence cannot generate on a virtual
|
|
79
|
+
Mac, in the Simulator either: test it on a device.
|
|
62
80
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
63
81
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
64
82
|
|
|
@@ -84,7 +102,10 @@ secrets as variables and files, masked in command output; `nomac secrets
|
|
|
84
102
|
install` refreshes a running Mac.
|
|
85
103
|
|
|
86
104
|
The human connects Apple once at https://nomac.app/apple; the App Store
|
|
87
|
-
Connect key stays on NoMac's server.
|
|
105
|
+
Connect key stays on NoMac's server. Check `nomac apple` (MCP
|
|
106
|
+
`connect_status`) before asking them: it shows the key, certificates and
|
|
107
|
+
profiles already there. Then, from your project folder (the CLI acts on the
|
|
108
|
+
Mac):
|
|
88
109
|
|
|
89
110
|
1. `nomac sign [path]` (MCP `set_up_signing`) makes and installs the App Store
|
|
90
111
|
profiles the project needs and prints the files, the reserved build number
|
package/src/cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ Use it:
|
|
|
17
17
|
nomac exec -- ls run a command; prints its output, keeps its exit code
|
|
18
18
|
nomac exec --wait 600 -- xcodebuild test
|
|
19
19
|
wait up to 10 minutes (the default is 30 seconds)
|
|
20
|
+
nomac cancel JOB_ID stop a running command and everything it started
|
|
20
21
|
nomac ssh open a shell (nomac ssh -- command runs one)
|
|
21
22
|
nomac sync . upload changed project files
|
|
22
23
|
nomac pull ./build download a folder from the Mac
|
|
@@ -31,6 +32,7 @@ Use it:
|
|
|
31
32
|
nomac record record a video; nomac record stop saves it with a share link
|
|
32
33
|
nomac status state, cost so far and when it stops
|
|
33
34
|
nomac secrets variables and files every Mac receives (nomac secrets --help)
|
|
35
|
+
nomac apple whether Apple is connected: team key, certificates, profiles
|
|
34
36
|
nomac sign [path] set up App Store signing for the project on the Mac
|
|
35
37
|
nomac testflight app.ipa send a signed .ipa on the Mac to TestFlight
|
|
36
38
|
nomac publish app.ipa submit it for App Review (--confirm sends it)
|
|
@@ -151,14 +153,20 @@ export async function main(argv) {
|
|
|
151
153
|
return secretsCommand(rest, { store: sessionStore() });
|
|
152
154
|
}
|
|
153
155
|
case "login": {
|
|
154
|
-
|
|
156
|
+
// A key the person made at nomac.app/keys: as an argument, or on stdin with --with-token
|
|
157
|
+
// (kept out of shell history and the process list).
|
|
158
|
+
const key = rest[0] === "--with-token" ? (await readStdin()).trim() : rest[0];
|
|
155
159
|
if (key) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
throw new CliError("bad key", "Usage: nomac login nmk_… (or just `nomac login` to approve in the browser)");
|
|
160
|
+
if (!/^nmk_[0-9a-f]{40}$/.test(key)) {
|
|
161
|
+
throw new CliError("bad key", "Usage: nomac login --with-token < key.txt, or nomac login nmk_… (or just `nomac login` to approve in the browser). Keys start with nmk_ and are made at https://nomac.app/keys.");
|
|
159
162
|
}
|
|
163
|
+
// Checked before it replaces the saved one.
|
|
164
|
+
const previous = process.env.NOMAC_API_KEY;
|
|
165
|
+
process.env.NOMAC_API_KEY = key;
|
|
166
|
+
let me;
|
|
167
|
+
try { me = await api("GET", "/api/v1/me"); }
|
|
168
|
+
finally { if (previous === undefined) delete process.env.NOMAC_API_KEY; else process.env.NOMAC_API_KEY = previous; }
|
|
160
169
|
saveApiKey(key);
|
|
161
|
-
const me = await api("GET", "/api/v1/me");
|
|
162
170
|
console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
|
|
163
171
|
return;
|
|
164
172
|
}
|
|
@@ -182,6 +190,13 @@ export async function main(argv) {
|
|
|
182
190
|
const { runMcpServer } = await import("./mcp.mjs");
|
|
183
191
|
return runMcpServer();
|
|
184
192
|
}
|
|
193
|
+
case "apple": {
|
|
194
|
+
if (rest.some(arg => arg !== "--json")) throw new CliError("apple usage", "Usage: nomac apple [--json]\n The account's Apple connection, signing certificates and profiles.");
|
|
195
|
+
const view = await api("GET", "/api/v1/apple");
|
|
196
|
+
if (rest.includes("--json")) { console.log(JSON.stringify(view, null, 2)); return view; }
|
|
197
|
+
console.log(describeApple(view));
|
|
198
|
+
return view;
|
|
199
|
+
}
|
|
185
200
|
case "whoami": {
|
|
186
201
|
const [me, credit] = await Promise.all([api("GET", "/api/v1/me"), api("GET", "/api/v1/mac/credits").catch(() => null)]);
|
|
187
202
|
const o = me.org ?? {}, c = credit?.credits ?? credit ?? {};
|
|
@@ -208,6 +223,27 @@ export async function main(argv) {
|
|
|
208
223
|
}
|
|
209
224
|
}
|
|
210
225
|
|
|
226
|
+
async function readStdin() {
|
|
227
|
+
if (process.stdin.isTTY) throw new CliError("no key on stdin", "Pipe the key in: nomac login --with-token < key.txt");
|
|
228
|
+
let text = "";
|
|
229
|
+
for await (const chunk of process.stdin) { text += chunk; if (text.length > 1024) break; }
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What the account has for Apple, for a person or an agent deciding whether to ask anyone. */
|
|
234
|
+
export function describeApple(view) {
|
|
235
|
+
const accounts = view.apple_accounts ?? [], identities = (view.identities ?? []).filter(id => id.state === "active"), profiles = view.profiles ?? [];
|
|
236
|
+
if (!accounts.length && !identities.length) {
|
|
237
|
+
return "Apple: not connected. The account owner connects an App Store Connect team key once at https://nomac.app/apple (only a person can); until then nomac sign and nomac testflight cannot reach Apple.";
|
|
238
|
+
}
|
|
239
|
+
const lines = accounts.map(a => `Apple: connected (App Store Connect key ${a.key_id}, issuer ${a.issuer_id}), ${a.status}, since ${String(a.connected_at).slice(0, 10)}.`);
|
|
240
|
+
if (!accounts.length) lines.push("Apple: no App Store Connect key connected; the certificates below still reach every Mac. Connect one at https://nomac.app/apple to make profiles and send to TestFlight.");
|
|
241
|
+
lines.push(identities.length ? `Certificates on every Mac: ${identities.map(id => `${id.name} (until ${String(id.expires_at).slice(0, 10)})`).join("; ")}.` : "Certificates: none yet; nomac sign makes the App Store one when needed.");
|
|
242
|
+
lines.push(profiles.length ? `Profiles: ${profiles.map(p => `${p.bundle_id} (${p.type})`).join(", ")}.` : "Profiles: none yet; nomac sign makes them for a project.");
|
|
243
|
+
lines.push("Next: nomac sign [path] from your project folder, then nomac testflight App.ipa.");
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
211
247
|
async function ensureProject() {
|
|
212
248
|
const state = loadProjectState();
|
|
213
249
|
if (state.project_id) return state;
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -19,9 +19,12 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
19
19
|
password prompts, its login password in
|
|
20
20
|
$NOMAC_MAC_PASSWORD. --prompts ask keeps macOS's
|
|
21
21
|
usual prompts.
|
|
22
|
-
nomac sync . [--dry-run]
|
|
22
|
+
nomac sync . [--dry-run] [--delete]
|
|
23
|
+
upload changed files to the project folder on the Mac:
|
|
23
24
|
~/drive/src/<this folder's name>, kept between sessions
|
|
24
|
-
(~/workspace with --drive none)
|
|
25
|
+
(~/workspace with --drive none). A file changed on the Mac
|
|
26
|
+
since is left alone and named (--overwrite replaces it);
|
|
27
|
+
--delete also removes there what you deleted here
|
|
25
28
|
nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
|
|
26
29
|
nomac ssh -- command args run any command over SSH
|
|
27
30
|
nomac desktop [--no-open] [--json] the full macOS desktop in your browser
|
|
@@ -49,7 +52,9 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
49
52
|
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
50
53
|
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
51
54
|
nomac cancel JOB_ID stop a running command and everything it started
|
|
52
|
-
nomac pull
|
|
55
|
+
nomac pull LOCAL [--from PATH] copy files from the Mac: LOCAL is a folder or file here
|
|
56
|
+
(./out, /tmp/shots, ~/Downloads/App.ipa), PATH is relative
|
|
57
|
+
to the project folder on the Mac (default: LOCAL)
|
|
53
58
|
nomac screenshot [--simulator] [-o FILE]
|
|
54
59
|
save the screen (or the Simulator) as a JPEG,
|
|
55
60
|
nomac-screenshot.jpg unless -o
|
|
@@ -73,7 +78,7 @@ const options = {
|
|
|
73
78
|
help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
|
|
74
79
|
"max-duration": { type: "string" }, "idle-timeout": { type: "string" },
|
|
75
80
|
"max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
|
|
76
|
-
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
|
|
81
|
+
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" },
|
|
77
82
|
forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
|
|
78
83
|
"no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
|
|
79
84
|
prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
|
|
@@ -177,7 +182,15 @@ const NOTICE_MS = 5000;
|
|
|
177
182
|
* running keeps running on the Mac; the CLI says how to wait for it and exits
|
|
178
183
|
* 75. --wait 0 only starts it.
|
|
179
184
|
*/
|
|
180
|
-
|
|
185
|
+
/** "13:05 UTC", when a running command's time limit stops it. */
|
|
186
|
+
const stopsAt = job => Number.isFinite(job?.deadline) && job.deadline > Date.UTC(2020, 0) ? ` Its time limit (--timeout) stops it at ${new Date(job.deadline).toISOString().slice(11, 16)} UTC.` : "";
|
|
187
|
+
/** Why a command ended without an exit code of its own: its time limit, or a cancel. */
|
|
188
|
+
function endedBy(job, timeoutSeconds) {
|
|
189
|
+
if (job.reason === "timeout") return `${job.id} was stopped by its time limit${timeoutSeconds ? ` (--timeout ${timeoutSeconds})` : " (--timeout)"}, not by an error of its own. Run it again with a longer --timeout, up to 3600 seconds; for a server that should keep running, use nomac-service on the Mac.`;
|
|
190
|
+
if (job.reason === "cancelled") return `${job.id} was cancelled (nomac cancel).`;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds }) {
|
|
181
194
|
let job = started;
|
|
182
195
|
const deadline = now() + waitSeconds * 1000;
|
|
183
196
|
const begun = now();
|
|
@@ -204,10 +217,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
204
217
|
const unknown = job.state === "unknown";
|
|
205
218
|
const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
|
|
206
219
|
const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
|
|
207
|
-
: `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac
|
|
220
|
+
: `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac.${stopsAt(job)} Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over an hour: nomac ssh -- command. Do not rerun it; to stop it: nomac cancel ${job.id}.`;
|
|
208
221
|
if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
|
|
209
222
|
else if (unknown) stderr(`nomac: ${next}\n`);
|
|
210
|
-
else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac
|
|
223
|
+
else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.${stopsAt(job)}\n wait for it: nomac job ${job.id} --wait 600\n stop it: nomac cancel ${job.id}\n next time, wait longer: nomac exec --wait 600 -- command\n over an hour: nomac ssh -- command\n`);
|
|
211
224
|
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
212
225
|
return job;
|
|
213
226
|
}
|
|
@@ -225,9 +238,9 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
225
238
|
return Buffer.concat(parts);
|
|
226
239
|
};
|
|
227
240
|
const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
|
|
228
|
-
const streams = { stdout: out, stderr: err };
|
|
229
|
-
if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8") }, null, 2));
|
|
230
|
-
else { stdout(streams.stdout); stderr(streams.stderr); }
|
|
241
|
+
const streams = { stdout: out, stderr: err }, ended = endedBy(job, timeoutSeconds);
|
|
242
|
+
if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8"), ...(ended ? { next_step: ended } : {}) }, null, 2));
|
|
243
|
+
else { stdout(streams.stdout); stderr(streams.stderr); if (ended) stderr(`nomac: ${ended}\n`); }
|
|
231
244
|
exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
|
|
232
245
|
return job;
|
|
233
246
|
}
|
|
@@ -413,7 +426,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
413
426
|
if (command === "status") {
|
|
414
427
|
if (positionals.length) throw new CliError("Usage: nomac status [--json]");
|
|
415
428
|
const loaded = store.load();
|
|
416
|
-
if (!loaded) {
|
|
429
|
+
if (!loaded) {
|
|
430
|
+
// Run from another folder, the account's Mac may well be running: say so rather than "none".
|
|
431
|
+
const live = await liveSessions().catch(() => []);
|
|
432
|
+
if (values.json) print(JSON.stringify({ session: null, ...(live.length ? { running_elsewhere: live[0].id } : {}) }));
|
|
433
|
+
else print(live.length ? `No Mac in this folder, but your account's Mac ${live[0].id} is ${live[0].state}: run nomac in the project folder that started it, or use it from here with nomac attach.` : "No Mac in this folder. Start one: nomac start");
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
417
436
|
const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
|
|
418
437
|
const session = saveView(local, result.session);
|
|
419
438
|
// This folder's Mac has stopped, but the account may run another, started elsewhere.
|
|
@@ -468,7 +487,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
468
487
|
throw error;
|
|
469
488
|
});
|
|
470
489
|
if (!waiting) return output(result);
|
|
471
|
-
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 3600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
|
|
490
|
+
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 3600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json), timeoutSeconds: params.body?.timeout_seconds });
|
|
472
491
|
}
|
|
473
492
|
if (command === "screenshot") {
|
|
474
493
|
if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
|
|
@@ -645,13 +664,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
645
664
|
await ready(local);
|
|
646
665
|
if (command === "sync") {
|
|
647
666
|
if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
|
|
648
|
-
const result = await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
667
|
+
const result = await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], deleteMissing: values.delete, run });
|
|
649
668
|
if (!values.json) { print(describeSync(result)); return result; }
|
|
650
669
|
return output(result);
|
|
651
670
|
}
|
|
652
671
|
if (command === "pull") {
|
|
653
|
-
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from
|
|
654
|
-
const result = await pullProject(local, store, positionals[0], { from: values.from
|
|
672
|
+
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from PATH_ON_THE_MAC]");
|
|
673
|
+
const result = await pullProject(local, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
655
674
|
if (!values.json) { print(describePull(result)); return result; }
|
|
656
675
|
return output(result);
|
|
657
676
|
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -40,7 +40,7 @@ export const MAC_TOOLS = [
|
|
|
40
40
|
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." });
|
|
41
41
|
} },
|
|
42
42
|
{ 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))) },
|
|
43
|
-
{ 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. cwd runs it in another folder, such as ~/drive/src/app. timeout_seconds defaults to 300 and caps at 3600 (an hour) for builds and test runs; a first simulator boot takes about 90 seconds. cancel_mac_job stops a command. 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 }, cwd: { type: "string", minLength: 1, maxLength: 1024, description: "The folder to run in: relative to the workspace, or ~/… and absolute paths anywhere on the Mac" }, timeout_seconds: { type: "integer", minimum: 1, maximum: 3600 }, 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 })) },
|
|
43
|
+
{ 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. cwd runs it in another folder, such as ~/drive/src/app. timeout_seconds defaults to 300 and caps at 3600 (an hour) for builds and test runs; a first simulator boot takes about 90 seconds. cancel_mac_job stops a command. A command ends together with everything it started: keep a dev server running with [\"nomac-service\", \"start\", \"api\", \"--\", \"npx\", \"wrangler\", \"dev\"] (then nomac-service logs api, stop api). 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 }, cwd: { type: "string", minLength: 1, maxLength: 1024, description: "The folder to run in: relative to the workspace, or ~/… and absolute paths anywhere on the Mac" }, timeout_seconds: { type: "integer", minimum: 1, maximum: 3600 }, 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 })) },
|
|
44
44
|
{ 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 }) => {
|
|
45
45
|
const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
|
|
46
46
|
if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
|
package/src/session-store.mjs
CHANGED
|
@@ -13,6 +13,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
|
|
|
13
13
|
chmodSync(directory, 0o700);
|
|
14
14
|
const stateFile = join(directory, "session.json");
|
|
15
15
|
const baselineFile = join(directory, "baseline.json");
|
|
16
|
+
// What each pull wrote, by local path: such a file, unchanged since, is not a local change.
|
|
17
|
+
const pulledFile = join(directory, "pulled.json");
|
|
16
18
|
const read = (file) => {
|
|
17
19
|
if (!existsSync(file)) return null;
|
|
18
20
|
try { return JSON.parse(readFileSync(file, "utf8")); }
|
|
@@ -31,6 +33,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
|
|
|
31
33
|
save: (value) => write(stateFile, value),
|
|
32
34
|
baseline: () => read(baselineFile) ?? {},
|
|
33
35
|
saveBaseline: (value) => write(baselineFile, value),
|
|
36
|
+
pulled: () => read(pulledFile) ?? {},
|
|
37
|
+
savePulled: (value) => write(pulledFile, value),
|
|
34
38
|
async locked(operation) {
|
|
35
39
|
const lock = join(directory, "operation.lock");
|
|
36
40
|
try { mkdirSync(lock, { mode: 0o700 }); }
|
package/src/session-transfer.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, chmodSync, utimesSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import ignoreFactory from "ignore";
|
|
6
7
|
import { CliError } from "./api.mjs";
|
|
@@ -23,6 +24,9 @@ const BUILT_IN = [
|
|
|
23
24
|
{ pattern: ".nomac/", reason: "NoMac's own files" },
|
|
24
25
|
{ pattern: ".nomac.json", reason: "NoMac's own files" },
|
|
25
26
|
{ pattern: ".nomac-transfer/", reason: "NoMac's own files" },
|
|
27
|
+
// nomac screenshot and nomac record save here unless -o says otherwise.
|
|
28
|
+
{ pattern: "nomac-screenshot.jpg", reason: "saved by nomac screenshot" },
|
|
29
|
+
{ pattern: "nomac-recording.mp4", reason: "saved by nomac record" },
|
|
26
30
|
];
|
|
27
31
|
// Built-in patterns are names, matched against the entry's own name, so a folder
|
|
28
32
|
// brought back with `!` is never skipped again through its parent.
|
|
@@ -93,7 +97,7 @@ export async function fingerprint(path) {
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export function safeRelative(path) {
|
|
96
|
-
if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path",
|
|
100
|
+
if (typeof path !== "string" || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\x00-\x1f\x7f]/.test(path) || path.includes("\\")) throw new CliError("invalid relative path", `${JSON.stringify(String(path))} is not a path inside the project folder on the Mac: give one relative to it, without '..' (nomac status names the folder). Copy anything from elsewhere on the Mac, such as /tmp, into the project folder first.`);
|
|
97
101
|
return posix.normalize(path || ".").replace(/\/$/, "") || ".";
|
|
98
102
|
}
|
|
99
103
|
|
|
@@ -157,6 +161,46 @@ export async function remoteFingerprints(session, files, options) {
|
|
|
157
161
|
return result;
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// Removes files sync put on the Mac and the person has since deleted here: never through a
|
|
165
|
+
// linked folder, and empty folders left behind go too (never the project folder itself).
|
|
166
|
+
const REMOTE_DELETE = `
|
|
167
|
+
use strict; use warnings;
|
|
168
|
+
use JSON::PP; use Encode qw(encode_utf8);
|
|
169
|
+
my $json = JSON::PP->new->utf8;
|
|
170
|
+
my $names = $json->decode(do { local $/; <STDIN> });
|
|
171
|
+
die "Invalid file list" unless ref($names) eq 'ARRAY';
|
|
172
|
+
my (@removed, %folders);
|
|
173
|
+
for my $name (@$names) {
|
|
174
|
+
die "Invalid path" if ref($name) || $name =~ m{^/|(?:^|/)\\.\\.(?:/|$)|[\\x00-\\x1f\\x7f\\\\]};
|
|
175
|
+
my $file = './' . encode_utf8($name);
|
|
176
|
+
my @parts = split m{/}, $file;
|
|
177
|
+
pop @parts;
|
|
178
|
+
my $unsafe = 0;
|
|
179
|
+
for (my @up = @parts; @up > 1; pop @up) { if (-l join('/', @up)) { $unsafe = 1; last; } }
|
|
180
|
+
next if $unsafe || !(-l $file || -f $file);
|
|
181
|
+
if (unlink $file) {
|
|
182
|
+
push @removed, $name;
|
|
183
|
+
for (my @up = @parts; @up > 1; pop @up) { $folders{join('/', @up)} = 1; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
rmdir $_ for sort { length($b) <=> length($a) } keys %folders;
|
|
187
|
+
print $json->encode(\\@removed);
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
async function removeOnMac(session, paths, options) {
|
|
191
|
+
const removed = [];
|
|
192
|
+
for (let offset = 0; offset < paths.length; offset += 1000) {
|
|
193
|
+
const batch = paths.slice(offset, offset + 1000);
|
|
194
|
+
for (const path of batch) safeRelative(path);
|
|
195
|
+
const { stdout } = await remote(session, `cd ${shellQuote(validateWorkspace(session.workspace))} && perl -e ${shellQuote(REMOTE_DELETE)}`, { ...options, input: JSON.stringify(batch) });
|
|
196
|
+
let values;
|
|
197
|
+
try { values = JSON.parse(stdout); } catch { throw new CliError("invalid remote delete response"); }
|
|
198
|
+
if (!Array.isArray(values) || values.some(value => !batch.includes(value))) throw new CliError("invalid remote delete response");
|
|
199
|
+
removed.push(...values);
|
|
200
|
+
}
|
|
201
|
+
return removed;
|
|
202
|
+
}
|
|
203
|
+
|
|
160
204
|
function transferArgs() {
|
|
161
205
|
const bridge = fileURLToPath(new URL("../bin/rsync-ssh.mjs", import.meta.url));
|
|
162
206
|
return ["-rpt", "--checksum", "--itemize-changes", "--stats", "--timeout=60", "-e", [process.execPath, bridge].map(rsyncQuote).join(" ")];
|
|
@@ -178,10 +222,12 @@ const megabytes = bytes => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1
|
|
|
178
222
|
/** The human summary of a sync: what changed, how much was considered, and what stayed behind. */
|
|
179
223
|
export function describeSync(result) {
|
|
180
224
|
const lines = [`${result.dry_run ? "Would sync" : "Synced"} ${result.changed.length} changed of ${result.files} files (${megabytes(result.bytes)}${result.links ? `, ${result.links} links` : ""}).`];
|
|
225
|
+
if (result.conflicts?.length) lines.push(`Would not send, changed on the Mac since the last sync: ${result.conflicts.slice(0, 10).join(", ")}${result.conflicts.length > 10 ? ` and ${result.conflicts.length - 10} more` : ""}.`);
|
|
226
|
+
if (result.deleted?.length) lines.push(`${result.dry_run ? "Would delete" : "Deleted"} on the Mac, as here: ${result.deleted.slice(0, 10).join(", ")}${result.deleted.length > 10 ? ` and ${result.deleted.length - 10} more` : ""}.`);
|
|
227
|
+
if (result.kept_on_mac?.length) lines.push(`Kept on the Mac, changed there since: ${result.kept_on_mac.slice(0, 10).join(", ")}${result.kept_on_mac.length > 10 ? ` and ${result.kept_on_mac.length - 10} more` : ""}.`);
|
|
181
228
|
if (result.skipped.length) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (result.skipped.length > 20) lines.push(` …and ${result.skipped.length - 20} more (--json lists all)`);
|
|
229
|
+
const shown = result.skipped.slice(0, 6).map(item => `${item.path} (${item.reason})`).join(", ");
|
|
230
|
+
lines.push(`Not sent: ${shown}${result.skipped.length > 6 ? `, and ${result.skipped.length - 6} more (--json lists them)` : ""}. To send one, add a line such as ${result.skipped[0].send_with} to .nomacignore.`);
|
|
185
231
|
}
|
|
186
232
|
return lines.join("\n");
|
|
187
233
|
}
|
|
@@ -198,28 +244,57 @@ function conflictError(paths, side) {
|
|
|
198
244
|
return error;
|
|
199
245
|
}
|
|
200
246
|
|
|
201
|
-
|
|
247
|
+
/** Sync's own refusal: what was sent, and the files left alone because they changed on the Mac. */
|
|
248
|
+
const listed = paths => paths.slice(0, 10).join(", ") + (paths.length > 10 ? ` and ${paths.length - 10} more` : "");
|
|
249
|
+
function syncConflictError(paths, sent, removal = {}) {
|
|
250
|
+
// What --delete did in the same run is said too: the refusal is the last word, not the only one.
|
|
251
|
+
const removed = removal.deleted?.length ? ` Deleted on the Mac, as here: ${listed(removal.deleted)}.` : "";
|
|
252
|
+
const kept = removal.kept_on_mac?.length ? ` Kept on the Mac, changed there since: ${listed(removal.kept_on_mac)}.` : "";
|
|
253
|
+
const error = new CliError("transfer conflict", `${sent ? `Sent ${sent} changed ${sent === 1 ? "file" : "files"}. ` : ""}Not sent, because ${paths.length === 1 ? "it" : "they"} changed on the Mac since this folder last synced: ${listed(paths)}. Keep the Mac's version: nomac pull ${paths[0]}. Replace it with yours: nomac sync . --overwrite${removed}${kept}`);
|
|
254
|
+
error.code = "transfer_conflict";
|
|
255
|
+
error.details = { paths, side: "Remote", sent, ...removal };
|
|
256
|
+
return error;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Sends the project's changed files. A file that changed on the Mac since this folder last
|
|
261
|
+
* synced is left alone (unless --overwrite) and named; everything else is still sent, so one
|
|
262
|
+
* file a tool rewrote there (a lockfile) does not hold back the rest. With --delete, files an
|
|
263
|
+
* earlier sync sent and the person has since deleted here are removed on the Mac too, unless
|
|
264
|
+
* they changed there.
|
|
265
|
+
*/
|
|
266
|
+
export async function syncProject(session, store, { overwrite = false, dryRun = false, deleteMissing = false, run = runProcess } = {}) {
|
|
202
267
|
const selection = projectFiles(store.project);
|
|
203
268
|
const paths = [...selection.files, ...selection.links].sort();
|
|
204
|
-
|
|
269
|
+
const baseline = Object.assign(Object.create(null), store.baseline());
|
|
270
|
+
const exists = path => lstatSync(join(store.project, path), { throwIfNoEntry: false }) !== undefined;
|
|
271
|
+
const gone = deleteMissing ? Object.keys(baseline).filter(path => { try { safeRelative(path); } catch { return false; } return !exists(path); }).sort() : [];
|
|
272
|
+
if (!paths.length && !gone.length) throw new CliError("no project files", "Nothing to send: every file is in a skipped folder. Add `!` lines to .nomacignore for what the Mac needs.");
|
|
205
273
|
const local = Object.create(null);
|
|
206
274
|
for (const path of paths) local[path] = await fingerprint(join(store.project, path));
|
|
207
|
-
const
|
|
208
|
-
const previous = await remoteFingerprints(session, paths, { run });
|
|
275
|
+
const previous = paths.length ? await remoteFingerprints(session, paths, { run }) : {};
|
|
209
276
|
const unsafe = paths.filter(path => ["symlink", "other"].includes(previous[path]));
|
|
210
277
|
if (unsafe.length) throw new CliError("unsafe remote path", `Remote paths are below a symlinked folder or have incompatible types: ${unsafe.slice(0, 10).join(", ")}. Resolve them inside the workspace first.`);
|
|
211
|
-
const conflicts = paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
|
|
212
|
-
|
|
213
|
-
const changed =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
278
|
+
const conflicts = overwrite ? [] : paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
|
|
279
|
+
const held = new Set(conflicts), send = paths.filter(path => !held.has(path));
|
|
280
|
+
const changed = send.filter(path => previous[path] !== local[path]), summary = summarize(store.project, selection);
|
|
281
|
+
const onMac = gone.length ? await remoteFingerprints(session, gone, { run }) : {};
|
|
282
|
+
const removable = gone.filter(path => onMac[path] === baseline[path]), keptOnMac = gone.filter(path => !["missing", baseline[path]].includes(onMac[path]));
|
|
283
|
+
if (dryRun) return { dry_run: true, files: paths.length, changed, ...(conflicts.length ? { conflicts } : {}), ...(deleteMissing ? { deleted: removable, kept_on_mac: keptOnMac } : {}), ...summary };
|
|
284
|
+
let output = "";
|
|
285
|
+
if (send.length) {
|
|
286
|
+
// --links sends a symlink as a link: its target is never read or followed.
|
|
287
|
+
output = (await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(send.join("\0") + "\0"), env: transferEnv(session) })).stdout;
|
|
288
|
+
}
|
|
289
|
+
const after = send.length ? await remoteFingerprints(session, send, { run }) : {};
|
|
290
|
+
const changedDuringTransfer = send.filter(path => after[path] !== local[path]);
|
|
291
|
+
for (const path of send) if (after[path] === local[path]) baseline[path] = local[path];
|
|
292
|
+
const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
|
|
293
|
+
for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
|
|
220
294
|
store.saveBaseline(baseline);
|
|
221
295
|
if (changedDuringTransfer.length) throw new CliError("transfer changed during verification", "Some files changed during transfer. Stop concurrent edits and rerun sync; verified files were recorded.");
|
|
222
|
-
|
|
296
|
+
if (conflicts.length) throw syncConflictError(conflicts, changed.length, deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {});
|
|
297
|
+
return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
|
|
223
298
|
}
|
|
224
299
|
|
|
225
300
|
/** Refuse symlink ancestors, including the destination itself, before local writes. */
|
|
@@ -234,12 +309,26 @@ export function assertSafeDestination(root, target) {
|
|
|
234
309
|
}
|
|
235
310
|
}
|
|
236
311
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Where a pull writes on this computer: anywhere the person names. A relative path is in the
|
|
314
|
+
* project folder (where the CLI runs); an absolute or ~ path may be outside it, such as /tmp.
|
|
315
|
+
*/
|
|
316
|
+
export function localDestination(store, destination) {
|
|
317
|
+
if (typeof destination !== "string" || !destination || /[\x00-\x1f\x7f]/.test(destination)) throw new CliError("invalid local path", "Give the local folder or file to write, such as ./out, /tmp/shots or ~/Downloads/App.ipa.");
|
|
318
|
+
const expanded = destination === "~" ? homedir() : destination.startsWith("~/") ? join(homedir(), destination.slice(2)) : destination;
|
|
319
|
+
const requested = resolve(store.project, expanded), rel = relative(store.project, requested);
|
|
320
|
+
const inProject = rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
321
|
+
return { requested, inProject, projectPath: inProject ? (rel.split(sep).join("/") || ".") : null };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function pullProject(session, store, destination, { from, overwrite = false, dryRun = false, run = runProcess } = {}) {
|
|
325
|
+
const { requested, inProject, projectPath } = localDestination(store, destination);
|
|
326
|
+
// A folder in the project comes from the same folder on the Mac unless --from says otherwise.
|
|
327
|
+
if (from === undefined && !inProject) throw new CliError("pull usage", `Name what to copy from the Mac: nomac pull ${destination} --from PATH, with PATH relative to the project folder there.`);
|
|
328
|
+
from = safeRelative(from ?? projectPath);
|
|
329
|
+
// Local safety first: a symlinked destination inside the project is refused before anything
|
|
330
|
+
// is asked of the Mac (outside it, the path the person named is theirs to choose).
|
|
331
|
+
if (inProject) assertSafeDestination(store.project, requested);
|
|
243
332
|
// rsync reaches its source by cd'ing into it, so a single file has to be fetched
|
|
244
333
|
// from its parent directory. Probe the remote path: pulling one file (a
|
|
245
334
|
// screenshot, an .ipa) used to fail with a bare rsync "not a directory".
|
|
@@ -270,23 +359,27 @@ export async function pullProject(session, store, destination, { from = destinat
|
|
|
270
359
|
}
|
|
271
360
|
};
|
|
272
361
|
walk(staging);
|
|
273
|
-
const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
|
|
362
|
+
const baseline = Object.assign(Object.create(null), store.baseline()), pulled = Object.assign(Object.create(null), store.pulled()), plan = [], conflicts = [];
|
|
363
|
+
// Writes stay inside the folder pulled into, never through a link in it.
|
|
364
|
+
const safeRoot = inProject ? store.project : localRoot;
|
|
274
365
|
for (const path of files) {
|
|
275
366
|
const target = join(localRoot, rename ?? path), incoming = await fingerprint(join(staging, path));
|
|
276
|
-
assertSafeDestination(
|
|
367
|
+
assertSafeDestination(safeRoot, target);
|
|
277
368
|
const current = existsSync(target) ? await fingerprint(target) : "missing";
|
|
278
369
|
if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
|
|
279
370
|
const remotePath = source === "." ? path : `${source.replace(/\/$/, "")}/${path}`;
|
|
280
371
|
// A baseline is meaningful only when downloading to the original project-relative path.
|
|
281
372
|
const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
|
|
282
|
-
|
|
283
|
-
|
|
373
|
+
// A local file is the person's change unless sync or an earlier pull left it exactly so.
|
|
374
|
+
const known = (samePath && baseline[remotePath] === current) || pulled[target] === current;
|
|
375
|
+
if (current !== "missing" && current !== incoming && !known) conflicts.push(path);
|
|
376
|
+
plan.push({ path, target, current, incoming, remotePath, samePath: inProject && samePath });
|
|
284
377
|
}
|
|
285
378
|
if (conflicts.length && !overwrite) throw conflictError(conflicts, "Local");
|
|
286
379
|
if (dryRun) return { dry_run: true, files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path) };
|
|
287
380
|
let backups = false;
|
|
288
381
|
for (const file of plan) {
|
|
289
|
-
assertSafeDestination(
|
|
382
|
+
assertSafeDestination(safeRoot, file.target);
|
|
290
383
|
const current = existsSync(file.target) ? await fingerprint(file.target) : "missing";
|
|
291
384
|
if (current !== file.current) throw new CliError("local file changed", "A local file changed during pull. Stop concurrent edits and retry. Earlier replacements have recovery copies.");
|
|
292
385
|
if (current !== file.incoming) {
|
|
@@ -307,8 +400,9 @@ export async function pullProject(session, store, destination, { from = destinat
|
|
|
307
400
|
} finally { rmSync(temporary, { force: true }); }
|
|
308
401
|
}
|
|
309
402
|
if (file.samePath) baseline[file.remotePath] = file.incoming;
|
|
403
|
+
pulled[file.target] = file.incoming;
|
|
310
404
|
}
|
|
311
|
-
store.saveBaseline(baseline);
|
|
405
|
+
store.saveBaseline(baseline); store.savePulled(pulled);
|
|
312
406
|
return { files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path), ...(backups ? { recovery_directory: backup } : {}) };
|
|
313
407
|
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
314
408
|
}
|
package/src/sessions.mjs
CHANGED
|
@@ -42,7 +42,7 @@ existing connections and remote processes continue. Manage rentals with their ow
|
|
|
42
42
|
const commonOptions = { help: { type: "boolean" }, json: { type: "boolean" } };
|
|
43
43
|
const commandOptions = {
|
|
44
44
|
start: { host: { type: "string" }, identity: { type: "string" }, port: { type: "string" } },
|
|
45
|
-
sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" } },
|
|
45
|
+
sync: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, delete: { type: "boolean" } },
|
|
46
46
|
pull: { overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" } },
|
|
47
47
|
ssh: { forward: { type: "string", multiple: true } },
|
|
48
48
|
stop: {}, session: {},
|
|
@@ -131,12 +131,12 @@ export async function sessionCommand(command, argv, { store: suppliedStore, run
|
|
|
131
131
|
const session = requireReady(store);
|
|
132
132
|
if (command === "sync") {
|
|
133
133
|
if (positionals.length > 1 || (positionals[0] && realpathSync(resolve(positionals[0])) !== store.project)) throw new CliError("sync project mismatch", "Run nomac sync . from the project used by nomac start.");
|
|
134
|
-
const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
134
|
+
const result = await syncProject(session, store, { overwrite: values.overwrite, dryRun: values["dry-run"], deleteMissing: values.delete, run });
|
|
135
135
|
return output(result, describeSync(result));
|
|
136
136
|
}
|
|
137
137
|
if (command === "pull") {
|
|
138
138
|
if (positionals.length !== 1) throw new CliError("pull usage", "Usage: nomac pull LOCAL_DIRECTORY [--from REMOTE_DIRECTORY] [--dry-run] [--overwrite]");
|
|
139
|
-
const result = await pullProject(session, store, positionals[0], { from: values.from
|
|
139
|
+
const result = await pullProject(session, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
140
140
|
return output(result, describePull(result));
|
|
141
141
|
}
|
|
142
142
|
throw new CliError("unknown session command");
|
package/src/testflight.mjs
CHANGED
|
@@ -17,11 +17,16 @@ needed, and NoMac puts it on TestFlight for your internal testers.
|
|
|
17
17
|
const DONE = new Set(["on_testflight", "failed"]);
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** The line for a view; minutes so far for a send still going, so a slow upload is told from a hang. */
|
|
21
|
+
export function describe(view, minutes = 0) {
|
|
21
22
|
const app = `${view.app.bundle_id} ${view.app.version} (${view.app.build_number})`;
|
|
22
23
|
switch (view.state) {
|
|
23
|
-
case "sending": return `Sending ${app} from the Mac to Apple
|
|
24
|
-
case "processing":
|
|
24
|
+
case "sending": return minutes ? `Still sending ${app} to Apple (${minutes} min so far)…` : `Sending ${app} from the Mac to Apple… Stopping this wait does not stop it: follow it any time with nomac testflight --id ${view.id}`;
|
|
25
|
+
case "processing":
|
|
26
|
+
// Apple is done; a person must act before testers get it. NoMac continues by itself after.
|
|
27
|
+
if (view.needs_human) return `Apple has processed ${app}. Before testers get it, a person must act:\n ${view.needs_human.message}\n ${view.needs_human.url}\n NoMac puts it on TestFlight by itself once that is done; check with nomac testflight --id ${view.id}`;
|
|
28
|
+
if (view.apple_processed) return `Apple has processed ${app}; NoMac is putting it on TestFlight.${view.waiting_for ? `\n Waiting for: ${view.waiting_for}` : ""}\n Stopping this wait does not stop it: follow it later with nomac testflight --id ${view.id}`;
|
|
29
|
+
return `Apple has ${app} and is processing it (usually 5 to 15 minutes; no Mac needed).${view.waiting_for ? `\n Waiting for: ${view.waiting_for}` : ""}\n Stopping this wait does not stop it: follow it later with nomac testflight --id ${view.id}`;
|
|
25
30
|
case "on_testflight": return `On TestFlight: ${app}. Internal testers can install it from the TestFlight app.\n ${view.url}\n ${view.app.platform === "IOS"
|
|
26
31
|
? `To submit it for App Review: nomac publish --project ${view.project_id} --build ${view.build_id}` : "Submit it for App Review in App Store Connect."}`;
|
|
27
32
|
// With a build_id, Apple had the file and refused it while processing; without one, it never reached Apple.
|
|
@@ -63,15 +68,17 @@ export async function testflightCommand(args, { api = defaultApi, print = consol
|
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
let last = "";
|
|
71
|
+
const began = Date.now();
|
|
66
72
|
const show = (current) => {
|
|
67
73
|
if (values.json) return;
|
|
68
|
-
const line = describe(current);
|
|
74
|
+
const line = describe(current, current.state === "sending" ? Math.floor((Date.now() - began) / 60_000) : 0);
|
|
69
75
|
if (line !== last) print(line);
|
|
70
76
|
last = line;
|
|
71
77
|
};
|
|
72
78
|
show(view);
|
|
73
79
|
if (!values["no-wait"]) {
|
|
74
|
-
|
|
80
|
+
// A step only a person can take ends the wait: it is said, and NoMac goes on by itself after it.
|
|
81
|
+
while (!DONE.has(view.state) && !view.needs_human) {
|
|
75
82
|
view = await api("GET", `/api/v1/testflight/${encodeURIComponent(view.id)}?wait_seconds=50`);
|
|
76
83
|
show(view);
|
|
77
84
|
}
|