@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 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
- 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) {
@@ -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 counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
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
- console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
282
+ if (printedIssues++ < 10)
283
+ console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
247
284
  }
248
285
  else if (r.status === 'error') {
249
- console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
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 since the last sync — run \`${cmd('sync')}\` first, then re-push.`;
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 — run \`${cmd('sync')}\` and try again.`;
410
+ return `conflict — review both copies, then press the artboard's Sync button to use MagicPixel's copy.`;
312
411
  }
313
412
  }
314
413
  /**
@@ -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
- 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
@@ -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
- if (entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
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: unknownFlagPaths,
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
- const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
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] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, diskSha256, legacy: true, ...(sourceRel ? { sourceRel } : {}) };
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
- console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
1303
- // Per-file change list. Suppress renames here — printRenames below owns
1304
- // the rename block (it adds the import-update hints). Letting both fire
1305
- // would print each rename twice.
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
- console.log(kleur.dim(` Keep the local edit: run \`${cmd('push')}\` (it will report the cloud change), or delete the PNG to accept MagicPixel's copy.`));
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
  }
@@ -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/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
- .addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n')
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, `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
  });
@@ -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';
@@ -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
- baseSha256: known.sha256,
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
  }
@@ -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.24",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {