@magicpixelart/cli 0.5.20 → 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 +24 -0
- package/dist/api.js +37 -14
- package/dist/commands/sync.js +119 -10
- package/dist/commands/whoami.js +3 -2
- package/dist/util/auth.js +4 -3
- package/dist/util/security.js +34 -2
- package/dist/util/unityFilter.js +20 -5
- package/dist/util/watchCopy.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ 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
|
+
|
|
23
|
+
## [0.5.21] — 2026-09-14
|
|
24
|
+
|
|
25
|
+
- `sync --watch` can no longer hang forever: every network request now has a
|
|
26
|
+
deadline (45s for manifest/JSON calls, 120s for sprite downloads and pushes),
|
|
27
|
+
and a stalled request is retried like any other network blip.
|
|
28
|
+
- A slow tick prints a heartbeat with elapsed time instead of a frozen line, and
|
|
29
|
+
the first big pull reports its running sprite count page by page.
|
|
30
|
+
- Timeout errors now end with a "check your internet connection" hint, matching
|
|
31
|
+
every other CLI error.
|
|
32
|
+
- The heartbeat line no longer prints a stray ellipsis before the sprite count.
|
|
33
|
+
|
|
10
34
|
## 0.5.20
|
|
11
35
|
|
|
12
36
|
- `sync` no longer deletes a PNG that was edited in your project when the sprite
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
|
|
2
|
-
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
2
|
+
import { ASSET_TIMEOUT_MS, etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
3
3
|
import { authHeaders } from './util/authHeaders.js';
|
|
4
4
|
import { cmd } from './util/invoke.js';
|
|
5
5
|
/**
|
|
@@ -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
|
}
|
|
@@ -275,8 +290,13 @@ function compareSemver(a, b) {
|
|
|
275
290
|
}
|
|
276
291
|
return 0;
|
|
277
292
|
}
|
|
278
|
-
/**
|
|
279
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Full manifest walk, including the cloud's removal list.
|
|
295
|
+
*
|
|
296
|
+
* `onProgress` fires after each page with the running entry count, so a big
|
|
297
|
+
* first pull shows movement instead of one frozen status line.
|
|
298
|
+
*/
|
|
299
|
+
export async function fetchManifestSnapshot(config, since, onProgress) {
|
|
280
300
|
const out = [];
|
|
281
301
|
const removed = new Set();
|
|
282
302
|
let removalsTruncated = false;
|
|
@@ -292,6 +312,8 @@ export async function fetchManifestSnapshot(config, since) {
|
|
|
292
312
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
293
313
|
const res = await fetchManifestPage({ config, since, cursor, limit: 500, conditional });
|
|
294
314
|
out.push(...res.items);
|
|
315
|
+
if (out.length > 0)
|
|
316
|
+
onProgress?.(out.length);
|
|
295
317
|
if (Array.isArray(res.removed_keys)) {
|
|
296
318
|
for (const k of res.removed_keys)
|
|
297
319
|
if (typeof k === 'string' && k)
|
|
@@ -328,7 +350,7 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
328
350
|
const conditional = knownSha ? { 'If-None-Match': etagForSha256(knownSha) } : undefined;
|
|
329
351
|
return retryTransient(`download ${key}`, async () => {
|
|
330
352
|
const { headers, requestId } = buildHeaders(conditional);
|
|
331
|
-
const res = await safeFetch(url.href, { headers });
|
|
353
|
+
const res = await safeFetch(url.href, { headers }, { timeoutMs: ASSET_TIMEOUT_MS });
|
|
332
354
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
333
355
|
if (res.status === 304) {
|
|
334
356
|
// Drain so undici can return the socket to the keep-alive pool.
|
|
@@ -338,7 +360,8 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
338
360
|
if (res.ok)
|
|
339
361
|
return await readBodyWithLimit(res, MAX_ASSET_BYTES);
|
|
340
362
|
const bodyText = await res.text();
|
|
341
|
-
|
|
363
|
+
const errorCode = errorCodeFromResponse(res);
|
|
364
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, `download ${key}`, errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
342
365
|
});
|
|
343
366
|
}
|
|
344
367
|
/**
|
|
@@ -372,11 +395,10 @@ export async function pushSprites(sprites, retryOpts) {
|
|
|
372
395
|
const url = resolveIngestEndpoint();
|
|
373
396
|
return retryTransient('push', async () => {
|
|
374
397
|
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
375
|
-
const res = await safeFetch(url, {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
});
|
|
398
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify({ sprites }) },
|
|
399
|
+
// A push batch carries PNG bytes; give it the asset deadline, not the
|
|
400
|
+
// shorter JSON one.
|
|
401
|
+
{ timeoutMs: ASSET_TIMEOUT_MS });
|
|
380
402
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
381
403
|
if (res.ok) {
|
|
382
404
|
const data = (await res.json());
|
|
@@ -387,7 +409,8 @@ export async function pushSprites(sprites, retryOpts) {
|
|
|
387
409
|
return results;
|
|
388
410
|
}
|
|
389
411
|
const bodyText = await res.text();
|
|
390
|
-
|
|
412
|
+
const errorCode = errorCodeFromResponse(res);
|
|
413
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, bodyText, 'push', errorCode), serverRequestId), serverRequestId, retryAfterMsFromResponse(res), errorCode);
|
|
391
414
|
}, retryOpts);
|
|
392
415
|
}
|
|
393
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';
|
|
@@ -23,7 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
|
|
|
23
23
|
import { formatBytes } from '../util/format.js';
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
|
-
import { formatWatchSpriteLine } from '../util/watchCopy.js';
|
|
26
|
+
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
27
|
import { decidePull } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.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.
|
|
@@ -177,14 +179,31 @@ async function watchLoop(opts) {
|
|
|
177
179
|
return;
|
|
178
180
|
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
|
|
179
181
|
};
|
|
182
|
+
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
183
|
+
// link) must not look like the silent hang a timeout-less fetch used to
|
|
184
|
+
// cause. Requests now have deadlines, so a live counter here means "slow",
|
|
185
|
+
// not "stuck".
|
|
186
|
+
const tickStartedAt = Date.now();
|
|
187
|
+
let lastStatus = '';
|
|
188
|
+
const wrappedStatus = (msg) => {
|
|
189
|
+
lastStatus = msg;
|
|
190
|
+
onStatus(msg);
|
|
191
|
+
};
|
|
192
|
+
const heartbeat = opts.quiet
|
|
193
|
+
? null
|
|
194
|
+
: setInterval(() => {
|
|
195
|
+
const elapsedSec = Math.round((Date.now() - tickStartedAt) / 1000);
|
|
196
|
+
onStatus(formatSlowTickLine(elapsedSec, lastStatus));
|
|
197
|
+
}, SLOW_TICK_HEARTBEAT_MS);
|
|
198
|
+
heartbeat?.unref?.();
|
|
180
199
|
try {
|
|
181
|
-
|
|
200
|
+
wrappedStatus('Checking MagicPixel…');
|
|
182
201
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
183
202
|
? gameIndexCache
|
|
184
203
|
: undefined;
|
|
185
204
|
const r = await runOnce({ ...opts, watch: false }, {
|
|
186
205
|
watchMode: true,
|
|
187
|
-
onStatus,
|
|
206
|
+
onStatus: wrappedStatus,
|
|
188
207
|
gameIndex: reuseIndex,
|
|
189
208
|
onGameIndex: (idx) => {
|
|
190
209
|
gameIndexCache = idx;
|
|
@@ -197,9 +216,11 @@ async function watchLoop(opts) {
|
|
|
197
216
|
// another terminal is picked up automatically by the next tick.
|
|
198
217
|
const wasPausedForAuth = pausedForAuth;
|
|
199
218
|
const wasPausedForNetwork = pausedForNetwork;
|
|
219
|
+
const wasPausedForQuota = pausedForQuota;
|
|
200
220
|
backoffSec = intervalSec;
|
|
201
221
|
pausedForAuth = false;
|
|
202
222
|
pausedForNetwork = false;
|
|
223
|
+
pausedForQuota = false;
|
|
203
224
|
consecutiveAuthFailures = 0;
|
|
204
225
|
if (wasPausedForAuth && !opts.quiet) {
|
|
205
226
|
process.stdout.write('\x1b[2K\r');
|
|
@@ -209,6 +230,10 @@ async function watchLoop(opts) {
|
|
|
209
230
|
process.stdout.write('\x1b[2K\r');
|
|
210
231
|
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
|
|
211
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
|
+
}
|
|
212
237
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
213
238
|
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
214
239
|
// the printed pull list. Without it, an artist working only in Unity
|
|
@@ -245,6 +270,7 @@ async function watchLoop(opts) {
|
|
|
245
270
|
backoffSec,
|
|
246
271
|
pausedForAuth,
|
|
247
272
|
pausedForNetwork,
|
|
273
|
+
pausedForQuota,
|
|
248
274
|
consecutiveAuthFailures,
|
|
249
275
|
maxAuthFailures: MAX_AUTH_FAILURES,
|
|
250
276
|
});
|
|
@@ -280,6 +306,17 @@ async function watchLoop(opts) {
|
|
|
280
306
|
pausedForNetwork = true;
|
|
281
307
|
backoffSec = decision.nextBackoffSec;
|
|
282
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
|
+
}
|
|
283
320
|
else {
|
|
284
321
|
consecutiveAuthFailures = 0;
|
|
285
322
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
@@ -288,6 +325,8 @@ async function watchLoop(opts) {
|
|
|
288
325
|
}
|
|
289
326
|
}
|
|
290
327
|
finally {
|
|
328
|
+
if (heartbeat)
|
|
329
|
+
clearInterval(heartbeat);
|
|
291
330
|
inFlight = false;
|
|
292
331
|
}
|
|
293
332
|
};
|
|
@@ -352,6 +391,19 @@ export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_B
|
|
|
352
391
|
return Math.max(intervalSec, 5);
|
|
353
392
|
return intervalSec;
|
|
354
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
|
+
}
|
|
355
407
|
/**
|
|
356
408
|
* Pure helper: classify a per-tick error and compute the next backoff +
|
|
357
409
|
* counter updates. Extracted so the auth-failure watchdog and the
|
|
@@ -372,6 +424,17 @@ export function classifyTickError(err, state) {
|
|
|
372
424
|
giveUp: next >= state.maxAuthFailures,
|
|
373
425
|
};
|
|
374
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
|
+
}
|
|
375
438
|
if (isNetworkError(err)) {
|
|
376
439
|
const nextBackoffSec = Math.min(state.backoffSec * 2, 60);
|
|
377
440
|
// Print on the first offline tick, then again whenever backoff changes —
|
|
@@ -434,14 +497,25 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
434
497
|
if (!spinner)
|
|
435
498
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
436
499
|
let manifest;
|
|
500
|
+
/** In-scope documents withheld by the server (unreleased edits): hold-only. */
|
|
501
|
+
let withheldEntries = [];
|
|
437
502
|
// Composite keys the cloud reports as gone (document trashed / componentized)
|
|
438
503
|
// since our cursor. Empty on --full, where the manifest itself is the truth.
|
|
439
504
|
let removedRemoteKeys = [];
|
|
440
505
|
// Capture before the Unity filter drops rows — lastSync must not jump past
|
|
441
506
|
// an editor save whose `unity` flag was omitted on this tick.
|
|
442
507
|
let observedUpdatedAt = null;
|
|
508
|
+
// Page-by-page progress: a first pull of thousands of sprites walks several
|
|
509
|
+
// pages, and a single static status line reads as a hang.
|
|
510
|
+
const onManifestProgress = (n) => {
|
|
511
|
+
const count = n.toLocaleString('en-US');
|
|
512
|
+
if (spinner)
|
|
513
|
+
spinner.text = `Fetching manifest… (${count})`;
|
|
514
|
+
else
|
|
515
|
+
onStatus?.(`Fetching your sprites from MagicPixel… (${count})`);
|
|
516
|
+
};
|
|
443
517
|
try {
|
|
444
|
-
let snapshot = await fetchManifestSnapshot(config, since);
|
|
518
|
+
let snapshot = await fetchManifestSnapshot(config, since, onManifestProgress);
|
|
445
519
|
if (snapshot.removalsTruncated && since) {
|
|
446
520
|
// The server couldn't list every removal in this window. Trusting the
|
|
447
521
|
// partial list and advancing the cursor would strand the rest forever, so
|
|
@@ -450,11 +524,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
450
524
|
console.log(kleur.dim('Many sprites were removed at once — re-reading the full manifest to stay in sync.'));
|
|
451
525
|
}
|
|
452
526
|
since = undefined;
|
|
453
|
-
snapshot = await fetchManifestSnapshot(config, undefined);
|
|
527
|
+
snapshot = await fetchManifestSnapshot(config, undefined, onManifestProgress);
|
|
454
528
|
}
|
|
455
|
-
|
|
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;
|
|
456
535
|
removedRemoteKeys = snapshot.removedKeys;
|
|
457
|
-
|
|
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));
|
|
458
539
|
const projectInfo = getLastProjectInfo();
|
|
459
540
|
const projectSuffix = projectInfo && verbose
|
|
460
541
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -519,6 +600,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
519
600
|
(connected.capped ? ` of ${connected.total} matched` : '')));
|
|
520
601
|
}
|
|
521
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
|
+
}
|
|
522
615
|
const workingSet = workingSetPullKeys(sourceByKey, state.synced);
|
|
523
616
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
524
617
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
@@ -721,10 +814,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
721
814
|
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
722
815
|
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
723
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
|
+
}
|
|
724
824
|
const policy = selectFullSyncOrphans({
|
|
725
825
|
localPaths: localPngs.map((a) => a.abs),
|
|
726
826
|
remoteDiskPaths,
|
|
727
|
-
protectedPaths
|
|
827
|
+
protectedPaths,
|
|
728
828
|
isTracked: (p) => keyByPath.has(p),
|
|
729
829
|
});
|
|
730
830
|
orphans = policy.orphans;
|
|
@@ -789,6 +889,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
789
889
|
// local-edit guard applied below. De-duplicates vs the full-sync orphan list.
|
|
790
890
|
const orphanSet = new Set(orphans);
|
|
791
891
|
const addOrphan = (p) => {
|
|
892
|
+
// Withheld documents are held whole: no prune source may touch them.
|
|
893
|
+
if (withheldDirs.has(dirname(p)))
|
|
894
|
+
return;
|
|
792
895
|
if (existsSync(p) && isPathInside(p, outRoot))
|
|
793
896
|
orphanSet.add(p);
|
|
794
897
|
};
|
|
@@ -889,6 +992,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
889
992
|
if (e.folder)
|
|
890
993
|
knownFolderSlugs.add(e.folder.split('/')[0]);
|
|
891
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
|
+
}
|
|
892
1001
|
for (const key of Object.values(previousAssets)) {
|
|
893
1002
|
const top = key.split('/')[0];
|
|
894
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/security.js
CHANGED
|
@@ -88,14 +88,46 @@ export function tmpPathFor(diskPath) {
|
|
|
88
88
|
const suffix = randomBytes(8).toString('hex');
|
|
89
89
|
return `${diskPath}.${process.pid}.${suffix}.tmp`;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Deadline for a single request (JSON APIs: manifest, push, whoami, key
|
|
93
|
+
* validation). Without a deadline a stalled socket — sleeping laptop, dropped
|
|
94
|
+
* Wi-Fi, dead keep-alive connection — makes `fetch` hang forever, so
|
|
95
|
+
* `sync --watch` sits silently on one tick and never retries.
|
|
96
|
+
*/
|
|
97
|
+
export const REQUEST_TIMEOUT_MS = 45_000;
|
|
98
|
+
/** Deadline for a PNG body, which can legitimately take much longer. */
|
|
99
|
+
export const ASSET_TIMEOUT_MS = 120_000;
|
|
100
|
+
function isAbortError(err) {
|
|
101
|
+
const name = err?.name;
|
|
102
|
+
return name === 'AbortError' || name === 'TimeoutError';
|
|
103
|
+
}
|
|
91
104
|
/**
|
|
92
105
|
* Follow redirects only within the same origin as the initial request (prevents API key leak).
|
|
106
|
+
*
|
|
107
|
+
* Each hop gets a fresh deadline. The signal stays armed after the response
|
|
108
|
+
* headers arrive, so a stalled *body* read aborts too — that abort surfaces
|
|
109
|
+
* raw here and is relabelled as a transient network failure by `retryTransient`
|
|
110
|
+
* one layer up; only the connection attempt gets the friendly "didn't respond
|
|
111
|
+
* in time" wording. Callers that pass their own `init.signal` own their timing
|
|
112
|
+
* (e.g. `doctor`'s 5s probe).
|
|
93
113
|
*/
|
|
94
|
-
export async function safeFetch(url, init = {}) {
|
|
114
|
+
export async function safeFetch(url, init = {}, opts = {}) {
|
|
95
115
|
const origin = new URL(url).origin;
|
|
116
|
+
const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
96
117
|
let current = url;
|
|
97
118
|
for (let hop = 0; hop < 5; hop++) {
|
|
98
|
-
const
|
|
119
|
+
const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(current, { ...init, signal, redirect: 'manual' });
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (!init.signal && isAbortError(e)) {
|
|
126
|
+
throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s — MagicPixel didn't respond in time.\n` +
|
|
127
|
+
` Fix: check your internet connection and that the MagicPixel API is reachable.`, { cause: e });
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
99
131
|
// 304 lives in the 3xx range but is a conditional-request answer, not a
|
|
100
132
|
// redirect: it carries no Location and callers handle it directly.
|
|
101
133
|
if (res.status < 300 || res.status >= 400 || res.status === 304)
|
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);
|
package/dist/util/watchCopy.js
CHANGED
|
@@ -19,3 +19,23 @@ export function formatWatchSpriteLine(c) {
|
|
|
19
19
|
return ` Sprites: ${fmt(set)} in your game`;
|
|
20
20
|
return ` Sprites: ${fmt(pulled)}`;
|
|
21
21
|
}
|
|
22
|
+
/** How long a single watch tick may run before the heartbeat starts printing. */
|
|
23
|
+
export const SLOW_TICK_HEARTBEAT_MS = 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* Heartbeat line for a tick that has outlived `SLOW_TICK_HEARTBEAT_MS`. Keeps
|
|
26
|
+
* the last status text so the user sees *what* is slow, not just that
|
|
27
|
+
* something is. Requests carry their own deadlines, so this always means slow.
|
|
28
|
+
*/
|
|
29
|
+
export function formatSlowTickLine(elapsedSec, lastStatus) {
|
|
30
|
+
const secs = Math.max(0, Math.round(elapsedSec));
|
|
31
|
+
const elapsed = secs >= 120 ? `${Math.round(secs / 60)}m` : `${secs}s`;
|
|
32
|
+
// "Fetching sprites… (1,200)" → "Fetching sprites (1,200)": the ellipsis and
|
|
33
|
+
// the heartbeat suffix would otherwise both claim the end of the line.
|
|
34
|
+
const what = lastStatus
|
|
35
|
+
?.trim()
|
|
36
|
+
.replace(/[.…]+(\s*\([^()]*\))?$/, '$1')
|
|
37
|
+
.trim();
|
|
38
|
+
return what
|
|
39
|
+
? `${what} — still working (${elapsed})`
|
|
40
|
+
: `Still working (${elapsed})`;
|
|
41
|
+
}
|