@nomac/cli 0.11.8 → 0.11.10

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.10",
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,9 @@ 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 (or the
31
+ `--timeout` given, up to 600 s). For builds and tests use
31
32
  `nomac exec --wait 600 -- COMMAND`. Exit code 75 means still running: wait
32
33
  with `nomac job JOB_ID --wait 600`, never rerun. In scripts add
33
34
  `--request-key stable-key-0001`. A command may run an hour (`--timeout
@@ -61,6 +62,7 @@ line in .nomacignore sends any of them. A file changed on the Mac since the
61
62
  last sync is left alone and named (the rest is sent, and sync exits 1): `nomac
62
63
  pull` it, or `nomac sync . --overwrite`. Sync deletes nothing on the Mac
63
64
  unless `--delete`, which removes what it sent earlier and you deleted here.
65
+ `nomac sync . --watch` syncs again whenever files change, until Ctrl-C.
64
66
  See the screen with `nomac screenshot` (saves
65
67
  `nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
66
68
  `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,8 +46,10 @@ 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
- Waits up to 30 s; for builds and tests use --wait 600.
49
+ runs it, prints its output as it arrives,
50
+ exits with its code.
51
+ Waits up to 30 s, or with --timeout N up to N s
52
+ (at most 600); for builds and tests use --wait 600.
50
53
  Exit code 75 means still running: see nomac job.
51
54
  (--request-key KEY makes a scripted retry safe)
52
55
  nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
@@ -78,7 +81,7 @@ const options = {
78
81
  help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
79
82
  "max-duration": { type: "string" }, "idle-timeout": { type: "string" },
80
83
  "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" },
84
+ overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" }, delete: { type: "boolean" }, watch: { type: "boolean" },
82
85
  forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
83
86
  "no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
84
87
  prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
@@ -176,11 +179,11 @@ export const STILL_RUNNING = 75;
176
179
  const POLL_MS = [250, 500];
177
180
  const NOTICE_MS = 5000;
178
181
  /**
179
- * nomac exec waits like a local command: up to --wait seconds (default 30), then
180
- * prints stdout and stderr and exits with the command's own exit code. The wait
181
- * is a ceiling: a quick command returns as soon as it finishes. A command still
182
- * running keeps running on the Mac; the CLI says how to wait for it and exits
183
- * 75. --wait 0 only starts it.
182
+ * nomac exec waits like a local command: up to --wait seconds (default 30, or
183
+ * --timeout's up to 600), then prints stdout and stderr and exits with the
184
+ * command's own exit code. The wait is a ceiling: a quick command returns as soon
185
+ * as it finishes. A command still running keeps running on the Mac; the CLI
186
+ * says how to wait for it and exits 75. --wait 0 only starts it.
184
187
  */
185
188
  /** "13:05 UTC", when a running command's time limit stops it. */
186
189
  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.` : "";
@@ -190,11 +193,33 @@ function endedBy(job, timeoutSeconds) {
190
193
  if (job.reason === "cancelled") return `${job.id} was cancelled (nomac cancel).`;
191
194
  return null;
192
195
  }
193
- async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds }) {
196
+ /** How often a waiting command's new output is fetched and printed (each read is a round trip to the Mac). */
197
+ const STREAM_MS = 2000;
198
+ async function waitForCommand(id, base, started, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json, timeoutSeconds, cursors = null, keepCursors = () => {} }) {
194
199
  let job = started;
195
200
  const deadline = now() + waitSeconds * 1000;
196
201
  const begun = now();
197
- let announced = false, busy = false, shownWaiting = "";
202
+ let announced = false, busy = false, shownWaiting = "", streamed = 0;
203
+ // Output is printed as it arrives, from where an earlier wait for this command stopped; --json
204
+ // answers once, with all of it.
205
+ const live = !json && waitSeconds > 0;
206
+ const cursor = { stdout: cursors?.stdout ?? 0, stderr: cursors?.stderr ?? 0 }, write = { stdout, stderr };
207
+ const drain = async (final) => {
208
+ for (const stream of ["stdout", "stderr"]) {
209
+ if (final && cursor[stream] === 0 && job.output_bytes?.[stream] === 0) continue;
210
+ for (let reads = 0; final || reads < 4; reads++) {
211
+ const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor[stream]}&stream=${stream}`, {}, { request, transport, cache });
212
+ const data = Buffer.from(part.data_base64 ?? "", "base64"), next = Number.isSafeInteger(part.next_cursor) ? part.next_cursor : cursor[stream];
213
+ if (data.length) write[stream](data);
214
+ if (part.eof && part.tail_base64) for (const piece of tailAfter(part)) write[stream](piece);
215
+ const done = part.eof || next === cursor[stream];
216
+ cursor[stream] = next;
217
+ if (done) break;
218
+ }
219
+ }
220
+ };
221
+ // A busy Mac only delays what is printed; the command and its output are unaffected.
222
+ const stream = async () => { try { await drain(false); } catch (error) { if (error?.code !== "guest_busy") throw error; } };
198
223
  for (let polls = 0; waitSeconds && !["exited", "unknown"].includes(job.state) && now() < deadline; polls++) {
199
224
  // A slow command can outlive the shell waiting for it (agent shells time out); leave a way back.
200
225
  if (!json && !announced && now() - begun >= NOTICE_MS && deadline - now() > 1000) {
@@ -211,9 +236,12 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
211
236
  shownWaiting = `${waiting.kind}:${waiting.text}`;
212
237
  stderr(`nomac: ${job.id} is waiting on macOS: ${waiting.text}\n ${waiting.fix}\n`);
213
238
  }
239
+ if (live && job.state === "running" && now() - streamed >= STREAM_MS) { streamed = now(); await stream(); }
214
240
  }
215
241
  if (job.state !== "exited") {
216
242
  if (!waitSeconds) { print(JSON.stringify(job, null, 2)); return job; }
243
+ // What it printed so far is shown, and the next wait for it goes on from there.
244
+ if (live) { await stream(); keepCursors(job.id, cursor); }
217
245
  const unknown = job.state === "unknown";
218
246
  const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
219
247
  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 +252,14 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
224
252
  exitWith(unknown ? 1 : STILL_RUNNING);
225
253
  return job;
226
254
  }
255
+ const ended = endedBy(job, timeoutSeconds);
256
+ if (live) {
257
+ await drain(true);
258
+ keepCursors(job.id, null);
259
+ if (ended) stderr(`nomac: ${ended}\n`);
260
+ exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
261
+ return job;
262
+ }
227
263
  // Each read is a round trip to the Mac: skip empty streams and read the others together.
228
264
  const read = async stream => {
229
265
  if (job.output_bytes?.[stream] === 0) return Buffer.alloc(0);
@@ -238,7 +274,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
238
274
  return Buffer.concat(parts);
239
275
  };
240
276
  const [out, err] = await Promise.all([read("stdout"), read("stderr")]);
241
- const streams = { stdout: out, stderr: err }, ended = endedBy(job, timeoutSeconds);
277
+ const streams = { stdout: out, stderr: err };
242
278
  if (json) print(JSON.stringify({ ...job, stdout: streams.stdout.toString("utf8"), stderr: streams.stderr.toString("utf8"), ...(ended ? { next_step: ended } : {}) }, null, 2));
243
279
  else { stdout(streams.stdout); stderr(streams.stderr); if (ended) stderr(`nomac: ${ended}\n`); }
244
280
  exitWith(typeof job.exit_code === "number" ? job.exit_code : 1);
@@ -307,7 +343,7 @@ export async function hostedRequest(id, method, path, params = {}, { request = a
307
343
  return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
308
344
  }
309
345
  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; } } = {}) {
346
+ stdout = data => process.stdout.write(data), stderr = data => process.stderr.write(data), exitWith = code => { process.exitCode = code; } , watch = watchFolder, stopWatching = undefined, debounceMs = 600} = {}) {
311
347
  let parsed;
312
348
  try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
313
349
  catch (error) { throw new CliError(error.message); }
@@ -452,7 +488,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
452
488
  }
453
489
  if (["exec", "job", "output"].includes(command)) {
454
490
  const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
455
- let path, method = "GET", params = {};
491
+ let path, method = "GET", params = {}, waitSeconds = integer(values.wait, 30, 0, 3600);
456
492
  if (command === "exec") {
457
493
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
458
494
  if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
@@ -461,8 +497,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
461
497
  // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
462
498
  // With a key the limit must not depend on --wait: a retry with another --wait is the same command.
463
499
  // A command may run an hour (the gateway's limit); waiting can cover all of it.
464
- const waitFor = integer(values.wait, 30, 0, 3600);
465
- const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 3600);
500
+ // A command given a long --timeout and no --wait is waited for too, up to 10 minutes (what an
501
+ // agent's shell usually allows); the line printed after 5 s says how to go on if interrupted.
502
+ const given = values.timeout === undefined ? null : integer(values.timeout, 0, 1, 3600);
503
+ const waitFor = waitSeconds = integer(values.wait, given === null ? 30 : Math.min(given, 600), 0, 3600);
504
+ const timeout = given ?? (values["request-key"] === undefined ? Math.max(300, waitFor) : 600);
466
505
  const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
467
506
  const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
468
507
  : line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
@@ -487,7 +526,14 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
487
526
  throw error;
488
527
  });
489
528
  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 });
529
+ // Where printing a command's output stopped, so a later nomac job --wait goes on from there.
530
+ const kept = store.jobCursors(), keepCursors = (job, at) => {
531
+ const next = Object.fromEntries(Object.entries(store.jobCursors()).filter(([id]) => id !== job).slice(-19));
532
+ if (at) next[job] = { stdout: at.stdout, stderr: at.stderr };
533
+ store.saveJobCursors(next);
534
+ };
535
+ return waitForCommand(local.id, base, result, waitSeconds, { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json), timeoutSeconds: params.body?.timeout_seconds,
536
+ cursors: command === "job" ? kept[result.id] ?? null : null, keepCursors });
491
537
  }
492
538
  if (command === "screenshot") {
493
539
  if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
@@ -580,6 +626,58 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
580
626
  const local = await ready(localSession());
581
627
  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
628
  }
629
+ if (command === "sync" && values.watch) {
630
+ if (values["dry-run"] || values.json) throw new CliError("nomac sync --watch keeps sending changes: use it without --dry-run and --json.");
631
+ if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . --watch from the attached project.");
632
+ return watchSync();
633
+ }
634
+ /**
635
+ * nomac sync . --watch: sync now, then again a moment after files change, until Ctrl-C. Each
636
+ * sync takes the folder's lock only while it runs, so pull, exec and stop work alongside;
637
+ * changes in skipped folders (node_modules, build output, .git) start none.
638
+ */
639
+ async function watchSync() {
640
+ const clock = () => new Date().toISOString().slice(11, 19);
641
+ let skipped = [], running = false, again = false, timer, finish;
642
+ const syncOnce = async (first) => {
643
+ if (running) { again = true; return; }
644
+ running = true;
645
+ try {
646
+ do {
647
+ again = false;
648
+ try {
649
+ const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }), "nomac sync --watch");
650
+ skipped = result.skipped;
651
+ if (first) print(`${describeSync(result)}\nWatching ${store.project} for changes; Ctrl-C stops.`);
652
+ else if (result.changed.length || result.deleted?.length || result.kept_on_mac?.length) print(`${clock()} ${describeSync({ ...result, skipped: [] })}`);
653
+ } catch (error) {
654
+ // A stopped Mac ends the watch; a conflict, a busy folder or a lost connection is said, and it goes on.
655
+ if (error?.code === "session_not_ready") throw error;
656
+ print(`${clock()} ${error.friendly || error.message}`);
657
+ }
658
+ first = false;
659
+ } while (again);
660
+ } finally { running = false; }
661
+ };
662
+ await syncOnce(true);
663
+ const ignored = name => {
664
+ if (!name) return false;
665
+ const path = String(name).split(sep).join("/");
666
+ if (/(^|\/)(\.DS_Store|\.nomac-download-[^/]*)$/.test(path) || inSkippedFolder(path)) return true;
667
+ return skipped.some(item => item.path.endsWith("/") ? path === item.path.slice(0, -1) || path.startsWith(item.path) : path === item.path);
668
+ };
669
+ const watcher = watch(store.project, { recursive: true }, (_event, name) => {
670
+ if (ignored(name)) return;
671
+ clearTimeout(timer);
672
+ timer = setTimeout(() => { syncOnce(false).catch(error => finish(error)); }, debounceMs);
673
+ });
674
+ await new Promise((done, fail) => {
675
+ finish = error => { clearTimeout(timer); watcher.close(); if (error) fail(error); else done(); };
676
+ watcher.on?.("error", error => finish(new CliError("watch failed", `Watching ${store.project} stopped: ${error.message}`)));
677
+ if (stopWatching) stopWatching.then(() => finish());
678
+ else process.once("SIGINT", () => finish());
679
+ });
680
+ }
583
681
  return store.locked(async () => {
584
682
  if (command === "attach") {
585
683
  if (positionals.length > 1) throw new CliError("Usage: nomac attach [SESSION_ID] [--wait SECONDS] [--json]");
@@ -669,11 +767,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
669
767
  return output(result);
670
768
  }
671
769
  if (command === "pull") {
770
+ // Like cp, the Mac's path first: say the command that does it.
771
+ if (positionals.length === 2 && values.from === undefined) throw new CliError("pull usage", `nomac pull takes one path, where the files go here; the Mac's path goes in --from: nomac pull ${shellWords([positionals[1]])} --from ${shellWords([positionals[0]])}`);
672
772
  if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from PATH_ON_THE_MAC]");
673
773
  const result = await pullProject(local, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
674
774
  if (!values.json) { print(describePull(result)); return result; }
675
775
  return output(result);
676
776
  }
677
777
  throw new CliError("Unknown cloud session command.");
678
- });
778
+ }, `nomac ${command}`);
679
779
  }
@@ -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,
@@ -4,6 +4,20 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { CliError } from "./api.mjs";
6
6
 
7
+ /** Who holds a folder's lock, or null for a lock without a readable record (an older nomac's). */
8
+ function lockOwner(file) {
9
+ try {
10
+ const owner = JSON.parse(readFileSync(file, "utf8"));
11
+ return Number.isSafeInteger(owner?.pid) && owner.pid > 0 ? { pid: owner.pid, command: String(owner.command ?? "a nomac command"), started_at: String(owner.started_at ?? "") } : null;
12
+ } catch { return null; }
13
+ }
14
+
15
+ /** Whether a process with this ID exists (one of another user's counts). */
16
+ function running(pid) {
17
+ try { process.kill(pid, 0); return true; }
18
+ catch (error) { return error.code === "EPERM"; }
19
+ }
20
+
7
21
  /** Session metadata stays outside the project: a checkout cannot choose an SSH target. */
8
22
  export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir(), ".nomac", "sessions")) {
9
23
  const project = realpathSync(projectDir);
@@ -15,6 +29,8 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
15
29
  const baselineFile = join(directory, "baseline.json");
16
30
  // What each pull wrote, by local path: such a file, unchanged since, is not a local change.
17
31
  const pulledFile = join(directory, "pulled.json");
32
+ // How much of each recent command's output was printed while waiting for it.
33
+ const cursorsFile = join(directory, "job-cursors.json");
18
34
  const read = (file) => {
19
35
  if (!existsSync(file)) return null;
20
36
  try { return JSON.parse(readFileSync(file, "utf8")); }
@@ -35,12 +51,32 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
35
51
  saveBaseline: (value) => write(baselineFile, value),
36
52
  pulled: () => read(pulledFile) ?? {},
37
53
  savePulled: (value) => write(pulledFile, value),
38
- async locked(operation) {
39
- const lock = join(directory, "operation.lock");
40
- try { mkdirSync(lock, { mode: 0o700 }); }
54
+ jobCursors: () => read(cursorsFile) ?? {},
55
+ saveJobCursors: (value) => write(cursorsFile, value),
56
+ /** Runs operation (nomac sync, pull, start…) alone for this folder; `what` names it to a second one. */
57
+ async locked(operation, what = "a nomac command") {
58
+ const lock = join(directory, "operation.lock"), ownerFile = join(lock, "owner.json");
59
+ const take = () => {
60
+ mkdirSync(lock, { mode: 0o700 });
61
+ writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, command: what, started_at: new Date().toISOString() }) + "\n", { mode: 0o600 });
62
+ };
63
+ try { take(); }
41
64
  catch (error) {
42
65
  if (error.code !== "EEXIST") throw error;
43
- throw new CliError("session busy", `Another session operation is running. If it was interrupted, verify it has ended before removing ${lock}.`);
66
+ const owner = lockOwner(ownerFile);
67
+ if (!owner) throw new CliError("session busy", `Another nomac command is using this folder (an older nomac's lock, which does not say which). If no other nomac command is running, remove it: rm -r '${lock}'`);
68
+ if (running(owner.pid)) throw new CliError("session busy", `Another nomac command is using this folder: ${owner.command} (pid ${owner.pid}, since ${owner.started_at}). Wait for it to finish, or stop it: kill ${owner.pid}. If pid ${owner.pid} is not nomac, remove the lock: rm -r '${lock}'`);
69
+ // Its process has ended (killed, or its terminal closed): move that lock aside, the same one
70
+ // only (another command may have just replaced it), and take it.
71
+ const aside = `${lock}.${randomUUID()}.ended`;
72
+ try { renameSync(lock, aside); } catch (moved) { if (moved.code !== "ENOENT") throw moved; }
73
+ if (existsSync(aside) && JSON.stringify(lockOwner(join(aside, "owner.json"))) !== JSON.stringify(owner)) {
74
+ try { renameSync(aside, lock); } catch { /* its command's own cleanup removes it */ }
75
+ throw new CliError("session busy", "Another nomac command is using this folder. Wait for it to finish.");
76
+ }
77
+ rmSync(aside, { recursive: true, force: true });
78
+ try { take(); }
79
+ catch (again) { if (again.code === "EEXIST") throw new CliError("session busy", "Another nomac command is using this folder. Wait for it to finish."); throw again; }
44
80
  }
45
81
  try { return await operation(); }
46
82
  finally { rmSync(lock, { recursive: true, force: true }); }
@@ -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,17 +284,25 @@ 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);
295
- if (changedDuringTransfer.length) throw new CliError("transfer changed during verification", "Some files changed during transfer. Stop concurrent edits and rerun sync; verified files were recorded.");
299
+ if (changedDuringTransfer.length) {
300
+ const one = changedDuringTransfer.length === 1;
301
+ const error = new CliError("transfer changed during verification", `Changed here while being sent: ${listed(changedDuringTransfer)}. The Mac may not have ${one ? "its" : "their"} latest version; once edits stop, run nomac sync again to send ${one ? "it" : "them"}.${conflicts.length
302
+ ? ` Not sent, because changed on the Mac since this folder last synced: ${listed(conflicts)}. Keep the Mac's version: nomac pull ${conflicts[0]}. Replace it with yours: nomac sync . --overwrite` : " Everything else was sent."}`);
303
+ error.details = { changed_during_transfer: changedDuringTransfer, ...(conflicts.length ? { conflicts } : {}) };
304
+ throw error;
305
+ }
296
306
  if (conflicts.length) throw syncConflictError(conflicts, changed.length, deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {});
297
307
  return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
298
308
  }
package/src/sessions.mjs CHANGED
@@ -140,5 +140,5 @@ export async function sessionCommand(command, argv, { store: suppliedStore, run
140
140
  return output(result, describePull(result));
141
141
  }
142
142
  throw new CliError("unknown session command");
143
- });
143
+ }, `nomac ${command}`);
144
144
  }