@nomac/cli 0.11.5 → 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.5",
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",
@@ -19,11 +19,11 @@
19
19
  "test": "vitest run"
20
20
  },
21
21
  "dependencies": {
22
+ "https-proxy-agent": "^9.1.0",
22
23
  "ignore": "^7.0.0",
24
+ "proxy-from-env": "^2.1.0",
23
25
  "tar": "^7.4.0",
24
- "ws": "^8.21.3",
25
- "https-proxy-agent": "^9.1.0",
26
- "proxy-from-env": "^2.1.0"
26
+ "ws": "^8.21.3"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.1.1",
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)
@@ -106,6 +108,7 @@ export async function main(argv) {
106
108
  case "exec":
107
109
  case "job":
108
110
  case "output":
111
+ case "cancel":
109
112
  case "sync":
110
113
  case "ssh":
111
114
  case "desktop":
@@ -150,14 +153,20 @@ export async function main(argv) {
150
153
  return secretsCommand(rest, { store: sessionStore() });
151
154
  }
152
155
  case "login": {
153
- 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];
154
159
  if (key) {
155
- // direct-key path for CI / power users
156
- if (!key.startsWith("nmk_")) {
157
- 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.");
158
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; }
159
169
  saveApiKey(key);
160
- const me = await api("GET", "/api/v1/me");
161
170
  console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
162
171
  return;
163
172
  }
@@ -181,6 +190,13 @@ export async function main(argv) {
181
190
  const { runMcpServer } = await import("./mcp.mjs");
182
191
  return runMcpServer();
183
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
+ }
184
200
  case "whoami": {
185
201
  const [me, credit] = await Promise.all([api("GET", "/api/v1/me"), api("GET", "/api/v1/mac/credits").catch(() => null)]);
186
202
  const o = me.org ?? {}, c = credit?.credits ?? credit ?? {};
@@ -207,6 +223,27 @@ export async function main(argv) {
207
223
  }
208
224
  }
209
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
+
210
247
  async function ensureProject() {
211
248
  const state = loadProjectState();
212
249
  if (state.project_id) return state;
@@ -1,15 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { api, CliError } from "./api.mjs";
6
6
  import { sessionStore } from "./session-store.mjs";
7
- import { forwardingArg, isShellLine, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
7
+ import { forwardingArg, isShellLine, runProcess, shellQuote, sshArgs, validateWorkspace, workspaceCommand } from "./session-ssh.mjs";
8
8
  import { describePull, describeSync, pullProject, syncProject } from "./session-transfer.mjs";
9
9
  import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
10
10
  import { copyToClipboard, desktop, DESKTOP_HELP, screenSharing } from "./desktop.mjs";
11
11
  import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
12
12
 
13
+ const WORKSPACE = "/Users/agent/workspace";
14
+
13
15
  const HELP = `Nomac cloud Mac sessions
14
16
  nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
15
17
  [--prompts ask]
@@ -17,7 +19,12 @@ const HELP = `Nomac cloud Mac sessions
17
19
  password prompts, its login password in
18
20
  $NOMAC_MAC_PASSWORD. --prompts ask keeps macOS's
19
21
  usual prompts.
20
- nomac sync . [--dry-run] upload changed files
22
+ nomac sync . [--dry-run] [--delete]
23
+ upload changed files to the project folder on the Mac:
24
+ ~/drive/src/<this folder's name>, kept between sessions
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
21
28
  nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
22
29
  nomac ssh -- command args run any command over SSH
23
30
  nomac desktop [--no-open] [--json] the full macOS desktop in your browser
@@ -44,7 +51,10 @@ const HELP = `Nomac cloud Mac sessions
44
51
  (--request-key KEY makes a scripted retry safe)
45
52
  nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
46
53
  nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
47
- nomac pull ./output [--from PATH] retrieve files before stopping
54
+ nomac cancel JOB_ID stop a running command and everything it started
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)
48
58
  nomac screenshot [--simulator] [-o FILE]
49
59
  save the screen (or the Simulator) as a JPEG,
50
60
  nomac-screenshot.jpg unless -o
@@ -54,20 +64,21 @@ const HELP = `Nomac cloud Mac sessions
54
64
  nomac status [--json] state, cost so far and when it stops
55
65
  nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
56
66
  agent started it elsewhere (MCP, the dashboard); start does it too
57
- nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files (not ~/drive)
67
+ nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
68
+ (your synced project stays there)
58
69
 
59
70
  Login once with nomac login. The CLI creates a local SSH key; it sends only the
60
71
  public key. Sessions use the port-443 gateway and verified guest host keys.
61
72
  Credit is used at $0.80 an hour, billed by the minute, while the VM is ready,
62
73
  including between commands. Sessions run 2 hours by default (--max-duration,
63
- up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and its files; pull first.
74
+ up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and every file outside ~/drive.
64
75
  A wait timeout leaves the session attached: inspect status or run stop.
65
76
  Use nomac start --host user@mac to connect an existing Mac instead.`;
66
77
  const options = {
67
78
  help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
68
79
  "max-duration": { type: "string" }, "idle-timeout": { type: "string" },
69
80
  "max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
70
- overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
81
+ overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" },
71
82
  forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
72
83
  "no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
73
84
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
@@ -171,7 +182,15 @@ const NOTICE_MS = 5000;
171
182
  * running keeps running on the Mac; the CLI says how to wait for it and exits
172
183
  * 75. --wait 0 only starts it.
173
184
  */
174
- 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 }) {
175
194
  let job = started;
176
195
  const deadline = now() + waitSeconds * 1000;
177
196
  const begun = now();
@@ -198,10 +217,10 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
198
217
  const unknown = job.state === "unknown";
199
218
  const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
200
219
  const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
201
- : `${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.`;
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}.`;
202
221
  if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
203
222
  else if (unknown) stderr(`nomac: ${next}\n`);
204
- 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 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`);
205
224
  exitWith(unknown ? 1 : STILL_RUNNING);
206
225
  return job;
207
226
  }
@@ -219,9 +238,9 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
219
238
  return Buffer.concat(parts);
220
239
  };
221
240
  const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
222
- const streams = { stdout: out, stderr: err };
223
- if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8") }, null, 2));
224
- 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`); }
225
244
  exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
226
245
  return job;
227
246
  }
@@ -247,6 +266,12 @@ const uncertainOutcome = error => !Number.isInteger(error?.status) || error.stat
247
266
  const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
248
267
  const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
249
268
  /** One readable paragraph for nomac status. */
269
+ /** "Project: ~/drive/src/app, on your drive: the next session continues from it." */
270
+ export function projectLine({ path, on_drive }) {
271
+ const short = String(path).replace(/^\/Users\/agent(?=\/)/, "~");
272
+ return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
273
+ : `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
274
+ }
250
275
  export function statusSummary(session, image) {
251
276
  const lines = [`${session.id}: ${session.state}`];
252
277
  if (image?.macos) {
@@ -297,7 +322,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
297
322
  const usage = billing?.mode === "weekly_included"
298
323
  ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
299
324
  : `Cost so far: $${(Number(result.session?.cost?.accrued_microunits ?? 0) / 1_000_000).toFixed(2)} ($0.80/hour, billed by the minute while the Mac is ready). Credit left: nomac credits`;
300
- const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
325
+ const next = result.session.state === "ready" ? `${result.project ? `${projectLine(result.project)}\n` : ""}Next: nomac sync . → nomac ssh`
301
326
  : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
302
327
  : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start"
303
328
  : ["stopped", "failed"].includes(result.session.state) ? `${result.session.drive ? "The drive saves in the background (nomac drive shows it). " : ""}Start another: nomac start`
@@ -319,10 +344,39 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
319
344
  const access = await sessionAccess(local.id, request);
320
345
  if (local.generation && (local.generation !== access.generation || local.hostKey !== access.ssh.host_key)) throw new CliError("The guest identity changed. Stop this session and start a fresh one.", undefined, undefined, "host_key_changed");
321
346
  if (local.attached && !local.keyAdded) await addKey(local);
322
- Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: "/Users/agent/workspace", knownHosts: join(store.directory, "known_hosts") });
347
+ // A session an older CLI began in ~/workspace stays there, so nothing moves mid-session.
348
+ if (local.workspace && !local.workspaceDecided) local.workspaceDecided = true;
349
+ if (!local.workspaceDecided) await placeWorkspace(local);
350
+ Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: local.workspace ?? WORKSPACE, knownHosts: join(store.directory, "known_hosts") });
323
351
  writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
324
352
  return local;
325
353
  }
354
+ /**
355
+ * With the account's drive, this folder's project lives on it, at ~/drive/src/<folder name>,
356
+ * so the next session finds it and builds only what changed (~/workspace is emptied every
357
+ * session). A name another local folder took gets a suffix; the claims are kept in
358
+ * ~/drive/.nomac/projects. Without a drive, the workspace is ~/workspace as before.
359
+ */
360
+ async function placeWorkspace(local) {
361
+ if (local.snapshot?.drive?.state === "mounted") {
362
+ const name = (basename(store.project).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "") || "project").slice(0, 64);
363
+ const origin = createHash("sha256").update(store.project).digest("hex").slice(0, 12);
364
+ const script = 'set -e; claims=~/drive/.nomac/projects; mkdir -p "$claims" ~/drive/src; d="$NAME"; '
365
+ // A folder another local folder claimed, or one made on the Mac some other way, is left alone.
366
+ + 'if { [ -e "$claims/$d" ] && [ "$(cat "$claims/$d")" != "$ORIGIN" ]; } || { [ ! -e "$claims/$d" ] && [ -n "$(ls -A ~/drive/src/"$d" 2>/dev/null)" ]; }; then d="$NAME-$ORIGIN"; fi; '
367
+ + 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
368
+ const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
369
+ body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
370
+ const path = String(job?.stdout ?? "").trim();
371
+ if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
372
+ // The same folder on the drive as last time: what was synced there then is still there.
373
+ if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
374
+ Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
375
+ }
376
+ }
377
+ delete local.previousDriveWorkspace;
378
+ local.workspaceDecided = true; store.save(local);
379
+ }
326
380
  /**
327
381
  * A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
328
382
  * from this folder: add this folder's own to its authorized keys with an ordinary command, so
@@ -335,6 +389,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
335
389
  if (job.exit_code !== 0) throw new CliError("Could not add this folder's SSH key to the Mac.", `Adding the key ended with ${job.state === "exited" ? `exit code ${job.exit_code}` : `the command ${job.state}`}. Try again: nomac attach ${local.id}`);
336
390
  local.keyAdded = true; store.save(local);
337
391
  }
392
+ /** Where this folder's project is on the Mac, once a ready Mac has decided it. */
393
+ const project = local => local.state === "ready" && local.workspaceDecided ? { project: { path: local.workspace, on_drive: Boolean(local.workspaceOnDrive) } } : {};
394
+ /** The last session's folder on the drive and what was synced to it, for the next session to pick up. */
395
+ const previousDrive = old => old?.workspaceOnDrive ? { previousDriveWorkspace: { path: old.workspace, baseline: store.baseline() } } : {};
338
396
  /** The account's running Macs, newest first: queued, starting or ready. */
339
397
  async function liveSessions() {
340
398
  const answer = await request("GET", "/api/v1/mac/sessions");
@@ -345,7 +403,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
345
403
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
346
404
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
347
405
  const local = { mode: "hosted", state: view.state, id: sessionId(view.id), identity, attached: true, outcomeUnknown: false, requestKey: `attach-${view.id}`,
348
- request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) } };
406
+ request: { profile: "standard", limits: view.limits ?? {}, ...(view.access ? { access: view.access } : {}) }, ...previousDrive(store.load()) };
349
407
  store.save(local); store.saveBaseline({});
350
408
  const deadline = now() + wait * 1000;
351
409
  for (;;) {
@@ -368,20 +426,36 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
368
426
  if (command === "status") {
369
427
  if (positionals.length) throw new CliError("Usage: nomac status [--json]");
370
428
  const loaded = store.load();
371
- 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
+ }
372
436
  const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
373
437
  const session = saveView(local, result.session);
374
438
  // This folder's Mac has stopped, but the account may run another, started elsewhere.
375
439
  const elsewhere = ["stopped", "failed"].includes(session.state) ? (await liveSessions().catch(() => [])).find(s => s.id !== session.id) : undefined;
376
- if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
377
- print(statusSummary(session, result.image) + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
440
+ if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}), ...project(local), ...(elsewhere ? { running_elsewhere: elsewhere.id } : {}) }, null, 2)); return session; }
441
+ print(statusSummary(session, result.image) + (project(local).project ? `\n${projectLine(project(local).project)}` : "") + (elsewhere ? `\nYour account's Mac ${elsewhere.id} is ${elsewhere.state}, started elsewhere: nomac attach` : ""));
378
442
  return session;
379
443
  }
444
+ if (command === "cancel") {
445
+ if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Usage: nomac cancel JOB_ID");
446
+ const local = localSession(), job = await hostedRequest(local.id, "POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs/${positionals[0]}/cancel`, {}, { request, transport });
447
+ if (values.json) { print(JSON.stringify(job, null, 2)); return job; }
448
+ const ended = job.exit_code === null || job.exit_code === undefined ? (job.signal ? `signal ${job.signal}` : job.state) : `exit code ${job.exit_code}`;
449
+ print(job.cancelled ? `Cancelled ${job.id}: it and everything it started were stopped (${ended}). Its output so far: nomac output ${job.id}`
450
+ : job.state === "exited" ? `${job.id} had already finished (${job.reason === "cancelled" ? "cancelled" : ended}).` : `${job.id} is ${job.state}.`);
451
+ return job;
452
+ }
380
453
  if (["exec", "job", "output"].includes(command)) {
381
454
  const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
382
455
  let path, method = "GET", params = {};
383
456
  if (command === "exec") {
384
457
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
458
+ if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
385
459
  if (values["request-key"] !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"])) throw new CliError("A request key is 8 to 128 letters, digits, underscores, periods, colons or hyphens.");
386
460
  // A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
387
461
  // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
@@ -389,7 +463,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
389
463
  // A command may run an hour (the gateway's limit); waiting can cover all of it.
390
464
  const waitFor = integer(values.wait, 30, 0, 3600);
391
465
  const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
392
- method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: isShellLine(positionals) ? ["/bin/bash", "-lc", positionals[0]] : positionals, timeout_seconds: timeout } };
466
+ const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
467
+ const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
468
+ : line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
469
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: remote, timeout_seconds: timeout } };
393
470
  } else {
394
471
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
395
472
  path = `${base}/jobs/${positionals[0]}`;
@@ -410,7 +487,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
410
487
  throw error;
411
488
  });
412
489
  if (!waiting) return output(result);
413
- 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 });
414
491
  }
415
492
  if (command === "screenshot") {
416
493
  if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
@@ -511,12 +588,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
511
588
  if (current?.mode === "hosted" && current.id && (!wanted || current.id === wanted) && live.some(s => s.id === current.id)) {
512
589
  await refresh(current);
513
590
  if (current.state === "ready") await ready(current);
514
- return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.` });
591
+ return output({ session: current.snapshot, attached: `This folder already uses ${current.id}.`, ...project(current) });
515
592
  }
516
593
  const target = wanted ? live.find(s => s.id === wanted) : live.length === 1 ? live[0] : undefined;
517
594
  if (!target) throw new CliError(wanted ? `No running Mac ${wanted} in this account.` : live.length ? `This account has ${live.length} running Macs: name one, nomac attach ${live.map(s => s.id).join(" | ")}` : "This account has no running Mac. Start one: nomac start");
518
595
  const local = await attachTo(target);
519
- return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.` });
596
+ return output({ session: local.snapshot, attached: `Attached to ${local.id}, started elsewhere: ssh, sync, pull and exec work from this folder.`, ...project(local) });
520
597
  }
521
598
  if (command === "start") {
522
599
  if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
@@ -540,7 +617,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
540
617
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
541
618
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
542
619
  local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
543
- request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) } };
620
+ request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) },
621
+ ...previousDrive(store.load()) };
544
622
  store.save(local); store.saveBaseline({});
545
623
  }
546
624
  if (!local.id) {
@@ -554,7 +632,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
554
632
  const [running] = await liveSessions();
555
633
  if (running) {
556
634
  local = await attachTo(running);
557
- return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.` });
635
+ return output({ session: local.snapshot, attached: `Your account's Mac ${local.id} was already running (started elsewhere): this folder now uses it.`, ...project(local) });
558
636
  }
559
637
  }
560
638
  if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
@@ -573,7 +651,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
573
651
  await sleep(2000);
574
652
  }
575
653
  if (local.state === "ready") await ready(local);
576
- return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
654
+ return output({ session: local.snapshot, request_key: local.requestKey, ...project(local), ...(local.warning ? { warning: local.warning } : {}) });
577
655
  }
578
656
  const local = localSession();
579
657
  if (command === "stop") {
@@ -586,13 +664,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
586
664
  await ready(local);
587
665
  if (command === "sync") {
588
666
  if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
589
- 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 });
590
668
  if (!values.json) { print(describeSync(result)); return result; }
591
669
  return output(result);
592
670
  }
593
671
  if (command === "pull") {
594
- if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from REMOTE_PATH]");
595
- 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 });
596
674
  if (!values.json) { print(describePull(result)); return result; }
597
675
  return output(result);
598
676
  }
package/src/mac-tools.mjs CHANGED
@@ -28,7 +28,7 @@ export const MAC_TOOLS = [
28
28
  { name: "get_mac_drive", description: "Read the account's persistent drive: a disk kept between sessions and mounted at ~/drive on every Mac (50 GB). What is there stays for the next session: keep projects in ~/drive/src for incremental builds; package and build caches (DerivedData, Swift packages, CocoaPods, npm) are kept there already. NoMac saves it when a Mac stops, with nothing to call. Shows the space used, the newest saved version, a save still in progress, recent versions, and the date it is deleted after 90 days without a Mac.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/drive")) },
29
29
  { name: "reset_mac_drive", description: "Empty the account's persistent drive: the next Mac starts with a new, empty ~/drive, and everything on the old drive is deleted, with no way back. Stop any running session first (stop_mac). For a single Mac without the drive, start_mac with access drive none instead.", inputSchema: schema({}), handler: async () => text(await api("DELETE", "/api/v1/mac/drive")) },
30
30
  { name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
31
- { name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
31
+ { name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the url to the person who holds the value; they need no NoMac account, and it works once, for 24 hours. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
32
32
  { name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
33
33
  { name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
34
34
  { name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
@@ -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. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command can be at most 64 KiB in total: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
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." } });
@@ -115,5 +115,6 @@ export const MAC_TOOLS = [
115
115
  return text(save_to && view.state === "ready" ? { ...view, ...await saveFromLink(view, save_to) } : view);
116
116
  } },
117
117
  { name: "get_mac_job", description: "Inspect the existing command without rerunning it. Retain its job ID until completion and retrieve output before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}`)) },
118
+ { name: "cancel_mac_job", description: "Stop a running command and everything it started (its whole process group): SIGTERM, a second to finish, then SIGKILL. It comes back exited with reason cancelled; its output so far stays readable. A command that already finished is returned unchanged (cancelled: false).", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("POST", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/cancel`)) },
118
119
  { name: "read_mac_output", description: "Read stdout/stderr as base64 chunks; resume from next_cursor. If truncated, the final response includes tail_base64 and its absolute tail_offset. Retrieve promptly: only recent completed jobs retain output. output_expired preserves the job receipt and never means rerun. Retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
119
120
  ];
package/src/secrets.mjs CHANGED
@@ -14,7 +14,8 @@ come back from NoMac, and are never taken as command-line arguments.
14
14
  nomac secrets rm NAME delete it
15
15
  nomac secrets install [NAME...] put them on this folder's Mac now [--session ses_…]
16
16
  nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]
17
- a link where someone enters it in the browser
17
+ a link for whoever holds it to enter it once,
18
+ in the browser, with no NoMac account
18
19
  Add --json for JSON.`;
19
20
 
20
21
  /**
@@ -130,7 +131,7 @@ export async function secretsCommand(args, { api = defaultApi, print = console.l
130
131
  if (names.length !== 1 || !values.description || !["variable", "file"].includes(kind)) throw new CliError("request usage", "Usage: nomac secrets request NAME --description TEXT [--kind file] [--destination ~/x]");
131
132
  const result = await api("POST", "/api/v1/secrets/requests", { body: { name: names[0], kind, description: values.description,
132
133
  ...(values.destination ? { destination: values.destination } : {}), delivery } });
133
- return out(result, `Open this link to enter ${result.name} (expires ${result.expires_at}):\n${result.url}`);
134
+ return out(result, `Send this link to whoever holds ${result.name}; they need no NoMac account. It works once, until ${result.expires_at.slice(0, 16).replace("T", " ")} UTC:\n${result.url}`);
134
135
  }
135
136
  throw new CliError("unknown secrets command", SECRETS_HELP);
136
137
  }
@@ -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
  }