@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 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
- constructor(status, message, requestId, retryAfterMs) {
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
- function friendly(status, body, context) {
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
- throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'manifest'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
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
- throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, `download ${key}`), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
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
- throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'push'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
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) {
@@ -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
- manifest = snapshot.entries;
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
- observedUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
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: unknownFlagPaths,
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)
@@ -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, bodyText.slice(0, 200), serverRequestId, retryAfterMsFromResponse(res));
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, `Server returned ${res.status}: ${body.slice(0, 120)}\n` +
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
  });
@@ -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: manifest, noneFlagged: false, unknown: [] };
24
- const entries = manifest.filter((e) => e.unity === true);
25
- const unknown = manifest.filter((e) => typeof e.unity !== 'boolean');
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: manifest.length > 0 && entries.length === 0,
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
- const extra = manifest.filter((e) => !already.has(e) && opts.alwaysPull(e));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.21",
3
+ "version": "0.5.22",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {