@magicpixelart/cli 0.5.38 → 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';
@@ -32,6 +32,7 @@ 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
34
  import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
35
+ import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
35
36
  /** How often the watcher asks for a library "Resync from game" request. */
36
37
  const RESYNC_POLL_MS = 15_000;
37
38
  let lastResyncPollAt = 0;
@@ -67,7 +68,7 @@ async function maybeRunQueuedResync(quiet) {
67
68
  catch (e) {
68
69
  await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
69
70
  if (!quiet)
70
- console.log(kleur.red(`! Resync failed: ${e.message}`));
71
+ console.log(ui.fail(`Resync failed: ${e.message}`));
71
72
  }
72
73
  }
73
74
  /** Did the push half of a run actually move bytes into MagicPixel? */
@@ -88,7 +89,7 @@ export function pushWasActive(pushed) {
88
89
  export function needsLocalPngWalk(since, toDownloadCount) {
89
90
  return !since || toDownloadCount > 0;
90
91
  }
91
- export async function syncCommand(opts) {
92
+ export async function syncCommand(opts, reuse = {}) {
92
93
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
93
94
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
94
95
  // (watch mode included) and only touches files older than 30s, so it can
@@ -110,7 +111,7 @@ export async function syncCommand(opts) {
110
111
  await watchLoop(opts);
111
112
  return;
112
113
  }
113
- await runOnce(opts);
114
+ await runOnce(opts, { gameIndex: reuse.gameIndex });
114
115
  }
115
116
  async function watchLoop(opts) {
116
117
  // Default 2s — matches the perceived-instant UX users expect when editing
@@ -122,7 +123,7 @@ async function watchLoop(opts) {
122
123
  const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
123
124
  // Header — print once on start. Counts are best-effort; if the first
124
125
  // manifest fetch fails we still want the loop to come up and retry.
125
- 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'));
126
127
  console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
127
128
  try {
128
129
  const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
@@ -139,7 +140,7 @@ async function watchLoop(opts) {
139
140
  const config = await loadConfig();
140
141
  const kind = await detectProjectKind();
141
142
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
142
- countSpinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
143
+ countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
143
144
  }
144
145
  }
145
146
  catch {
@@ -156,6 +157,8 @@ async function watchLoop(opts) {
156
157
  else if (header.line && !opts.quiet) {
157
158
  console.log(kleur.dim(header.line));
158
159
  }
160
+ if (header.excluded && !opts.quiet)
161
+ console.log(kleur.yellow(` ! ${header.excluded}`));
159
162
  console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
160
163
  console.log();
161
164
  let stopping = false;
@@ -189,7 +192,11 @@ async function watchLoop(opts) {
189
192
  let lastChangeAt = Date.now();
190
193
  // Re-walk the game tree on this cadence so new PNGs are ingested without
191
194
  // listing thousands of folders on every 2s tick.
192
- 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 = '';
193
200
  let gameIndexCache = header.gameIndex;
194
201
  let gameIndexAt = Date.now();
195
202
  // Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
@@ -226,9 +233,9 @@ async function watchLoop(opts) {
226
233
  return;
227
234
  inFlight = true;
228
235
  const onStatus = (msg) => {
229
- if (opts.quiet)
230
- return;
231
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${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))}`);
232
239
  };
233
240
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
234
241
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -248,7 +255,7 @@ async function watchLoop(opts) {
248
255
  }, SLOW_TICK_HEARTBEAT_MS);
249
256
  heartbeat?.unref?.();
250
257
  try {
251
- wrappedStatus('Checking MagicPixel…');
258
+ wrappedStatus(`${icon('work')} Checking MagicPixel…`);
252
259
  const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
253
260
  ? gameIndexCache
254
261
  : undefined;
@@ -276,15 +283,15 @@ async function watchLoop(opts) {
276
283
  await maybeRunQueuedResync(opts.quiet === true);
277
284
  if (wasPausedForAuth && !opts.quiet) {
278
285
  process.stdout.write('\x1b[2K\r');
279
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
286
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
280
287
  }
281
288
  if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
282
289
  process.stdout.write('\x1b[2K\r');
283
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
290
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
284
291
  }
285
292
  if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
286
293
  process.stdout.write('\x1b[2K\r');
287
- 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.`);
288
295
  }
289
296
  const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
290
297
  // Pushing a locally edited sprite is activity too — it just isn't part of
@@ -300,15 +307,37 @@ async function watchLoop(opts) {
300
307
  }
301
308
  if (opts.quiet)
302
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;
303
330
  if (changedCount > 0) {
304
331
  process.stdout.write('\x1b[2K\r');
305
332
  const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
306
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${verb} ${changedCount} ` +
333
+ console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
307
334
  `change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
308
335
  printChanges(r, /* indent */ ' ');
309
336
  }
310
337
  else {
311
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} 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
+ }
312
341
  }
313
342
  }
314
343
  catch (e) {
@@ -331,15 +360,15 @@ async function watchLoop(opts) {
331
360
  // Surface the request id (from B1) so the user can paste it into a
332
361
  // support thread and we can correlate against edge function logs.
333
362
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
334
- console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
335
- 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).`)}`);
336
365
  }
337
366
  pausedForAuth = true;
338
367
  consecutiveAuthFailures = decision.consecutiveAuthFailures;
339
368
  backoffSec = decision.nextBackoffSec;
340
369
  if (decision.giveUp) {
341
- console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
342
- 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.`)}`);
343
372
  // Surface this to /admin/errors — persistent watcher auth failure
344
373
  // means a key is mass-rejected (revoked, project deleted, edge
345
374
  // misconfig) and we want visibility without waiting for a support
@@ -352,7 +381,7 @@ async function watchLoop(opts) {
352
381
  consecutiveAuthFailures = 0;
353
382
  if (decision.printMessage) {
354
383
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
355
- 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. ` +
356
385
  `Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
357
386
  }
358
387
  pausedForNetwork = true;
@@ -362,7 +391,7 @@ async function watchLoop(opts) {
362
391
  consecutiveAuthFailures = 0;
363
392
  if (decision.printMessage) {
364
393
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
365
- 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. ` +
366
395
  `Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
367
396
  `(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
368
397
  }
@@ -372,7 +401,7 @@ async function watchLoop(opts) {
372
401
  else {
373
402
  consecutiveAuthFailures = 0;
374
403
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
375
- console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}${idSuffix}`);
404
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
376
405
  backoffSec = decision.nextBackoffSec;
377
406
  }
378
407
  }
@@ -380,6 +409,10 @@ async function watchLoop(opts) {
380
409
  if (heartbeat)
381
410
  clearInterval(heartbeat);
382
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
+ }
383
416
  }
384
417
  };
385
418
  await tick();
@@ -508,15 +541,18 @@ async function loadWatchHeader(onProgress) {
508
541
  const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
509
542
  let workingSet = 0;
510
543
  let gameIndex;
544
+ let excluded = null;
511
545
  const kind = await detectProjectKind();
512
546
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
513
547
  gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
514
- 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);
515
551
  }
516
552
  else if (state.synced) {
517
553
  workingSet = Object.keys(state.synced).length;
518
554
  }
519
- return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
555
+ return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
520
556
  }
521
557
  catch {
522
558
  return { line: null };
@@ -537,7 +573,7 @@ async function runOnce(opts, runOpts = {}) {
537
573
  const onStatus = runOpts.onStatus;
538
574
  const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
539
575
  if (verbose && config.endpoint) {
540
- console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
576
+ console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
541
577
  }
542
578
  // Incremental unless asked for a full run — or unless a full pass is due:
543
579
  // permanently-deleted rows can't be reported incrementally (see reconcile.ts).
@@ -554,7 +590,7 @@ async function runOnce(opts, runOpts = {}) {
554
590
  since = undefined;
555
591
  const previousAssets = state.assets ?? {}; // id → key from prior sync
556
592
  const spinner = verbose
557
- ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
593
+ ? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
558
594
  : null;
559
595
  if (!spinner)
560
596
  onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
@@ -603,7 +639,7 @@ async function runOnce(opts, runOpts = {}) {
603
639
  if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
604
640
  await saveState(state);
605
641
  if (verbose)
606
- console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
642
+ console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
607
643
  }
608
644
  const projectSuffix = projectInfo && verbose
609
645
  ? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
@@ -615,7 +651,7 @@ async function runOnce(opts, runOpts = {}) {
615
651
  // so the user isn't left wondering why sync "did nothing".
616
652
  if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
617
653
  console.log();
618
- console.log(kleur.yellow(`! ${projectInfo.hint}`));
654
+ console.log(ui.warn(`${projectInfo.hint}`));
619
655
  }
620
656
  }
621
657
  catch (e) {
@@ -627,7 +663,7 @@ async function runOnce(opts, runOpts = {}) {
627
663
  const pushed = await maybePushLocalSprites(config.push, verbose);
628
664
  if (verbose && pushed && pushed.created > 0) {
629
665
  console.log();
630
- 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.`));
631
667
  console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
632
668
  }
633
669
  }
@@ -643,11 +679,9 @@ async function runOnce(opts, runOpts = {}) {
643
679
  onProgress: (p) => {
644
680
  const n = p.pngs.toLocaleString('en-US');
645
681
  const f = p.folders.toLocaleString('en-US');
646
- onStatus?.(p.current
647
- ? `Looking through your game sprites… ${n} sprites · ${f} folders · ${p.current}`
648
- : p.folders > 0
649
- ? `Looking through your game sprites… ${n} sprites · ${f} folders`
650
- : `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}`);
651
685
  },
652
686
  })
653
687
  : { files: [] });
@@ -687,6 +721,15 @@ async function runOnce(opts, runOpts = {}) {
687
721
  }
688
722
  }
689
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
+ }
690
733
  if (verbose && isEngineKind(projectKind)) {
691
734
  console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
692
735
  const skipped = describeExcluded(connected);
@@ -723,8 +766,8 @@ async function runOnce(opts, runOpts = {}) {
723
766
  const n = filtered.unknown.length;
724
767
  const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
725
768
  if (verbose) {
726
- console.log(kleur.yellow(`! ${msg}`));
727
- 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\`.`));
728
771
  }
729
772
  else {
730
773
  onStatus?.(msg);
@@ -792,7 +835,7 @@ async function runOnce(opts, runOpts = {}) {
792
835
  : 'Checking MagicPixel…');
793
836
  const diffLimit = createLimit(concurrency);
794
837
  const shaByEntryId = new Map();
795
- const toDownload = [];
838
+ let toDownload = [];
796
839
  const conflicts = [];
797
840
  const failedDownloadKeys = new Set();
798
841
  let bytesSaved = 0;
@@ -886,6 +929,23 @@ async function runOnce(opts, runOpts = {}) {
886
929
  // Wrong-folder guard: sprites connected to another game folder on this
887
930
  // machine would land here as stray copies under outDir. Stop before writing.
888
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
+ }
889
949
  if (projectIdForRoots && !opts.here) {
890
950
  const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
891
951
  if (owner) {
@@ -1137,7 +1197,7 @@ async function runOnce(opts, runOpts = {}) {
1137
1197
  }
1138
1198
  if (opts.dryRun) {
1139
1199
  if (verbose) {
1140
- console.log(kleur.dim('--dry-run: no files written.'));
1200
+ console.log(ui.dryRun());
1141
1201
  if (renamed.length > 0)
1142
1202
  printRenames(renamed, { withHints: false });
1143
1203
  if (orphans.length > 0)
@@ -1171,7 +1231,7 @@ async function runOnce(opts, runOpts = {}) {
1171
1231
  let progress = null;
1172
1232
  if (live && toDownload.length > 0) {
1173
1233
  onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
1174
- progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
1234
+ progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
1175
1235
  }
1176
1236
  const run = createLimit(concurrency);
1177
1237
  let done = 0;
@@ -1241,12 +1301,12 @@ async function runOnce(opts, runOpts = {}) {
1241
1301
  // so the user sees the fix hint. Single-line errors stay terse.
1242
1302
  const msg = e.message ?? String(e);
1243
1303
  if (msg.includes('\n')) {
1244
- console.log(` ${kleur.red('!')} ${entry.key}:`);
1304
+ console.log(` ${icon('fail')} ${entry.key}:`);
1245
1305
  for (const line of msg.split('\n'))
1246
1306
  console.log(` ${line}`);
1247
1307
  }
1248
1308
  else {
1249
- console.log(` ${kleur.red('!')} ${entry.key}: ${msg}`);
1309
+ console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
1250
1310
  }
1251
1311
  progress?.start();
1252
1312
  }
@@ -1303,7 +1363,7 @@ async function runOnce(opts, runOpts = {}) {
1303
1363
  }
1304
1364
  catch (e) {
1305
1365
  if (verbose)
1306
- 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}`);
1307
1367
  }
1308
1368
  }
1309
1369
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
@@ -1331,7 +1391,7 @@ async function runOnce(opts, runOpts = {}) {
1331
1391
  }
1332
1392
  catch (e) {
1333
1393
  if (verbose)
1334
- 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}`);
1335
1395
  }
1336
1396
  }
1337
1397
  // Always surface the import-update notice — even in --quiet/watch mode
@@ -1515,23 +1575,28 @@ async function runOnce(opts, runOpts = {}) {
1515
1575
  const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
1516
1576
  if (verbose) {
1517
1577
  console.log();
1518
- const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
1519
- `unchanged ${result.unchanged}` +
1520
- (result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
1521
- (shouldPrune ? `, pruned ${result.removed.length}` : '') +
1522
- (renamed.length ? `, renamed ${renamed.length}` : '') +
1523
- (result.failed ? `, failed ${result.failed}` : '');
1524
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
+ ];
1525
1590
  console.log(result.failed || pushFailed
1526
- ? kleur.yellow(`Sync finished with issues. ${summary}`)
1527
- : kleur.green(`✓ Sync complete. ${summary}`));
1591
+ ? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
1592
+ : card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
1528
1593
  printChanges(result, ' ', { includeRenames: renamed.length === 0 });
1529
1594
  if (renamed.length > 0)
1530
1595
  printRenames(renamed, { withHints: true });
1531
1596
  }
1532
1597
  if (result.failed)
1533
1598
  process.exitCode = 1;
1534
- return { ...result, pushed };
1599
+ return { ...result, pushed, ...(strays ? { strays } : {}) };
1535
1600
  }
1536
1601
  async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
1537
1602
  if (pushEnabled === false)
@@ -1542,8 +1607,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
1542
1607
  catch (e) {
1543
1608
  if (announceErrors) {
1544
1609
  console.log();
1545
- console.log(kleur.yellow(`! could not update MagicPixel from your game files: ${e.message}`));
1546
- 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.`));
1547
1612
  }
1548
1613
  return null;
1549
1614
  }
@@ -1587,11 +1652,11 @@ function printRenames(renamed, opts) {
1587
1652
  }
1588
1653
  }
1589
1654
  function progressText(done, total, bytes) {
1590
- const pct = total === 0 ? 100 : Math.round((done / total) * 100);
1591
- const barWidth = 24;
1592
- const filled = Math.round((pct / 100) * barWidth);
1593
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1594
- return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
1655
+ return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
1656
+ }
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);
1595
1660
  }
1596
1661
  function timestamp() {
1597
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
+ }
package/dist/config.js CHANGED
@@ -113,6 +113,9 @@ export async function loadConfig(cwd = process.cwd()) {
113
113
  push: parsed.push === false ? false : undefined,
114
114
  connect,
115
115
  ...(parsed.syncOnly !== undefined ? { syncOnly: normalizeGlobList(parsed.syncOnly, 'syncOnly') } : {}),
116
+ ...(parsed.workspace === true && Array.isArray(parsed.members)
117
+ ? { workspace: true, members: normalizeGlobList(parsed.members, 'members') }
118
+ : {}),
116
119
  };
117
120
  }
118
121
  const MAX_GLOBS = 64;