@magicpixelart/cli 0.5.14 → 0.5.20

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,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 0.5.20
11
+
12
+ - `sync` no longer deletes a PNG that was edited in your project when the sprite
13
+ leaves the manifest — un-checking "Sync to Unity", trashing the document, or a
14
+ rename/previous-key sweep now keep the local edit and report it instead.
15
+
16
+ ## 0.5.19
17
+
18
+ - `sync` no longer overwrites a PNG that changed both on disk and in MagicPixel. Such sprites are reported as conflicts, kept on disk, never pruned, and re-reported until you `push` them or delete the local file.
19
+ - A full reconcile is promoted automatically every 15 minutes (and whenever the server reports it truncated its removal list), so sprites deleted permanently in MagicPixel stop lingering in the game project.
20
+
21
+ ## [0.5.18] — 2026-08-27
22
+
23
+ ### Fixed
24
+ - Trashed documents (and documents converted into component masters) no longer
25
+ linger in the game project. The manifest now excludes them, and incremental
26
+ syncs receive an explicit `removed_keys` list so previously pulled sprites are
27
+ pruned on the next run. Sprites authored in Unity are still never touched.
28
+ - Renaming a folder or toggling its "Sync to Unity" flag now invalidates the
29
+ manifest cache, so the CLI stops receiving a stale `304 Not Modified`.
30
+
31
+ ## [0.5.17] — 2026-08-27
32
+
33
+ ### Fixed
34
+ - Watch-mode idle backoff now measures wall-clock seconds since the last change
35
+ instead of `ticks x interval`. Once the watcher had stepped down to 5s/10s
36
+ ticks the old estimate undercounted elapsed time, so the 5-minute step landed
37
+ well past 5 minutes.
38
+ - Pushing a locally edited sprite counts as activity again: a run that only
39
+ pushes (an artist working entirely inside their engine) resets the hot window
40
+ to the fast interval instead of sliding to the 10s idle poll.
41
+
42
+ ### Changed
43
+ - Idle incremental ticks no longer walk the whole output directory. The listing
44
+ is only needed by the full-sync orphan sweep and the rename fallback, so it is
45
+ now computed lazily — an unchanged (304) tick does no filesystem scan.
46
+
47
+ ## [0.5.16] — 2026-08-27
48
+
49
+ ### Changed
50
+ - Manifest validators are persisted in `.magicpixel/state.json`, so a one-shot
51
+ `sync` (CI, cron, a manual run) gets the same idle `304 Not Modified` a
52
+ watcher already got on its second tick. Validators are keyed by endpoint plus
53
+ exact query, so a staging/self-hosted swap or an edited `connect`/`exclude`
54
+ glob can never be answered from a stale validator.
55
+
56
+ ## [0.5.15] — 2026-08-26
57
+
58
+ ### Changed
59
+ - `sync --watch` sends conditional manifest requests (`If-None-Match`), so an
60
+ idle tick costs a `304` with no body instead of a full manifest render.
61
+ Validators are only used on incremental fetches — a full fetch, which prune
62
+ compares against, always reads the body.
63
+ - Idle backoff is now a hot window: the configured interval applies for ~1 min
64
+ after the last change, then steps to 5s, then to 10s after ~5 min idle
65
+ (previously ~3 min / ~15 min).
66
+
67
+ ### Fixed
68
+ - `safeFetch` no longer treated `304` as a malformed redirect. This made every
69
+ conditional asset download retry five times and then fail as a network error,
70
+ so unchanged PNGs were re-downloaded in full.
71
+
72
+
10
73
  ## [0.5.14] — 2026-08-26
11
74
 
12
75
  ### Fixed
package/dist/api.js CHANGED
@@ -2,6 +2,52 @@ import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
2
2
  import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
3
3
  import { authHeaders } from './util/authHeaders.js';
4
4
  import { cmd } from './util/invoke.js';
5
+ /**
6
+ * Manifest validators, so an idle poll costs a conditional GET (304, no body)
7
+ * instead of a full manifest render. Keyed by endpoint + exact query string: a
8
+ * different `since`/glob set — or a different server — is a different
9
+ * projection and must not reuse a validator.
10
+ *
11
+ * Bounded so a long-lived watcher walking many `since` values can't grow it
12
+ * without limit; eviction is FIFO and only costs one non-conditional GET.
13
+ *
14
+ * `sync` persists this map into state.json (see `manifestEtagsSnapshot` /
15
+ * `primeManifestEtags`) so one-shot runs — CI, cron, a manual `sync` — get the
16
+ * same 304 as a watcher's second tick.
17
+ */
18
+ const manifestEtags = new Map();
19
+ const MAX_MANIFEST_ETAGS = 32;
20
+ function rememberManifestEtag(key, etag) {
21
+ manifestEtags.delete(key);
22
+ manifestEtags.set(key, etag);
23
+ while (manifestEtags.size > MAX_MANIFEST_ETAGS) {
24
+ const oldest = manifestEtags.keys().next().value;
25
+ if (oldest === undefined)
26
+ break;
27
+ manifestEtags.delete(oldest);
28
+ }
29
+ }
30
+ /**
31
+ * Seed validators persisted by an earlier run. Ignores non-string junk so a
32
+ * hand-edited state.json can't inject a bogus header value; a wrong-but-typed
33
+ * validator is harmless (the server just answers 200 with a body).
34
+ */
35
+ export function primeManifestEtags(entries) {
36
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
37
+ return;
38
+ for (const [key, etag] of Object.entries(entries)) {
39
+ if (typeof etag === 'string' && etag)
40
+ rememberManifestEtag(key, etag);
41
+ }
42
+ }
43
+ /** Snapshot of the current validators, for persisting into state.json. */
44
+ export function manifestEtagsSnapshot() {
45
+ return Object.fromEntries(manifestEtags);
46
+ }
47
+ /** Test seam: drop cached manifest validators between cases. */
48
+ export function __resetManifestEtagsForTesting() {
49
+ manifestEtags.clear();
50
+ }
5
51
  /**
6
52
  * Last-seen project metadata from any manifest response this process made.
7
53
  * Populated as a side-effect of `fetchManifestPage`; read by `whoami`,
@@ -84,17 +130,32 @@ export async function fetchManifestPage(opts) {
84
130
  url.searchParams.set('cursor', opts.cursor);
85
131
  if (opts.limit)
86
132
  url.searchParams.set('limit', String(opts.limit));
133
+ // Conditional GET: an unchanged scope comes back as a 304 with no body.
134
+ // Only for un-paginated requests — the server doesn't validate cursor pages.
135
+ // Key includes the origin+path so a persisted validator can't be replayed
136
+ // against a different endpoint (self-hosted / staging swap).
137
+ const etagKey = opts.conditional && !opts.cursor ? `${url.origin}${url.pathname}${url.search}` : null;
138
+ const knownEtag = etagKey ? manifestEtags.get(etagKey) : undefined;
87
139
  // Reuse the same transient-failure policy as asset downloads, with extra
88
140
  // 546 attempts so first-run can ride out a worker recycle without stalling
89
141
  // every PNG download for ~30s.
90
142
  return retryTransient(`manifest`, async () => {
91
- const { headers, requestId } = buildHeaders();
143
+ const { headers, requestId } = buildHeaders(knownEtag ? { 'If-None-Match': knownEtag } : undefined);
92
144
  const res = await safeFetch(url.href, { headers });
93
145
  const serverRequestId = res.headers.get('x-request-id') ?? requestId;
146
+ if (res.status === 304) {
147
+ const minCli = res.headers.get('x-magicpixel-min-cli-version');
148
+ if (minCli)
149
+ maybeWarnStaleCli(minCli);
150
+ return { items: [], nextCursor: null, count: 0 };
151
+ }
94
152
  if (res.status >= 200 && res.status < 300) {
95
153
  const minCli = res.headers.get('x-magicpixel-min-cli-version');
96
154
  if (minCli)
97
155
  maybeWarnStaleCli(minCli);
156
+ const etag = res.headers.get('etag');
157
+ if (etagKey && etag)
158
+ rememberManifestEtag(etagKey, etag);
98
159
  const data = (await res.json());
99
160
  // Shape-guard the response. A malformed edge response (null,
100
161
  // {items: null}, nextCursor: 42, etc.) would otherwise either crash
@@ -214,18 +275,33 @@ function compareSemver(a, b) {
214
275
  }
215
276
  return 0;
216
277
  }
217
- export async function fetchAllManifest(config, since) {
278
+ /** Full manifest walk, including the cloud's removal list. */
279
+ export async function fetchManifestSnapshot(config, since) {
218
280
  const out = [];
281
+ const removed = new Set();
282
+ let removalsTruncated = false;
219
283
  let cursor;
284
+ // Conditional GETs only on incremental fetches. An incremental 304 means
285
+ // "nothing changed since `since`", which is indistinguishable from the empty
286
+ // incremental page callers already handle. A full fetch (`since` unset) is
287
+ // the cloud truth that prune compares against, so it always reads the body.
288
+ const conditional = !!since;
220
289
  // Cycle guard: caps total pages so a buggy server cursor can't hang the CLI.
221
290
  // At 500 entries/page this is 100k assets — well past any realistic project.
222
291
  const MAX_PAGES = 200;
223
292
  for (let page = 0; page < MAX_PAGES; page++) {
224
- const res = await fetchManifestPage({ config, since, cursor, limit: 500 });
293
+ const res = await fetchManifestPage({ config, since, cursor, limit: 500, conditional });
225
294
  out.push(...res.items);
295
+ if (Array.isArray(res.removed_keys)) {
296
+ for (const k of res.removed_keys)
297
+ if (typeof k === 'string' && k)
298
+ removed.add(k);
299
+ }
300
+ if (res.removals_truncated === true)
301
+ removalsTruncated = true;
226
302
  const nextCursor = res.nextCursor ?? undefined;
227
303
  if (!nextCursor)
228
- return out;
304
+ return { entries: out, removedKeys: [...removed], removalsTruncated };
229
305
  // Detect a stuck cursor (server returns the same token it just received)
230
306
  // in O(1) round-trips instead of burning the full page budget.
231
307
  if (nextCursor === cursor) {
@@ -237,6 +313,10 @@ export async function fetchAllManifest(config, since) {
237
313
  throw new Error(`manifest: pagination exceeded ${MAX_PAGES} pages — possible server cursor loop.\n` +
238
314
  ` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
239
315
  }
316
+ /** Entries only — for read-only commands (`list`, `status`). */
317
+ export async function fetchAllManifest(config, since) {
318
+ return (await fetchManifestSnapshot(config, since)).entries;
319
+ }
240
320
  /**
241
321
  * Download a single asset by key. Returns null on 304 (not modified).
242
322
  * Shares the retryTransient policy with manifest fetches — including
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
5
5
  import { dirname, relative, resolve } from 'node:path';
6
6
  import { loadConfig, loadState, saveState } from '../config.js';
7
7
  import { ensureEngineConnect } from '../util/engineConnect.js';
8
- import { fetchAllManifest, fetchAssetBytes, ApiError, getLastProjectInfo } from '../api.js';
8
+ import { fetchManifestSnapshot, fetchAssetBytes, ApiError, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
9
9
  import { fileSha256 } from '../util/hash.js';
10
10
  import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
11
11
  import { createLimit } from '../util/limit.js';
@@ -24,8 +24,28 @@ 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 { formatWatchSpriteLine } from '../util/watchCopy.js';
27
- import { shouldPullEntry } from '../util/pullDecision.js';
27
+ import { decidePull } from '../util/pullDecision.js';
28
+ import { hasUnpushedLocalEdit } from '../util/localEdit.js';
29
+ import { shouldReconcile } from '../util/reconcile.js';
28
30
  import { runPush } from './push.js';
31
+ /** Did the push half of a run actually move bytes into MagicPixel? */
32
+ export function pushWasActive(pushed) {
33
+ if (!pushed)
34
+ return false;
35
+ return pushed.created > 0 || pushed.updated > 0 || pushed.imported > 0;
36
+ }
37
+ /**
38
+ * Two consumers need the local PNG listing: the full-sync orphan sweep (no
39
+ * `since`) and the sha-based rename fallback (incremental, and only when
40
+ * something is download-bound). On an idle incremental tick — the common case
41
+ * now that unchanged manifests come back as a bodyless 304 — neither runs, so
42
+ * walking the whole output tree would be pure waste.
43
+ *
44
+ * Full syncs must ALWAYS walk: skipping there would silently disable pruning.
45
+ */
46
+ export function needsLocalPngWalk(since, toDownloadCount) {
47
+ return !since || toDownloadCount > 0;
48
+ }
29
49
  export async function syncCommand(opts) {
30
50
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
31
51
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
@@ -101,17 +121,19 @@ async function watchLoop(opts) {
101
121
  // genuinely broken (revoked key) rather than transiently blipped.
102
122
  const MAX_AUTH_FAILURES = 5;
103
123
  let consecutiveAuthFailures = 0;
104
- // Adaptive idle backoff: after a few minutes of nothing-to-do we slow the
105
- // poll from intervalSec → 5s → 10s so a dev who walked away isn't hammering
106
- // the manifest endpoint. ANY change OR error resets this back to intervalSec,
107
- // so the "edit → see it" promise stays intact the moment the user comes
108
- // back. Error backoff (2→60s) is separate and continues to win.
109
- let consecutiveIdleTicks = 0;
110
- // Thresholds in seconds (NOT ticks) so a `--watch 10` user gets the same
111
- // ~3 min / ~15 min UX as a default `--watch 2` user. Previously these were
112
- // tick counts (90 / 300) which made the slowdown timing scale with intervalSec.
113
- const IDLE_SOFT_BACKOFF_SECONDS = 180;
114
- const IDLE_HARD_BACKOFF_SECONDS = 900;
124
+ // Adaptive idle backoff: the fast interval is a "hot window" around actual
125
+ // work, not a steady state. After ~1 min of nothing-to-do we slow the poll
126
+ // from intervalSec → 5s, and after ~5 min → 10s, so a dev who walked away
127
+ // isn't polling the manifest hundreds of times an hour. ANY change (pulled
128
+ // OR pushed) or error resets this, so the "edit → see it" promise stays
129
+ // intact the moment the user comes back. Error backoff (2→60s) is separate
130
+ // and continues to win.
131
+ //
132
+ // Idle is measured in wall-clock seconds since the last change, not in
133
+ // ticks: once we've backed off, a "tick" is 5s or 10s long, so counting
134
+ // ticks × intervalSec would undercount elapsed time and push the 5-minute
135
+ // step far past 5 minutes.
136
+ let lastChangeAt = Date.now();
115
137
  // Re-walk the game tree on this cadence so new PNGs are ingested without
116
138
  // listing thousands of folders on every 2s tick.
117
139
  const GAME_INDEX_TTL_MS = 30_000;
@@ -188,17 +210,16 @@ async function watchLoop(opts) {
188
210
  console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
189
211
  }
190
212
  const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
191
- if (changedCount > 0) {
213
+ // Pushing a locally edited sprite is activity too — it just isn't part of
214
+ // the printed pull list. Without it, an artist working only in Unity
215
+ // would slide into the 10s idle poll while actively saving files.
216
+ if (changedCount > 0 || pushWasActive(r.pushed)) {
192
217
  // Snap back to the fast interval the moment anything changes.
193
- consecutiveIdleTicks = 0;
218
+ lastChangeAt = Date.now();
194
219
  backoffSec = intervalSec;
195
220
  }
196
221
  else {
197
- consecutiveIdleTicks++;
198
- backoffSec = nextBackoffForIdle(consecutiveIdleTicks, intervalSec, {
199
- softSec: IDLE_SOFT_BACKOFF_SECONDS,
200
- hardSec: IDLE_HARD_BACKOFF_SECONDS,
201
- });
222
+ backoffSec = nextBackoffForIdle((Date.now() - lastChangeAt) / 1000, intervalSec);
202
223
  }
203
224
  if (opts.quiet)
204
225
  return;
@@ -219,7 +240,7 @@ async function watchLoop(opts) {
219
240
  const firstLine = err.message.split('\n')[0];
220
241
  process.stdout.write('\x1b[2K\r');
221
242
  // Any error breaks the idle streak so we come back fast once it clears.
222
- consecutiveIdleTicks = 0;
243
+ lastChangeAt = Date.now();
223
244
  const decision = classifyTickError(err, {
224
245
  backoffSec,
225
246
  pausedForAuth,
@@ -311,13 +332,20 @@ function isNetworkError(err) {
311
332
  /\b(502|504)\b.*\b(bad gateway|gateway timeout)\b/i.test(msg));
312
333
  }
313
334
  /**
314
- * Pure helper: given how many consecutive idle ticks we've seen and the
315
- * configured poll interval, return the next backoff in seconds. Exported so
316
- * the watch-mode regression test can guard the "ticks vs seconds" bug fixed
317
- * in 0.4.0 without exercising the full watch loop.
335
+ * Idle thresholds (seconds of consecutive idleness) at which the watcher steps
336
+ * down from the configured interval to 5s, then to 10s. Single source of truth
337
+ * for both the watch loop and its regression tests.
318
338
  */
319
- export function nextBackoffForIdle(consecutiveIdleTicks, intervalSec, thresholds = { softSec: 180, hardSec: 900 }) {
320
- const idleSeconds = consecutiveIdleTicks * intervalSec;
339
+ export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
340
+ /**
341
+ * Pure helper: given how long we've been idle (wall-clock seconds since the
342
+ * last pulled or pushed change) and the configured poll interval, return the
343
+ * next backoff in seconds. Exported so the watch-mode regression test can guard
344
+ * the "ticks vs seconds" bug fixed in 0.4.0 without exercising the full watch
345
+ * loop. Callers pass elapsed time, not tick counts: once the watcher has backed
346
+ * off, ticks are longer than `intervalSec`.
347
+ */
348
+ export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_BACKOFF_THRESHOLDS) {
321
349
  if (idleSeconds >= thresholds.hardSec)
322
350
  return Math.max(intervalSec, 10);
323
351
  if (idleSeconds >= thresholds.softSec)
@@ -378,6 +406,11 @@ async function loadWatchHeader(onProgress) {
378
406
  async function runOnce(opts, runOpts = {}) {
379
407
  const config = await loadConfig();
380
408
  const state = await loadState();
409
+ // Seed manifest validators persisted by the previous run so a one-shot sync
410
+ // can answer with a 304 on its very first poll (watchers already keep them
411
+ // in memory). A `--full` run intentionally sends no `since`, which is a
412
+ // different validator key, so it can't be short-circuited by a stale entry.
413
+ primeManifestEtags(state.manifestEtags);
381
414
  const startedAt = new Date().toISOString();
382
415
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
383
416
  const verbose = !opts.quiet && !runOpts.watchMode;
@@ -387,7 +420,13 @@ async function runOnce(opts, runOpts = {}) {
387
420
  if (verbose && config.endpoint) {
388
421
  console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
389
422
  }
390
- const since = opts.full ? undefined : state.lastSync;
423
+ // Incremental unless asked for a full run — or unless a full pass is due:
424
+ // permanently-deleted rows can't be reported incrementally (see reconcile.ts).
425
+ const reconcileDue = !opts.full && !!state.lastSync && shouldReconcile(state.lastReconcile, Date.now());
426
+ let since = opts.full || reconcileDue ? undefined : state.lastSync;
427
+ if (reconcileDue && verbose) {
428
+ console.log(kleur.dim('Running a full reconcile (periodic) to clear anything deleted in MagicPixel.'));
429
+ }
391
430
  const previousAssets = state.assets ?? {}; // id → key from prior sync
392
431
  const spinner = verbose
393
432
  ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
@@ -395,11 +434,26 @@ async function runOnce(opts, runOpts = {}) {
395
434
  if (!spinner)
396
435
  onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
397
436
  let manifest;
437
+ // Composite keys the cloud reports as gone (document trashed / componentized)
438
+ // since our cursor. Empty on --full, where the manifest itself is the truth.
439
+ let removedRemoteKeys = [];
398
440
  // Capture before the Unity filter drops rows — lastSync must not jump past
399
441
  // an editor save whose `unity` flag was omitted on this tick.
400
442
  let observedUpdatedAt = null;
401
443
  try {
402
- manifest = await fetchAllManifest(config, since);
444
+ let snapshot = await fetchManifestSnapshot(config, since);
445
+ if (snapshot.removalsTruncated && since) {
446
+ // The server couldn't list every removal in this window. Trusting the
447
+ // partial list and advancing the cursor would strand the rest forever, so
448
+ // re-read the whole manifest and let the orphan sweep decide.
449
+ if (verbose) {
450
+ console.log(kleur.dim('Many sprites were removed at once — re-reading the full manifest to stay in sync.'));
451
+ }
452
+ since = undefined;
453
+ snapshot = await fetchManifestSnapshot(config, undefined);
454
+ }
455
+ manifest = snapshot.entries;
456
+ removedRemoteKeys = snapshot.removedKeys;
403
457
  observedUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
404
458
  const projectInfo = getLastProjectInfo();
405
459
  const projectSuffix = projectInfo && verbose
@@ -524,11 +578,19 @@ async function runOnce(opts, runOpts = {}) {
524
578
  bytesIn: 0,
525
579
  bytesSaved: 0,
526
580
  renamed: [],
581
+ conflicts: [],
527
582
  };
528
- if (observedUpdatedAt) {
529
- const lastSync = state.lastSync && state.lastSync > observedUpdatedAt ? state.lastSync : observedUpdatedAt;
530
- await saveState({ ...state, lastSync });
531
- }
583
+ // Persist validators even when the cursor didn't move, so the next
584
+ // one-shot run starts from a conditional GET.
585
+ const lastSync = observedUpdatedAt && !(state.lastSync && state.lastSync > observedUpdatedAt)
586
+ ? observedUpdatedAt
587
+ : state.lastSync;
588
+ await saveState({
589
+ ...state,
590
+ ...(lastSync ? { lastSync } : {}),
591
+ ...(since ? {} : { lastReconcile: startedAt }),
592
+ manifestEtags: manifestEtagsSnapshot(),
593
+ });
532
594
  await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
533
595
  return empty;
534
596
  }
@@ -571,6 +633,7 @@ async function runOnce(opts, runOpts = {}) {
571
633
  const diffLimit = createLimit(concurrency);
572
634
  const shaByEntryId = new Map();
573
635
  const toDownload = [];
636
+ const conflicts = [];
574
637
  let bytesSaved = 0;
575
638
  let unchanged = 0;
576
639
  const synced = state.synced ?? {};
@@ -614,13 +677,17 @@ async function runOnce(opts, runOpts = {}) {
614
677
  }
615
678
  const localSha = await fileSha256(pathFor(entry));
616
679
  shaByEntryId.set(entry.id, localSha);
617
- const pull = shouldPullEntry({
680
+ const decision = decidePull({
618
681
  cloudSha256: entry.sha256,
619
682
  localSha256: localSha,
620
683
  previousCloudSha256,
684
+ lastPushedDiskSha256: synced[entry.key]?.diskSha256,
621
685
  inWorkingSet,
622
686
  });
623
- if (!pull) {
687
+ if (decision === 'conflict') {
688
+ conflicts.push(entry.key);
689
+ }
690
+ else if (decision === 'skip') {
624
691
  unchanged++;
625
692
  if (entry.size_bytes)
626
693
  bytesSaved += entry.size_bytes;
@@ -633,30 +700,32 @@ async function runOnce(opts, runOpts = {}) {
633
700
  // (Renames also produce a stale path on disk — collected separately below.)
634
701
  let orphans = [];
635
702
  const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
636
- // We walk local PNGs even in incremental mode so the sha-based rename
637
- // fallback below has something to match against. The full-orphan path is
638
- // still gated on `!since` to preserve incremental-mode safety (we never
639
- // delete files outside what the current manifest delta touches).
640
- const localPngs = await walkOutDirPngs(config.outDir);
703
+ const remoteKeySet = new Set(manifest.map((e) => e.key));
704
+ const previousKeySet = new Set(Object.values(previousAssets));
705
+ const localPngs = needsLocalPngWalk(since, toDownload.length)
706
+ ? await walkOutDirPngs(config.outDir)
707
+ : [];
708
+ // Every key we have ever synced, indexed by the disk path it maps to. Used
709
+ // both for orphan tracking and to look up a prune candidate's edit baseline.
710
+ const keyByPath = new Map();
711
+ for (const key of Object.keys(state.synced ?? {}).concat(Object.values(previousAssets))) {
712
+ try {
713
+ const p = pathForKey(key);
714
+ if (!keyByPath.has(p))
715
+ keyByPath.set(p, key);
716
+ }
717
+ catch {
718
+ /* unusable key — nothing to track */
719
+ }
720
+ }
641
721
  // Sprites created in Unity (or any PNG we never pulled) are pending a
642
722
  // push into MagicPixel, not orphans — see prunePolicy.ts.
643
723
  if (!since) {
644
- const trackedPaths = new Set(Object.keys(state.synced ?? {})
645
- .concat(Object.values(previousAssets))
646
- .map((key) => {
647
- try {
648
- return pathForKey(key);
649
- }
650
- catch {
651
- return '';
652
- }
653
- })
654
- .filter(Boolean));
655
724
  const policy = selectFullSyncOrphans({
656
725
  localPaths: localPngs.map((a) => a.abs),
657
726
  remoteDiskPaths,
658
727
  protectedPaths: unknownFlagPaths,
659
- isTracked: (p) => trackedPaths.has(p),
728
+ isTracked: (p) => keyByPath.has(p),
660
729
  });
661
730
  orphans = policy.orphans;
662
731
  }
@@ -716,15 +785,20 @@ async function runOnce(opts, runOpts = {}) {
716
785
  }
717
786
  })
718
787
  .filter((p) => p && existsSync(p) && isPathInside(p, outRoot));
719
- // De-duplicate vs the full-sync orphan list.
788
+ // Every prune candidate goes through `addOrphan`, so no source can bypass the
789
+ // local-edit guard applied below. De-duplicates vs the full-sync orphan list.
720
790
  const orphanSet = new Set(orphans);
791
+ const addOrphan = (p) => {
792
+ if (existsSync(p) && isPathInside(p, outRoot))
793
+ orphanSet.add(p);
794
+ };
721
795
  for (const p of renameStalePaths)
722
- orphanSet.add(p);
796
+ addOrphan(p);
723
797
  // Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
724
798
  // Unity project on the next sync, incremental or not. Working-set originals
725
799
  // are never in this list.
726
800
  for (const p of deselectedPaths)
727
- orphanSet.add(p);
801
+ addOrphan(p);
728
802
  // Server-side rename history. Each manifest entry carries the composite
729
803
  // keys this row was previously emitted under (populated by the editor when
730
804
  // a doc/artboard is renamed). For each prior key whose file is still on
@@ -737,16 +811,58 @@ async function runOnce(opts, runOpts = {}) {
737
811
  resolveDiskPath: (key) => pathForKey(key),
738
812
  fileExists: existsSync,
739
813
  });
740
- for (const p of prevKeyResult.orphanPaths) {
741
- if (isPathInside(p, outRoot))
742
- orphanSet.add(p);
743
- }
814
+ for (const p of prevKeyResult.orphanPaths)
815
+ addOrphan(p);
744
816
  for (const r of prevKeyResult.renames) {
745
817
  if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
746
818
  renamed.push(r);
747
819
  }
748
820
  }
749
- orphans = [...orphanSet];
821
+ // Cloud-side removals (document trashed, or converted into a component
822
+ // master). These rows leave the manifest entirely, so an incremental run has
823
+ // no other way to learn about them — without this the PNG would live on in
824
+ // the game project forever. Only keys we pulled ourselves are eligible:
825
+ // a same-named sprite authored in Unity is pending push, not an orphan.
826
+ for (const key of removedRemoteKeys) {
827
+ if (remoteKeySet.has(key))
828
+ continue; // still live under another row
829
+ if (!synced[key] && !previousKeySet.has(key))
830
+ continue; // never ours
831
+ try {
832
+ addOrphan(pathForKey(key));
833
+ }
834
+ catch {
835
+ continue;
836
+ }
837
+ }
838
+ // A locally-edited file is never deleted, whatever put it in the prune set
839
+ // (deselected "Sync to Unity", trashed document, rename fallback,
840
+ // previous-key sweep): that edit is the only copy of the work until the user
841
+ // pushes it or discards it. Manifest-resident conflicts were already detected
842
+ // during the diff, so they skip the re-hash.
843
+ const conflictPaths = new Set();
844
+ for (const key of conflicts) {
845
+ try {
846
+ conflictPaths.add(pathForKey(key));
847
+ }
848
+ catch {
849
+ /* unusable key — nothing to protect */
850
+ }
851
+ }
852
+ orphans = [];
853
+ const keptFromPrune = [];
854
+ for (const p of orphanSet) {
855
+ if (conflictPaths.has(p))
856
+ continue;
857
+ const key = keyByPath.get(p);
858
+ if (key &&
859
+ (await hasUnpushedLocalEdit({ absPath: p, baselineDiskSha256: synced[key]?.diskSha256 }))) {
860
+ keptFromPrune.push(key);
861
+ continue;
862
+ }
863
+ orphans.push(p);
864
+ }
865
+ conflicts.push(...keptFromPrune);
750
866
  // Legacy-suffix folder sweep — always runs, even in incremental mode.
751
867
  //
752
868
  // Background: server-side slug uniqueness rules have changed over time
@@ -799,6 +915,12 @@ async function runOnce(opts, runOpts = {}) {
799
915
  }
800
916
  console.log();
801
917
  }
918
+ if (conflicts.length > 0) {
919
+ if (live) {
920
+ printConflicts(conflicts, new Set(keptFromPrune));
921
+ }
922
+ onStatus?.(`${conflicts.length} sprite${conflicts.length === 1 ? '' : 's'} changed in both places — kept your local copy.`);
923
+ }
802
924
  if (opts.dryRun) {
803
925
  if (verbose) {
804
926
  console.log(kleur.dim('--dry-run: no files written.'));
@@ -818,6 +940,7 @@ async function runOnce(opts, runOpts = {}) {
818
940
  failed: 0,
819
941
  bytesIn: 0,
820
942
  bytesSaved,
943
+ conflicts,
821
944
  };
822
945
  }
823
946
  const result = {
@@ -829,6 +952,7 @@ async function runOnce(opts, runOpts = {}) {
829
952
  failed: 0,
830
953
  bytesIn: 0,
831
954
  bytesSaved,
955
+ conflicts,
832
956
  };
833
957
  let progress = null;
834
958
  if (live && toDownload.length > 0) {
@@ -1030,9 +1154,15 @@ async function runOnce(opts, runOpts = {}) {
1030
1154
  for (const key of result.removed)
1031
1155
  delete nextSynced[key];
1032
1156
  const writtenKeys = new Set([...result.added, ...result.modified]);
1157
+ const conflictSet = new Set(conflicts);
1033
1158
  for (const entry of manifest) {
1034
1159
  if (!entry.asset_id)
1035
1160
  continue;
1161
+ // Conflicted keys keep their old baselines: recording the new cloud sha
1162
+ // (with the locally-edited disk sha) would make both halves believe they
1163
+ // are in sync and freeze the divergence forever.
1164
+ if (conflictSet.has(entry.key))
1165
+ continue;
1036
1166
  const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
1037
1167
  if (!sha)
1038
1168
  continue;
@@ -1057,8 +1187,17 @@ async function runOnce(opts, runOpts = {}) {
1057
1187
  ...(sourceRel ? { sourceRel } : {}),
1058
1188
  };
1059
1189
  }
1060
- const nextState = { ...state, assets: nextAssets, synced: nextSynced };
1061
- if (result.failed === 0) {
1190
+ const nextState = {
1191
+ ...state,
1192
+ assets: nextAssets,
1193
+ synced: nextSynced,
1194
+ manifestEtags: manifestEtagsSnapshot(),
1195
+ // A completed full pass is the only run that can prove nothing is orphaned.
1196
+ ...(!since && result.failed === 0 ? { lastReconcile: startedAt } : {}),
1197
+ };
1198
+ // Unresolved conflicts hold the cursor, exactly like a failed download: the
1199
+ // next sync must see the same rows again and re-report them.
1200
+ if (result.failed === 0 && conflicts.length === 0) {
1062
1201
  // Advance the cursor to the newest row we actually observed, NOT the
1063
1202
  // wall-clock time the sync started. Using startedAt silently skipped
1064
1203
  // rows whose `updated_at` lived in the gap between the manifest snapshot
@@ -1082,9 +1221,14 @@ async function runOnce(opts, runOpts = {}) {
1082
1221
  }
1083
1222
  delete nextState.lastError;
1084
1223
  }
1085
- else {
1224
+ else if (result.failed > 0) {
1086
1225
  nextState.lastError = `${result.failed} download${result.failed === 1 ? '' : 's'} failed at ${startedAt}`;
1087
1226
  }
1227
+ else {
1228
+ nextState.lastError =
1229
+ `${conflicts.length} sprite${conflicts.length === 1 ? '' : 's'} changed both locally and in MagicPixel at ${startedAt}` +
1230
+ ` (${conflicts.slice(0, 3).join(', ')}${conflicts.length > 3 ? ', …' : ''})`;
1231
+ }
1088
1232
  // Emit typed index from the filesystem — never from the manifest. This
1089
1233
  // guarantees the barrel can never disagree with what bundlers see on disk,
1090
1234
  // and removes the silent-failure mode where a flaky manifest call left
@@ -1136,10 +1280,10 @@ async function runOnce(opts, runOpts = {}) {
1136
1280
  printRenames(renamed, { withHints: true });
1137
1281
  }
1138
1282
  onStatus?.('Checking your game files…');
1139
- await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
1283
+ const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
1140
1284
  if (result.failed)
1141
1285
  process.exitCode = 1;
1142
- return result;
1286
+ return { ...result, pushed };
1143
1287
  }
1144
1288
  async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
1145
1289
  if (pushEnabled === false)
@@ -1213,6 +1357,23 @@ function humanTime(iso) {
1213
1357
  return iso;
1214
1358
  }
1215
1359
  }
1360
+ /**
1361
+ * Both-sides-changed report. Printed on every run that has conflicts (also in
1362
+ * watch mode) because the alternative — staying silent — is what makes a lost
1363
+ * edit feel like a sync bug.
1364
+ */
1365
+ function printConflicts(keys, removedRemotely = new Set()) {
1366
+ console.log();
1367
+ console.log(kleur.yellow(`${keys.length} sprite${keys.length === 1 ? '' : 's'} changed in both places — not overwritten:`));
1368
+ for (const key of keys) {
1369
+ const note = removedRemotely.has(key) ? kleur.dim(' (no longer synced from MagicPixel)') : '';
1370
+ console.log(` ${kleur.yellow('~')} ${key}${note}`);
1371
+ }
1372
+ console.log(kleur.dim(` Keep the local edit: run \`${cmd('push')}\` (it will report the cloud change), or delete the PNG to accept MagicPixel's copy.`));
1373
+ if (removedRemotely.size > 0) {
1374
+ 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.`));
1375
+ }
1376
+ }
1216
1377
  function printOrphans(orphans) {
1217
1378
  console.log();
1218
1379
  console.log(kleur.yellow(`Orphaned local files (not in manifest):`));
package/dist/index.js CHANGED
@@ -87,7 +87,7 @@ program
87
87
  .option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
88
88
  .option('--dry-run', 'Print the plan without writing files')
89
89
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
90
- .option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~3min idle, 10s after ~15min)', parseWatchInterval)
90
+ .option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
91
91
  .option('-q, --quiet', 'Minimal output (for CI)')
92
92
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
93
93
  .addHelpText('after', '\nExamples:\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
@@ -0,0 +1,29 @@
1
+ /**
2
+ * "Was this PNG edited in the game project since we last wrote or pushed it?"
3
+ *
4
+ * The single definition of a local edit, used by two callers:
5
+ *
6
+ * - `pullDecision` — an entry still in the manifest whose disk bytes diverged
7
+ * is a conflict, not a download target.
8
+ * - the prune sweep in `sync` — a path that LEFT the manifest (deselected
9
+ * "Sync to Unity", trashed document, rename/previous-key cruft) must not be
10
+ * deleted while it holds work MagicPixel has never seen.
11
+ *
12
+ * Without a recorded baseline (`state.synced[key].diskSha256`) we can't tell an
13
+ * edit from a file we never wrote, so we report "no edit" and let the caller's
14
+ * existing rules apply unchanged.
15
+ */
16
+ import { fileSha256 } from './hash.js';
17
+ /** Pure comparison — no filesystem access, safe for decision unit tests. */
18
+ export function isLocalEdit(baselineDiskSha256, localSha256) {
19
+ if (!baselineDiskSha256 || !localSha256)
20
+ return false;
21
+ return baselineDiskSha256 !== localSha256;
22
+ }
23
+ /** Hashes `absPath` only when a baseline exists (nothing else can be an edit). */
24
+ export async function hasUnpushedLocalEdit({ absPath, baselineDiskSha256, knownSha256, }) {
25
+ if (!baselineDiskSha256)
26
+ return false;
27
+ const sha = knownSha256 !== undefined ? knownSha256 : await fileSha256(absPath);
28
+ return isLocalEdit(baselineDiskSha256, sha);
29
+ }
@@ -6,25 +6,40 @@
6
6
  * library on every catch-up and overwrite the originals. Pull only when the
7
7
  * cloud composite actually changed since we last recorded it, or when there
8
8
  * is no local file to protect.
9
+ *
10
+ * Third outcome: `conflict`. `sync` pulls before it pushes, so a PNG edited on
11
+ * disk *and* in MagicPixel since the last sync would be overwritten before
12
+ * `push` ever sees it — the local edit would vanish with no message. When the
13
+ * disk bytes diverge from the fingerprint we recorded when we last wrote/pushed
14
+ * that file, the entry is reported instead of downloaded.
9
15
  */
10
- export function shouldPullEntry(opts) {
16
+ import { isLocalEdit } from './localEdit.js';
17
+ export function decidePull(opts) {
11
18
  const cloud = opts.cloudSha256 ?? null;
12
19
  const local = opts.localSha256;
13
20
  const prev = opts.previousCloudSha256;
14
21
  // Missing on disk → restore from cloud (MagicPixel-only art and deletions).
15
22
  if (!local)
16
- return true;
23
+ return 'pull';
17
24
  if (cloud && cloud === local)
18
- return false;
25
+ return 'skip';
19
26
  // Cloud composite unchanged since last pull/push — keep the original PNG.
20
27
  if (cloud && prev && cloud === prev)
21
- return false;
28
+ return 'skip';
22
29
  if (opts.inWorkingSet) {
23
30
  // Never recorded a cloud hash: disk is the source of truth (connect/ingest).
24
31
  if (!prev)
25
- return false;
32
+ return 'skip';
26
33
  // Editor save often nulls the cached hash; treat that as a cloud change.
27
- return true;
34
+ return localEdit(opts) ? 'conflict' : 'pull';
28
35
  }
29
- return true;
36
+ return localEdit(opts) ? 'conflict' : 'pull';
37
+ }
38
+ /**
39
+ * Disk bytes changed since we recorded them → someone edited the PNG in the
40
+ * game project. Without a recorded baseline we can't tell an edit from a file
41
+ * we never wrote, so we stay out of the way and let the pull proceed.
42
+ */
43
+ function localEdit(opts) {
44
+ return isLocalEdit(opts.lastPushedDiskSha256, opts.localSha256);
30
45
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * When to promote an incremental sync to a full reconcile.
3
+ *
4
+ * `removed_keys` can only describe rows that still exist (trashed /
5
+ * componentized). A permanent delete ("Empty Trash") leaves nothing to report,
6
+ * so an incremental client keeps the PNG in the game project indefinitely. A
7
+ * periodic full pass — the manifest itself is the truth there — closes that
8
+ * without tombstone tables or per-tick cost.
9
+ */
10
+ /** 15 minutes: bounded orphan lifetime, ~4 full manifests/hour worst case. */
11
+ export const RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
12
+ export function shouldReconcile(lastReconcile, nowMs, intervalMs = RECONCILE_INTERVAL_MS) {
13
+ if (!lastReconcile)
14
+ return true;
15
+ const then = Date.parse(lastReconcile);
16
+ if (!Number.isFinite(then))
17
+ return true;
18
+ // Clock moved backwards (machine sleep / NTP step): reconcile rather than
19
+ // waiting out a bogus future timestamp.
20
+ if (then > nowMs)
21
+ return true;
22
+ return nowMs - then >= intervalMs;
23
+ }
@@ -96,7 +96,9 @@ export async function safeFetch(url, init = {}) {
96
96
  let current = url;
97
97
  for (let hop = 0; hop < 5; hop++) {
98
98
  const res = await fetch(current, { ...init, redirect: 'manual' });
99
- if (res.status < 300 || res.status >= 400)
99
+ // 304 lives in the 3xx range but is a conditional-request answer, not a
100
+ // redirect: it carries no Location and callers handle it directly.
101
+ if (res.status < 300 || res.status >= 400 || res.status === 304)
100
102
  return res;
101
103
  const location = res.headers.get('location');
102
104
  await res.body?.cancel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.14",
3
+ "version": "0.5.20",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {