@magicpixelart/cli 0.5.21 → 0.5.22
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/CHANGELOG.md +13 -0
- package/dist/api.js +22 -5
- package/dist/commands/sync.js +86 -5
- package/dist/commands/whoami.js +3 -2
- package/dist/util/auth.js +4 -3
- package/dist/util/unityFilter.js +20 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.22] — 2026-09-14
|
|
11
|
+
|
|
12
|
+
- Raised the daily sprite download allowance from 1 GiB to 10 GiB for large
|
|
13
|
+
projects.
|
|
14
|
+
- Daily allowance exhaustion now stops the current request with its UTC reset
|
|
15
|
+
time instead of sleeping inside `sync --watch` and appearing frozen. Short
|
|
16
|
+
burst limits continue to retry automatically.
|
|
17
|
+
- `sync --watch` now reports an exhausted allowance once, with a countdown to
|
|
18
|
+
the reset, then re-checks every 15 minutes instead of retrying every minute
|
|
19
|
+
all night (each retry counted against the daily request allowance).
|
|
20
|
+
- Allowance messages now say "API allowance" — the same limit covers requests
|
|
21
|
+
as well as downloaded bytes.
|
|
22
|
+
|
|
10
23
|
## [0.5.21] — 2026-09-14
|
|
11
24
|
|
|
12
25
|
- `sync --watch` can no longer hang forever: every network request now has a
|
package/dist/api.js
CHANGED
|
@@ -75,14 +75,21 @@ export class ApiError extends Error {
|
|
|
75
75
|
status;
|
|
76
76
|
requestId;
|
|
77
77
|
retryAfterMs;
|
|
78
|
-
|
|
78
|
+
code;
|
|
79
|
+
constructor(status, message, requestId, retryAfterMs, code) {
|
|
79
80
|
super(message);
|
|
80
81
|
this.status = status;
|
|
81
82
|
this.requestId = requestId;
|
|
82
83
|
this.retryAfterMs = retryAfterMs;
|
|
84
|
+
this.code = code;
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
|
-
|
|
87
|
+
export const DAILY_QUOTA_ERROR_CODE = 'daily_quota_exceeded';
|
|
88
|
+
/** Stable API error code; the response body retains legacy text for old CLIs. */
|
|
89
|
+
export function errorCodeFromResponse(res) {
|
|
90
|
+
return res.headers.get('x-magicpixel-error-code') ?? undefined;
|
|
91
|
+
}
|
|
92
|
+
export function friendlyApiError(status, body, context, code) {
|
|
86
93
|
if (status === 401 || status === 403) {
|
|
87
94
|
return (`${context}: ${status} — API key rejected.\n` +
|
|
88
95
|
` Fix: regenerate at https://magicpixel.art/settings and re-run \`${cmd('login')}\`.`);
|
|
@@ -92,6 +99,9 @@ function friendly(status, body, context) {
|
|
|
92
99
|
` Fix: check the "endpoint" field in magicpixel.json, or remove it to use the default.`);
|
|
93
100
|
}
|
|
94
101
|
if (status === 429) {
|
|
102
|
+
if (code === DAILY_QUOTA_ERROR_CODE) {
|
|
103
|
+
return `${context}: daily MagicPixel API allowance reached. Resets at 00:00 UTC.`;
|
|
104
|
+
}
|
|
95
105
|
return `${context}: 429 — rate limited. Retry shortly.`;
|
|
96
106
|
}
|
|
97
107
|
if (status === 546) {
|
|
@@ -194,7 +204,8 @@ export async function fetchManifestPage(opts) {
|
|
|
194
204
|
return data;
|
|
195
205
|
}
|
|
196
206
|
const bodyText = await res.text();
|
|
197
|
-
|
|
207
|
+
const errorCode = errorCodeFromResponse(res);
|
|
208
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, 'manifest', errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
198
209
|
}, { max546Attempts: 7 });
|
|
199
210
|
}
|
|
200
211
|
const defaultRetrySleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -224,6 +235,10 @@ export async function retryTransient(context, fn, opts = {}) {
|
|
|
224
235
|
if (err instanceof ApiError) {
|
|
225
236
|
if (err.status < 500 && err.status !== 429)
|
|
226
237
|
throw err;
|
|
238
|
+
// A daily allowance cannot recover during this request. In particular,
|
|
239
|
+
// never honour its hours-long Retry-After inside a single watch tick.
|
|
240
|
+
if (err.code === DAILY_QUOTA_ERROR_CODE)
|
|
241
|
+
throw err;
|
|
227
242
|
lastErr = err;
|
|
228
243
|
nextDelayMs = err.retryAfterMs ?? 0;
|
|
229
244
|
}
|
|
@@ -345,7 +360,8 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
345
360
|
if (res.ok)
|
|
346
361
|
return await readBodyWithLimit(res, MAX_ASSET_BYTES);
|
|
347
362
|
const bodyText = await res.text();
|
|
348
|
-
|
|
363
|
+
const errorCode = errorCodeFromResponse(res);
|
|
364
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, `download ${key}`, errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
349
365
|
});
|
|
350
366
|
}
|
|
351
367
|
/**
|
|
@@ -393,7 +409,8 @@ export async function pushSprites(sprites, retryOpts) {
|
|
|
393
409
|
return results;
|
|
394
410
|
}
|
|
395
411
|
const bodyText = await res.text();
|
|
396
|
-
|
|
412
|
+
const errorCode = errorCodeFromResponse(res);
|
|
413
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, 'push', errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
397
414
|
}, retryOpts);
|
|
398
415
|
}
|
|
399
416
|
function isTransientPushError(e) {
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
|
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
6
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
7
7
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
8
|
-
import { fetchManifestSnapshot, fetchAssetBytes, ApiError, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
|
|
8
|
+
import { fetchManifestSnapshot, fetchAssetBytes, ApiError, DAILY_QUOTA_ERROR_CODE, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
10
10
|
import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
|
|
11
11
|
import { createLimit } from '../util/limit.js';
|
|
@@ -15,7 +15,7 @@ import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
|
15
15
|
import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
|
|
16
16
|
import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
17
17
|
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
18
|
-
import { applyUnityPullPolicy, isWorkingSetEntry, workingSetPullKeys } from '../util/unityFilter.js';
|
|
18
|
+
import { applyUnityPullPolicy, isWorkingSetEntry, partitionWithheldEntries, workingSetPullKeys } from '../util/unityFilter.js';
|
|
19
19
|
import { selectFullSyncOrphans } from '../util/prunePolicy.js';
|
|
20
20
|
import { runTmpJanitor } from '../util/tmpJanitor.js';
|
|
21
21
|
import { friendlyFsError } from '../util/errors.js';
|
|
@@ -116,6 +116,8 @@ async function watchLoop(opts) {
|
|
|
116
116
|
// "back online" recovery line. Without this flag a user who walked away
|
|
117
117
|
// during an outage has no signal that things are healthy again.
|
|
118
118
|
let pausedForNetwork = false;
|
|
119
|
+
// Same idea for the daily API allowance: say it once, then wait quietly.
|
|
120
|
+
let pausedForQuota = false;
|
|
119
121
|
// After this many consecutive 401/403s we give up and exit non-zero so a
|
|
120
122
|
// parent process (Vite plugin, systemd, pm2) can tell the watcher is
|
|
121
123
|
// genuinely broken (revoked key) rather than transiently blipped.
|
|
@@ -214,9 +216,11 @@ async function watchLoop(opts) {
|
|
|
214
216
|
// another terminal is picked up automatically by the next tick.
|
|
215
217
|
const wasPausedForAuth = pausedForAuth;
|
|
216
218
|
const wasPausedForNetwork = pausedForNetwork;
|
|
219
|
+
const wasPausedForQuota = pausedForQuota;
|
|
217
220
|
backoffSec = intervalSec;
|
|
218
221
|
pausedForAuth = false;
|
|
219
222
|
pausedForNetwork = false;
|
|
223
|
+
pausedForQuota = false;
|
|
220
224
|
consecutiveAuthFailures = 0;
|
|
221
225
|
if (wasPausedForAuth && !opts.quiet) {
|
|
222
226
|
process.stdout.write('\x1b[2K\r');
|
|
@@ -226,6 +230,10 @@ async function watchLoop(opts) {
|
|
|
226
230
|
process.stdout.write('\x1b[2K\r');
|
|
227
231
|
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
|
|
228
232
|
}
|
|
233
|
+
if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
|
|
234
|
+
process.stdout.write('\x1b[2K\r');
|
|
235
|
+
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Daily allowance available again — resuming.`);
|
|
236
|
+
}
|
|
229
237
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
230
238
|
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
231
239
|
// the printed pull list. Without it, an artist working only in Unity
|
|
@@ -262,6 +270,7 @@ async function watchLoop(opts) {
|
|
|
262
270
|
backoffSec,
|
|
263
271
|
pausedForAuth,
|
|
264
272
|
pausedForNetwork,
|
|
273
|
+
pausedForQuota,
|
|
265
274
|
consecutiveAuthFailures,
|
|
266
275
|
maxAuthFailures: MAX_AUTH_FAILURES,
|
|
267
276
|
});
|
|
@@ -297,6 +306,17 @@ async function watchLoop(opts) {
|
|
|
297
306
|
pausedForNetwork = true;
|
|
298
307
|
backoffSec = decision.nextBackoffSec;
|
|
299
308
|
}
|
|
309
|
+
else if (decision.kind === 'quota') {
|
|
310
|
+
consecutiveAuthFailures = 0;
|
|
311
|
+
if (decision.printMessage) {
|
|
312
|
+
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
313
|
+
console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} Daily MagicPixel API allowance reached. ` +
|
|
314
|
+
`Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
|
|
315
|
+
`(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
|
|
316
|
+
}
|
|
317
|
+
pausedForQuota = true;
|
|
318
|
+
backoffSec = decision.nextBackoffSec;
|
|
319
|
+
}
|
|
300
320
|
else {
|
|
301
321
|
consecutiveAuthFailures = 0;
|
|
302
322
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
@@ -371,6 +391,19 @@ export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_B
|
|
|
371
391
|
return Math.max(intervalSec, 5);
|
|
372
392
|
return intervalSec;
|
|
373
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Longest quiet wait while the daily allowance is exhausted. The allowance
|
|
396
|
+
* resets at UTC midnight, but we re-check periodically so a raised cap (or a
|
|
397
|
+
* clock/timezone surprise) is picked up without restarting the watcher.
|
|
398
|
+
*/
|
|
399
|
+
export const QUOTA_RECHECK_MAX_SEC = 900;
|
|
400
|
+
/** Human reset countdown: "42 min" / "3h 05m". */
|
|
401
|
+
export function formatQuotaReset(resetSec) {
|
|
402
|
+
const mins = Math.max(1, Math.round(resetSec / 60));
|
|
403
|
+
if (mins < 60)
|
|
404
|
+
return `${mins} min`;
|
|
405
|
+
return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
|
|
406
|
+
}
|
|
374
407
|
/**
|
|
375
408
|
* Pure helper: classify a per-tick error and compute the next backoff +
|
|
376
409
|
* counter updates. Extracted so the auth-failure watchdog and the
|
|
@@ -391,6 +424,17 @@ export function classifyTickError(err, state) {
|
|
|
391
424
|
giveUp: next >= state.maxAuthFailures,
|
|
392
425
|
};
|
|
393
426
|
}
|
|
427
|
+
if (apiErr?.code === DAILY_QUOTA_ERROR_CODE) {
|
|
428
|
+
// Nothing recovers until the allowance resets, so stop hammering: each
|
|
429
|
+
// retry still burns a request against the daily request allowance.
|
|
430
|
+
const resetSec = Math.max(1, Math.round((apiErr.retryAfterMs ?? 0) / 1000));
|
|
431
|
+
return {
|
|
432
|
+
kind: 'quota',
|
|
433
|
+
nextBackoffSec: Math.min(QUOTA_RECHECK_MAX_SEC, resetSec),
|
|
434
|
+
printMessage: !state.pausedForQuota,
|
|
435
|
+
resetSec,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
394
438
|
if (isNetworkError(err)) {
|
|
395
439
|
const nextBackoffSec = Math.min(state.backoffSec * 2, 60);
|
|
396
440
|
// Print on the first offline tick, then again whenever backoff changes —
|
|
@@ -453,6 +497,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
453
497
|
if (!spinner)
|
|
454
498
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
455
499
|
let manifest;
|
|
500
|
+
/** In-scope documents withheld by the server (unreleased edits): hold-only. */
|
|
501
|
+
let withheldEntries = [];
|
|
456
502
|
// Composite keys the cloud reports as gone (document trashed / componentized)
|
|
457
503
|
// since our cursor. Empty on --full, where the manifest itself is the truth.
|
|
458
504
|
let removedRemoteKeys = [];
|
|
@@ -480,9 +526,16 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
480
526
|
since = undefined;
|
|
481
527
|
snapshot = await fetchManifestSnapshot(config, undefined, onManifestProgress);
|
|
482
528
|
}
|
|
483
|
-
|
|
529
|
+
// Documents the user hasn't released with the editor's Sync button are
|
|
530
|
+
// listed but not syncable: they stay out of every pull/prune decision and
|
|
531
|
+
// only protect what's already on disk (see `withheldDirs` below).
|
|
532
|
+
const partitioned = partitionWithheldEntries(snapshot.entries);
|
|
533
|
+
manifest = partitioned.entries;
|
|
534
|
+
withheldEntries = partitioned.withheld;
|
|
484
535
|
removedRemoteKeys = snapshot.removedKeys;
|
|
485
|
-
|
|
536
|
+
// Cursor advances past withheld rows too — they are re-listed whenever the
|
|
537
|
+
// user presses Sync (the release bumps `updated_at`).
|
|
538
|
+
observedUpdatedAt = maxIsoTimestamp(snapshot.entries.map((e) => e.updated_at));
|
|
486
539
|
const projectInfo = getLastProjectInfo();
|
|
487
540
|
const projectSuffix = projectInfo && verbose
|
|
488
541
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -547,6 +600,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
547
600
|
(connected.capped ? ` of ${connected.total} matched` : '')));
|
|
548
601
|
}
|
|
549
602
|
const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
|
|
603
|
+
// Every folder on disk that belongs to a withheld document. Anything already
|
|
604
|
+
// written there is held as-is: unreleased edits must never pull, and a full
|
|
605
|
+
// reconcile must never read the absence of these entries as "deleted".
|
|
606
|
+
const withheldDirs = new Set();
|
|
607
|
+
for (const entry of withheldEntries) {
|
|
608
|
+
try {
|
|
609
|
+
withheldDirs.add(dirname(pathForKey(entry.key)));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
/* unusable key — nothing to protect */
|
|
613
|
+
}
|
|
614
|
+
}
|
|
550
615
|
const workingSet = workingSetPullKeys(sourceByKey, state.synced);
|
|
551
616
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
552
617
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
@@ -749,10 +814,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
749
814
|
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
750
815
|
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
751
816
|
if (!since) {
|
|
817
|
+
const protectedPaths = new Set(unknownFlagPaths);
|
|
818
|
+
if (withheldDirs.size > 0) {
|
|
819
|
+
for (const local of localPngs) {
|
|
820
|
+
if (withheldDirs.has(dirname(local.abs)))
|
|
821
|
+
protectedPaths.add(local.abs);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
752
824
|
const policy = selectFullSyncOrphans({
|
|
753
825
|
localPaths: localPngs.map((a) => a.abs),
|
|
754
826
|
remoteDiskPaths,
|
|
755
|
-
protectedPaths
|
|
827
|
+
protectedPaths,
|
|
756
828
|
isTracked: (p) => keyByPath.has(p),
|
|
757
829
|
});
|
|
758
830
|
orphans = policy.orphans;
|
|
@@ -817,6 +889,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
817
889
|
// local-edit guard applied below. De-duplicates vs the full-sync orphan list.
|
|
818
890
|
const orphanSet = new Set(orphans);
|
|
819
891
|
const addOrphan = (p) => {
|
|
892
|
+
// Withheld documents are held whole: no prune source may touch them.
|
|
893
|
+
if (withheldDirs.has(dirname(p)))
|
|
894
|
+
return;
|
|
820
895
|
if (existsSync(p) && isPathInside(p, outRoot))
|
|
821
896
|
orphanSet.add(p);
|
|
822
897
|
};
|
|
@@ -917,6 +992,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
917
992
|
if (e.folder)
|
|
918
993
|
knownFolderSlugs.add(e.folder.split('/')[0]);
|
|
919
994
|
}
|
|
995
|
+
// Withheld docs are absent from `manifest` — without their slugs a sibling
|
|
996
|
+
// folder like `tiles-2/` would read as a legacy suffix collision.
|
|
997
|
+
for (const e of withheldEntries) {
|
|
998
|
+
if (e.folder)
|
|
999
|
+
knownFolderSlugs.add(e.folder.split('/')[0]);
|
|
1000
|
+
}
|
|
920
1001
|
for (const key of Object.values(previousAssets)) {
|
|
921
1002
|
const top = key.split('/')[0];
|
|
922
1003
|
if (top)
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import { loadConfig, defaultConfig, resolveEndpoint, getApiKey } from '../config.js';
|
|
3
|
-
import { ApiError, retryTransient, retryAfterMsFromResponse, getLastProjectInfo } from '../api.js';
|
|
3
|
+
import { ApiError, errorCodeFromResponse, friendlyApiError, retryTransient, retryAfterMsFromResponse, getLastProjectInfo } from '../api.js';
|
|
4
4
|
import { safeFetch } from '../util/security.js';
|
|
5
5
|
import { authHeaders } from '../util/authHeaders.js';
|
|
6
6
|
export async function whoamiCommand() {
|
|
@@ -32,9 +32,10 @@ export async function whoamiCommand() {
|
|
|
32
32
|
}
|
|
33
33
|
if (res.status >= 500 || res.status === 429) {
|
|
34
34
|
const bodyText = await res.text();
|
|
35
|
+
const errorCode = errorCodeFromResponse(res);
|
|
35
36
|
// Pass Retry-After through so retryTransient honours server back-pressure
|
|
36
37
|
// (matches fetchManifestPage / fetchAssetBytes).
|
|
37
|
-
throw new ApiError(res.status,
|
|
38
|
+
throw new ApiError(res.status, friendlyApiError(res.status, bodyText, 'whoami', errorCode), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
38
39
|
}
|
|
39
40
|
if (!res.ok) {
|
|
40
41
|
const bodyText = await res.text();
|
package/dist/util/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveEndpoint } from '../config.js';
|
|
2
|
-
import { ApiError, retryTransient } from '../api.js';
|
|
2
|
+
import { ApiError, errorCodeFromResponse, friendlyApiError, retryAfterMsFromResponse, retryTransient } from '../api.js';
|
|
3
3
|
import { safeFetch, readBodyWithLimit } from './security.js';
|
|
4
4
|
import { authHeaders } from './authHeaders.js';
|
|
5
5
|
/**
|
|
@@ -32,9 +32,10 @@ export async function assertKeyValid(key, config) {
|
|
|
32
32
|
// we ultimately surface.
|
|
33
33
|
const bodyBytes = await readBodyWithLimit(res, 16 * 1024).catch(() => new Uint8Array());
|
|
34
34
|
const body = new TextDecoder().decode(bodyBytes);
|
|
35
|
+
const errorCode = errorCodeFromResponse(res);
|
|
35
36
|
// 5xx + 429 retry through retryTransient; other 4xx bubble immediately.
|
|
36
|
-
throw new ApiError(res.status,
|
|
37
|
-
` (request id: ${serverRequestId})`, serverRequestId);
|
|
37
|
+
throw new ApiError(res.status, `${friendlyApiError(res.status, body, 'validate key', errorCode)}\n` +
|
|
38
|
+
` (request id: ${serverRequestId})`, serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
38
39
|
}
|
|
39
40
|
await res.body?.cancel();
|
|
40
41
|
});
|
package/dist/util/unityFilter.js
CHANGED
|
@@ -18,14 +18,27 @@
|
|
|
18
18
|
* layer JSON dropped `syncToUnity`). Those saves must still write back to
|
|
19
19
|
* the original PNG.
|
|
20
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Split the manifest into syncable entries and withheld ones. Withheld entries
|
|
23
|
+
* are never pull candidates (not even under `unitySyncAll`) and carry no bytes;
|
|
24
|
+
* callers use them only to protect what's already on disk from pruning.
|
|
25
|
+
*/
|
|
26
|
+
export function partitionWithheldEntries(manifest) {
|
|
27
|
+
const withheld = [];
|
|
28
|
+
const entries = [];
|
|
29
|
+
for (const e of manifest)
|
|
30
|
+
(e.withheld === true ? withheld : entries).push(e);
|
|
31
|
+
return { entries, withheld };
|
|
32
|
+
}
|
|
21
33
|
export function filterUnityManifest(manifest, opts = {}) {
|
|
34
|
+
const syncable = partitionWithheldEntries(manifest).entries;
|
|
22
35
|
if (opts.syncAll)
|
|
23
|
-
return { entries:
|
|
24
|
-
const entries =
|
|
25
|
-
const unknown =
|
|
36
|
+
return { entries: syncable, noneFlagged: false, unknown: [] };
|
|
37
|
+
const entries = syncable.filter((e) => e.unity === true);
|
|
38
|
+
const unknown = syncable.filter((e) => typeof e.unity !== 'boolean');
|
|
26
39
|
return {
|
|
27
40
|
entries,
|
|
28
|
-
noneFlagged:
|
|
41
|
+
noneFlagged: syncable.length > 0 && entries.length === 0,
|
|
29
42
|
unknown,
|
|
30
43
|
};
|
|
31
44
|
}
|
|
@@ -65,7 +78,9 @@ export function applyUnityPullPolicy(manifest, opts = {}) {
|
|
|
65
78
|
if (opts.syncAll || !opts.alwaysPull)
|
|
66
79
|
return filtered;
|
|
67
80
|
const already = new Set(filtered.entries);
|
|
68
|
-
|
|
81
|
+
// Withheld entries stay out: a working-set sprite the user hasn't released
|
|
82
|
+
// must not be pulled over the copy in the game project.
|
|
83
|
+
const extra = partitionWithheldEntries(manifest).entries.filter((e) => !already.has(e) && opts.alwaysPull(e));
|
|
69
84
|
if (extra.length === 0)
|
|
70
85
|
return filtered;
|
|
71
86
|
const extraSet = new Set(extra);
|