@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 +1 -1
- package/skill/SKILL.md +4 -2
- package/src/hosted-sessions.mjs +122 -22
- package/src/lifecycle-schemas.json +132 -0
- package/src/mcp.mjs +4 -3
- package/src/session-store.mjs +40 -4
- package/src/session-transfer.mjs +16 -6
- package/src/sessions.mjs +1 -1
package/package.json
CHANGED
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
|
|
30
|
-
with the command's code, waiting at most 30 s
|
|
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
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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
|
|
49
|
-
|
|
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
|
|
180
|
-
* prints stdout and stderr and exits with the
|
|
181
|
-
* is a ceiling: a quick command returns as soon
|
|
182
|
-
* running keeps running on the Mac; the CLI
|
|
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
|
-
|
|
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 }
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/session-store.mjs
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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 }); }
|
package/src/session-transfer.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
290
|
-
const changedDuringTransfer =
|
|
291
|
-
for (const path of
|
|
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)
|
|
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