@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 +1 -1
- package/skill/SKILL.md +3 -2
- package/src/hosted-sessions.mjs +106 -12
- package/src/lifecycle-schemas.json +132 -0
- package/src/mcp.mjs +4 -3
- package/src/session-store.mjs +4 -0
- package/src/session-transfer.mjs +9 -5
package/package.json
CHANGED
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
|
|
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
|
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,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
|
|
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
|
-
|
|
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 }
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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 }); }
|
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,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
|
-
|
|
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);
|