@nomac/cli 0.11.8 → 0.11.9

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.8",
3
+ "version": "0.11.9",
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
@@ -26,8 +26,8 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
26
26
  `NOMAC_API_KEY`. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
27
27
  Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
28
28
  3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
29
- Run commands with `nomac exec -- COMMAND`: it prints the output and exits
30
- with the command's code, waiting at most 30 s. For builds and tests use
29
+ Run commands with `nomac exec -- COMMAND`: it prints the output as it
30
+ arrives and exits with the command's code, waiting at most 30 s. For builds and tests use
31
31
  `nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
32
32
  with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
33
33
  `--request-key stable-key-0001`. A command may run an hour (`--timeout
@@ -61,6 +61,7 @@ line in .nomacignore sends any of them. A file changed on the Mac since the
61
61
  last sync is left alone and named (the rest is sent, and sync exits 1): `nomac
62
62
  pull` it, or `nomac sync . --overwrite`. Sync deletes nothing on the Mac
63
63
  unless `--delete`, which removes what it sent earlier and you deleted here.
64
+ `nomac sync . --watch` syncs again whenever files change, until Ctrl-C.
64
65
  See the screen with `nomac screenshot` (saves
65
66
  `nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
66
67
  `screenshot_mac`, which returns an image you can look at. Record a video for
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3
- import { basename, join, resolve } from "node:path";
2
+ import { chmodSync, readFileSync, watch as watchFolder, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve, sep } 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
7
  import { forwardingArg, isShellLine, runProcess, shellQuote, sshArgs, validateWorkspace, workspaceCommand } from "./session-ssh.mjs";
8
- import { describePull, describeSync, pullProject, syncProject } from "./session-transfer.mjs";
8
+ import { describePull, describeSync, inSkippedFolder, 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";
@@ -19,12 +19,13 @@ 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] [--delete]
22
+ nomac sync . [--dry-run] [--delete] [--watch]
23
23
  upload changed files to the project folder on the Mac:
24
24
  ~/drive/src/<this folder's name>, kept between sessions
25
25
  (~/workspace with --drive none). A file changed on the Mac
26
26
  since is left alone and named (--overwrite replaces it);
27
- --delete also removes there what you deleted here
27
+ --delete also removes there what you deleted here;
28
+ --watch syncs again whenever files change, until Ctrl-C
28
29
  nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
29
30
  nomac ssh -- command args run any command over SSH
30
31
  nomac desktop [--no-open] [--json] the full macOS desktop in your browser
@@ -45,7 +46,8 @@ const HELP = `Nomac cloud Mac sessions
45
46
  nomac start --permit APP=PERMISSIONS
46
47
  grant permissions on this Mac at start
47
48
  nomac exec [--wait 30] -- command args
48
- runs it, prints its output, exits with its code.
49
+ runs it, prints its output as it arrives,
50
+ exits with its code.
49
51
  Waits up to 30 s; for builds and tests use --wait 600.
50
52
  Exit code 75 means still running: see nomac job.
51
53
  (--request-key KEY makes a scripted retry safe)
@@ -78,7 +80,7 @@ const options = {
78
80
  help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
79
81
  "max-duration": { type: "string" }, "idle-timeout": { type: "string" },
80
82
  "max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
81
- overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" },
83
+ overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" }, watch: { type: "boolean" },
82
84
  forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
83
85
  "no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
84
86
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
@@ -190,11 +192,33 @@ function endedBy(job, timeoutSeconds) {
190
192
  if (job.reason === "cancelled") return `${job.id} was cancelled (nomac cancel).`;
191
193
  return null;
192
194
  }
193
- async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds }) {
195
+ /** How often a waiting command's new output is fetched and printed (each read is a round trip to the Mac). */
196
+ const STREAM_MS = 2000;
197
+ async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds, cursors = null, keepCursors = () => {} }) {
194
198
  let job = started;
195
199
  const deadline = now() + waitSeconds * 1000;
196
200
  const begun = now();
197
- let announced = false, busy = false, shownWaiting = "";
201
+ let announced = false, busy = false, shownWaiting = "", streamed = 0;
202
+ // Output is printed as it arrives, from where an earlier wait for this command stopped; --json
203
+ // answers once, with all of it.
204
+ const live = !json && waitSeconds > 0;
205
+ const cursor = { stdout: cursors?.stdout ?? 0, stderr: cursors?.stderr ?? 0 }, write = { stdout, stderr };
206
+ const drain = async (final) => {
207
+ for (const stream of ["stdout", "stderr"]) {
208
+ if (final && cursor[stream] === 0 && job.output_bytes?.[stream] === 0) continue;
209
+ for (let reads = 0; final || reads < 4; reads++) {
210
+ const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor[stream]}&stream=${stream}`, {}, { request, transport, cache });
211
+ const data = Buffer.from(part.data_base64 ?? "", "base64"), next = Number.isSafeInteger(part.next_cursor) ? part.next_cursor : cursor[stream];
212
+ if (data.length) write[stream](data);
213
+ if (part.eof && part.tail_base64) for (const piece of tailAfter(part)) write[stream](piece);
214
+ const done = part.eof || next === cursor[stream];
215
+ cursor[stream] = next;
216
+ if (done) break;
217
+ }
218
+ }
219
+ };
220
+ // A busy Mac only delays what is printed; the command and its output are unaffected.
221
+ const stream = async () => { try { await drain(false); } catch (error) { if (error?.code !== "guest_busy") throw error; } };
198
222
  for (let polls = 0; waitSeconds && !["exited", "unknown"].includes(job.state) && now() < deadline; polls++) {
199
223
  // A slow command can outlive the shell waiting for it (agent shells time out); leave a way back.
200
224
  if (!json && !announced && now() - begun >= NOTICE_MS && deadline - now() > 1000) {
@@ -211,9 +235,12 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
211
235
  shownWaiting = `${waiting.kind}:${waiting.text}`;
212
236
  stderr(`nomac: ${job.id} is waiting on macOS: ${waiting.text}\n ${waiting.fix}\n`);
213
237
  }
238
+ if (live && job.state === "running" && now() - streamed >= STREAM_MS) { streamed = now(); await stream(); }
214
239
  }
215
240
  if (job.state !== "exited") {
216
241
  if (!waitSeconds) { print(JSON.stringify(job, null, 2)); return job; }
242
+ // What it printed so far is shown, and the next wait for it goes on from there.
243
+ if (live) { await stream(); keepCursors(job.id, cursor); }
217
244
  const unknown = job.state === "unknown";
218
245
  const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
219
246
  const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
@@ -224,6 +251,14 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
224
251
  exitWith(unknown ? 1 : STILL_RUNNING);
225
252
  return job;
226
253
  }
254
+ const ended = endedBy(job, timeoutSeconds);
255
+ if (live) {
256
+ await drain(true);
257
+ keepCursors(job.id, null);
258
+ if (ended) stderr(`nomac: ${ended}\n`);
259
+ exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
260
+ return job;
261
+ }
227
262
  // Each read is a round trip to the Mac: skip empty streams and read the others together.
228
263
  const read = async stream => {
229
264
  if (job.output_bytes?.[stream] === 0) return Buffer.alloc(0);
@@ -238,7 +273,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
238
273
  return Buffer.concat(parts);
239
274
  };
240
275
  const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
241
- const streams = { stdout: out, stderr: err }, ended = endedBy(job, timeoutSeconds);
276
+ const streams = { stdout: out, stderr: err };
242
277
  if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8"), ...(ended ? { next_step: ended } : {}) }, null, 2));
243
278
  else { stdout(streams.stdout); stderr(streams.stderr); if (ended) stderr(`nomac: ${ended}\n`); }
244
279
  exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
@@ -307,7 +342,7 @@ export async function hostedRequest(id, method, path, params = {}, { request = a
307
342
  return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
308
343
  }
309
344
  export async function hostedSessionCommand(command, argv, { store = sessionStore(), request = api, transport = macRequest, run = runProcess, print = console.log, sleep = ms => new Promise(r => setTimeout(r, ms)), now = Date.now,
310
- stdout = data => process.stdout.write(data), stderr = data => process.stderr.write(data), exitWith = code => { process.exitCode = code; } } = {}) {
345
+ stdout = data => process.stdout.write(data), stderr = data => process.stderr.write(data), exitWith = code => { process.exitCode = code; } , watch = watchFolder, stopWatching = undefined, debounceMs = 600} = {}) {
311
346
  let parsed;
312
347
  try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
313
348
  catch (error) { throw new CliError(error.message); }
@@ -487,7 +522,14 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
487
522
  throw error;
488
523
  });
489
524
  if (!waiting) return output(result);
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 });
525
+ // Where printing a command's output stopped, so a later nomac job --wait goes on from there.
526
+ const kept = store.jobCursors(), keepCursors = (job, at) => {
527
+ const next = Object.fromEntries(Object.entries(store.jobCursors()).filter(([id]) => id !== job).slice(-19));
528
+ if (at) next[job] = { stdout: at.stdout, stderr: at.stderr };
529
+ store.saveJobCursors(next);
530
+ };
531
+ 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,
532
+ cursors: command === "job" ? kept[result.id] ?? null : null, keepCursors });
491
533
  }
492
534
  if (command === "screenshot") {
493
535
  if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
@@ -580,6 +622,58 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
580
622
  const local = await ready(localSession());
581
623
  return run("ssh", [...sshArgs(local, { batch: false }), ...(!positionals.length && process.stdin.isTTY ? ["-t"] : []), ...(values.forward ?? []).flatMap(v => ["-L", forwardingArg(v)]), "-o", "ExitOnForwardFailure=yes", local.target, workspaceCommand(local, positionals)], { inherit: true });
582
624
  }
625
+ if (command === "sync" && values.watch) {
626
+ if (values["dry-run"] || values.json) throw new CliError("nomac sync --watch keeps sending changes: use it without --dry-run and --json.");
627
+ if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . --watch from the attached project.");
628
+ return watchSync();
629
+ }
630
+ /**
631
+ * nomac sync . --watch: sync now, then again a moment after files change, until Ctrl-C. Each
632
+ * sync takes the folder's lock only while it runs, so pull, exec and stop work alongside;
633
+ * changes in skipped folders (node_modules, build output, .git) start none.
634
+ */
635
+ async function watchSync() {
636
+ const clock = () => new Date().toISOString().slice(11, 19);
637
+ let skipped = [], running = false, again = false, timer, finish;
638
+ const syncOnce = async (first) => {
639
+ if (running) { again = true; return; }
640
+ running = true;
641
+ try {
642
+ do {
643
+ again = false;
644
+ try {
645
+ const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }));
646
+ skipped = result.skipped;
647
+ if (first) print(`${describeSync(result)}\nWatching ${store.project} for changes; Ctrl-C stops.`);
648
+ else if (result.changed.length || result.deleted?.length || result.kept_on_mac?.length) print(`${clock()} ${describeSync({ ...result, skipped: [] })}`);
649
+ } catch (error) {
650
+ // A stopped Mac ends the watch; a conflict, a busy folder or a lost connection is said, and it goes on.
651
+ if (error?.code === "session_not_ready") throw error;
652
+ print(`${clock()} ${error.friendly || error.message}`);
653
+ }
654
+ first = false;
655
+ } while (again);
656
+ } finally { running = false; }
657
+ };
658
+ await syncOnce(true);
659
+ const ignored = name => {
660
+ if (!name) return false;
661
+ const path = String(name).split(sep).join("/");
662
+ if (/(^|\/)(\.DS_Store|\.nomac-download-[^/]*)$/.test(path) || inSkippedFolder(path)) return true;
663
+ return skipped.some(item => item.path.endsWith("/") ? path === item.path.slice(0, -1) || path.startsWith(item.path) : path === item.path);
664
+ };
665
+ const watcher = watch(store.project, { recursive: true }, (_event, name) => {
666
+ if (ignored(name)) return;
667
+ clearTimeout(timer);
668
+ timer = setTimeout(() => { syncOnce(false).catch(error => finish(error)); }, debounceMs);
669
+ });
670
+ await new Promise((done, fail) => {
671
+ finish = error => { clearTimeout(timer); watcher.close(); if (error) fail(error); else done(); };
672
+ watcher.on?.("error", error => finish(new CliError("watch failed", `Watching ${store.project} stopped: ${error.message}`)));
673
+ if (stopWatching) stopWatching.then(() => finish());
674
+ else process.once("SIGINT", () => finish());
675
+ });
676
+ }
583
677
  return store.locked(async () => {
584
678
  if (command === "attach") {
585
679
  if (positionals.length > 1) throw new CliError("Usage: nomac attach [SESSION_ID] [--wait SECONDS] [--json]");
@@ -627,5 +627,137 @@
627
627
  "action"
628
628
  ],
629
629
  "additionalProperties": false
630
+ },
631
+ "set_subscription_prices": {
632
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
633
+ "type": "object",
634
+ "properties": {
635
+ "subscription": {
636
+ "minItems": 1,
637
+ "maxItems": 8,
638
+ "type": "array",
639
+ "items": {
640
+ "type": "object",
641
+ "properties": {
642
+ "relationship": {
643
+ "type": "string",
644
+ "maxLength": 100,
645
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
646
+ },
647
+ "id": {
648
+ "type": "string",
649
+ "minLength": 1,
650
+ "maxLength": 512
651
+ },
652
+ "query": {
653
+ "type": "object",
654
+ "propertyNames": {
655
+ "type": "string",
656
+ "maxLength": 120
657
+ },
658
+ "additionalProperties": {
659
+ "anyOf": [
660
+ {
661
+ "type": "string",
662
+ "maxLength": 4096
663
+ },
664
+ {
665
+ "type": "number"
666
+ },
667
+ {
668
+ "type": "boolean"
669
+ },
670
+ {
671
+ "maxItems": 200,
672
+ "type": "array",
673
+ "items": {
674
+ "type": "string",
675
+ "maxLength": 1024
676
+ }
677
+ }
678
+ ]
679
+ }
680
+ }
681
+ },
682
+ "required": [
683
+ "relationship",
684
+ "id"
685
+ ],
686
+ "additionalProperties": false
687
+ },
688
+ "description": "The subscription, as the scope read_store returned for it"
689
+ },
690
+ "base_price_point": {
691
+ "minItems": 1,
692
+ "maxItems": 8,
693
+ "type": "array",
694
+ "items": {
695
+ "type": "object",
696
+ "properties": {
697
+ "relationship": {
698
+ "type": "string",
699
+ "maxLength": 100,
700
+ "pattern": "^[A-Za-z][A-Za-z0-9]*$"
701
+ },
702
+ "id": {
703
+ "type": "string",
704
+ "minLength": 1,
705
+ "maxLength": 512
706
+ },
707
+ "query": {
708
+ "type": "object",
709
+ "propertyNames": {
710
+ "type": "string",
711
+ "maxLength": 120
712
+ },
713
+ "additionalProperties": {
714
+ "anyOf": [
715
+ {
716
+ "type": "string",
717
+ "maxLength": 4096
718
+ },
719
+ {
720
+ "type": "number"
721
+ },
722
+ {
723
+ "type": "boolean"
724
+ },
725
+ {
726
+ "maxItems": 200,
727
+ "type": "array",
728
+ "items": {
729
+ "type": "string",
730
+ "maxLength": 1024
731
+ }
732
+ }
733
+ ]
734
+ }
735
+ }
736
+ },
737
+ "required": [
738
+ "relationship",
739
+ "id"
740
+ ],
741
+ "additionalProperties": false
742
+ },
743
+ "description": "The price point to start from (for example the USA one), as the scope read_store returned for it"
744
+ },
745
+ "plan_type": {
746
+ "description": "The prices' plan type, when the subscription's prices have one (read_store shows each price's planType)",
747
+ "type": "string",
748
+ "enum": [
749
+ "MONTHLY",
750
+ "UPFRONT"
751
+ ]
752
+ },
753
+ "confirm": {
754
+ "type": "boolean"
755
+ }
756
+ },
757
+ "required": [
758
+ "subscription",
759
+ "base_price_point"
760
+ ],
761
+ "additionalProperties": false
630
762
  }
631
763
  }
package/src/mcp.mjs CHANGED
@@ -71,13 +71,14 @@ export const TOOLS = [
71
71
  },
72
72
  ...[
73
73
  ["read_store", "read", "Read a discovered Apple GET operation. Start with an apps_* relationship and scope:[]; reuse the returned resource scopes. Paginate with next_cursor and unchanged query. Raw IDs are accepted only for global lookups. Reads live Apple state, file upload operations and analytics report URLs."],
74
+ ["set_subscription_prices", "subscription-prices", "Sets a subscription's price in every territory from one base price point, using Apple's equalized prices (what App Store Connect's all-territories pricing does): territories already at that price are skipped, the others are set one by one, about 40 per call within 35 seconds. Call again (a new request_key, or none) until remaining is 0; a repeat changes nothing. Apple's refusal for a territory is listed in failed (UNSUPPORTED_TERRITORY: set the subscription's availability there first). Without confirm it returns a preview with the counts. Prices come from the human."],
74
75
  ["update_store", "write", "Execute a discovered Apple write using its exact JSON:API body. Copy scope and relationship references from read_store. Preview by default; confirm:true changes real Apple resources. Use owner-authorized prices and attestations. On pending, retry identical arguments with request_key; get_store_operation recovers lost keys. Use publish/update_review/manage_testflight for their dedicated lifecycle actions."],
75
76
  ].map(([name, route, description]) => ({
76
77
  name, description,
77
- inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(route === "write" ? { request_key: { type: "string" } } : {}) } },
78
+ inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(route !== "read" ? { request_key: { type: "string" } } : {}) } },
78
79
  handler: async args => {
79
80
  const { project_id: _project, request_key, ...body } = args;
80
- return text(await api("POST", `/api/v1/projects/${encodeURIComponent(await resolveProjectId(args))}/store/${route}`, { body, ...(route === "write" ? { idempotencyKey: request_key ?? `mcp-store-${randomUUID()}` } : {}) }));
81
+ return text(await api("POST", `/api/v1/projects/${encodeURIComponent(await resolveProjectId(args))}/store/${route}`, { body, ...(route !== "read" ? { idempotencyKey: request_key ?? `mcp-store-${randomUUID()}` } : {}) }));
81
82
  },
82
83
  })),
83
84
  {
@@ -340,7 +341,7 @@ export const TOOLS = [
340
341
  })),
341
342
  ...[
342
343
  ["update_review", "reviews", "Resolve a fixed rejected item, remove an item, cancel review, resubmit ready items or release an approved version. Use exact IDs from get_review. Preview without confirm; confirm:true performs the action. Removal cannot be undone in that submission. Fix actual issues before resolving. On pending, repeat unchanged arguments with request_key; get_store_operation recovers it."],
343
- ["manage_testflight", "testflight", "Set beta metadata/contact/demo credentials, create/update external groups/public links, invite/remove an explicit tester, distribute a specific Apple build to explicit groups and Beta App Review, notify after approval, expire testing, or relay a human-provided encryption answer. Preview without confirm; confirm:true applies. auto_notify defaults false. Never guess attestations. On pending, repeat unchanged arguments with request_key."],
344
+ ["manage_testflight", "testflight", "Set beta metadata/contact/demo credentials, create/update external groups/public links, invite/remove an explicit tester (internal groups take App Store Connect team members), distribute a specific Apple build to explicit groups (external ones go through Beta App Review, the internal group needs none), notify after approval, expire testing, or relay a human-provided encryption answer. Preview without confirm; confirm:true applies. auto_notify defaults false. Never guess attestations. On pending, repeat unchanged arguments with request_key."],
344
345
  ["prepare_review_reply", "reviews/reply", "Prepare YOUR authored reply with an App Store Connect handoff. Always returns sent:false: Apple's public API cannot read or send reviewer messages. The human must paste and send it. This does not change reviewer notes or public customer reviews."],
345
346
  ].map(([name, path, description]) => ({
346
347
  name, description,
@@ -15,6 +15,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
15
15
  const baselineFile = join(directory, "baseline.json");
16
16
  // What each pull wrote, by local path: such a file, unchanged since, is not a local change.
17
17
  const pulledFile = join(directory, "pulled.json");
18
+ // How much of each recent command's output was printed while waiting for it.
19
+ const cursorsFile = join(directory, "job-cursors.json");
18
20
  const read = (file) => {
19
21
  if (!existsSync(file)) return null;
20
22
  try { return JSON.parse(readFileSync(file, "utf8")); }
@@ -35,6 +37,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
35
37
  saveBaseline: (value) => write(baselineFile, value),
36
38
  pulled: () => read(pulledFile) ?? {},
37
39
  savePulled: (value) => write(pulledFile, value),
40
+ jobCursors: () => read(cursorsFile) ?? {},
41
+ saveJobCursors: (value) => write(cursorsFile, value),
38
42
  async locked(operation) {
39
43
  const lock = join(directory, "operation.lock");
40
44
  try { mkdirSync(lock, { mode: 0o700 }); }
@@ -31,6 +31,8 @@ const BUILT_IN = [
31
31
  // Built-in patterns are names, matched against the entry's own name, so a folder
32
32
  // brought back with `!` is never skipped again through its parent.
33
33
  const builtInRules = BUILT_IN.map(({ pattern, reason }) => ({ rules: ignoreFactory().add(pattern), reason }));
34
+ /** Whether a path is inside a folder sync always leaves out (node_modules, build output, .git…). */
35
+ export const inSkippedFolder = path => String(path).split(/[\\/]/).slice(0, -1).some(name => builtInRules.some(rule => rule.rules.ignores(`${name}/`)));
34
36
 
35
37
  /** Code-unit order, the same as Array.prototype.sort on the file lists. */
36
38
  const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -282,13 +284,15 @@ export async function syncProject(session, store, { overwrite = false, dryRun =
282
284
  const removable = gone.filter(path => onMac[path] === baseline[path]), keptOnMac = gone.filter(path => !["missing", baseline[path]].includes(onMac[path]));
283
285
  if (dryRun) return { dry_run: true, files: paths.length, changed, ...(conflicts.length ? { conflicts } : {}), ...(deleteMissing ? { deleted: removable, kept_on_mac: keptOnMac } : {}), ...summary };
284
286
  let output = "";
285
- if (send.length) {
287
+ // Only what differs travels; a file already the same on the Mac is recorded as such.
288
+ for (const path of send) if (previous[path] === local[path]) baseline[path] = local[path];
289
+ if (changed.length) {
286
290
  // --links sends a symlink as a link: its target is never read or followed.
287
- output = (await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(send.join("\0") + "\0"), env: transferEnv(session) })).stdout;
291
+ output = (await run("rsync", [...transferArgs(), "--links", "--from0", "--files-from=-", `${store.project}/`, `${session.target}:./`], { input: Buffer.from(changed.join("\0") + "\0"), env: transferEnv(session) })).stdout;
288
292
  }
289
- const after = send.length ? await remoteFingerprints(session, send, { run }) : {};
290
- const changedDuringTransfer = send.filter(path => after[path] !== local[path]);
291
- for (const path of send) if (after[path] === local[path]) baseline[path] = local[path];
293
+ const after = changed.length ? await remoteFingerprints(session, changed, { run }) : {};
294
+ const changedDuringTransfer = changed.filter(path => after[path] !== local[path]);
295
+ for (const path of changed) if (after[path] === local[path]) baseline[path] = local[path];
292
296
  const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
293
297
  for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
294
298
  store.saveBaseline(baseline);