@magicpixelart/cli 0.5.21 → 0.5.24
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 +31 -0
- package/dist/api.js +22 -5
- package/dist/commands/push.js +106 -7
- package/dist/commands/sync.js +141 -20
- package/dist/commands/whoami.js +3 -2
- package/dist/index.js +2 -1
- package/dist/util/auth.js +4 -3
- package/dist/util/pullDecision.js +20 -0
- package/dist/util/pushPlan.js +4 -1
- package/dist/util/unityFilter.js +20 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.24] — 2026-09-21
|
|
11
|
+
|
|
12
|
+
- `push --force` keeps your local copy when a sprite changed in both places. It
|
|
13
|
+
re-sends against the live cloud state rather than skipping the safety check,
|
|
14
|
+
so a save that lands in between is still reported instead of overwritten.
|
|
15
|
+
- A both-sides-changed refusal now carries the live cloud fingerprint, so a
|
|
16
|
+
follow-up `push` works even for large documents whose hash wasn't cached —
|
|
17
|
+
the last case where the sync/push advice could still dead-end.
|
|
18
|
+
- Conflict messages name both ways out: press Sync on the artboard, or
|
|
19
|
+
`push --force`.
|
|
20
|
+
|
|
21
|
+
## [0.5.23] — 2026-09-21
|
|
22
|
+
|
|
23
|
+
- Pressing Sync on an artboard now explicitly chooses MagicPixel's copy when
|
|
24
|
+
that PNG was also edited locally, resolving the previous sync/push loop.
|
|
25
|
+
- Conflict lists are capped, nested push warnings no longer repeat during
|
|
26
|
+
sync, and completion summaries include locally unchanged sprites accurately.
|
|
27
|
+
|
|
28
|
+
## [0.5.22] — 2026-09-14
|
|
29
|
+
|
|
30
|
+
- Raised the daily sprite download allowance from 1 GiB to 10 GiB for large
|
|
31
|
+
projects.
|
|
32
|
+
- Daily allowance exhaustion now stops the current request with its UTC reset
|
|
33
|
+
time instead of sleeping inside `sync --watch` and appearing frozen. Short
|
|
34
|
+
burst limits continue to retry automatically.
|
|
35
|
+
- `sync --watch` now reports an exhausted allowance once, with a countdown to
|
|
36
|
+
the reset, then re-checks every 15 minutes instead of retrying every minute
|
|
37
|
+
all night (each retry counted against the daily request allowance).
|
|
38
|
+
- Allowance messages now say "API allowance" — the same limit covers requests
|
|
39
|
+
as well as downloaded bytes.
|
|
40
|
+
|
|
10
41
|
## [0.5.21] — 2026-09-14
|
|
11
42
|
|
|
12
43
|
- `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/push.js
CHANGED
|
@@ -110,6 +110,10 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
110
110
|
const segments = entry
|
|
111
111
|
? entry.adoptRel.replace(/\.png$/i, '').split('/')
|
|
112
112
|
: key.split('/');
|
|
113
|
+
if (opts.excludeKeys?.has(key)) {
|
|
114
|
+
reportHash();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
113
117
|
candidates.push({
|
|
114
118
|
key,
|
|
115
119
|
segments,
|
|
@@ -141,12 +145,12 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
141
145
|
if (skippedUnsafe > 0 && !quiet) {
|
|
142
146
|
console.log(kleur.yellow(`! skipped ${skippedUnsafe} path${skippedUnsafe === 1 ? '' : 's'} that were not regular files inside the project.`));
|
|
143
147
|
}
|
|
144
|
-
if (legacy.length > 0 && !quiet) {
|
|
148
|
+
if (legacy.length > 0 && !quiet && !opts.nestedSync) {
|
|
145
149
|
console.log();
|
|
146
150
|
console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
|
|
147
151
|
console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
|
|
148
152
|
}
|
|
149
|
-
if (flattenBlocked.length > 0 && !quiet) {
|
|
153
|
+
if (flattenBlocked.length > 0 && !quiet && !opts.nestedSync) {
|
|
150
154
|
console.log();
|
|
151
155
|
console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
|
|
152
156
|
for (const a of flattenBlocked.slice(0, 10)) {
|
|
@@ -225,7 +229,38 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
225
229
|
await saveState({ ...state, synced });
|
|
226
230
|
throw e;
|
|
227
231
|
}
|
|
228
|
-
const
|
|
232
|
+
const spriteByKey = new Map(sprites.map((s) => [s.key, s]));
|
|
233
|
+
if (opts.force) {
|
|
234
|
+
// Two rounds at most: an adopt-shaped or hash-less refusal comes back
|
|
235
|
+
// without a live sha, and the retry as an update is what makes the server
|
|
236
|
+
// composite the artboard and report one.
|
|
237
|
+
for (let round = 0; round < 2; round++) {
|
|
238
|
+
const retry = forcedRetrySprites(results, spriteByKey, opts.flatten === true);
|
|
239
|
+
if (retry.length === 0)
|
|
240
|
+
break;
|
|
241
|
+
const retryResults = await pushSpritesAdaptive(retry);
|
|
242
|
+
synced = await commitPushResults({
|
|
243
|
+
synced,
|
|
244
|
+
batchResults: retryResults,
|
|
245
|
+
candidates,
|
|
246
|
+
sourceByKey,
|
|
247
|
+
shaByKey,
|
|
248
|
+
fpByKey,
|
|
249
|
+
outDir: config.outDir,
|
|
250
|
+
});
|
|
251
|
+
await saveState({ ...state, synced });
|
|
252
|
+
replaceResultsByKey(results, retryResults);
|
|
253
|
+
if (retryResults.every((r) => r.status === 'conflict' && !r.sha256))
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Remember the live cloud sha a refusal reported, so a later `push` (with or
|
|
258
|
+
// without --force) has a baseline the concurrency check accepts. The manifest
|
|
259
|
+
// hash can be absent for big documents, which used to dead-end this path.
|
|
260
|
+
if (rememberRefusedCloudShas(synced, results)) {
|
|
261
|
+
await saveState({ ...state, synced });
|
|
262
|
+
}
|
|
263
|
+
const counts = { created: 0, updated: 0, unchanged: skipped, conflict: 0, error: 0 };
|
|
229
264
|
for (const r of results) {
|
|
230
265
|
if (r.status in counts)
|
|
231
266
|
counts[r.status]++;
|
|
@@ -241,14 +276,19 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
241
276
|
progress.succeed(`Pushed. ${summary}`);
|
|
242
277
|
}
|
|
243
278
|
if (!quiet) {
|
|
279
|
+
let printedIssues = 0;
|
|
244
280
|
for (const r of results) {
|
|
245
281
|
if (r.status === 'conflict') {
|
|
246
|
-
|
|
282
|
+
if (printedIssues++ < 10)
|
|
283
|
+
console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
|
|
247
284
|
}
|
|
248
285
|
else if (r.status === 'error') {
|
|
249
|
-
|
|
286
|
+
if (printedIssues++ < 10)
|
|
287
|
+
console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
|
|
250
288
|
}
|
|
251
289
|
}
|
|
290
|
+
if (printedIssues > 10)
|
|
291
|
+
console.log(kleur.dim(` …and ${printedIssues - 10} more`));
|
|
252
292
|
if (counts.created > 0) {
|
|
253
293
|
console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
|
|
254
294
|
`(working set + ${relative(process.cwd(), outRoot)}). They appear in Connected as they import.`));
|
|
@@ -258,6 +298,62 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
258
298
|
process.exitCode = 1;
|
|
259
299
|
return { ...counts, imported: matched.entries.length };
|
|
260
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Sprites to re-send under `--force`: every sprite the cloud refused because it
|
|
303
|
+
* also changed there, re-addressed as an update against the sha the refusal
|
|
304
|
+
* reported. Not a bypass — the server still compares, so a save that lands in
|
|
305
|
+
* between refuses again.
|
|
306
|
+
*/
|
|
307
|
+
export function forcedRetrySprites(results, spriteByKey, flatten) {
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const r of results) {
|
|
310
|
+
if (r.status !== 'conflict' || r.reason !== 'cloud-changed' || !r.assetId)
|
|
311
|
+
continue;
|
|
312
|
+
const original = spriteByKey.get(r.key);
|
|
313
|
+
if (!original)
|
|
314
|
+
continue;
|
|
315
|
+
// Skip a round we already tried with this exact baseline.
|
|
316
|
+
if (r.sha256 && original.baseSha256 === r.sha256)
|
|
317
|
+
continue;
|
|
318
|
+
out.push({
|
|
319
|
+
key: original.key,
|
|
320
|
+
pngBase64: original.pngBase64,
|
|
321
|
+
diskSha256: original.diskSha256,
|
|
322
|
+
assetId: r.assetId,
|
|
323
|
+
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (original.layerIdx ?? 0),
|
|
324
|
+
baseSha256: r.sha256 ?? null,
|
|
325
|
+
flatten,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
/** Overwrite the earlier outcome for every retried key, in place. */
|
|
331
|
+
export function replaceResultsByKey(results, updates) {
|
|
332
|
+
for (const u of updates) {
|
|
333
|
+
const i = results.findIndex((r) => r.key === u.key);
|
|
334
|
+
if (i >= 0)
|
|
335
|
+
results[i] = u;
|
|
336
|
+
else
|
|
337
|
+
results.push(u);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Store the live cloud sha a `cloud-changed` refusal reported as this sprite's
|
|
342
|
+
* pending baseline. Returns true when anything changed.
|
|
343
|
+
*/
|
|
344
|
+
export function rememberRefusedCloudShas(synced, results) {
|
|
345
|
+
let changed = false;
|
|
346
|
+
for (const r of results) {
|
|
347
|
+
if (r.status !== 'conflict' || r.reason !== 'cloud-changed' || !r.sha256)
|
|
348
|
+
continue;
|
|
349
|
+
const prev = synced[r.key];
|
|
350
|
+
if (!prev || prev.pendingCloudSha256 === r.sha256)
|
|
351
|
+
continue;
|
|
352
|
+
synced[r.key] = { ...prev, pendingCloudSha256: r.sha256 };
|
|
353
|
+
changed = true;
|
|
354
|
+
}
|
|
355
|
+
return changed;
|
|
356
|
+
}
|
|
261
357
|
async function commitPushResults(opts) {
|
|
262
358
|
const { batchResults, candidates, sourceByKey, shaByKey, fpByKey, outDir } = opts;
|
|
263
359
|
const failedKeys = new Set(batchResults.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
|
|
@@ -276,6 +372,7 @@ async function commitPushResults(opts) {
|
|
|
276
372
|
delete nextSynced[r.key];
|
|
277
373
|
const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
|
|
278
374
|
const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
|
|
375
|
+
const previous = nextSynced[stateKey];
|
|
279
376
|
nextSynced[stateKey] = {
|
|
280
377
|
assetId: r.assetId,
|
|
281
378
|
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
|
|
@@ -285,6 +382,8 @@ async function commitPushResults(opts) {
|
|
|
285
382
|
...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
|
|
286
383
|
...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
|
|
287
384
|
...(sourceRel ? { sourceRel } : {}),
|
|
385
|
+
...(previous?.releasedAt ? { releasedAt: previous.releasedAt } : {}),
|
|
386
|
+
...(previous?.releasedVersion != null ? { releasedVersion: previous.releasedVersion } : {}),
|
|
288
387
|
};
|
|
289
388
|
}
|
|
290
389
|
}
|
|
@@ -300,7 +399,7 @@ async function persistDiskFingerprints(state, candidates) {
|
|
|
300
399
|
function conflictHint(reason) {
|
|
301
400
|
switch (reason) {
|
|
302
401
|
case 'cloud-changed':
|
|
303
|
-
return `changed in MagicPixel
|
|
402
|
+
return `also changed in MagicPixel. Press Sync on that artboard to use MagicPixel's copy, or run \`${cmd('push --force')}\` to keep this local one.`;
|
|
304
403
|
case 'would-flatten':
|
|
305
404
|
return 'artboard has multiple layers or frames — re-run with --flatten to replace it.';
|
|
306
405
|
case 'legacy-document':
|
|
@@ -308,7 +407,7 @@ function conflictHint(reason) {
|
|
|
308
407
|
case 'not-found':
|
|
309
408
|
return `artboard no longer exists in MagicPixel — run \`${cmd('sync')}\` to refresh.`;
|
|
310
409
|
default:
|
|
311
|
-
return `conflict —
|
|
410
|
+
return `conflict — review both copies, then press the artboard's Sync button to use MagicPixel's copy.`;
|
|
312
411
|
}
|
|
313
412
|
}
|
|
314
413
|
/**
|
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';
|
|
@@ -24,7 +24,7 @@ import { formatBytes } from '../util/format.js';
|
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
26
|
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
|
-
import { decidePull } from '../util/pullDecision.js';
|
|
27
|
+
import { decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.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
|
|
@@ -662,6 +727,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
662
727
|
const shaByEntryId = new Map();
|
|
663
728
|
const toDownload = [];
|
|
664
729
|
const conflicts = [];
|
|
730
|
+
const failedDownloadKeys = new Set();
|
|
665
731
|
let bytesSaved = 0;
|
|
666
732
|
let unchanged = 0;
|
|
667
733
|
const synced = state.synced ?? {};
|
|
@@ -696,7 +762,15 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
696
762
|
const inWorkingSet = isWorkingSetEntry(entry, workingSet);
|
|
697
763
|
// Unchanged cloud composite: skip hashing the original PNG (it almost
|
|
698
764
|
// never matches) unless the file is gone and we need to restore it.
|
|
699
|
-
|
|
765
|
+
const previousReleasedAt = synced[entry.key]?.releasedAt;
|
|
766
|
+
const freshRelease = hasNewExplicitRelease({
|
|
767
|
+
releasedAt: entry.released_at,
|
|
768
|
+
previousReleasedAt,
|
|
769
|
+
releasedVersion: entry.released_version,
|
|
770
|
+
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
771
|
+
lastSync: state.lastSync,
|
|
772
|
+
});
|
|
773
|
+
if (!freshRelease && entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
|
|
700
774
|
shaByEntryId.set(entry.id, null);
|
|
701
775
|
unchanged++;
|
|
702
776
|
if (entry.size_bytes)
|
|
@@ -711,6 +785,11 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
711
785
|
previousCloudSha256,
|
|
712
786
|
lastPushedDiskSha256: synced[entry.key]?.diskSha256,
|
|
713
787
|
inWorkingSet,
|
|
788
|
+
releasedAt: entry.released_at,
|
|
789
|
+
previousReleasedAt,
|
|
790
|
+
releasedVersion: entry.released_version,
|
|
791
|
+
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
792
|
+
lastSync: state.lastSync,
|
|
714
793
|
});
|
|
715
794
|
if (decision === 'conflict') {
|
|
716
795
|
conflicts.push(entry.key);
|
|
@@ -749,10 +828,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
749
828
|
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
750
829
|
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
751
830
|
if (!since) {
|
|
831
|
+
const protectedPaths = new Set(unknownFlagPaths);
|
|
832
|
+
if (withheldDirs.size > 0) {
|
|
833
|
+
for (const local of localPngs) {
|
|
834
|
+
if (withheldDirs.has(dirname(local.abs)))
|
|
835
|
+
protectedPaths.add(local.abs);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
752
838
|
const policy = selectFullSyncOrphans({
|
|
753
839
|
localPaths: localPngs.map((a) => a.abs),
|
|
754
840
|
remoteDiskPaths,
|
|
755
|
-
protectedPaths
|
|
841
|
+
protectedPaths,
|
|
756
842
|
isTracked: (p) => keyByPath.has(p),
|
|
757
843
|
});
|
|
758
844
|
orphans = policy.orphans;
|
|
@@ -817,6 +903,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
817
903
|
// local-edit guard applied below. De-duplicates vs the full-sync orphan list.
|
|
818
904
|
const orphanSet = new Set(orphans);
|
|
819
905
|
const addOrphan = (p) => {
|
|
906
|
+
// Withheld documents are held whole: no prune source may touch them.
|
|
907
|
+
if (withheldDirs.has(dirname(p)))
|
|
908
|
+
return;
|
|
820
909
|
if (existsSync(p) && isPathInside(p, outRoot))
|
|
821
910
|
orphanSet.add(p);
|
|
822
911
|
};
|
|
@@ -917,6 +1006,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
917
1006
|
if (e.folder)
|
|
918
1007
|
knownFolderSlugs.add(e.folder.split('/')[0]);
|
|
919
1008
|
}
|
|
1009
|
+
// Withheld docs are absent from `manifest` — without their slugs a sibling
|
|
1010
|
+
// folder like `tiles-2/` would read as a legacy suffix collision.
|
|
1011
|
+
for (const e of withheldEntries) {
|
|
1012
|
+
if (e.folder)
|
|
1013
|
+
knownFolderSlugs.add(e.folder.split('/')[0]);
|
|
1014
|
+
}
|
|
920
1015
|
for (const key of Object.values(previousAssets)) {
|
|
921
1016
|
const top = key.split('/')[0];
|
|
922
1017
|
if (top)
|
|
@@ -1049,6 +1144,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1049
1144
|
}
|
|
1050
1145
|
catch (e) {
|
|
1051
1146
|
result.failed++;
|
|
1147
|
+
failedDownloadKeys.add(entry.key);
|
|
1052
1148
|
progress?.stop();
|
|
1053
1149
|
// Multi-line messages come from friendlyFsError — print all lines
|
|
1054
1150
|
// so the user sees the fix hint. Single-line errors stay terse.
|
|
@@ -1189,9 +1285,21 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1189
1285
|
// Conflicted keys keep their old baselines: recording the new cloud sha
|
|
1190
1286
|
// (with the locally-edited disk sha) would make both halves believe they
|
|
1191
1287
|
// are in sync and freeze the divergence forever.
|
|
1192
|
-
if (conflictSet.has(entry.key))
|
|
1288
|
+
if (conflictSet.has(entry.key)) {
|
|
1289
|
+
const previous = nextSynced[entry.key];
|
|
1290
|
+
if (previous && entry.sha256) {
|
|
1291
|
+
nextSynced[entry.key] = { ...previous, pendingCloudSha256: entry.sha256 };
|
|
1292
|
+
}
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (failedDownloadKeys.has(entry.key))
|
|
1193
1296
|
continue;
|
|
1194
|
-
|
|
1297
|
+
// After a write, the actual downloaded bytes are the disk/cloud baseline.
|
|
1298
|
+
// The manifest hash may be null or represent a cached composite while the
|
|
1299
|
+
// download endpoint serves storage bytes for a single-artboard document.
|
|
1300
|
+
const sha = writtenKeys.has(entry.key)
|
|
1301
|
+
? await fileSha256(pathFor(entry))
|
|
1302
|
+
: (entry.sha256 ?? (await fileSha256(pathFor(entry))));
|
|
1195
1303
|
if (!sha)
|
|
1196
1304
|
continue;
|
|
1197
1305
|
const prev = nextSynced[entry.key];
|
|
@@ -1203,7 +1311,16 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1203
1311
|
// them anyway, flagged, so `push` skips them with a hint instead of
|
|
1204
1312
|
// adopting the same art as a second document.
|
|
1205
1313
|
if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
|
|
1206
|
-
nextSynced[entry.key] = {
|
|
1314
|
+
nextSynced[entry.key] = {
|
|
1315
|
+
assetId: entry.asset_id,
|
|
1316
|
+
layerIdx: 0,
|
|
1317
|
+
sha256: sha,
|
|
1318
|
+
diskSha256,
|
|
1319
|
+
legacy: true,
|
|
1320
|
+
...(sourceRel ? { sourceRel } : {}),
|
|
1321
|
+
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1322
|
+
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
1323
|
+
};
|
|
1207
1324
|
continue;
|
|
1208
1325
|
}
|
|
1209
1326
|
nextSynced[entry.key] = {
|
|
@@ -1213,6 +1330,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1213
1330
|
diskSha256,
|
|
1214
1331
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
1215
1332
|
...(sourceRel ? { sourceRel } : {}),
|
|
1333
|
+
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1334
|
+
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
1216
1335
|
};
|
|
1217
1336
|
}
|
|
1218
1337
|
const nextState = {
|
|
@@ -1291,6 +1410,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1291
1410
|
if (result.failed > 0 && verbose) {
|
|
1292
1411
|
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
1293
1412
|
}
|
|
1413
|
+
onStatus?.('Checking your game files…');
|
|
1414
|
+
const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1294
1415
|
if (verbose) {
|
|
1295
1416
|
console.log();
|
|
1296
1417
|
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
|
@@ -1299,25 +1420,23 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1299
1420
|
(shouldPrune ? `, pruned ${result.removed.length}` : '') +
|
|
1300
1421
|
(renamed.length ? `, renamed ${renamed.length}` : '') +
|
|
1301
1422
|
(result.failed ? `, failed ${result.failed}` : '');
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1423
|
+
const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
|
|
1424
|
+
console.log(result.failed || pushFailed
|
|
1425
|
+
? kleur.yellow(`Sync finished with issues. ${summary}`)
|
|
1426
|
+
: kleur.green(`✓ Sync complete. ${summary}`));
|
|
1306
1427
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1307
1428
|
if (renamed.length > 0)
|
|
1308
1429
|
printRenames(renamed, { withHints: true });
|
|
1309
1430
|
}
|
|
1310
|
-
onStatus?.('Checking your game files…');
|
|
1311
|
-
const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
1312
1431
|
if (result.failed)
|
|
1313
1432
|
process.exitCode = 1;
|
|
1314
1433
|
return { ...result, pushed };
|
|
1315
1434
|
}
|
|
1316
|
-
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
|
|
1435
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1317
1436
|
if (pushEnabled === false)
|
|
1318
1437
|
return null;
|
|
1319
1438
|
try {
|
|
1320
|
-
return await runPush({ quiet: !verbose, gameIndex, onStatus });
|
|
1439
|
+
return await runPush({ quiet: !verbose, nestedSync: true, gameIndex, onStatus, excludeKeys });
|
|
1321
1440
|
}
|
|
1322
1441
|
catch (e) {
|
|
1323
1442
|
if (announceErrors) {
|
|
@@ -1393,11 +1512,13 @@ function humanTime(iso) {
|
|
|
1393
1512
|
function printConflicts(keys, removedRemotely = new Set()) {
|
|
1394
1513
|
console.log();
|
|
1395
1514
|
console.log(kleur.yellow(`${keys.length} sprite${keys.length === 1 ? '' : 's'} changed in both places — not overwritten:`));
|
|
1396
|
-
for (const key of keys) {
|
|
1515
|
+
for (const key of keys.slice(0, 10)) {
|
|
1397
1516
|
const note = removedRemotely.has(key) ? kleur.dim(' (no longer synced from MagicPixel)') : '';
|
|
1398
1517
|
console.log(` ${kleur.yellow('~')} ${key}${note}`);
|
|
1399
1518
|
}
|
|
1400
|
-
|
|
1519
|
+
if (keys.length > 10)
|
|
1520
|
+
console.log(kleur.dim(` …and ${keys.length - 10} more`));
|
|
1521
|
+
console.log(kleur.dim(` Use MagicPixel's copy: press Sync on that artboard. Keep the local edit: run \`${cmd('push --force')}\`.`));
|
|
1401
1522
|
if (removedRemotely.size > 0) {
|
|
1402
1523
|
console.log(kleur.dim(` Marked (no longer synced): re-check "Sync to Unity" (or restore the document) to push the edit up, or delete the PNG to accept the removal.`));
|
|
1403
1524
|
}
|
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/index.js
CHANGED
|
@@ -97,7 +97,8 @@ program
|
|
|
97
97
|
.description('Upload local sprite edits back to MagicPixel (two-way sync)')
|
|
98
98
|
.option('--dry-run', 'Print what would be pushed without sending anything')
|
|
99
99
|
.option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
|
|
100
|
-
.
|
|
100
|
+
.option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
|
|
101
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n $ magicpixel push --force # your local copy wins on both-sides-changed\n')
|
|
101
102
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
102
103
|
program
|
|
103
104
|
.command('connect <glob>')
|
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
|
});
|
|
@@ -14,10 +14,30 @@
|
|
|
14
14
|
* that file, the entry is reported instead of downloaded.
|
|
15
15
|
*/
|
|
16
16
|
import { isLocalEdit } from './localEdit.js';
|
|
17
|
+
/** A newer editor Sync press explicitly chooses MagicPixel for this sprite. */
|
|
18
|
+
export function hasNewExplicitRelease(opts) {
|
|
19
|
+
if (opts.releasedVersion != null && opts.previousReleasedVersion != null) {
|
|
20
|
+
return opts.releasedVersion > opts.previousReleasedVersion;
|
|
21
|
+
}
|
|
22
|
+
if (!opts.releasedAt)
|
|
23
|
+
return false;
|
|
24
|
+
const released = Date.parse(opts.releasedAt);
|
|
25
|
+
if (!Number.isFinite(released))
|
|
26
|
+
return false;
|
|
27
|
+
const baseline = opts.previousReleasedAt ?? opts.lastSync;
|
|
28
|
+
if (!baseline)
|
|
29
|
+
return false;
|
|
30
|
+
const previous = Date.parse(baseline);
|
|
31
|
+
return Number.isFinite(previous) && released > previous;
|
|
32
|
+
}
|
|
17
33
|
export function decidePull(opts) {
|
|
18
34
|
const cloud = opts.cloudSha256 ?? null;
|
|
19
35
|
const local = opts.localSha256;
|
|
20
36
|
const prev = opts.previousCloudSha256;
|
|
37
|
+
// A fresh explicit release is the user's scoped instruction for the cloud
|
|
38
|
+
// copy to replace this PNG, even if the PNG also changed locally.
|
|
39
|
+
if (hasNewExplicitRelease(opts))
|
|
40
|
+
return 'pull';
|
|
21
41
|
// Missing on disk → restore from cloud (MagicPixel-only art and deletions).
|
|
22
42
|
if (!local)
|
|
23
43
|
return 'pull';
|
package/dist/util/pushPlan.js
CHANGED
|
@@ -145,7 +145,10 @@ export function planPush(candidates, synced, opts = {}) {
|
|
|
145
145
|
key: c.key,
|
|
146
146
|
assetId: known.assetId,
|
|
147
147
|
layerIdx: known.layerIdx,
|
|
148
|
-
|
|
148
|
+
// `sync` records the live cloud hash when it protects a dual edit.
|
|
149
|
+
// A later explicit `push` can then safely keep the local copy without
|
|
150
|
+
// bypassing the server's optimistic-concurrency check.
|
|
151
|
+
baseSha256: known.pendingCloudSha256 ?? known.sha256,
|
|
149
152
|
});
|
|
150
153
|
continue;
|
|
151
154
|
}
|
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);
|