@nomac/cli 0.11.6 → 0.11.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
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. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
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`. Over ten minutes, use `nomac ssh -- COMMAND`. MCP equivalents are `start_mac`, `exec_mac` (also waits),
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` (use `--from PATH` for another
37
- remote directory), then `nomac stop --json`. Confirm `cleanup_confirmed`.
38
-
39
- Stopping deletes files and logs. Download before stopping. SSH disconnects
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
- Managed jobs have a 600-second limit; SSH can run longer within session limits.
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. It preserves remote extra files. See the screen with `nomac screenshot` (saves
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`. Persistent disks are not currently offered.
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. Then, on the Mac:
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
- const key = rest[0];
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
- // direct-key path for CI / power users
157
- if (!key.startsWith("nmk_")) {
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;
@@ -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] upload changed files to the project folder on the Mac:
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 ./output [--from PATH] retrieve files before stopping
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
- async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json }) {
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. 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}.`;
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.\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`);
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) { print(values.json ? JSON.stringify({ session: null }) : "No Mac in this folder. Start one: nomac start"); return null; }
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 REMOTE_PATH]");
654
- const result = await pullProject(local, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run });
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." } });
@@ -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 }); }
@@ -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", "Use a path inside the Mac's workspace, relative and without '..' or control characters. Copy anything outside the workspace, such as /tmp, into it first.");
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
- lines.push("Skipped (add the line to .nomacignore to send it):");
183
- for (const item of result.skipped.slice(0, 20)) lines.push(` ${item.path} ${item.reason} ${item.send_with}`);
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,54 @@ function conflictError(paths, side) {
198
244
  return error;
199
245
  }
200
246
 
201
- export async function syncProject(session, store, { overwrite = false, dryRun = false, run = runProcess } = {}) {
247
+ /** Sync's own refusal: what was sent, and the files left alone because they changed on the Mac. */
248
+ function syncConflictError(paths, sent) {
249
+ const shown = paths.slice(0, 10).join(", ") + (paths.length > 10 ? ` and ${paths.length - 10} more` : "");
250
+ 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: ${shown}. Keep the Mac's version: nomac pull ${paths[0]}. Replace it with yours: nomac sync . --overwrite`);
251
+ error.code = "transfer_conflict";
252
+ error.details = { paths, side: "Remote", sent };
253
+ return error;
254
+ }
255
+
256
+ /**
257
+ * Sends the project's changed files. A file that changed on the Mac since this folder last
258
+ * synced is left alone (unless --overwrite) and named; everything else is still sent, so one
259
+ * file a tool rewrote there (a lockfile) does not hold back the rest. With --delete, files an
260
+ * earlier sync sent and the person has since deleted here are removed on the Mac too, unless
261
+ * they changed there.
262
+ */
263
+ export async function syncProject(session, store, { overwrite = false, dryRun = false, deleteMissing = false, run = runProcess } = {}) {
202
264
  const selection = projectFiles(store.project);
203
265
  const paths = [...selection.files, ...selection.links].sort();
204
- if (!paths.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.");
266
+ const baseline = Object.assign(Object.create(null), store.baseline());
267
+ const exists = path => lstatSync(join(store.project, path), { throwIfNoEntry: false }) !== undefined;
268
+ const gone = deleteMissing ? Object.keys(baseline).filter(path => { try { safeRelative(path); } catch { return false; } return !exists(path); }).sort() : [];
269
+ 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
270
  const local = Object.create(null);
206
271
  for (const path of paths) local[path] = await fingerprint(join(store.project, path));
207
- const baseline = Object.assign(Object.create(null), store.baseline());
208
- const previous = await remoteFingerprints(session, paths, { run });
272
+ const previous = paths.length ? await remoteFingerprints(session, paths, { run }) : {};
209
273
  const unsafe = paths.filter(path => ["symlink", "other"].includes(previous[path]));
210
274
  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
- if (conflicts.length && !overwrite) throw conflictError(conflicts, "Remote");
213
- const changed = paths.filter(path => previous[path] !== local[path]), summary = summarize(store.project, selection);
214
- if (dryRun) return { dry_run: true, files: paths.length, changed, ...summary };
215
- // --links sends a symlink as a link: its target is never read or followed.
216
- const output = await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(paths.join("\0") + "\0"), env: transferEnv(session) });
217
- const after = await remoteFingerprints(session, paths, { run });
218
- const changedDuringTransfer = paths.filter(path => after[path] !== local[path]);
219
- for (const path of paths) if (after[path] === local[path]) baseline[path] = local[path];
275
+ const conflicts = overwrite ? [] : paths.filter(path => previous[path] !== "missing" && previous[path] !== local[path] && previous[path] !== baseline[path]);
276
+ const held = new Set(conflicts), send = paths.filter(path => !held.has(path));
277
+ const changed = send.filter(path => previous[path] !== local[path]), summary = summarize(store.project, selection);
278
+ const onMac = gone.length ? await remoteFingerprints(session, gone, { run }) : {};
279
+ const removable = gone.filter(path => onMac[path] === baseline[path]), keptOnMac = gone.filter(path => !["missing", baseline[path]].includes(onMac[path]));
280
+ if (dryRun) return { dry_run: true, files: paths.length, changed, ...(conflicts.length ? { conflicts } : {}), ...(deleteMissing ? { deleted: removable, kept_on_mac: keptOnMac } : {}), ...summary };
281
+ let output = "";
282
+ if (send.length) {
283
+ // --links sends a symlink as a link: its target is never read or followed.
284
+ output = (await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(send.join("\0") + "\0"), env: transferEnv(session) })).stdout;
285
+ }
286
+ const after = send.length ? await remoteFingerprints(session, send, { run }) : {};
287
+ const changedDuringTransfer = send.filter(path => after[path] !== local[path]);
288
+ for (const path of send) if (after[path] === local[path]) baseline[path] = local[path];
289
+ const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
290
+ for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
220
291
  store.saveBaseline(baseline);
221
292
  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
- return { files: paths.length, changed, ...summary, output: output.stdout };
293
+ if (conflicts.length) throw syncConflictError(conflicts, changed.length);
294
+ return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
223
295
  }
224
296
 
225
297
  /** Refuse symlink ancestors, including the destination itself, before local writes. */
@@ -234,12 +306,26 @@ export function assertSafeDestination(root, target) {
234
306
  }
235
307
  }
236
308
 
237
- export async function pullProject(session, store, destination, { from = destination, overwrite = false, dryRun = false, run = runProcess } = {}) {
238
- destination = safeRelative(destination); from = safeRelative(from);
239
- // Local safety first: a symlinked destination is refused before anything is
240
- // asked of the Mac.
241
- const requested = resolve(store.project, destination);
242
- assertSafeDestination(store.project, requested);
309
+ /**
310
+ * Where a pull writes on this computer: anywhere the person names. A relative path is in the
311
+ * project folder (where the CLI runs); an absolute or ~ path may be outside it, such as /tmp.
312
+ */
313
+ export function localDestination(store, destination) {
314
+ 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.");
315
+ const expanded = destination === "~" ? homedir() : destination.startsWith("~/") ? join(homedir(), destination.slice(2)) : destination;
316
+ const requested = resolve(store.project, expanded), rel = relative(store.project, requested);
317
+ const inProject = rel === "" || !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
318
+ return { requested, inProject, projectPath: inProject ? (rel.split(sep).join("/") || ".") : null };
319
+ }
320
+
321
+ export async function pullProject(session, store, destination, { from, overwrite = false, dryRun = false, run = runProcess } = {}) {
322
+ const { requested, inProject, projectPath } = localDestination(store, destination);
323
+ // A folder in the project comes from the same folder on the Mac unless --from says otherwise.
324
+ 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.`);
325
+ from = safeRelative(from ?? projectPath);
326
+ // Local safety first: a symlinked destination inside the project is refused before anything
327
+ // is asked of the Mac (outside it, the path the person named is theirs to choose).
328
+ if (inProject) assertSafeDestination(store.project, requested);
243
329
  // rsync reaches its source by cd'ing into it, so a single file has to be fetched
244
330
  // from its parent directory. Probe the remote path: pulling one file (a
245
331
  // screenshot, an .ipa) used to fail with a bare rsync "not a directory".
@@ -270,23 +356,27 @@ export async function pullProject(session, store, destination, { from = destinat
270
356
  }
271
357
  };
272
358
  walk(staging);
273
- const baseline = Object.assign(Object.create(null), store.baseline()), plan = [], conflicts = [];
359
+ const baseline = Object.assign(Object.create(null), store.baseline()), pulled = Object.assign(Object.create(null), store.pulled()), plan = [], conflicts = [];
360
+ // Writes stay inside the folder pulled into, never through a link in it.
361
+ const safeRoot = inProject ? store.project : localRoot;
274
362
  for (const path of files) {
275
363
  const target = join(localRoot, rename ?? path), incoming = await fingerprint(join(staging, path));
276
- assertSafeDestination(store.project, target);
364
+ assertSafeDestination(safeRoot, target);
277
365
  const current = existsSync(target) ? await fingerprint(target) : "missing";
278
366
  if (current === "other") throw new CliError("incompatible local path", `A directory or special file occupies ${target}.`);
279
367
  const remotePath = source === "." ? path : `${source.replace(/\/$/, "")}/${path}`;
280
368
  // A baseline is meaningful only when downloading to the original project-relative path.
281
369
  const samePath = relative(store.project, target).split(sep).join("/") === remotePath.split(sep).join("/");
282
- if (current !== "missing" && current !== incoming && (!samePath || baseline[remotePath] !== current)) conflicts.push(path);
283
- plan.push({ path, target, current, incoming, remotePath, samePath });
370
+ // A local file is the person's change unless sync or an earlier pull left it exactly so.
371
+ const known = (samePath && baseline[remotePath] === current) || pulled[target] === current;
372
+ if (current !== "missing" && current !== incoming && !known) conflicts.push(path);
373
+ plan.push({ path, target, current, incoming, remotePath, samePath: inProject && samePath });
284
374
  }
285
375
  if (conflicts.length && !overwrite) throw conflictError(conflicts, "Local");
286
376
  if (dryRun) return { dry_run: true, files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path) };
287
377
  let backups = false;
288
378
  for (const file of plan) {
289
- assertSafeDestination(store.project, file.target);
379
+ assertSafeDestination(safeRoot, file.target);
290
380
  const current = existsSync(file.target) ? await fingerprint(file.target) : "missing";
291
381
  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
382
  if (current !== file.incoming) {
@@ -307,8 +397,9 @@ export async function pullProject(session, store, destination, { from = destinat
307
397
  } finally { rmSync(temporary, { force: true }); }
308
398
  }
309
399
  if (file.samePath) baseline[file.remotePath] = file.incoming;
400
+ pulled[file.target] = file.incoming;
310
401
  }
311
- store.saveBaseline(baseline);
402
+ store.saveBaseline(baseline); store.savePulled(pulled);
312
403
  return { files: files.length, changed: plan.filter(file => file.current !== file.incoming).map(file => file.path), ...(backups ? { recovery_directory: backup } : {}) };
313
404
  } finally { rmSync(staging, { recursive: true, force: true }); }
314
405
  }
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 ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run });
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");
@@ -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
- function describe(view) {
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": 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}`;
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
- while (!DONE.has(view.state)) {
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
  }