@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 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
- 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
  }
@@ -275,8 +290,13 @@ function compareSemver(a, b) {
275
290
  }
276
291
  return 0;
277
292
  }
278
- /** Full manifest walk, including the cloud's removal list. */
279
- export async function fetchManifestSnapshot(config, since) {
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
- 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);
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
- method: 'POST',
377
- headers,
378
- body: JSON.stringify({ sprites }),
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
- 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);
391
414
  }, retryOpts);
392
415
  }
393
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';
@@ -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
- onStatus('Checking MagicPixel…');
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
- 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;
456
535
  removedRemoteKeys = snapshot.removedKeys;
457
- 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));
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: unknownFlagPaths,
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)
@@ -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
  });
@@ -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 res = await fetch(current, { ...init, redirect: 'manual' });
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)
@@ -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);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {