@magicpixelart/cli 0.5.38 → 0.6.2

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, isPlain, 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,8 @@ 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';
36
+ import { syncFooterHint } from '../util/commandTips.js';
35
37
  /** How often the watcher asks for a library "Resync from game" request. */
36
38
  const RESYNC_POLL_MS = 15_000;
37
39
  let lastResyncPollAt = 0;
@@ -67,7 +69,7 @@ async function maybeRunQueuedResync(quiet) {
67
69
  catch (e) {
68
70
  await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
69
71
  if (!quiet)
70
- console.log(kleur.red(`! Resync failed: ${e.message}`));
72
+ console.log(ui.fail(`Resync failed: ${e.message}`));
71
73
  }
72
74
  }
73
75
  /** Did the push half of a run actually move bytes into MagicPixel? */
@@ -88,7 +90,7 @@ export function pushWasActive(pushed) {
88
90
  export function needsLocalPngWalk(since, toDownloadCount) {
89
91
  return !since || toDownloadCount > 0;
90
92
  }
91
- export async function syncCommand(opts) {
93
+ export async function syncCommand(opts, reuse = {}) {
92
94
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
93
95
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
94
96
  // (watch mode included) and only touches files older than 30s, so it can
@@ -110,7 +112,7 @@ export async function syncCommand(opts) {
110
112
  await watchLoop(opts);
111
113
  return;
112
114
  }
113
- await runOnce(opts);
115
+ await runOnce(opts, { gameIndex: reuse.gameIndex });
114
116
  }
115
117
  async function watchLoop(opts) {
116
118
  // Default 2s — matches the perceived-instant UX users expect when editing
@@ -122,7 +124,7 @@ async function watchLoop(opts) {
122
124
  const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
123
125
  // Header — print once on start. Counts are best-effort; if the first
124
126
  // manifest fetch fails we still want the loop to come up and retry.
125
- console.log(kleur.bold('👀 MagicPixel watching for changes…'));
127
+ 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
128
  console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
127
129
  try {
128
130
  const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
@@ -139,7 +141,7 @@ async function watchLoop(opts) {
139
141
  const config = await loadConfig();
140
142
  const kind = await detectProjectKind();
141
143
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
142
- countSpinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
144
+ countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
143
145
  }
144
146
  }
145
147
  catch {
@@ -156,6 +158,8 @@ async function watchLoop(opts) {
156
158
  else if (header.line && !opts.quiet) {
157
159
  console.log(kleur.dim(header.line));
158
160
  }
161
+ if (header.excluded && !opts.quiet)
162
+ console.log(kleur.yellow(` ! ${header.excluded}`));
159
163
  console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
160
164
  console.log();
161
165
  let stopping = false;
@@ -189,7 +193,11 @@ async function watchLoop(opts) {
189
193
  let lastChangeAt = Date.now();
190
194
  // Re-walk the game tree on this cadence so new PNGs are ingested without
191
195
  // listing thousands of folders on every 2s tick.
192
- const GAME_INDEX_TTL_MS = 30_000;
196
+ // Edits to already-indexed files are picked up every tick (push re-hashes
197
+ // them); the full tree walk only finds brand-new PNGs, so it runs rarely.
198
+ const GAME_INDEX_TTL_MS = 5 * 60_000;
199
+ const reportedIssues = new Set();
200
+ let reportedStrays = '';
193
201
  let gameIndexCache = header.gameIndex;
194
202
  let gameIndexAt = Date.now();
195
203
  // Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
@@ -226,9 +234,9 @@ async function watchLoop(opts) {
226
234
  return;
227
235
  inFlight = true;
228
236
  const onStatus = (msg) => {
229
- if (opts.quiet)
230
- return;
231
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
237
+ if (opts.quiet || !canRedraw())
238
+ return; // in-place updates only on a live terminal
239
+ process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns))}`);
232
240
  };
233
241
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
234
242
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -248,7 +256,7 @@ async function watchLoop(opts) {
248
256
  }, SLOW_TICK_HEARTBEAT_MS);
249
257
  heartbeat?.unref?.();
250
258
  try {
251
- wrappedStatus('Checking MagicPixel…');
259
+ wrappedStatus(`${icon('work')} Checking MagicPixel…`);
252
260
  const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
253
261
  ? gameIndexCache
254
262
  : undefined;
@@ -276,15 +284,15 @@ async function watchLoop(opts) {
276
284
  await maybeRunQueuedResync(opts.quiet === true);
277
285
  if (wasPausedForAuth && !opts.quiet) {
278
286
  process.stdout.write('\x1b[2K\r');
279
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
287
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
280
288
  }
281
289
  if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
282
290
  process.stdout.write('\x1b[2K\r');
283
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
291
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
284
292
  }
285
293
  if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
286
294
  process.stdout.write('\x1b[2K\r');
287
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Daily allowance available again — resuming.`);
295
+ console.log(`${kleur.dim(timestamp())} ${icon('ok')} Daily allowance available again — resuming.`);
288
296
  }
289
297
  const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
290
298
  // Pushing a locally edited sprite is activity too — it just isn't part of
@@ -300,15 +308,37 @@ async function watchLoop(opts) {
300
308
  }
301
309
  if (opts.quiet)
302
310
  return;
311
+ const sent = (r.pushed?.created ?? 0) + (r.pushed?.updated ?? 0);
312
+ const freshIssues = (r.pushed?.issues ?? []).filter((i) => !reportedIssues.has(`${i.key}\0${i.text}`));
313
+ const strayLine = r.strays ? `${r.strays.count}\0${r.strays.owner ?? ''}` : '';
314
+ if (sent > 0 || freshIssues.length > 0 || (strayLine && strayLine !== reportedStrays)) {
315
+ process.stdout.write('\x1b[2K\r');
316
+ }
317
+ if (sent > 0) {
318
+ console.log(`${kleur.dim(timestamp())} ${icon('up')} Sent ${ui.count(sent)} edited sprite${sent === 1 ? '' : 's'} to MagicPixel.`);
319
+ }
320
+ for (const i of freshIssues.slice(0, 10)) {
321
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${ui.name(i.key)}: ${i.text}`);
322
+ }
323
+ if (freshIssues.length > 10)
324
+ console.log(kleur.dim(` …and ${freshIssues.length - 10} more not sent.`));
325
+ for (const i of freshIssues)
326
+ reportedIssues.add(`${i.key}\0${i.text}`);
327
+ if (strayLine && strayLine !== reportedStrays && r.strays) {
328
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${straySkipMessage(r.strays.count, r.strays.owner, `restart with ${kleur.cyan('--here')}`)}`);
329
+ }
330
+ reportedStrays = strayLine;
303
331
  if (changedCount > 0) {
304
332
  process.stdout.write('\x1b[2K\r');
305
333
  const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
306
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${verb} ${changedCount} ` +
334
+ console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
307
335
  `change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
308
336
  printChanges(r, /* indent */ ' ');
309
337
  }
310
338
  else {
311
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} Waiting for edits… (${r.unchanged} up to date)`)}`);
339
+ if (canRedraw()) {
340
+ 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))}`);
341
+ }
312
342
  }
313
343
  }
314
344
  catch (e) {
@@ -331,15 +361,15 @@ async function watchLoop(opts) {
331
361
  // Surface the request id (from B1) so the user can paste it into a
332
362
  // support thread and we can correlate against edge function logs.
333
363
  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).`));
364
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} Your key looks invalid or rotated.${idSuffix}`);
365
+ console.log(`${ui.fix(cmd('login'))}${kleur.dim(` (this watcher will keep retrying every 30s).`)}`);
336
366
  }
337
367
  pausedForAuth = true;
338
368
  consecutiveAuthFailures = decision.consecutiveAuthFailures;
339
369
  backoffSec = decision.nextBackoffSec;
340
370
  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.`));
371
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
372
+ console.log(`${ui.fix(cmd('login'))}${kleur.dim(` with a fresh key, then restart the watcher.`)}`);
343
373
  // Surface this to /admin/errors — persistent watcher auth failure
344
374
  // means a key is mass-rejected (revoked, project deleted, edge
345
375
  // misconfig) and we want visibility without waiting for a support
@@ -352,7 +382,7 @@ async function watchLoop(opts) {
352
382
  consecutiveAuthFailures = 0;
353
383
  if (decision.printMessage) {
354
384
  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. ` +
385
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${kleur.yellow('Paused')} — MagicPixel is offline or your internet is. ` +
356
386
  `Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
357
387
  }
358
388
  pausedForNetwork = true;
@@ -362,7 +392,7 @@ async function watchLoop(opts) {
362
392
  consecutiveAuthFailures = 0;
363
393
  if (decision.printMessage) {
364
394
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
365
- console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} Daily MagicPixel API allowance reached. ` +
395
+ console.log(`${kleur.dim(timestamp())} ${icon('warn')} Daily MagicPixel API allowance reached. ` +
366
396
  `Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
367
397
  `(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
368
398
  }
@@ -372,7 +402,7 @@ async function watchLoop(opts) {
372
402
  else {
373
403
  consecutiveAuthFailures = 0;
374
404
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
375
- console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}${idSuffix}`);
405
+ console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
376
406
  backoffSec = decision.nextBackoffSec;
377
407
  }
378
408
  }
@@ -380,6 +410,10 @@ async function watchLoop(opts) {
380
410
  if (heartbeat)
381
411
  clearInterval(heartbeat);
382
412
  inFlight = false;
413
+ if ((pausedForAuth || pausedForNetwork || pausedForQuota) && !opts.quiet && canRedraw()) {
414
+ const why = pausedForAuth ? 'waiting for a valid key' : pausedForQuota ? 'daily allowance reached' : 'offline';
415
+ process.stdout.write(`\r\x1b[2K${kleur.yellow(fitStatusLine(`${timestamp()} ${icon('paused')} Paused (${why}) · retrying in ${formatDuration(backoffSec * 1000)}`, process.stdout.columns))}`);
416
+ }
383
417
  }
384
418
  };
385
419
  await tick();
@@ -508,15 +542,18 @@ async function loadWatchHeader(onProgress) {
508
542
  const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
509
543
  let workingSet = 0;
510
544
  let gameIndex;
545
+ let excluded = null;
511
546
  const kind = await detectProjectKind();
512
547
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
513
548
  gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
514
- workingSet = matchConnectGlobs(gameIndex, config.connect, config.exclude).entries.length;
549
+ const matched = matchConnectGlobs(gameIndex, config.connect, config.exclude);
550
+ workingSet = matched.entries.length;
551
+ excluded = describeExcluded(matched);
515
552
  }
516
553
  else if (state.synced) {
517
554
  workingSet = Object.keys(state.synced).length;
518
555
  }
519
- return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
556
+ return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
520
557
  }
521
558
  catch {
522
559
  return { line: null };
@@ -537,7 +574,7 @@ async function runOnce(opts, runOpts = {}) {
537
574
  const onStatus = runOpts.onStatus;
538
575
  const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
539
576
  if (verbose && config.endpoint) {
540
- console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
577
+ console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
541
578
  }
542
579
  // Incremental unless asked for a full run — or unless a full pass is due:
543
580
  // permanently-deleted rows can't be reported incrementally (see reconcile.ts).
@@ -554,7 +591,7 @@ async function runOnce(opts, runOpts = {}) {
554
591
  since = undefined;
555
592
  const previousAssets = state.assets ?? {}; // id → key from prior sync
556
593
  const spinner = verbose
557
- ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
594
+ ? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
558
595
  : null;
559
596
  if (!spinner)
560
597
  onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
@@ -603,7 +640,7 @@ async function runOnce(opts, runOpts = {}) {
603
640
  if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
604
641
  await saveState(state);
605
642
  if (verbose)
606
- console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
643
+ console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
607
644
  }
608
645
  const projectSuffix = projectInfo && verbose
609
646
  ? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
@@ -615,7 +652,7 @@ async function runOnce(opts, runOpts = {}) {
615
652
  // so the user isn't left wondering why sync "did nothing".
616
653
  if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
617
654
  console.log();
618
- console.log(kleur.yellow(`! ${projectInfo.hint}`));
655
+ console.log(ui.warn(`${projectInfo.hint}`));
619
656
  }
620
657
  }
621
658
  catch (e) {
@@ -627,7 +664,7 @@ async function runOnce(opts, runOpts = {}) {
627
664
  const pushed = await maybePushLocalSprites(config.push, verbose);
628
665
  if (verbose && pushed && pushed.created > 0) {
629
666
  console.log();
630
- console.log(kleur.green(`✓ pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
667
+ console.log(ui.ok(`pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
631
668
  console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
632
669
  }
633
670
  }
@@ -643,11 +680,9 @@ async function runOnce(opts, runOpts = {}) {
643
680
  onProgress: (p) => {
644
681
  const n = p.pngs.toLocaleString('en-US');
645
682
  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}`);
683
+ onStatus?.(p.folders > 0
684
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders`
685
+ : `Looking through your game sprites… ${n}`);
651
686
  },
652
687
  })
653
688
  : { files: [] });
@@ -687,6 +722,15 @@ async function runOnce(opts, runOpts = {}) {
687
722
  }
688
723
  }
689
724
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
725
+ if (isEngineKind(projectKind)) {
726
+ placeBesideSiblings(sourceByKey, manifest, new Set(fullGameIndex.files.map((f) => f.sourceRel)));
727
+ }
728
+ if (verbose && gameIndex.keyClashes?.length) {
729
+ console.log(ui.warn(`${gameIndex.keyClashes.length} file${gameIndex.keyClashes.length === 1 ? '' : 's'} share a name with a file in another game folder — skipped:`));
730
+ for (const r of gameIndex.keyClashes.slice(0, 5))
731
+ console.log(kleur.dim(` ${r}`));
732
+ console.log(ui.fixText('rename one of them so each sprite has its own name.'));
733
+ }
690
734
  if (verbose && isEngineKind(projectKind)) {
691
735
  console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
692
736
  const skipped = describeExcluded(connected);
@@ -723,8 +767,8 @@ async function runOnce(opts, runOpts = {}) {
723
767
  const n = filtered.unknown.length;
724
768
  const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
725
769
  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\`.`));
770
+ console.log(ui.warn(`${msg}`));
771
+ console.log(ui.fixText(`re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
728
772
  }
729
773
  else {
730
774
  onStatus?.(msg);
@@ -792,7 +836,7 @@ async function runOnce(opts, runOpts = {}) {
792
836
  : 'Checking MagicPixel…');
793
837
  const diffLimit = createLimit(concurrency);
794
838
  const shaByEntryId = new Map();
795
- const toDownload = [];
839
+ let toDownload = [];
796
840
  const conflicts = [];
797
841
  const failedDownloadKeys = new Set();
798
842
  let bytesSaved = 0;
@@ -886,6 +930,23 @@ async function runOnce(opts, runOpts = {}) {
886
930
  // Wrong-folder guard: sprites connected to another game folder on this
887
931
  // machine would land here as stray copies under outDir. Stop before writing.
888
932
  const projectIdForRoots = getLastProjectInfo()?.id;
933
+ // Game-owned sprites (server flag) with no home in this folder are skipped —
934
+ // this works even on a machine where the owning folder never synced.
935
+ let strays;
936
+ if (!opts.here) {
937
+ const split = splitStrayDownloads(toDownload, [...sourceByKey.keys(), ...connected.entries.map((e) => e.key)]);
938
+ if (split.foreign.length > 0) {
939
+ toDownload = split.keep;
940
+ const owner = projectIdForRoots
941
+ ? await findForeignOwner(projectIdForRoots, process.cwd(), split.foreign.map((e) => e.key))
942
+ : null;
943
+ strays = { count: split.foreign.length, owner: owner?.root ?? null };
944
+ if (verbose) {
945
+ console.log();
946
+ console.log(ui.warn(straySkipMessage(strays.count, strays.owner, `add ${kleur.cyan('--here')}`)));
947
+ }
948
+ }
949
+ }
889
950
  if (projectIdForRoots && !opts.here) {
890
951
  const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
891
952
  if (owner) {
@@ -1137,7 +1198,7 @@ async function runOnce(opts, runOpts = {}) {
1137
1198
  }
1138
1199
  if (opts.dryRun) {
1139
1200
  if (verbose) {
1140
- console.log(kleur.dim('--dry-run: no files written.'));
1201
+ console.log(ui.dryRun());
1141
1202
  if (renamed.length > 0)
1142
1203
  printRenames(renamed, { withHints: false });
1143
1204
  if (orphans.length > 0)
@@ -1171,7 +1232,7 @@ async function runOnce(opts, runOpts = {}) {
1171
1232
  let progress = null;
1172
1233
  if (live && toDownload.length > 0) {
1173
1234
  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();
1235
+ progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
1175
1236
  }
1176
1237
  const run = createLimit(concurrency);
1177
1238
  let done = 0;
@@ -1241,12 +1302,12 @@ async function runOnce(opts, runOpts = {}) {
1241
1302
  // so the user sees the fix hint. Single-line errors stay terse.
1242
1303
  const msg = e.message ?? String(e);
1243
1304
  if (msg.includes('\n')) {
1244
- console.log(` ${kleur.red('!')} ${entry.key}:`);
1305
+ console.log(` ${icon('fail')} ${entry.key}:`);
1245
1306
  for (const line of msg.split('\n'))
1246
1307
  console.log(` ${line}`);
1247
1308
  }
1248
1309
  else {
1249
- console.log(` ${kleur.red('!')} ${entry.key}: ${msg}`);
1310
+ console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
1250
1311
  }
1251
1312
  progress?.start();
1252
1313
  }
@@ -1303,7 +1364,7 @@ async function runOnce(opts, runOpts = {}) {
1303
1364
  }
1304
1365
  catch (e) {
1305
1366
  if (verbose)
1306
- console.log(` ${kleur.yellow('!')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
1367
+ console.log(` ${icon('warn')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
1307
1368
  }
1308
1369
  }
1309
1370
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
@@ -1331,7 +1392,7 @@ async function runOnce(opts, runOpts = {}) {
1331
1392
  }
1332
1393
  catch (e) {
1333
1394
  if (verbose)
1334
- console.log(` ${kleur.yellow('!')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
1395
+ console.log(` ${icon('warn')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
1335
1396
  }
1336
1397
  }
1337
1398
  // Always surface the import-update notice — even in --quiet/watch mode
@@ -1515,23 +1576,30 @@ async function runOnce(opts, runOpts = {}) {
1515
1576
  const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
1516
1577
  if (verbose) {
1517
1578
  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
1579
  const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
1580
+ const skipped = pushed?.issues.length ?? 0;
1581
+ const sent = (pushed?.created ?? 0) + (pushed?.updated ?? 0);
1582
+ const rows = [
1583
+ `${icon('down')} ${ui.count(downloaded)} downloaded ${icon('up')} ${ui.count(sent)} uploaded`,
1584
+ `${icon('ok')} ${ui.count(result.unchanged)} unchanged` +
1585
+ (shouldPrune && result.removed.length ? ` ${icon('removed')} ${ui.count(result.removed.length)} removed` : '') +
1586
+ (skipped ? ` ${icon('warn')} ${ui.count(skipped)} skipped` : ''),
1587
+ ...(renamed.length ? [`↪ ${ui.count(renamed.length)} renamed — see below`] : []),
1588
+ ...(result.failed || pushFailed ? [`${icon('fail')} ${ui.count(result.failed + pushFailed)} failed — re-run to retry`] : []),
1589
+ kleur.dim(`${icon('time')} ${formatDuration(Date.now() - Date.parse(startedAt))}${result.bytesSaved ? ` · ~${formatBytes(result.bytesSaved)} saved` : ''}`),
1590
+ ];
1525
1591
  console.log(result.failed || pushFailed
1526
- ? kleur.yellow(`Sync finished with issues. ${summary}`)
1527
- : kleur.green(`✓ Sync complete. ${summary}`));
1592
+ ? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
1593
+ : card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
1528
1594
  printChanges(result, ' ', { includeRenames: renamed.length === 0 });
1529
1595
  if (renamed.length > 0)
1530
1596
  printRenames(renamed, { withHints: true });
1597
+ if (!runOpts.watchMode && !isPlain())
1598
+ console.log(kleur.dim(syncFooterHint()));
1531
1599
  }
1532
1600
  if (result.failed)
1533
1601
  process.exitCode = 1;
1534
- return { ...result, pushed };
1602
+ return { ...result, pushed, ...(strays ? { strays } : {}) };
1535
1603
  }
1536
1604
  async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
1537
1605
  if (pushEnabled === false)
@@ -1542,8 +1610,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
1542
1610
  catch (e) {
1543
1611
  if (announceErrors) {
1544
1612
  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.`));
1613
+ console.log(ui.warn(`could not update MagicPixel from your game files: ${e.message}`));
1614
+ console.log(ui.fixText(`run \`${cmd('push')}\` once the connection is healthy.`));
1547
1615
  }
1548
1616
  return null;
1549
1617
  }
@@ -1587,11 +1655,11 @@ function printRenames(renamed, opts) {
1587
1655
  }
1588
1656
  }
1589
1657
  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))}`;
1658
+ return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
1659
+ }
1660
+ /** Cut a status line to the terminal width so `\r` redraws never wrap. */
1661
+ export function fitStatusLine(line, columns) {
1662
+ return fitLine(line, columns);
1595
1663
  }
1596
1664
  function timestamp() {
1597
1665
  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,105 @@
1
+ import kleur from 'kleur';
2
+ import { spinner as makeSpinner, ui } 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) {
31
+ switch (v.kind) {
32
+ case 'not-watched':
33
+ return 'not watched — no folder pattern covers it';
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
+ let unwatched = 0;
77
+ for (const e of files) {
78
+ const sha = (await hashFile(e.abs))?.sha256 ?? null;
79
+ const verdict = explainSprite(e, config, synced, sha);
80
+ if (verdict.kind === 'not-watched')
81
+ unwatched++;
82
+ const text = describeVerdict(verdict);
83
+ counts.set(text, (counts.get(text) ?? 0) + 1);
84
+ if (lines.length < 20)
85
+ lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
86
+ }
87
+ console.log(kleur.bold(`${files.length.toLocaleString('en-US')} PNG${files.length === 1 ? '' : 's'} under ${pathArg}:`));
88
+ for (const [text, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
89
+ console.log(` ${kleur.cyan(String(n).padStart(5))} ${text}`);
90
+ }
91
+ console.log();
92
+ console.log(lines.join('\n'));
93
+ if (files.length > lines.length)
94
+ console.log(kleur.dim(` …and ${files.length - lines.length} more`));
95
+ if (unwatched > 0) {
96
+ console.log();
97
+ console.log(kleur.dim(` Watching: ${(config.connect ?? []).join(', ') || 'nothing'}`));
98
+ console.log(ui.fix(whyConnectCommand(files[0].sourceRel, target), ' '));
99
+ }
100
+ }
101
+ /** Ready-to-run connect command for the folder the user asked about. */
102
+ export function whyConnectCommand(firstRel, target) {
103
+ const folder = firstRel.toLowerCase() === target ? firstRel.split('/').slice(0, -1).join('/') : firstRel.slice(0, target.length);
104
+ return `${cmd('connect')} '${folder ? `${folder}/**` : '**'}'`;
105
+ }
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;