@magicpixelart/cli 0.5.36 → 0.6.1

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.
@@ -1,5 +1,5 @@
1
1
  import kleur from 'kleur';
2
- import ora from 'ora';
2
+ import { canRedraw, card, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
3
3
  import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { dirname, relative, resolve } from 'node:path';
@@ -12,7 +12,7 @@ import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
13
  import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
14
14
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
15
- import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
15
+ import { describeExcluded, indexGamePngs, matchConnectGlobs, 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
18
  import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, workingSetPullKeys } from '../util/unityFilter.js';
@@ -23,7 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
23
23
  import { formatBytes } from '../util/format.js';
24
24
  import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
25
25
  import { cmd } from '../util/invoke.js';
26
- import { fitStatusLine, formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
26
+ import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
27
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';
@@ -31,6 +31,8 @@ import { runPush } from './push.js';
31
31
  import { claimResyncRequest, reportResyncRequest } from '../api.js';
32
32
  import { runResync } from './resync.js';
33
33
  import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
34
+ import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
35
+ import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
34
36
  /** How often the watcher asks for a library "Resync from game" request. */
35
37
  const RESYNC_POLL_MS = 15_000;
36
38
  let lastResyncPollAt = 0;
@@ -66,7 +68,7 @@ async function maybeRunQueuedResync(quiet) {
66
68
  catch (e) {
67
69
  await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
68
70
  if (!quiet)
69
- console.log(kleur.red(`! Resync failed: ${e.message}`));
71
+ console.log(ui.fail(`Resync failed: ${e.message}`));
70
72
  }
71
73
  }
72
74
  /** Did the push half of a run actually move bytes into MagicPixel? */
@@ -87,7 +89,7 @@ export function pushWasActive(pushed) {
87
89
  export function needsLocalPngWalk(since, toDownloadCount) {
88
90
  return !since || toDownloadCount > 0;
89
91
  }
90
- export async function syncCommand(opts) {
92
+ export async function syncCommand(opts, reuse = {}) {
91
93
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
92
94
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
93
95
  // (watch mode included) and only touches files older than 30s, so it can
@@ -109,7 +111,7 @@ export async function syncCommand(opts) {
109
111
  await watchLoop(opts);
110
112
  return;
111
113
  }
112
- await runOnce(opts);
114
+ await runOnce(opts, { gameIndex: reuse.gameIndex });
113
115
  }
114
116
  async function watchLoop(opts) {
115
117
  // Default 2s — matches the perceived-instant UX users expect when editing
@@ -121,7 +123,7 @@ async function watchLoop(opts) {
121
123
  const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
122
124
  // Header — print once on start. Counts are best-effort; if the first
123
125
  // manifest fetch fails we still want the loop to come up and retry.
124
- console.log(kleur.bold('👀 MagicPixel watching for changes…'));
126
+ console.log(card(`${icon('watch')} MagicPixel is watching for changes`, [`Edit at ${kleur.cyan('https://magicpixel.art')}`, kleur.dim('Changes show up here as they happen · Stop: Ctrl+C')], 'info'));
125
127
  console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
126
128
  try {
127
129
  const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
@@ -138,7 +140,7 @@ async function watchLoop(opts) {
138
140
  const config = await loadConfig();
139
141
  const kind = await detectProjectKind();
140
142
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
141
- countSpinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
143
+ countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
142
144
  }
143
145
  }
144
146
  catch {
@@ -155,6 +157,8 @@ async function watchLoop(opts) {
155
157
  else if (header.line && !opts.quiet) {
156
158
  console.log(kleur.dim(header.line));
157
159
  }
160
+ if (header.excluded && !opts.quiet)
161
+ console.log(kleur.yellow(` ! ${header.excluded}`));
158
162
  console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
159
163
  console.log();
160
164
  let stopping = false;
@@ -188,7 +192,11 @@ async function watchLoop(opts) {
188
192
  let lastChangeAt = Date.now();
189
193
  // Re-walk the game tree on this cadence so new PNGs are ingested without
190
194
  // listing thousands of folders on every 2s tick.
191
- const GAME_INDEX_TTL_MS = 30_000;
195
+ // Edits to already-indexed files are picked up every tick (push re-hashes
196
+ // them); the full tree walk only finds brand-new PNGs, so it runs rarely.
197
+ const GAME_INDEX_TTL_MS = 5 * 60_000;
198
+ const reportedIssues = new Set();
199
+ let reportedStrays = '';
192
200
  let gameIndexCache = header.gameIndex;
193
201
  let gameIndexAt = Date.now();
194
202
  // Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
@@ -225,9 +233,9 @@ async function watchLoop(opts) {
225
233
  return;
226
234
  inFlight = true;
227
235
  const onStatus = (msg) => {
228
- if (opts.quiet)
229
- return;
230
- writeStatusLine(msg);
236
+ if (opts.quiet || !canRedraw())
237
+ return; // in-place updates only on a live terminal
238
+ process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns))}`);
231
239
  };
232
240
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
233
241
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -247,7 +255,7 @@ async function watchLoop(opts) {
247
255
  }, SLOW_TICK_HEARTBEAT_MS);
248
256
  heartbeat?.unref?.();
249
257
  try {
250
- wrappedStatus('Checking MagicPixel…');
258
+ wrappedStatus(`${icon('work')} Checking MagicPixel…`);
251
259
  const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
252
260
  ? gameIndexCache
253
261
  : undefined;
@@ -275,15 +283,15 @@ async function watchLoop(opts) {
275
283
  await maybeRunQueuedResync(opts.quiet === true);
276
284
  if (wasPausedForAuth && !opts.quiet) {
277
285
  process.stdout.write('\x1b[2K\r');
278
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
286
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
279
287
  }
280
288
  if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
281
289
  process.stdout.write('\x1b[2K\r');
282
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
290
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
283
291
  }
284
292
  if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
285
293
  process.stdout.write('\x1b[2K\r');
286
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Daily allowance available again — resuming.`);
294
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Daily allowance available again — resuming.`);
287
295
  }
288
296
  const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
289
297
  // Pushing a locally edited sprite is activity too — it just isn't part of
@@ -299,15 +307,37 @@ async function watchLoop(opts) {
299
307
  }
300
308
  if (opts.quiet)
301
309
  return;
310
+ const sent = (r.pushed?.created ?? 0) + (r.pushed?.updated ?? 0);
311
+ const freshIssues = (r.pushed?.issues ?? []).filter((i) => !reportedIssues.has(`${i.key}\0${i.text}`));
312
+ const strayLine = r.strays ? `${r.strays.count}\0${r.strays.owner ?? ''}` : '';
313
+ if (sent > 0 || freshIssues.length > 0 || (strayLine && strayLine !== reportedStrays)) {
314
+ process.stdout.write('\x1b[2K\r');
315
+ }
316
+ if (sent > 0) {
317
+ console.log(`${kleur.dim(timestamp())} ${icon('up')} Sent ${ui.count(sent)} edited sprite${sent === 1 ? '' : 's'} to MagicPixel.`);
318
+ }
319
+ for (const i of freshIssues.slice(0, 10)) {
320
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${ui.name(i.key)}: ${i.text}`);
321
+ }
322
+ if (freshIssues.length > 10)
323
+ console.log(kleur.dim(` …and ${freshIssues.length - 10} more not sent.`));
324
+ for (const i of freshIssues)
325
+ reportedIssues.add(`${i.key}\0${i.text}`);
326
+ if (strayLine && strayLine !== reportedStrays && r.strays) {
327
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${straySkipMessage(r.strays.count, r.strays.owner, `restart with ${kleur.cyan('--here')}`)}`);
328
+ }
329
+ reportedStrays = strayLine;
302
330
  if (changedCount > 0) {
303
331
  process.stdout.write('\x1b[2K\r');
304
332
  const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
305
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${verb} ${changedCount} ` +
333
+ console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
306
334
  `change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
307
335
  printChanges(r, /* indent */ ' ');
308
336
  }
309
337
  else {
310
- writeStatusLine(`Waiting for edits… (${r.unchanged} up to date)`);
338
+ if (canRedraw()) {
339
+ process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${icon('watch')} Watching · all in sync (${r.unchanged.toLocaleString('en-US')} up to date)`, process.stdout.columns))}`);
340
+ }
311
341
  }
312
342
  }
313
343
  catch (e) {
@@ -330,15 +360,15 @@ async function watchLoop(opts) {
330
360
  // Surface the request id (from B1) so the user can paste it into a
331
361
  // support thread and we can correlate against edge function logs.
332
362
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
333
- console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
334
- console.log(kleur.dim(` Fix: run \`${cmd('login')}\` (this watcher will keep retrying every 30s).`));
363
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} Your key looks invalid or rotated.${idSuffix}`);
364
+ console.log(`${ui.fix(cmd('login'))}${kleur.dim(` (this watcher will keep retrying every 30s).`)}`);
335
365
  }
336
366
  pausedForAuth = true;
337
367
  consecutiveAuthFailures = decision.consecutiveAuthFailures;
338
368
  backoffSec = decision.nextBackoffSec;
339
369
  if (decision.giveUp) {
340
- console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
341
- console.log(kleur.dim(` Fix: run \`${cmd('login')}\` with a fresh key, then restart the watcher.`));
370
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
371
+ console.log(`${ui.fix(cmd('login'))}${kleur.dim(` with a fresh key, then restart the watcher.`)}`);
342
372
  // Surface this to /admin/errors — persistent watcher auth failure
343
373
  // means a key is mass-rejected (revoked, project deleted, edge
344
374
  // misconfig) and we want visibility without waiting for a support
@@ -351,7 +381,7 @@ async function watchLoop(opts) {
351
381
  consecutiveAuthFailures = 0;
352
382
  if (decision.printMessage) {
353
383
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
354
- console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} MagicPixel is offline or your internet is. ` +
384
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${kleur.yellow('Paused')} — MagicPixel is offline or your internet is. ` +
355
385
  `Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
356
386
  }
357
387
  pausedForNetwork = true;
@@ -361,7 +391,7 @@ async function watchLoop(opts) {
361
391
  consecutiveAuthFailures = 0;
362
392
  if (decision.printMessage) {
363
393
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
364
- console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} Daily MagicPixel API allowance reached. ` +
394
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} Daily MagicPixel API allowance reached. ` +
365
395
  `Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
366
396
  `(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
367
397
  }
@@ -371,7 +401,7 @@ async function watchLoop(opts) {
371
401
  else {
372
402
  consecutiveAuthFailures = 0;
373
403
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
374
- console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}${idSuffix}`);
404
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
375
405
  backoffSec = decision.nextBackoffSec;
376
406
  }
377
407
  }
@@ -379,6 +409,10 @@ async function watchLoop(opts) {
379
409
  if (heartbeat)
380
410
  clearInterval(heartbeat);
381
411
  inFlight = false;
412
+ if ((pausedForAuth || pausedForNetwork || pausedForQuota) && !opts.quiet && canRedraw()) {
413
+ const why = pausedForAuth ? 'waiting for a valid key' : pausedForQuota ? 'daily allowance reached' : 'offline';
414
+ process.stdout.write(`\r\x1b[2K${kleur.yellow(fitStatusLine(`${timestamp()} ${icon('paused')} Paused (${why}) · retrying in ${formatDuration(backoffSec * 1000)}`, process.stdout.columns))}`);
415
+ }
382
416
  }
383
417
  };
384
418
  await tick();
@@ -507,15 +541,18 @@ async function loadWatchHeader(onProgress) {
507
541
  const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
508
542
  let workingSet = 0;
509
543
  let gameIndex;
544
+ let excluded = null;
510
545
  const kind = await detectProjectKind();
511
546
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
512
547
  gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
513
- workingSet = matchConnectGlobs(gameIndex, config.connect, config.exclude).entries.length;
548
+ const matched = matchConnectGlobs(gameIndex, config.connect, config.exclude);
549
+ workingSet = matched.entries.length;
550
+ excluded = describeExcluded(matched);
514
551
  }
515
552
  else if (state.synced) {
516
553
  workingSet = Object.keys(state.synced).length;
517
554
  }
518
- return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
555
+ return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
519
556
  }
520
557
  catch {
521
558
  return { line: null };
@@ -536,7 +573,7 @@ async function runOnce(opts, runOpts = {}) {
536
573
  const onStatus = runOpts.onStatus;
537
574
  const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
538
575
  if (verbose && config.endpoint) {
539
- console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
576
+ console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
540
577
  }
541
578
  // Incremental unless asked for a full run — or unless a full pass is due:
542
579
  // permanently-deleted rows can't be reported incrementally (see reconcile.ts).
@@ -553,7 +590,7 @@ async function runOnce(opts, runOpts = {}) {
553
590
  since = undefined;
554
591
  const previousAssets = state.assets ?? {}; // id → key from prior sync
555
592
  const spinner = verbose
556
- ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
593
+ ? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
557
594
  : null;
558
595
  if (!spinner)
559
596
  onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
@@ -602,7 +639,7 @@ async function runOnce(opts, runOpts = {}) {
602
639
  if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
603
640
  await saveState(state);
604
641
  if (verbose)
605
- console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
642
+ console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
606
643
  }
607
644
  const projectSuffix = projectInfo && verbose
608
645
  ? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
@@ -614,7 +651,7 @@ async function runOnce(opts, runOpts = {}) {
614
651
  // so the user isn't left wondering why sync "did nothing".
615
652
  if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
616
653
  console.log();
617
- console.log(kleur.yellow(`! ${projectInfo.hint}`));
654
+ console.log(ui.warn(`${projectInfo.hint}`));
618
655
  }
619
656
  }
620
657
  catch (e) {
@@ -626,7 +663,7 @@ async function runOnce(opts, runOpts = {}) {
626
663
  const pushed = await maybePushLocalSprites(config.push, verbose);
627
664
  if (verbose && pushed && pushed.created > 0) {
628
665
  console.log();
629
- console.log(kleur.green(`✓ pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
666
+ console.log(ui.ok(`pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
630
667
  console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
631
668
  }
632
669
  }
@@ -642,11 +679,9 @@ async function runOnce(opts, runOpts = {}) {
642
679
  onProgress: (p) => {
643
680
  const n = p.pngs.toLocaleString('en-US');
644
681
  const f = p.folders.toLocaleString('en-US');
645
- onStatus?.(p.current
646
- ? `Looking through your game sprites… ${n} sprites · ${f} folders · ${p.current}`
647
- : p.folders > 0
648
- ? `Looking through your game sprites… ${n} sprites · ${f} folders`
649
- : `Looking through your game sprites… ${n}`);
682
+ onStatus?.(p.folders > 0
683
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders`
684
+ : `Looking through your game sprites… ${n}`);
650
685
  },
651
686
  })
652
687
  : { files: [] });
@@ -686,8 +721,20 @@ async function runOnce(opts, runOpts = {}) {
686
721
  }
687
722
  }
688
723
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
724
+ if (isEngineKind(projectKind)) {
725
+ placeBesideSiblings(sourceByKey, manifest, new Set(fullGameIndex.files.map((f) => f.sourceRel)));
726
+ }
727
+ if (verbose && gameIndex.keyClashes?.length) {
728
+ console.log(ui.warn(`${gameIndex.keyClashes.length} file${gameIndex.keyClashes.length === 1 ? '' : 's'} share a name with a file in another game folder — skipped:`));
729
+ for (const r of gameIndex.keyClashes.slice(0, 5))
730
+ console.log(kleur.dim(` ${r}`));
731
+ console.log(ui.fixText('rename one of them so each sprite has its own name.'));
732
+ }
689
733
  if (verbose && isEngineKind(projectKind)) {
690
734
  console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
735
+ const skipped = describeExcluded(connected);
736
+ if (skipped)
737
+ console.log(kleur.dim(` ${skipped}`));
691
738
  }
692
739
  const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
693
740
  // Every folder on disk that belongs to a withheld document. Anything already
@@ -719,8 +766,8 @@ async function runOnce(opts, runOpts = {}) {
719
766
  const n = filtered.unknown.length;
720
767
  const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
721
768
  if (verbose) {
722
- console.log(kleur.yellow(`! ${msg}`));
723
- console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
769
+ console.log(ui.warn(`${msg}`));
770
+ console.log(ui.fixText(`re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
724
771
  }
725
772
  else {
726
773
  onStatus?.(msg);
@@ -788,7 +835,7 @@ async function runOnce(opts, runOpts = {}) {
788
835
  : 'Checking MagicPixel…');
789
836
  const diffLimit = createLimit(concurrency);
790
837
  const shaByEntryId = new Map();
791
- const toDownload = [];
838
+ let toDownload = [];
792
839
  const conflicts = [];
793
840
  const failedDownloadKeys = new Set();
794
841
  let bytesSaved = 0;
@@ -879,6 +926,37 @@ async function runOnce(opts, runOpts = {}) {
879
926
  const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
880
927
  const remoteKeySet = new Set(manifest.map((e) => e.key));
881
928
  const previousKeySet = new Set(Object.values(previousAssets));
929
+ // Wrong-folder guard: sprites connected to another game folder on this
930
+ // machine would land here as stray copies under outDir. Stop before writing.
931
+ const projectIdForRoots = getLastProjectInfo()?.id;
932
+ // Game-owned sprites (server flag) with no home in this folder are skipped —
933
+ // this works even on a machine where the owning folder never synced.
934
+ let strays;
935
+ if (!opts.here) {
936
+ const split = splitStrayDownloads(toDownload, [...sourceByKey.keys(), ...connected.entries.map((e) => e.key)]);
937
+ if (split.foreign.length > 0) {
938
+ toDownload = split.keep;
939
+ const owner = projectIdForRoots
940
+ ? await findForeignOwner(projectIdForRoots, process.cwd(), split.foreign.map((e) => e.key))
941
+ : null;
942
+ strays = { count: split.foreign.length, owner: owner?.root ?? null };
943
+ if (verbose) {
944
+ console.log();
945
+ console.log(ui.warn(straySkipMessage(strays.count, strays.owner, `add ${kleur.cyan('--here')}`)));
946
+ }
947
+ }
948
+ }
949
+ if (projectIdForRoots && !opts.here) {
950
+ const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
951
+ if (owner) {
952
+ const msg = foreignOwnerMessage(owner, process.cwd(), `add ${kleur.cyan('--here')}`);
953
+ if (runOpts.watchMode) {
954
+ console.error(kleur.red(msg));
955
+ process.exit(1);
956
+ }
957
+ throw new Error(msg);
958
+ }
959
+ }
882
960
  const localPngs = needsLocalPngWalk(since, toDownload.length)
883
961
  ? await walkOutDirPngs(config.outDir)
884
962
  : [];
@@ -1119,7 +1197,7 @@ async function runOnce(opts, runOpts = {}) {
1119
1197
  }
1120
1198
  if (opts.dryRun) {
1121
1199
  if (verbose) {
1122
- console.log(kleur.dim('--dry-run: no files written.'));
1200
+ console.log(ui.dryRun());
1123
1201
  if (renamed.length > 0)
1124
1202
  printRenames(renamed, { withHints: false });
1125
1203
  if (orphans.length > 0)
@@ -1153,7 +1231,7 @@ async function runOnce(opts, runOpts = {}) {
1153
1231
  let progress = null;
1154
1232
  if (live && toDownload.length > 0) {
1155
1233
  onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
1156
- progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
1234
+ progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
1157
1235
  }
1158
1236
  const run = createLimit(concurrency);
1159
1237
  let done = 0;
@@ -1223,12 +1301,12 @@ async function runOnce(opts, runOpts = {}) {
1223
1301
  // so the user sees the fix hint. Single-line errors stay terse.
1224
1302
  const msg = e.message ?? String(e);
1225
1303
  if (msg.includes('\n')) {
1226
- console.log(` ${kleur.red('!')} ${entry.key}:`);
1304
+ console.log(` ${icon('fail')} ${entry.key}:`);
1227
1305
  for (const line of msg.split('\n'))
1228
1306
  console.log(` ${line}`);
1229
1307
  }
1230
1308
  else {
1231
- console.log(` ${kleur.red('!')} ${entry.key}: ${msg}`);
1309
+ console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
1232
1310
  }
1233
1311
  progress?.start();
1234
1312
  }
@@ -1285,7 +1363,7 @@ async function runOnce(opts, runOpts = {}) {
1285
1363
  }
1286
1364
  catch (e) {
1287
1365
  if (verbose)
1288
- console.log(` ${kleur.yellow('!')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
1366
+ console.log(` ${icon('warn')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
1289
1367
  }
1290
1368
  }
1291
1369
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
@@ -1313,7 +1391,7 @@ async function runOnce(opts, runOpts = {}) {
1313
1391
  }
1314
1392
  catch (e) {
1315
1393
  if (verbose)
1316
- console.log(` ${kleur.yellow('!')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
1394
+ console.log(` ${icon('warn')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
1317
1395
  }
1318
1396
  }
1319
1397
  // Always surface the import-update notice — even in --quiet/watch mode
@@ -1485,6 +1563,11 @@ async function runOnce(opts, runOpts = {}) {
1485
1563
  // Never let an AGENTS.md write failure break sync.
1486
1564
  }
1487
1565
  await saveState(nextState);
1566
+ // This folder owns real game paths → remember it so a sync elsewhere can
1567
+ // point back here instead of writing stray copies.
1568
+ if (projectIdForRoots && sourceByKey.size > 0) {
1569
+ await recordProjectRoot(projectIdForRoots, process.cwd());
1570
+ }
1488
1571
  if (result.failed > 0 && verbose) {
1489
1572
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
1490
1573
  }
@@ -1492,23 +1575,28 @@ async function runOnce(opts, runOpts = {}) {
1492
1575
  const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
1493
1576
  if (verbose) {
1494
1577
  console.log();
1495
- const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
1496
- `unchanged ${result.unchanged}` +
1497
- (result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
1498
- (shouldPrune ? `, pruned ${result.removed.length}` : '') +
1499
- (renamed.length ? `, renamed ${renamed.length}` : '') +
1500
- (result.failed ? `, failed ${result.failed}` : '');
1501
1578
  const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
1579
+ const skipped = pushed?.issues.length ?? 0;
1580
+ const sent = (pushed?.created ?? 0) + (pushed?.updated ?? 0);
1581
+ const rows = [
1582
+ `${icon('down')} ${ui.count(downloaded)} downloaded ${icon('up')} ${ui.count(sent)} uploaded`,
1583
+ `${icon('ok')} ${ui.count(result.unchanged)} unchanged` +
1584
+ (shouldPrune && result.removed.length ? ` ${icon('removed')} ${ui.count(result.removed.length)} removed` : '') +
1585
+ (skipped ? ` ${icon('warn')} ${ui.count(skipped)} skipped` : ''),
1586
+ ...(renamed.length ? [`↪ ${ui.count(renamed.length)} renamed — see below`] : []),
1587
+ ...(result.failed || pushFailed ? [`${icon('fail')} ${ui.count(result.failed + pushFailed)} failed — re-run to retry`] : []),
1588
+ kleur.dim(`${icon('time')} ${formatDuration(Date.now() - Date.parse(startedAt))}${result.bytesSaved ? ` · ~${formatBytes(result.bytesSaved)} saved` : ''}`),
1589
+ ];
1502
1590
  console.log(result.failed || pushFailed
1503
- ? kleur.yellow(`Sync finished with issues. ${summary}`)
1504
- : kleur.green(`✓ Sync complete. ${summary}`));
1591
+ ? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
1592
+ : card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
1505
1593
  printChanges(result, ' ', { includeRenames: renamed.length === 0 });
1506
1594
  if (renamed.length > 0)
1507
1595
  printRenames(renamed, { withHints: true });
1508
1596
  }
1509
1597
  if (result.failed)
1510
1598
  process.exitCode = 1;
1511
- return { ...result, pushed };
1599
+ return { ...result, pushed, ...(strays ? { strays } : {}) };
1512
1600
  }
1513
1601
  async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
1514
1602
  if (pushEnabled === false)
@@ -1519,8 +1607,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
1519
1607
  catch (e) {
1520
1608
  if (announceErrors) {
1521
1609
  console.log();
1522
- console.log(kleur.yellow(`! could not update MagicPixel from your game files: ${e.message}`));
1523
- console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
1610
+ console.log(ui.warn(`could not update MagicPixel from your game files: ${e.message}`));
1611
+ console.log(ui.fixText(`run \`${cmd('push')}\` once the connection is healthy.`));
1524
1612
  }
1525
1613
  return null;
1526
1614
  }
@@ -1564,16 +1652,11 @@ function printRenames(renamed, opts) {
1564
1652
  }
1565
1653
  }
1566
1654
  function progressText(done, total, bytes) {
1567
- const pct = total === 0 ? 100 : Math.round((done / total) * 100);
1568
- const barWidth = 24;
1569
- const filled = Math.round((pct / 100) * barWidth);
1570
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1571
- return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
1655
+ return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
1572
1656
  }
1573
- /** Rewrite the watcher's single status row, clipped so it never wraps. */
1574
- function writeStatusLine(msg) {
1575
- const line = fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns);
1576
- process.stdout.write(`\r\x1b[2K${kleur.dim(line)}`);
1657
+ /** Cut a status line to the terminal width so `\r` redraws never wrap. */
1658
+ export function fitStatusLine(line, columns) {
1659
+ return fitLine(line, columns);
1577
1660
  }
1578
1661
  function timestamp() {
1579
1662
  const d = new Date();
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { loadConfig, defaultConfig, resolveEndpoint, getApiKey } from '../config.js';
3
4
  import { ApiError, errorCodeFromResponse, friendlyApiError, retryTransient, retryAfterMsFromResponse, getLastProjectInfo } from '../api.js';
4
5
  import { safeFetch } from '../util/security.js';
@@ -71,21 +72,21 @@ export async function whoamiCommand() {
71
72
  return { kind: 'ok', body, project, serverRequestId };
72
73
  });
73
74
  if (result.kind === 'rejected') {
74
- console.log(kleur.red(`✗ API key rejected (${result.status}).`));
75
+ console.log(ui.fail(`API key rejected (${result.status}).`));
75
76
  console.log(kleur.dim(' Generate a new key at MagicPixel → Settings → API Keys.'));
76
77
  console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
77
78
  process.exitCode = 1;
78
79
  return;
79
80
  }
80
81
  if (result.kind === 'error') {
81
- console.log(kleur.red(`✗ ${result.status}: ${result.bodyText.slice(0, 200)}`));
82
+ console.log(ui.fail(`${result.status}: ${result.bodyText.slice(0, 200)}`));
82
83
  console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
83
84
  process.exitCode = 1;
84
85
  return;
85
86
  }
86
87
  const { body, project } = result;
87
88
  const more = body.nextCursor ? '+' : '';
88
- console.log(kleur.green('✓ key valid'));
89
+ console.log(ui.ok('key valid'));
89
90
  console.log(` endpoint: ${resolveEndpoint(config)}`);
90
91
  if (project) {
91
92
  const label = project.name ?? '(unnamed)';
@@ -0,0 +1,91 @@
1
+ import kleur from 'kleur';
2
+ import { spinner as makeSpinner } from '../util/ui.js';
3
+ import { loadConfig, loadState } from '../config.js';
4
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
+ import { countingSpritesText, entryMatchesGlob, indexGamePngs, rootSpriteDotDirs } from '../util/gameScan.js';
6
+ import { hashFile } from '../util/hash.js';
7
+ import { indexSyncedBySourceRel, resolveSyncedKey } from '../util/pushPlan.js';
8
+ import { normalizeScopeFolder } from '../util/syncScope.js';
9
+ import { cmd } from '../util/invoke.js';
10
+ import { resolveInvokedPath } from '../util/workspace.js';
11
+ import { relative } from 'node:path';
12
+ /** What the next sync does with one game PNG — no network, local facts only. */
13
+ export function explainSprite(entry, config, synced, currentSha) {
14
+ const hit = (g) => entryMatchesGlob(entry, g);
15
+ if (!(config.connect ?? []).some(hit))
16
+ return { kind: 'not-watched' };
17
+ const rule = (config.exclude ?? []).find(hit);
18
+ if (rule !== undefined)
19
+ return { kind: 'excluded', rule };
20
+ const stateKey = resolveSyncedKey({ key: entry.key, sourceRel: entry.sourceRel }, synced, indexSyncedBySourceRel(synced));
21
+ const known = stateKey ? synced[stateKey] : undefined;
22
+ if (!known)
23
+ return { kind: 'new' };
24
+ if (known.legacy)
25
+ return { kind: 'legacy' };
26
+ if (currentSha && known.diskSha256 === currentSha)
27
+ return { kind: 'unchanged' };
28
+ return { kind: 'changed', ...(typeof known.layers === 'number' ? { layers: known.layers } : {}) };
29
+ }
30
+ export function describeVerdict(v, config) {
31
+ switch (v.kind) {
32
+ case 'not-watched':
33
+ return `not watched — no "connect" pattern matches it (${(config.connect ?? []).join(', ') || 'none'}). Fix: ${cmd('connect')} '<folder>/**'`;
34
+ case 'excluded':
35
+ return `skipped by exclude rule "${v.rule}" — remove it from "exclude" in magicpixel.json to sync this file`;
36
+ case 'new':
37
+ return 'new — the next sync adds it to MagicPixel';
38
+ case 'changed':
39
+ return v.layers && v.layers > 1
40
+ ? `changed locally, but the MagicPixel artboard has ${v.layers} layers — run \`${cmd('push')} --flatten\` to replace it`
41
+ : 'changed locally — the next sync uploads it';
42
+ case 'unchanged':
43
+ return 'unchanged since the last sync';
44
+ case 'legacy':
45
+ return 'linked to a legacy single-image file — open it in MagicPixel and save once, then sync again';
46
+ }
47
+ }
48
+ export async function whyCommand(pathArg) {
49
+ const config = await loadConfig();
50
+ const state = await loadState();
51
+ const kind = await detectProjectKind();
52
+ if (!isEngineKind(kind)) {
53
+ console.log(kleur.yellow('This folder is not a Unity/Godot/GameMaker project — sync only downloads here, it never uploads.'));
54
+ return;
55
+ }
56
+ const target = normalizeScopeFolder(resolveInvokedPath(pathArg)).toLowerCase();
57
+ const spinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
58
+ const index = await indexGamePngs(kind, process.cwd(), config.outDir, {
59
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
60
+ });
61
+ spinner.stop();
62
+ const files = index.files.filter((e) => {
63
+ const rel = e.sourceRel.toLowerCase();
64
+ return rel === target || rel.startsWith(`${target}/`);
65
+ });
66
+ if (files.length === 0) {
67
+ const dots = (await rootSpriteDotDirs(process.cwd())).map((d) => relative(process.cwd(), d));
68
+ console.log(kleur.yellow(`No PNGs found under ${pathArg} (scanned ${index.files.length.toLocaleString('en-US')} PNGs).`));
69
+ const scanned = config.members?.length ? config.members.map((m) => `${m}/`).join(', ') : `Assets/${dots.length ? `, ${dots.join(', ')}` : ''}`;
70
+ console.log(kleur.dim(` Paths are relative to ${process.cwd()}. Scanned: ${scanned}.`));
71
+ return;
72
+ }
73
+ const synced = state.synced ?? {};
74
+ const counts = new Map();
75
+ const lines = [];
76
+ for (const e of files) {
77
+ const sha = (await hashFile(e.abs))?.sha256 ?? null;
78
+ const text = describeVerdict(explainSprite(e, config, synced, sha), config);
79
+ counts.set(text, (counts.get(text) ?? 0) + 1);
80
+ if (lines.length < 20)
81
+ lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
82
+ }
83
+ console.log(kleur.bold(`${files.length.toLocaleString('en-US')} PNG${files.length === 1 ? '' : 's'} under ${pathArg}:`));
84
+ for (const [text, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
85
+ console.log(` ${kleur.cyan(String(n).padStart(5))} ${text}`);
86
+ }
87
+ console.log();
88
+ console.log(lines.join('\n'));
89
+ if (files.length > lines.length)
90
+ console.log(kleur.dim(` …and ${files.length - lines.length} more`));
91
+ }