@magicpixelart/cli 0.5.24 → 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 +20 -0
- package/README.md +1 -1
- package/dist/commands/sync.js +39 -53
- package/dist/util/pullDecision.js +24 -0
- package/dist/util/unityFilter.js +14 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ 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
|
+
|
|
10
30
|
## [0.5.24] — 2026-09-21
|
|
11
31
|
|
|
12
32
|
- `push --force` keeps your local copy when a sprite changed in both places. It
|
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` |
|
|
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
|
|
package/dist/commands/sync.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
626
|
-
const filtered =
|
|
627
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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(`
|
|
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.
|
|
@@ -770,7 +747,14 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
770
747
|
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
771
748
|
lastSync: state.lastSync,
|
|
772
749
|
});
|
|
773
|
-
if (
|
|
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
|
+
})) {
|
|
774
758
|
shaByEntryId.set(entry.id, null);
|
|
775
759
|
unchanged++;
|
|
776
760
|
if (entry.size_bytes)
|
|
@@ -911,8 +895,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
911
895
|
};
|
|
912
896
|
for (const p of renameStalePaths)
|
|
913
897
|
addOrphan(p);
|
|
914
|
-
//
|
|
915
|
-
//
|
|
898
|
+
// Deselected artboards: their PNG (and Unity .meta) leaves the game project
|
|
899
|
+
// on the next sync, incremental or not. Working-set originals
|
|
916
900
|
// are never in this list.
|
|
917
901
|
for (const p of deselectedPaths)
|
|
918
902
|
addOrphan(p);
|
|
@@ -953,7 +937,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
953
937
|
}
|
|
954
938
|
}
|
|
955
939
|
// A locally-edited file is never deleted, whatever put it in the prune set
|
|
956
|
-
// (deselected
|
|
940
|
+
// (deselected game sync, trashed document, rename fallback,
|
|
957
941
|
// previous-key sweep): that edit is the only copy of the work until the user
|
|
958
942
|
// pushes it or discards it. Manifest-resident conflicts were already detected
|
|
959
943
|
// during the diff, so they skip the re-hash.
|
|
@@ -1317,6 +1301,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1317
1301
|
sha256: sha,
|
|
1318
1302
|
diskSha256,
|
|
1319
1303
|
legacy: true,
|
|
1304
|
+
...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
|
|
1320
1305
|
...(sourceRel ? { sourceRel } : {}),
|
|
1321
1306
|
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1322
1307
|
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
@@ -1329,6 +1314,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1329
1314
|
sha256: sha,
|
|
1330
1315
|
diskSha256,
|
|
1331
1316
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
1317
|
+
...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
|
|
1332
1318
|
...(sourceRel ? { sourceRel } : {}),
|
|
1333
1319
|
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1334
1320
|
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
@@ -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;
|
package/dist/util/unityFilter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-artboard
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
}
|