@magicpixelart/cli 0.5.24 → 0.5.27

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,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.27] — 2026-09-21
11
+
12
+ - Fixed Sync presses that changed only an artboard selection being skipped as
13
+ unchanged, which left the server without the selected-artboard metadata.
14
+ - Removed connected-folder and `unitySyncAll` selection fallbacks. Game sync
15
+ now downloads only artboards explicitly selected with Sync to Game.
16
+ - Previously downloaded, now-unselected variants remain eligible for safe,
17
+ tracked-only pruning during reconciliation.
18
+
19
+ ## [0.5.26] — 2026-09-21
20
+
21
+ - Clicking Sync now selects exactly that artboard and clears sibling selections,
22
+ so generated variants and pasted items in the same document stay out of the
23
+ game project.
24
+ - Unity, Godot, and GameMaker now strictly require an explicit per-artboard
25
+ sync flag. Existing working-set paths can no longer bypass that choice.
26
+ - Previously downloaded, now-unmarked siblings are removed on the next sync;
27
+ locally edited files remain protected as conflicts.
28
+
29
+ ## [0.5.25] — 2026-09-21
30
+
31
+ - A sync no longer spends one request per file just to be told nothing changed:
32
+ files whose MagicPixel document has not been touched since the last sync are
33
+ skipped outright, instead of being re-checked one at a time.
34
+ - "Nothing changed" replies no longer count against the daily request
35
+ allowance, so a watcher can idle all day without exhausting it.
36
+ - A watcher that has been quiet for 15 minutes now polls every 30 seconds
37
+ (was 10), and snaps straight back to the fast interval on the first change.
38
+
10
39
  ## [0.5.24] — 2026-09-21
11
40
 
12
41
  - `push --force` keeps your local copy when a sprite changed in both places. It
package/README.md CHANGED
@@ -150,7 +150,6 @@ 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. |
154
153
  | `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
155
154
  | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
156
155
 
@@ -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, hasNewExplicitRelease } 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,9 +569,8 @@ 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
569
- // editor (parity with the in-app sync button). `unitySyncAll: true` in
570
- // magicpixel.json opts back into everything.
572
+ // Game-engine projects sync only artboards explicitly selected in the
573
+ // editor (parity with the in-app sync button).
571
574
  onStatus?.('Looking through your game sprites…');
572
575
  const projectKind = await detectProjectKind();
573
576
  const gameIndex = runOpts.gameIndex
@@ -613,23 +616,21 @@ async function runOnce(opts, runOpts = {}) {
613
616
  }
614
617
  }
615
618
  const workingSet = workingSetPullKeys(sourceByKey, state.synced);
616
- // Files whose artboard is no longer flagged for Unity. Deleted even in
619
+ // Files whose artboard is no longer selected. Deleted even in
617
620
  // incremental mode: un-checking the box in the editor must actually remove
618
621
  // the sprite from the game project, not leave a stale copy behind.
619
622
  // Working-set (sourceRel) files are never deleted — they're the user's art.
620
623
  const deselectedPaths = [];
621
- // Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
624
+ // Sprites whose game-sync flag the server omitted. Not downloaded
622
625
  // (strict opt-in) but explicitly shielded from pruning: a transient server
623
626
  // response must never delete art from someone's game project.
624
627
  const unknownFlagPaths = new Set();
625
- if (projectKind === 'Unity') {
626
- const filtered = applyUnityPullPolicy(manifest, {
627
- syncAll: config.unitySyncAll,
628
- alwaysPull: (entry) => isWorkingSetEntry(entry, workingSet),
629
- });
628
+ if (isEngineKind(projectKind)) {
629
+ const filtered = filterUnityManifest(manifest);
630
+ const syncedKeys = new Set(Object.keys(state.synced ?? {}));
630
631
  if (filtered.unknown.length > 0) {
631
632
  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).`;
633
+ const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
633
634
  if (verbose) {
634
635
  console.log(kleur.yellow(`! ${msg}`));
635
636
  console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
@@ -648,49 +649,24 @@ async function runOnce(opts, runOpts = {}) {
648
649
  unknownFlagPaths.add(p);
649
650
  continue;
650
651
  }
651
- if (sourceByKey.has(entry.key))
652
+ // A tracked download is ours to reconcile even when its path now falls
653
+ // under a connect glob. Genuine game-authored files have no synced
654
+ // baseline and remain protected.
655
+ if (!shouldPruneDeselectedEntry(entry, { sourceByKey, syncedKeys }))
652
656
  continue;
653
657
  if (existsSync(p))
654
658
  deselectedPaths.push(p);
655
659
  }
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;
660
+ if (filtered.noneFlagged && verbose) {
661
+ console.log();
662
+ console.log(kleur.dim(since
663
+ ? ' No changed artboards are selected for download.'
664
+ : ' No artboards are selected for download.'));
689
665
  }
690
666
  const skipped = manifest.length - filtered.entries.length;
691
667
  manifest = filtered.entries;
692
668
  if (verbose && skipped > 0) {
693
- console.log(kleur.dim(` unity → ${skipped} artboard${skipped === 1 ? '' : 's'} not flagged for sync (skipped)`));
669
+ console.log(kleur.dim(` game sync → ${skipped} artboard${skipped === 1 ? '' : 's'} not selected (skipped)`));
694
670
  }
695
671
  }
696
672
  // Detect renames: same id, different key vs prior snapshot.
@@ -770,7 +746,14 @@ async function runOnce(opts, runOpts = {}) {
770
746
  previousReleasedVersion: synced[entry.key]?.releasedVersion,
771
747
  lastSync: state.lastSync,
772
748
  });
773
- if (!freshRelease && entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
749
+ if (canSkipWithoutHashing({
750
+ freshRelease,
751
+ fileExists: existsSync(pathFor(entry)),
752
+ cloudSha256: entry.sha256,
753
+ previousCloudSha256,
754
+ cloudUpdatedAt: entry.updated_at,
755
+ previousCloudUpdatedAt: synced[entry.key]?.cloudUpdatedAt,
756
+ })) {
774
757
  shaByEntryId.set(entry.id, null);
775
758
  unchanged++;
776
759
  if (entry.size_bytes)
@@ -911,8 +894,8 @@ async function runOnce(opts, runOpts = {}) {
911
894
  };
912
895
  for (const p of renameStalePaths)
913
896
  addOrphan(p);
914
- // Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
915
- // Unity project on the next sync, incremental or not. Working-set originals
897
+ // Deselected artboards: their PNG (and Unity .meta) leaves the game project
898
+ // on the next sync, incremental or not. Working-set originals
916
899
  // are never in this list.
917
900
  for (const p of deselectedPaths)
918
901
  addOrphan(p);
@@ -953,7 +936,7 @@ async function runOnce(opts, runOpts = {}) {
953
936
  }
954
937
  }
955
938
  // A locally-edited file is never deleted, whatever put it in the prune set
956
- // (deselected "Sync to Unity", trashed document, rename fallback,
939
+ // (deselected game sync, trashed document, rename fallback,
957
940
  // previous-key sweep): that edit is the only copy of the work until the user
958
941
  // pushes it or discards it. Manifest-resident conflicts were already detected
959
942
  // during the diff, so they skip the re-hash.
@@ -1317,6 +1300,7 @@ async function runOnce(opts, runOpts = {}) {
1317
1300
  sha256: sha,
1318
1301
  diskSha256,
1319
1302
  legacy: true,
1303
+ ...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
1320
1304
  ...(sourceRel ? { sourceRel } : {}),
1321
1305
  ...(entry.released_at ? { releasedAt: entry.released_at } : {}),
1322
1306
  ...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
@@ -1329,6 +1313,7 @@ async function runOnce(opts, runOpts = {}) {
1329
1313
  sha256: sha,
1330
1314
  diskSha256,
1331
1315
  ...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
1316
+ ...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
1332
1317
  ...(sourceRel ? { sourceRel } : {}),
1333
1318
  ...(entry.released_at ? { releasedAt: entry.released_at } : {}),
1334
1319
  ...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
package/dist/config.js CHANGED
@@ -87,7 +87,6 @@ export async function loadConfig(cwd = process.cwd()) {
87
87
  endpoint,
88
88
  emitIndex,
89
89
  unityPpu,
90
- unitySyncAll: parsed.unitySyncAll === true ? true : undefined,
91
90
  push: parsed.push === false ? false : undefined,
92
91
  connect,
93
92
  };
@@ -30,6 +30,30 @@ export function hasNewExplicitRelease(opts) {
30
30
  const previous = Date.parse(baseline);
31
31
  return Number.isFinite(previous) && released > previous;
32
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
+ }
33
57
  export function decidePull(opts) {
34
58
  const cloud = opts.cloudSha256 ?? null;
35
59
  const local = opts.localSha256;
@@ -1,10 +1,9 @@
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
- * In the editor, each artboard has a "Sync to Unity" checkbox (and library
5
- * folders have a folder-level switch that cascades). A project may hold 100
6
- * drafts but only 3 artboards that belong in the Unity project, so the CLI
7
- * mirrors the in-app sync button and pulls ONLY the flagged ones.
4
+ * A project may hold 100 drafts but only 3 artboards that belong in the game,
5
+ * so the CLI mirrors the in-app Sync to Game action and pulls ONLY the
6
+ * explicitly flagged ones.
8
7
  *
9
8
  * Strict opt-in: `unity === true` syncs, anything else does not. A missing
10
9
  * flag means the server didn't say (older edge deploy, or a budget-starved
@@ -12,15 +11,12 @@
12
11
  * `unknown` so the caller can warn instead of silently syncing the whole
13
12
  * library into someone's game project.
14
13
  *
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.
14
+ * There is deliberately no working-set override: a local path or previous
15
+ * download must never turn an unmarked sibling into a pull candidate.
20
16
  */
21
17
  /**
22
18
  * Split the manifest into syncable entries and withheld ones. Withheld entries
23
- * are never pull candidates (not even under `unitySyncAll`) and carry no bytes;
19
+ * are never pull candidates and carry no bytes;
24
20
  * callers use them only to protect what's already on disk from pruning.
25
21
  */
26
22
  export function partitionWithheldEntries(manifest) {
@@ -30,10 +26,8 @@ export function partitionWithheldEntries(manifest) {
30
26
  (e.withheld === true ? withheld : entries).push(e);
31
27
  return { entries, withheld };
32
28
  }
33
- export function filterUnityManifest(manifest, opts = {}) {
29
+ export function filterUnityManifest(manifest) {
34
30
  const syncable = partitionWithheldEntries(manifest).entries;
35
- if (opts.syncAll)
36
- return { entries: syncable, noneFlagged: false, unknown: [] };
37
31
  const entries = syncable.filter((e) => e.unity === true);
38
32
  const unknown = syncable.filter((e) => typeof e.unity !== 'boolean');
39
33
  return {
@@ -69,24 +63,18 @@ export function isWorkingSetEntry(entry, pull) {
69
63
  }
70
64
  return false;
71
65
  }
66
+ /**
67
+ * Whether an explicitly unmarked manifest entry should leave the local game.
68
+ * A file previously pulled by MagicPixel is safe to reconcile even if it now
69
+ * matches a connect glob; a game-authored working-set file without a sync
70
+ * baseline remains protected.
71
+ */
72
+ export function shouldPruneDeselectedEntry(entry, opts) {
73
+ if (!entry.key)
74
+ return false;
75
+ return !opts.sourceByKey.has(entry.key) || opts.syncedKeys.has(entry.key);
76
+ }
72
77
  /**
73
78
  * Flagged artboards, plus any working-set sprite whose Unity flag is missing
74
79
  * or false after an editor save / index rebuild.
75
80
  */
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.24",
3
+ "version": "0.5.27",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {