@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 +38 -0
- package/README.md +1 -1
- package/dist/commands/push.js +106 -7
- package/dist/commands/sync.js +92 -66
- package/dist/index.js +2 -1
- package/dist/util/pullDecision.js +44 -0
- package/dist/util/pushPlan.js +4 -1
- package/dist/util/unityFilter.js +14 -23
- package/package.json +1 -1
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` |
|
|
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/push.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
282
|
+
if (printedIssues++ < 10)
|
|
283
|
+
console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
|
|
247
284
|
}
|
|
248
285
|
else if (r.status === 'error') {
|
|
249
|
-
|
|
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
|
|
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 —
|
|
410
|
+
return `conflict — review both copies, then press the artboard's Sync button to use MagicPixel's copy.`;
|
|
312
411
|
}
|
|
313
412
|
}
|
|
314
413
|
/**
|
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 } 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.
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
901
|
-
//
|
|
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
|
|
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
|
-
|
|
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] = {
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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';
|
package/dist/util/pushPlan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
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
|
-
}
|