@magicpixelart/cli 0.5.22 → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/commands/push.js +106 -7
- package/dist/commands/sync.js +55 -15
- package/dist/index.js +2 -1
- package/dist/util/pullDecision.js +20 -0
- package/dist/util/pushPlan.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.24] — 2026-09-21
|
|
11
|
+
|
|
12
|
+
- `push --force` keeps your local copy when a sprite changed in both places. It
|
|
13
|
+
re-sends against the live cloud state rather than skipping the safety check,
|
|
14
|
+
so a save that lands in between is still reported instead of overwritten.
|
|
15
|
+
- A both-sides-changed refusal now carries the live cloud fingerprint, so a
|
|
16
|
+
follow-up `push` works even for large documents whose hash wasn't cached —
|
|
17
|
+
the last case where the sync/push advice could still dead-end.
|
|
18
|
+
- Conflict messages name both ways out: press Sync on the artboard, or
|
|
19
|
+
`push --force`.
|
|
20
|
+
|
|
21
|
+
## [0.5.23] — 2026-09-21
|
|
22
|
+
|
|
23
|
+
- Pressing Sync on an artboard now explicitly chooses MagicPixel's copy when
|
|
24
|
+
that PNG was also edited locally, resolving the previous sync/push loop.
|
|
25
|
+
- Conflict lists are capped, nested push warnings no longer repeat during
|
|
26
|
+
sync, and completion summaries include locally unchanged sprites accurately.
|
|
27
|
+
|
|
10
28
|
## [0.5.22] — 2026-09-14
|
|
11
29
|
|
|
12
30
|
- Raised the daily sprite download allowance from 1 GiB to 10 GiB for large
|
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
|
@@ -24,7 +24,7 @@ import { formatBytes } from '../util/format.js';
|
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
26
|
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
|
-
import { decidePull } from '../util/pullDecision.js';
|
|
27
|
+
import { decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.js';
|
|
@@ -727,6 +727,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
727
727
|
const shaByEntryId = new Map();
|
|
728
728
|
const toDownload = [];
|
|
729
729
|
const conflicts = [];
|
|
730
|
+
const failedDownloadKeys = new Set();
|
|
730
731
|
let bytesSaved = 0;
|
|
731
732
|
let unchanged = 0;
|
|
732
733
|
const synced = state.synced ?? {};
|
|
@@ -761,7 +762,15 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
761
762
|
const inWorkingSet = isWorkingSetEntry(entry, workingSet);
|
|
762
763
|
// Unchanged cloud composite: skip hashing the original PNG (it almost
|
|
763
764
|
// never matches) unless the file is gone and we need to restore it.
|
|
764
|
-
|
|
765
|
+
const previousReleasedAt = synced[entry.key]?.releasedAt;
|
|
766
|
+
const freshRelease = hasNewExplicitRelease({
|
|
767
|
+
releasedAt: entry.released_at,
|
|
768
|
+
previousReleasedAt,
|
|
769
|
+
releasedVersion: entry.released_version,
|
|
770
|
+
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
771
|
+
lastSync: state.lastSync,
|
|
772
|
+
});
|
|
773
|
+
if (!freshRelease && entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
|
|
765
774
|
shaByEntryId.set(entry.id, null);
|
|
766
775
|
unchanged++;
|
|
767
776
|
if (entry.size_bytes)
|
|
@@ -776,6 +785,11 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
776
785
|
previousCloudSha256,
|
|
777
786
|
lastPushedDiskSha256: synced[entry.key]?.diskSha256,
|
|
778
787
|
inWorkingSet,
|
|
788
|
+
releasedAt: entry.released_at,
|
|
789
|
+
previousReleasedAt,
|
|
790
|
+
releasedVersion: entry.released_version,
|
|
791
|
+
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
792
|
+
lastSync: state.lastSync,
|
|
779
793
|
});
|
|
780
794
|
if (decision === 'conflict') {
|
|
781
795
|
conflicts.push(entry.key);
|
|
@@ -1130,6 +1144,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1130
1144
|
}
|
|
1131
1145
|
catch (e) {
|
|
1132
1146
|
result.failed++;
|
|
1147
|
+
failedDownloadKeys.add(entry.key);
|
|
1133
1148
|
progress?.stop();
|
|
1134
1149
|
// Multi-line messages come from friendlyFsError — print all lines
|
|
1135
1150
|
// so the user sees the fix hint. Single-line errors stay terse.
|
|
@@ -1270,9 +1285,21 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1270
1285
|
// Conflicted keys keep their old baselines: recording the new cloud sha
|
|
1271
1286
|
// (with the locally-edited disk sha) would make both halves believe they
|
|
1272
1287
|
// are in sync and freeze the divergence forever.
|
|
1273
|
-
if (conflictSet.has(entry.key))
|
|
1288
|
+
if (conflictSet.has(entry.key)) {
|
|
1289
|
+
const previous = nextSynced[entry.key];
|
|
1290
|
+
if (previous && entry.sha256) {
|
|
1291
|
+
nextSynced[entry.key] = { ...previous, pendingCloudSha256: entry.sha256 };
|
|
1292
|
+
}
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (failedDownloadKeys.has(entry.key))
|
|
1274
1296
|
continue;
|
|
1275
|
-
|
|
1297
|
+
// After a write, the actual downloaded bytes are the disk/cloud baseline.
|
|
1298
|
+
// The manifest hash may be null or represent a cached composite while the
|
|
1299
|
+
// download endpoint serves storage bytes for a single-artboard document.
|
|
1300
|
+
const sha = writtenKeys.has(entry.key)
|
|
1301
|
+
? await fileSha256(pathFor(entry))
|
|
1302
|
+
: (entry.sha256 ?? (await fileSha256(pathFor(entry))));
|
|
1276
1303
|
if (!sha)
|
|
1277
1304
|
continue;
|
|
1278
1305
|
const prev = nextSynced[entry.key];
|
|
@@ -1284,7 +1311,16 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1284
1311
|
// them anyway, flagged, so `push` skips them with a hint instead of
|
|
1285
1312
|
// adopting the same art as a second document.
|
|
1286
1313
|
if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
|
|
1287
|
-
nextSynced[entry.key] = {
|
|
1314
|
+
nextSynced[entry.key] = {
|
|
1315
|
+
assetId: entry.asset_id,
|
|
1316
|
+
layerIdx: 0,
|
|
1317
|
+
sha256: sha,
|
|
1318
|
+
diskSha256,
|
|
1319
|
+
legacy: true,
|
|
1320
|
+
...(sourceRel ? { sourceRel } : {}),
|
|
1321
|
+
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1322
|
+
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
1323
|
+
};
|
|
1288
1324
|
continue;
|
|
1289
1325
|
}
|
|
1290
1326
|
nextSynced[entry.key] = {
|
|
@@ -1294,6 +1330,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1294
1330
|
diskSha256,
|
|
1295
1331
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
1296
1332
|
...(sourceRel ? { sourceRel } : {}),
|
|
1333
|
+
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1334
|
+
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
1297
1335
|
};
|
|
1298
1336
|
}
|
|
1299
1337
|
const nextState = {
|
|
@@ -1372,6 +1410,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1372
1410
|
if (result.failed > 0 && verbose) {
|
|
1373
1411
|
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
1374
1412
|
}
|
|
1413
|
+
onStatus?.('Checking your game files…');
|
|
1414
|
+
const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1375
1415
|
if (verbose) {
|
|
1376
1416
|
console.log();
|
|
1377
1417
|
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
|
@@ -1380,25 +1420,23 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1380
1420
|
(shouldPrune ? `, pruned ${result.removed.length}` : '') +
|
|
1381
1421
|
(renamed.length ? `, renamed ${renamed.length}` : '') +
|
|
1382
1422
|
(result.failed ? `, failed ${result.failed}` : '');
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1423
|
+
const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
|
|
1424
|
+
console.log(result.failed || pushFailed
|
|
1425
|
+
? kleur.yellow(`Sync finished with issues. ${summary}`)
|
|
1426
|
+
: kleur.green(`✓ Sync complete. ${summary}`));
|
|
1387
1427
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1388
1428
|
if (renamed.length > 0)
|
|
1389
1429
|
printRenames(renamed, { withHints: true });
|
|
1390
1430
|
}
|
|
1391
|
-
onStatus?.('Checking your game files…');
|
|
1392
|
-
const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
1393
1431
|
if (result.failed)
|
|
1394
1432
|
process.exitCode = 1;
|
|
1395
1433
|
return { ...result, pushed };
|
|
1396
1434
|
}
|
|
1397
|
-
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
|
|
1435
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1398
1436
|
if (pushEnabled === false)
|
|
1399
1437
|
return null;
|
|
1400
1438
|
try {
|
|
1401
|
-
return await runPush({ quiet: !verbose, gameIndex, onStatus });
|
|
1439
|
+
return await runPush({ quiet: !verbose, nestedSync: true, gameIndex, onStatus, excludeKeys });
|
|
1402
1440
|
}
|
|
1403
1441
|
catch (e) {
|
|
1404
1442
|
if (announceErrors) {
|
|
@@ -1474,11 +1512,13 @@ function humanTime(iso) {
|
|
|
1474
1512
|
function printConflicts(keys, removedRemotely = new Set()) {
|
|
1475
1513
|
console.log();
|
|
1476
1514
|
console.log(kleur.yellow(`${keys.length} sprite${keys.length === 1 ? '' : 's'} changed in both places — not overwritten:`));
|
|
1477
|
-
for (const key of keys) {
|
|
1515
|
+
for (const key of keys.slice(0, 10)) {
|
|
1478
1516
|
const note = removedRemotely.has(key) ? kleur.dim(' (no longer synced from MagicPixel)') : '';
|
|
1479
1517
|
console.log(` ${kleur.yellow('~')} ${key}${note}`);
|
|
1480
1518
|
}
|
|
1481
|
-
|
|
1519
|
+
if (keys.length > 10)
|
|
1520
|
+
console.log(kleur.dim(` …and ${keys.length - 10} more`));
|
|
1521
|
+
console.log(kleur.dim(` Use MagicPixel's copy: press Sync on that artboard. Keep the local edit: run \`${cmd('push --force')}\`.`));
|
|
1482
1522
|
if (removedRemotely.size > 0) {
|
|
1483
1523
|
console.log(kleur.dim(` Marked (no longer synced): re-check "Sync to Unity" (or restore the document) to push the edit up, or delete the PNG to accept the removal.`));
|
|
1484
1524
|
}
|
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,30 @@
|
|
|
14
14
|
* that file, the entry is reported instead of downloaded.
|
|
15
15
|
*/
|
|
16
16
|
import { isLocalEdit } from './localEdit.js';
|
|
17
|
+
/** A newer editor Sync press explicitly chooses MagicPixel for this sprite. */
|
|
18
|
+
export function hasNewExplicitRelease(opts) {
|
|
19
|
+
if (opts.releasedVersion != null && opts.previousReleasedVersion != null) {
|
|
20
|
+
return opts.releasedVersion > opts.previousReleasedVersion;
|
|
21
|
+
}
|
|
22
|
+
if (!opts.releasedAt)
|
|
23
|
+
return false;
|
|
24
|
+
const released = Date.parse(opts.releasedAt);
|
|
25
|
+
if (!Number.isFinite(released))
|
|
26
|
+
return false;
|
|
27
|
+
const baseline = opts.previousReleasedAt ?? opts.lastSync;
|
|
28
|
+
if (!baseline)
|
|
29
|
+
return false;
|
|
30
|
+
const previous = Date.parse(baseline);
|
|
31
|
+
return Number.isFinite(previous) && released > previous;
|
|
32
|
+
}
|
|
17
33
|
export function decidePull(opts) {
|
|
18
34
|
const cloud = opts.cloudSha256 ?? null;
|
|
19
35
|
const local = opts.localSha256;
|
|
20
36
|
const prev = opts.previousCloudSha256;
|
|
37
|
+
// A fresh explicit release is the user's scoped instruction for the cloud
|
|
38
|
+
// copy to replace this PNG, even if the PNG also changed locally.
|
|
39
|
+
if (hasNewExplicitRelease(opts))
|
|
40
|
+
return 'pull';
|
|
21
41
|
// Missing on disk → restore from cloud (MagicPixel-only art and deletions).
|
|
22
42
|
if (!local)
|
|
23
43
|
return 'pull';
|
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
|
}
|