@magicpixelart/cli 0.5.22 → 0.5.26

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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.26] — 2026-09-21
11
+
12
+ - Clicking Sync now selects exactly that artboard and clears sibling selections,
13
+ so generated variants and pasted items in the same document stay out of the
14
+ game project.
15
+ - Unity, Godot, and GameMaker now strictly require an explicit per-artboard
16
+ sync flag. Existing working-set paths can no longer bypass that choice.
17
+ - Previously downloaded, now-unmarked siblings are removed on the next sync;
18
+ locally edited files remain protected as conflicts.
19
+
20
+ ## [0.5.25] — 2026-09-21
21
+
22
+ - A sync no longer spends one request per file just to be told nothing changed:
23
+ files whose MagicPixel document has not been touched since the last sync are
24
+ skipped outright, instead of being re-checked one at a time.
25
+ - "Nothing changed" replies no longer count against the daily request
26
+ allowance, so a watcher can idle all day without exhausting it.
27
+ - A watcher that has been quiet for 15 minutes now polls every 30 seconds
28
+ (was 10), and snaps straight back to the fast interval on the first change.
29
+
30
+ ## [0.5.24] — 2026-09-21
31
+
32
+ - `push --force` keeps your local copy when a sprite changed in both places. It
33
+ re-sends against the live cloud state rather than skipping the safety check,
34
+ so a save that lands in between is still reported instead of overwritten.
35
+ - A both-sides-changed refusal now carries the live cloud fingerprint, so a
36
+ follow-up `push` works even for large documents whose hash wasn't cached —
37
+ the last case where the sync/push advice could still dead-end.
38
+ - Conflict messages name both ways out: press Sync on the artboard, or
39
+ `push --force`.
40
+
41
+ ## [0.5.23] — 2026-09-21
42
+
43
+ - Pressing Sync on an artboard now explicitly chooses MagicPixel's copy when
44
+ that PNG was also edited locally, resolving the previous sync/push loop.
45
+ - Conflict lists are capped, nested push warnings no longer repeat during
46
+ sync, and completion summaries include locally unchanged sprites accurately.
47
+
10
48
  ## [0.5.22] — 2026-09-14
11
49
 
12
50
  - Raised the daily sprite download allowance from 1 GiB to 10 GiB for large
package/README.md CHANGED
@@ -150,7 +150,7 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
150
150
  | `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
151
151
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
152
152
  | `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
153
- | `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
153
+ | `unitySyncAll` | `boolean?` | `false` | Game engines only: sync every artboard instead of only the one explicitly selected in the editor. |
154
154
  | `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
155
155
  | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
156
156
 
@@ -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
  /**
@@ -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, partitionWithheldEntries, workingSetPullKeys } from '../util/unityFilter.js';
18
+ import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, 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 { canSkipWithoutHashing, 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';
@@ -372,10 +372,12 @@ function isNetworkError(err) {
372
372
  }
373
373
  /**
374
374
  * Idle thresholds (seconds of consecutive idleness) at which the watcher steps
375
- * down from the configured interval to 5s, then to 10s. Single source of truth
376
- * for both the watch loop and its regression tests.
375
+ * down from the configured interval to 5s, then 10s, then 30s. Single source of
376
+ * truth for both the watch loop and its regression tests. The last step keeps a
377
+ * watcher left running overnight from spending the daily request allowance on
378
+ * polls nobody is waiting for.
377
379
  */
378
- export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
380
+ export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300, coldSec: 900 };
379
381
  /**
380
382
  * Pure helper: given how long we've been idle (wall-clock seconds since the
381
383
  * last pulled or pushed change) and the configured poll interval, return the
@@ -385,6 +387,8 @@ export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
385
387
  * off, ticks are longer than `intervalSec`.
386
388
  */
387
389
  export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_BACKOFF_THRESHOLDS) {
390
+ if (thresholds.coldSec != null && idleSeconds >= thresholds.coldSec)
391
+ return Math.max(intervalSec, 30);
388
392
  if (idleSeconds >= thresholds.hardSec)
389
393
  return Math.max(intervalSec, 10);
390
394
  if (idleSeconds >= thresholds.softSec)
@@ -565,7 +569,7 @@ async function runOnce(opts, runOpts = {}) {
565
569
  }
566
570
  throw e;
567
571
  }
568
- // Unity projects sync only the artboards flagged "Sync to Unity" in the
572
+ // Game-engine projects sync only artboards explicitly selected in the
569
573
  // editor (parity with the in-app sync button). `unitySyncAll: true` in
570
574
  // magicpixel.json opts back into everything.
571
575
  onStatus?.('Looking through your game sprites…');
@@ -613,23 +617,21 @@ async function runOnce(opts, runOpts = {}) {
613
617
  }
614
618
  }
615
619
  const workingSet = workingSetPullKeys(sourceByKey, state.synced);
616
- // Files whose artboard is no longer flagged for Unity. Deleted even in
620
+ // Files whose artboard is no longer selected. Deleted even in
617
621
  // incremental mode: un-checking the box in the editor must actually remove
618
622
  // the sprite from the game project, not leave a stale copy behind.
619
623
  // Working-set (sourceRel) files are never deleted — they're the user's art.
620
624
  const deselectedPaths = [];
621
- // Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
625
+ // Sprites whose game-sync flag the server omitted. Not downloaded
622
626
  // (strict opt-in) but explicitly shielded from pruning: a transient server
623
627
  // response must never delete art from someone's game project.
624
628
  const unknownFlagPaths = new Set();
625
- if (projectKind === 'Unity') {
626
- const filtered = applyUnityPullPolicy(manifest, {
627
- syncAll: config.unitySyncAll,
628
- alwaysPull: (entry) => isWorkingSetEntry(entry, workingSet),
629
- });
629
+ if (isEngineKind(projectKind)) {
630
+ const filtered = filterUnityManifest(manifest, { syncAll: config.unitySyncAll });
631
+ const syncedKeys = new Set(Object.keys(state.synced ?? {}));
630
632
  if (filtered.unknown.length > 0) {
631
633
  const n = filtered.unknown.length;
632
- const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a "Sync to Unity" flag — skipped (existing files kept).`;
634
+ const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
633
635
  if (verbose) {
634
636
  console.log(kleur.yellow(`! ${msg}`));
635
637
  console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
@@ -648,49 +650,24 @@ async function runOnce(opts, runOpts = {}) {
648
650
  unknownFlagPaths.add(p);
649
651
  continue;
650
652
  }
651
- if (sourceByKey.has(entry.key))
653
+ // A tracked download is ours to reconcile even when its path now falls
654
+ // under a connect glob. Genuine game-authored files have no synced
655
+ // baseline and remain protected.
656
+ if (!shouldPruneDeselectedEntry(entry, { sourceByKey, syncedKeys }))
652
657
  continue;
653
658
  if (existsSync(p))
654
659
  deselectedPaths.push(p);
655
660
  }
656
- if (filtered.noneFlagged) {
657
- // Nothing flagged for pull — don't dump the library, and don't prune.
658
- // Local sprites still go up so they appear in MagicPixel on connect.
659
- if (verbose) {
660
- console.log();
661
- console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
662
- console.log(kleur.dim(` Working-set sprites (\`connect\` in magicpixel.json) still push into MagicPixel.`));
663
- console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
664
- }
665
- const empty = {
666
- added: [],
667
- modified: [],
668
- removed: [],
669
- unchanged: 0,
670
- failed: 0,
671
- bytesIn: 0,
672
- bytesSaved: 0,
673
- renamed: [],
674
- conflicts: [],
675
- };
676
- // Persist validators even when the cursor didn't move, so the next
677
- // one-shot run starts from a conditional GET.
678
- const lastSync = observedUpdatedAt && !(state.lastSync && state.lastSync > observedUpdatedAt)
679
- ? observedUpdatedAt
680
- : state.lastSync;
681
- await saveState({
682
- ...state,
683
- ...(lastSync ? { lastSync } : {}),
684
- ...(since ? {} : { lastReconcile: startedAt }),
685
- manifestEtags: manifestEtagsSnapshot(),
686
- });
687
- await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
688
- return empty;
661
+ if (filtered.noneFlagged && verbose) {
662
+ console.log();
663
+ console.log(kleur.dim(since
664
+ ? ' No changed artboards are selected for download.'
665
+ : ' No artboards are selected for download.'));
689
666
  }
690
667
  const skipped = manifest.length - filtered.entries.length;
691
668
  manifest = filtered.entries;
692
669
  if (verbose && skipped > 0) {
693
- console.log(kleur.dim(` unity → ${skipped} artboard${skipped === 1 ? '' : 's'} not flagged for sync (skipped)`));
670
+ console.log(kleur.dim(` game sync → ${skipped} artboard${skipped === 1 ? '' : 's'} not selected (skipped)`));
694
671
  }
695
672
  }
696
673
  // Detect renames: same id, different key vs prior snapshot.
@@ -727,6 +704,7 @@ async function runOnce(opts, runOpts = {}) {
727
704
  const shaByEntryId = new Map();
728
705
  const toDownload = [];
729
706
  const conflicts = [];
707
+ const failedDownloadKeys = new Set();
730
708
  let bytesSaved = 0;
731
709
  let unchanged = 0;
732
710
  const synced = state.synced ?? {};
@@ -761,7 +739,22 @@ async function runOnce(opts, runOpts = {}) {
761
739
  const inWorkingSet = isWorkingSetEntry(entry, workingSet);
762
740
  // Unchanged cloud composite: skip hashing the original PNG (it almost
763
741
  // never matches) unless the file is gone and we need to restore it.
764
- if (entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
742
+ const previousReleasedAt = synced[entry.key]?.releasedAt;
743
+ const freshRelease = hasNewExplicitRelease({
744
+ releasedAt: entry.released_at,
745
+ previousReleasedAt,
746
+ releasedVersion: entry.released_version,
747
+ previousReleasedVersion: synced[entry.key]?.releasedVersion,
748
+ lastSync: state.lastSync,
749
+ });
750
+ if (canSkipWithoutHashing({
751
+ freshRelease,
752
+ fileExists: existsSync(pathFor(entry)),
753
+ cloudSha256: entry.sha256,
754
+ previousCloudSha256,
755
+ cloudUpdatedAt: entry.updated_at,
756
+ previousCloudUpdatedAt: synced[entry.key]?.cloudUpdatedAt,
757
+ })) {
765
758
  shaByEntryId.set(entry.id, null);
766
759
  unchanged++;
767
760
  if (entry.size_bytes)
@@ -776,6 +769,11 @@ async function runOnce(opts, runOpts = {}) {
776
769
  previousCloudSha256,
777
770
  lastPushedDiskSha256: synced[entry.key]?.diskSha256,
778
771
  inWorkingSet,
772
+ releasedAt: entry.released_at,
773
+ previousReleasedAt,
774
+ releasedVersion: entry.released_version,
775
+ previousReleasedVersion: synced[entry.key]?.releasedVersion,
776
+ lastSync: state.lastSync,
779
777
  });
780
778
  if (decision === 'conflict') {
781
779
  conflicts.push(entry.key);
@@ -897,8 +895,8 @@ async function runOnce(opts, runOpts = {}) {
897
895
  };
898
896
  for (const p of renameStalePaths)
899
897
  addOrphan(p);
900
- // Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
901
- // Unity project on the next sync, incremental or not. Working-set originals
898
+ // Deselected artboards: their PNG (and Unity .meta) leaves the game project
899
+ // on the next sync, incremental or not. Working-set originals
902
900
  // are never in this list.
903
901
  for (const p of deselectedPaths)
904
902
  addOrphan(p);
@@ -939,7 +937,7 @@ async function runOnce(opts, runOpts = {}) {
939
937
  }
940
938
  }
941
939
  // A locally-edited file is never deleted, whatever put it in the prune set
942
- // (deselected "Sync to Unity", trashed document, rename fallback,
940
+ // (deselected game sync, trashed document, rename fallback,
943
941
  // previous-key sweep): that edit is the only copy of the work until the user
944
942
  // pushes it or discards it. Manifest-resident conflicts were already detected
945
943
  // during the diff, so they skip the re-hash.
@@ -1130,6 +1128,7 @@ async function runOnce(opts, runOpts = {}) {
1130
1128
  }
1131
1129
  catch (e) {
1132
1130
  result.failed++;
1131
+ failedDownloadKeys.add(entry.key);
1133
1132
  progress?.stop();
1134
1133
  // Multi-line messages come from friendlyFsError — print all lines
1135
1134
  // so the user sees the fix hint. Single-line errors stay terse.
@@ -1270,9 +1269,21 @@ async function runOnce(opts, runOpts = {}) {
1270
1269
  // Conflicted keys keep their old baselines: recording the new cloud sha
1271
1270
  // (with the locally-edited disk sha) would make both halves believe they
1272
1271
  // are in sync and freeze the divergence forever.
1273
- if (conflictSet.has(entry.key))
1272
+ if (conflictSet.has(entry.key)) {
1273
+ const previous = nextSynced[entry.key];
1274
+ if (previous && entry.sha256) {
1275
+ nextSynced[entry.key] = { ...previous, pendingCloudSha256: entry.sha256 };
1276
+ }
1277
+ continue;
1278
+ }
1279
+ if (failedDownloadKeys.has(entry.key))
1274
1280
  continue;
1275
- const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
1281
+ // After a write, the actual downloaded bytes are the disk/cloud baseline.
1282
+ // The manifest hash may be null or represent a cached composite while the
1283
+ // download endpoint serves storage bytes for a single-artboard document.
1284
+ const sha = writtenKeys.has(entry.key)
1285
+ ? await fileSha256(pathFor(entry))
1286
+ : (entry.sha256 ?? (await fileSha256(pathFor(entry))));
1276
1287
  if (!sha)
1277
1288
  continue;
1278
1289
  const prev = nextSynced[entry.key];
@@ -1284,7 +1295,17 @@ async function runOnce(opts, runOpts = {}) {
1284
1295
  // them anyway, flagged, so `push` skips them with a hint instead of
1285
1296
  // adopting the same art as a second document.
1286
1297
  if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
1287
- nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, diskSha256, legacy: true, ...(sourceRel ? { sourceRel } : {}) };
1298
+ nextSynced[entry.key] = {
1299
+ assetId: entry.asset_id,
1300
+ layerIdx: 0,
1301
+ sha256: sha,
1302
+ diskSha256,
1303
+ legacy: true,
1304
+ ...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
1305
+ ...(sourceRel ? { sourceRel } : {}),
1306
+ ...(entry.released_at ? { releasedAt: entry.released_at } : {}),
1307
+ ...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
1308
+ };
1288
1309
  continue;
1289
1310
  }
1290
1311
  nextSynced[entry.key] = {
@@ -1293,7 +1314,10 @@ async function runOnce(opts, runOpts = {}) {
1293
1314
  sha256: sha,
1294
1315
  diskSha256,
1295
1316
  ...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
1317
+ ...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
1296
1318
  ...(sourceRel ? { sourceRel } : {}),
1319
+ ...(entry.released_at ? { releasedAt: entry.released_at } : {}),
1320
+ ...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
1297
1321
  };
1298
1322
  }
1299
1323
  const nextState = {
@@ -1372,6 +1396,8 @@ async function runOnce(opts, runOpts = {}) {
1372
1396
  if (result.failed > 0 && verbose) {
1373
1397
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
1374
1398
  }
1399
+ onStatus?.('Checking your game files…');
1400
+ const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
1375
1401
  if (verbose) {
1376
1402
  console.log();
1377
1403
  const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
@@ -1380,25 +1406,23 @@ async function runOnce(opts, runOpts = {}) {
1380
1406
  (shouldPrune ? `, pruned ${result.removed.length}` : '') +
1381
1407
  (renamed.length ? `, renamed ${renamed.length}` : '') +
1382
1408
  (result.failed ? `, failed ${result.failed}` : '');
1383
- console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
1384
- // Per-file change list. Suppress renames here — printRenames below owns
1385
- // the rename block (it adds the import-update hints). Letting both fire
1386
- // would print each rename twice.
1409
+ const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
1410
+ console.log(result.failed || pushFailed
1411
+ ? kleur.yellow(`Sync finished with issues. ${summary}`)
1412
+ : kleur.green(`✓ Sync complete. ${summary}`));
1387
1413
  printChanges(result, ' ', { includeRenames: renamed.length === 0 });
1388
1414
  if (renamed.length > 0)
1389
1415
  printRenames(renamed, { withHints: true });
1390
1416
  }
1391
- onStatus?.('Checking your game files…');
1392
- const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
1393
1417
  if (result.failed)
1394
1418
  process.exitCode = 1;
1395
1419
  return { ...result, pushed };
1396
1420
  }
1397
- async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
1421
+ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
1398
1422
  if (pushEnabled === false)
1399
1423
  return null;
1400
1424
  try {
1401
- return await runPush({ quiet: !verbose, gameIndex, onStatus });
1425
+ return await runPush({ quiet: !verbose, nestedSync: true, gameIndex, onStatus, excludeKeys });
1402
1426
  }
1403
1427
  catch (e) {
1404
1428
  if (announceErrors) {
@@ -1474,11 +1498,13 @@ function humanTime(iso) {
1474
1498
  function printConflicts(keys, removedRemotely = new Set()) {
1475
1499
  console.log();
1476
1500
  console.log(kleur.yellow(`${keys.length} sprite${keys.length === 1 ? '' : 's'} changed in both places — not overwritten:`));
1477
- for (const key of keys) {
1501
+ for (const key of keys.slice(0, 10)) {
1478
1502
  const note = removedRemotely.has(key) ? kleur.dim(' (no longer synced from MagicPixel)') : '';
1479
1503
  console.log(` ${kleur.yellow('~')} ${key}${note}`);
1480
1504
  }
1481
- 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.`));
1505
+ if (keys.length > 10)
1506
+ console.log(kleur.dim(` …and ${keys.length - 10} more`));
1507
+ console.log(kleur.dim(` Use MagicPixel's copy: press Sync on that artboard. Keep the local edit: run \`${cmd('push --force')}\`.`));
1482
1508
  if (removedRemotely.size > 0) {
1483
1509
  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.`));
1484
1510
  }
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>')
@@ -14,10 +14,54 @@
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
+ }
33
+ /**
34
+ * Cheap "nothing to do" test that runs before we hash the local PNG and before
35
+ * any per-file request. Two ways to prove a row is unchanged:
36
+ *
37
+ * 1. The manifest still reports the exact cloud composite sha we recorded.
38
+ * 2. The manifest has no sha at all (legacy / uncached documents) but the
39
+ * row's `updated_at` has not moved since we last synced this key. A row
40
+ * cannot change — including an editor Sync release — without its
41
+ * `updated_at` advancing, so there is nothing to fetch.
42
+ *
43
+ * Case 2 is what stops a quiet project from spending one conditional download
44
+ * per file on every sync just to be told "304 Not Modified".
45
+ */
46
+ export function canSkipWithoutHashing(opts) {
47
+ if (opts.freshRelease || !opts.fileExists)
48
+ return false;
49
+ if (opts.cloudSha256 && opts.previousCloudSha256 === opts.cloudSha256)
50
+ return true;
51
+ if (opts.cloudSha256)
52
+ return false;
53
+ return Boolean(opts.cloudUpdatedAt &&
54
+ opts.previousCloudUpdatedAt &&
55
+ opts.cloudUpdatedAt === opts.previousCloudUpdatedAt);
56
+ }
17
57
  export function decidePull(opts) {
18
58
  const cloud = opts.cloudSha256 ?? null;
19
59
  const local = opts.localSha256;
20
60
  const prev = opts.previousCloudSha256;
61
+ // A fresh explicit release is the user's scoped instruction for the cloud
62
+ // copy to replace this PNG, even if the PNG also changed locally.
63
+ if (hasNewExplicitRelease(opts))
64
+ return 'pull';
21
65
  // Missing on disk → restore from cloud (MagicPixel-only art and deletions).
22
66
  if (!local)
23
67
  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
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-artboard Unity sync opt-in (strict), with a working-set override.
2
+ * Per-artboard game sync opt-in (strict).
3
3
  *
4
4
  * In the editor, each artboard has a "Sync to Unity" checkbox (and library
5
5
  * folders have a folder-level switch that cascades). A project may hold 100
@@ -12,11 +12,8 @@
12
12
  * `unknown` so the caller can warn instead of silently syncing the whole
13
13
  * library into someone's game project.
14
14
  *
15
- * Exception: sprites already in the game (connect working set / previously
16
- * synced keys) always pull. An editor save nulls `artboard_index` and the
17
- * next manifest often omits `unity` (or rebuilds it as false because the
18
- * layer JSON dropped `syncToUnity`). Those saves must still write back to
19
- * the original PNG.
15
+ * There is deliberately no working-set override: a local path or previous
16
+ * download must never turn an unmarked sibling into a pull candidate.
20
17
  */
21
18
  /**
22
19
  * Split the manifest into syncable entries and withheld ones. Withheld entries
@@ -69,24 +66,18 @@ export function isWorkingSetEntry(entry, pull) {
69
66
  }
70
67
  return false;
71
68
  }
69
+ /**
70
+ * Whether an explicitly unmarked manifest entry should leave the local game.
71
+ * A file previously pulled by MagicPixel is safe to reconcile even if it now
72
+ * matches a connect glob; a game-authored working-set file without a sync
73
+ * baseline remains protected.
74
+ */
75
+ export function shouldPruneDeselectedEntry(entry, opts) {
76
+ if (!entry.key)
77
+ return false;
78
+ return !opts.sourceByKey.has(entry.key) || opts.syncedKeys.has(entry.key);
79
+ }
72
80
  /**
73
81
  * Flagged artboards, plus any working-set sprite whose Unity flag is missing
74
82
  * or false after an editor save / index rebuild.
75
83
  */
76
- export function applyUnityPullPolicy(manifest, opts = {}) {
77
- const filtered = filterUnityManifest(manifest, { syncAll: opts.syncAll });
78
- if (opts.syncAll || !opts.alwaysPull)
79
- return filtered;
80
- const already = new Set(filtered.entries);
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));
84
- if (extra.length === 0)
85
- return filtered;
86
- const extraSet = new Set(extra);
87
- return {
88
- entries: filtered.entries.concat(extra),
89
- unknown: filtered.unknown.filter((e) => !extraSet.has(e)),
90
- noneFlagged: false,
91
- };
92
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.22",
3
+ "version": "0.5.26",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {