@nomac/cli 0.11.9 → 0.11.11
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 +2 -1
- package/src/hosted-sessions.mjs +37 -20
- package/src/mcp.mjs +2 -2
- package/src/session-store.mjs +36 -4
- package/src/session-transfer.mjs +7 -1
- package/src/sessions.mjs +1 -1
- package/src/store-upload.mjs +6 -5
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -27,7 +27,8 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
|
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
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
|
|
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
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -48,7 +48,8 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
48
48
|
nomac exec [--wait 30] -- command args
|
|
49
49
|
runs it, prints its output as it arrives,
|
|
50
50
|
exits with its code.
|
|
51
|
-
Waits up to 30 s
|
|
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.
|
|
52
53
|
Exit code 75 means still running: see nomac job.
|
|
53
54
|
(--request-key KEY makes a scripted retry safe)
|
|
54
55
|
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
@@ -178,11 +179,11 @@ export const STILL_RUNNING = 75;
|
|
|
178
179
|
const POLL_MS = [250, 500];
|
|
179
180
|
const NOTICE_MS = 5000;
|
|
180
181
|
/**
|
|
181
|
-
* nomac exec waits like a local command: up to --wait seconds (default 30
|
|
182
|
-
* prints stdout and stderr and exits with the
|
|
183
|
-
* is a ceiling: a quick command returns as soon
|
|
184
|
-
* running keeps running on the Mac; the CLI
|
|
185
|
-
* 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.
|
|
186
187
|
*/
|
|
187
188
|
/** "13:05 UTC", when a running command's time limit stops it. */
|
|
188
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.` : "";
|
|
@@ -307,14 +308,17 @@ export function projectLine({ path, on_drive }) {
|
|
|
307
308
|
return on_drive ? `Project: ${short}, on your drive: kept when the Mac stops, and the next session builds only what changed.`
|
|
308
309
|
: `Project: ${short}, deleted when the Mac stops: nomac pull what you need first.`;
|
|
309
310
|
}
|
|
311
|
+
/** "Mac: macOS 26.6.2, Xcode 27.0 (default) and Xcode 26.6 (/Applications/Xcode-26.6.app), iOS 27.0 Simulator", or "". */
|
|
312
|
+
export function macLine(image) {
|
|
313
|
+
if (!image?.macos) return "";
|
|
314
|
+
// An image with a second Xcode names it and its path, as the API's image line does.
|
|
315
|
+
const others = (image.xcodes ?? []).filter(x => !x.default);
|
|
316
|
+
const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
|
|
317
|
+
return `Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`;
|
|
318
|
+
}
|
|
310
319
|
export function statusSummary(session, image) {
|
|
311
320
|
const lines = [`${session.id}: ${session.state}`];
|
|
312
|
-
if (image
|
|
313
|
-
// An image with a second Xcode names it and its path, as the API's image line does.
|
|
314
|
-
const others = (image.xcodes ?? []).filter(x => !x.default);
|
|
315
|
-
const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
|
|
316
|
-
lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
|
|
317
|
-
}
|
|
321
|
+
if (macLine(image)) lines.push(macLine(image));
|
|
318
322
|
if (["ready", "provisioning", "queued"].includes(session.state)) lines.push(`Cost so far: ${usd(session.cost?.accrued_microunits)} ($0.80/hour, billed by the minute while ready). Credit left: nomac credits`);
|
|
319
323
|
const a = session.activity ?? {};
|
|
320
324
|
if (session.state === "ready") {
|
|
@@ -350,6 +354,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
350
354
|
if (values.help) return print(command === "desktop" ? DESKTOP_HELP : HELP);
|
|
351
355
|
const wait = ["start", "attach", "stop"].includes(command) ? integer(values.wait, command === "stop" ? 60 : 300, 0, command === "stop" ? 300 : 600) : 0;
|
|
352
356
|
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
357
|
+
// The image the last read reported (macOS, Xcodes, Simulators), for start's summary.
|
|
358
|
+
let lastImage;
|
|
353
359
|
const output = result => {
|
|
354
360
|
// Sync, pull and the other commands answer with their own result, which has no session.
|
|
355
361
|
if (values.json || !["start", "attach", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
|
|
@@ -363,7 +369,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
363
369
|
: ["stopped", "failed"].includes(result.session.state) ? `${result.session.drive ? "The drive saves in the background (nomac drive shows it). " : ""}Start another: nomac start`
|
|
364
370
|
: result.session.state === "stopping" ? "Stopping. Check: nomac status"
|
|
365
371
|
: "Check: nomac status";
|
|
366
|
-
|
|
372
|
+
// A ready Mac says what is on it, as nomac status does.
|
|
373
|
+
const mac = result.session.state === "ready" && macLine(lastImage) ? `${macLine(lastImage)}\n` : "";
|
|
374
|
+
print(`${result.attached ? `${result.attached}\n` : ""}${result.session.id}: ${result.session.state}\n${mac}${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
|
|
367
375
|
return result;
|
|
368
376
|
};
|
|
369
377
|
const saveView = (local, view) => {
|
|
@@ -372,7 +380,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
372
380
|
Object.assign(local, { id: view.id, state: view.state, snapshot: view }); store.save(local);
|
|
373
381
|
return view;
|
|
374
382
|
};
|
|
375
|
-
const refresh = async local =>
|
|
383
|
+
const refresh = async local => {
|
|
384
|
+
const answer = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
|
|
385
|
+
if (answer.image) lastImage = answer.image;
|
|
386
|
+
return saveView(local, answer.session);
|
|
387
|
+
};
|
|
376
388
|
async function ready(local) {
|
|
377
389
|
await refresh(local);
|
|
378
390
|
if (local.state !== "ready") throw new CliError("Mac is not ready.", `Session ${local.id} is ${local.state}. Run nomac session status --json.`, undefined, "session_not_ready");
|
|
@@ -487,7 +499,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
487
499
|
}
|
|
488
500
|
if (["exec", "job", "output"].includes(command)) {
|
|
489
501
|
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
490
|
-
let path, method = "GET", params = {};
|
|
502
|
+
let path, method = "GET", params = {}, waitSeconds = integer(values.wait, 30, 0, 3600);
|
|
491
503
|
if (command === "exec") {
|
|
492
504
|
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
493
505
|
if (!local.workspaceDecided && !local.workspace) { await refresh(local); if (local.state === "ready") await placeWorkspace(local); }
|
|
@@ -496,8 +508,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
496
508
|
// Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
|
|
497
509
|
// With a key the limit must not depend on --wait: a retry with another --wait is the same command.
|
|
498
510
|
// A command may run an hour (the gateway's limit); waiting can cover all of it.
|
|
499
|
-
|
|
500
|
-
|
|
511
|
+
// A command given a long --timeout and no --wait is waited for too, up to 10 minutes (what an
|
|
512
|
+
// agent's shell usually allows); the line printed after 5 s says how to go on if interrupted.
|
|
513
|
+
const given = values.timeout === undefined ? null : integer(values.timeout, 0, 1, 3600);
|
|
514
|
+
const waitFor = waitSeconds = integer(values.wait, given === null ? 30 : Math.min(given, 600), 0, 3600);
|
|
515
|
+
const timeout = given ?? (values["request-key"] === undefined ? Math.max(300, waitFor) : 600);
|
|
501
516
|
const line = isShellLine(positionals), dir = local.workspaceOnDrive ? local.workspace : null;
|
|
502
517
|
const remote = dir ? (line ? ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && ${positionals[0]}`] : ["/bin/bash", "-lc", `cd ${shellQuote(dir)} && exec "$@"`, "nomac", ...positionals])
|
|
503
518
|
: line ? ["/bin/bash", "-lc", positionals[0]] : positionals;
|
|
@@ -528,7 +543,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
528
543
|
if (at) next[job] = { stdout: at.stdout, stderr: at.stderr };
|
|
529
544
|
store.saveJobCursors(next);
|
|
530
545
|
};
|
|
531
|
-
return waitForCommand(local.id, base, result,
|
|
546
|
+
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,
|
|
532
547
|
cursors: command === "job" ? kept[result.id] ?? null : null, keepCursors });
|
|
533
548
|
}
|
|
534
549
|
if (command === "screenshot") {
|
|
@@ -642,7 +657,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
642
657
|
do {
|
|
643
658
|
again = false;
|
|
644
659
|
try {
|
|
645
|
-
const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }));
|
|
660
|
+
const result = await store.locked(async () => syncProject(await ready(localSession()), store, { overwrite: values.overwrite, deleteMissing: values.delete, run }), "nomac sync --watch");
|
|
646
661
|
skipped = result.skipped;
|
|
647
662
|
if (first) print(`${describeSync(result)}\nWatching ${store.project} for changes; Ctrl-C stops.`);
|
|
648
663
|
else if (result.changed.length || result.deleted?.length || result.kept_on_mac?.length) print(`${clock()} ${describeSync({ ...result, skipped: [] })}`);
|
|
@@ -763,11 +778,13 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
763
778
|
return output(result);
|
|
764
779
|
}
|
|
765
780
|
if (command === "pull") {
|
|
781
|
+
// Like cp, the Mac's path first: say the command that does it.
|
|
782
|
+
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]])}`);
|
|
766
783
|
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from PATH_ON_THE_MAC]");
|
|
767
784
|
const result = await pullProject(local, store, positionals[0], { from: values.from, overwrite: values.overwrite, dryRun: values["dry-run"], run });
|
|
768
785
|
if (!values.json) { print(describePull(result)); return result; }
|
|
769
786
|
return output(result);
|
|
770
787
|
}
|
|
771
788
|
throw new CliError("Unknown cloud session command.");
|
|
772
|
-
});
|
|
789
|
+
}, `nomac ${command}`);
|
|
773
790
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export const TOOLS = [
|
|
|
70
70
|
handler: async args => text(await api("GET", `/api/v1/store-capabilities?${new URLSearchParams(Object.entries(args ?? {}).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]))}`)),
|
|
71
71
|
},
|
|
72
72
|
...[
|
|
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
|
|
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 and analytics report URLs; a file's upload ranges come only in the answer to its reservation, so use upload_store_asset."],
|
|
74
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."],
|
|
75
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."],
|
|
76
76
|
].map(([name, route, description]) => ({
|
|
@@ -260,7 +260,7 @@ export const TOOLS = [
|
|
|
260
260
|
{
|
|
261
261
|
name: "upload_screenshots",
|
|
262
262
|
description:
|
|
263
|
-
"Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, data: base64 PNG}].",
|
|
263
|
+
"Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, link}] with a create_mac_download_link url for each PNG on the Mac (any size; preferred), or [{filename, data: base64 PNG}] for small images (a request is capped near 4.5 MB in all).",
|
|
264
264
|
inputSchema: {
|
|
265
265
|
type: "object",
|
|
266
266
|
properties: {
|
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);
|
|
@@ -39,12 +53,30 @@ export function sessionStore(projectDir = process.cwd(), stateDir = join(homedir
|
|
|
39
53
|
savePulled: (value) => write(pulledFile, value),
|
|
40
54
|
jobCursors: () => read(cursorsFile) ?? {},
|
|
41
55
|
saveJobCursors: (value) => write(cursorsFile, value),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(); }
|
|
45
64
|
catch (error) {
|
|
46
65
|
if (error.code !== "EEXIST") throw error;
|
|
47
|
-
|
|
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; }
|
|
48
80
|
}
|
|
49
81
|
try { return await operation(); }
|
|
50
82
|
finally { rmSync(lock, { recursive: true, force: true }); }
|
package/src/session-transfer.mjs
CHANGED
|
@@ -296,7 +296,13 @@ export async function syncProject(session, store, { overwrite = false, dryRun =
|
|
|
296
296
|
const deleted = removable.length ? await removeOnMac(session, removable, { run }) : [];
|
|
297
297
|
for (const path of [...deleted, ...gone.filter(path => onMac[path] === "missing")]) delete baseline[path];
|
|
298
298
|
store.saveBaseline(baseline);
|
|
299
|
-
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
|
+
}
|
|
300
306
|
if (conflicts.length) throw syncConflictError(conflicts, changed.length, deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {});
|
|
301
307
|
return { files: paths.length, changed, ...(deleteMissing ? { deleted, kept_on_mac: keptOnMac } : {}), ...summary, output };
|
|
302
308
|
}
|
package/src/sessions.mjs
CHANGED
package/src/store-upload.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared by the hosted upload endpoint and the published CLI (copied by the
|
|
2
|
-
// schema generator). Provider upload URLs come only from
|
|
3
|
-
// app-scoped
|
|
2
|
+
// schema generator). Provider upload URLs come only from the authenticated,
|
|
3
|
+
// app-scoped answer to the reservation; Nomac credentials never go to those URLs.
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { open } from 'node:fs/promises';
|
|
6
6
|
import { basename } from 'node:path';
|
|
@@ -82,8 +82,9 @@ export async function uploadStoreAsset(args, call) {
|
|
|
82
82
|
stage = 'upload';
|
|
83
83
|
const completed = ['UPLOAD_COMPLETE', 'COMPLETE', 'FAILED'].includes(attributes.assetDeliveryState?.state);
|
|
84
84
|
if (!completed) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Apple lists the upload ranges in its answer to the reservation only (a later read has none).
|
|
86
|
+
const operations = reservation.upload_operations?.length ? reservation.upload_operations : attributes.uploadOperations;
|
|
87
|
+
if (!Array.isArray(operations) || !operations.length) throw new Error(`Apple gives a file's upload links only in its answer to the reservation, and this request has none (that answer was lost). Delete the reservation (update_store with ${args.operation.replace(/_createInstance$/, '_deleteInstance')} and this scope), then upload again with a new request_key.`);
|
|
87
88
|
let end = 0;
|
|
88
89
|
for (const op of [...operations].sort((a, b) => a.offset - b.offset)) {
|
|
89
90
|
if (op.method !== 'PUT' || !Number.isSafeInteger(op.offset) || !Number.isSafeInteger(op.length) || op.length < 1 || op.offset !== end || op.offset + op.length > size) throw new Error('Apple returned invalid or incomplete upload byte ranges.');
|
|
@@ -105,7 +106,7 @@ export async function uploadStoreAsset(args, call) {
|
|
|
105
106
|
const part = await source(op.offset, op.length);
|
|
106
107
|
try {
|
|
107
108
|
const response = await fetch(op.url, { method: 'PUT', headers, body: part, duplex: 'half', redirect: 'error', signal: AbortSignal.timeout(120_000) });
|
|
108
|
-
if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key
|
|
109
|
+
if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key; if it fails again, its upload links have expired: delete the reservation (update_store with ${args.operation.replace(/_createInstance$/, '_deleteInstance')} and this scope) and upload again with a new request_key.`);
|
|
109
110
|
await response.body?.cancel();
|
|
110
111
|
} finally { if (typeof part.destroy === 'function') part.destroy(); }
|
|
111
112
|
}
|