@magicpixelart/cli 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.12] — 2026-08-26
11
+
12
+ ### Fixed
13
+
14
+ - **Watch counting no longer looks hung.** Sprite counting used to stay on
15
+ `Counting sprites in your game…` with no numbers until the first PNG, which
16
+ on a large Unity tree can take a long time. The spinner now updates as
17
+ folders are visited (`0 sprites · 128 folders`) and walks `Assets/` before
18
+ sibling project folders so the sprite count appears sooner.
19
+
20
+ ## [0.5.11] — 2026-08-26
21
+
22
+ ### Changed
23
+
24
+ - **Sprite counting shows a running total** (`Counting sprites in your
25
+ game… 1,247`) while walking the project, and watch reuses that first
26
+ scan so startup does not walk the tree twice.
27
+
10
28
  ## [0.5.10] — 2026-08-26
11
29
 
12
30
  ### Changed
@@ -1,8 +1,9 @@
1
1
  import kleur from 'kleur';
2
+ import ora from 'ora';
2
3
  import { loadConfig, saveConfig } from '../config.js';
3
4
  import { assertSafeGlob } from '../util/security.js';
4
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
- import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
6
+ import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
6
7
  import { cmd } from '../util/invoke.js';
7
8
  import { runPush } from './push.js';
8
9
  /**
@@ -26,7 +27,11 @@ export async function connectCommand(glob) {
26
27
  console.log(kleur.yellow(' This folder is not a Unity/Godot/GameMaker project — nothing to ingest from disk.'));
27
28
  return;
28
29
  }
29
- const index = await indexGamePngs(kind, process.cwd(), config.outDir);
30
+ const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
31
+ const index = await indexGamePngs(kind, process.cwd(), config.outDir, {
32
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
33
+ });
34
+ spinner.stop();
30
35
  if (index.capped) {
31
36
  console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
32
37
  }
@@ -19,7 +19,7 @@ import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
19
19
  import { applyDiskFingerprints, indexSyncedBySourceRel, planPush, resolveSyncedKey } from '../util/pushPlan.js';
20
20
  import { cmd } from '../util/invoke.js';
21
21
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
22
- import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
22
+ import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
23
23
  import { collectSourceRelMap, remapAbsToCloudKeys } from '../util/syncPath.js';
24
24
  import { assertSafeIoPath } from '../util/security.js';
25
25
  /** Parallel hashing / path checks — enough to saturate disk, low enough to stay polite. */
@@ -42,11 +42,13 @@ export async function runPush(opts = {}) {
42
42
  export async function runPushWith(config, state, opts = {}) {
43
43
  const quiet = opts.quiet === true;
44
44
  const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
45
- const spinner = quiet ? null : ora('Scanning local sprites…').start();
45
+ const spinner = quiet ? null : ora(countingSpritesText(0)).start();
46
46
  const kind = await detectProjectKind();
47
47
  const index = opts.gameIndex
48
48
  ?? (isEngineKind(kind)
49
- ? await indexGamePngs(kind, process.cwd(), config.outDir)
49
+ ? await indexGamePngs(kind, process.cwd(), config.outDir, {
50
+ onProgress: spinner ? (p) => { spinner.text = countingSpritesText(p); } : undefined,
51
+ })
50
52
  : { files: [], capped: false });
51
53
  const matched = matchConnectGlobs(index, config.connect ?? []);
52
54
  const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
@@ -1,7 +1,8 @@
1
1
  import kleur from 'kleur';
2
+ import ora from 'ora';
2
3
  import { loadConfig } from '../config.js';
3
4
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
4
- import { indexGamePngs, searchGameIndex, GAME_INDEX_CAP_HINT } from '../util/gameScan.js';
5
+ import { indexGamePngs, searchGameIndex, GAME_INDEX_CAP_HINT, countingSpritesText } from '../util/gameScan.js';
5
6
  import { cmd } from '../util/invoke.js';
6
7
  const PRINT_CAP = 50;
7
8
  /**
@@ -25,7 +26,11 @@ export async function searchCommand(query) {
25
26
  catch {
26
27
  // Config is optional for search — skip MagicPixel outDir when missing.
27
28
  }
28
- const index = await indexGamePngs(kind, process.cwd(), outDir);
29
+ const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
30
+ const index = await indexGamePngs(kind, process.cwd(), outDir, {
31
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
32
+ });
33
+ spinner.stop();
29
34
  if (index.capped) {
30
35
  console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
31
36
  }
@@ -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, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
15
+ import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
16
16
  import { collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
17
17
  import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
18
18
  import { filterUnityManifest } from '../util/unityFilter.js';
@@ -61,22 +61,22 @@ async function watchLoop(opts) {
61
61
  const config = await loadConfig();
62
62
  const kind = await detectProjectKind();
63
63
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
64
- countSpinner = ora({ text: 'Counting sprites in your game…', spinner: 'dots' }).start();
64
+ countSpinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
65
65
  }
66
66
  }
67
67
  catch {
68
68
  /* header is cosmetic */
69
69
  }
70
70
  }
71
- const spriteLine = await loadWatchSpriteLine();
71
+ const header = await loadWatchHeader(countSpinner ? (p) => { countSpinner.text = countingSpritesText(p); } : undefined);
72
72
  if (countSpinner) {
73
- if (spriteLine)
74
- countSpinner.succeed(spriteLine.trim());
73
+ if (header.line)
74
+ countSpinner.succeed(header.line.trim());
75
75
  else
76
76
  countSpinner.stop();
77
77
  }
78
- else if (spriteLine && !opts.quiet) {
79
- console.log(kleur.dim(spriteLine));
78
+ else if (header.line && !opts.quiet) {
79
+ console.log(kleur.dim(header.line));
80
80
  }
81
81
  console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
82
82
  console.log();
@@ -143,7 +143,7 @@ async function watchLoop(opts) {
143
143
  };
144
144
  try {
145
145
  onStatus('Checking MagicPixel…');
146
- const r = await runOnce({ ...opts, watch: false }, { watchMode: true, onStatus });
146
+ const r = await runOnce({ ...opts, watch: false }, { watchMode: true, onStatus, gameIndex: header.gameIndex });
147
147
  // Reset backoff on any successful tick. Resume message fires once when
148
148
  // we recover from an auth pause — `getApiKey()` re-reads
149
149
  // .magicpixel/credentials on every call, so a `magicpixel login` in
@@ -242,6 +242,7 @@ async function watchLoop(opts) {
242
242
  }
243
243
  }
244
244
  finally {
245
+ header.gameIndex = undefined;
245
246
  inFlight = false;
246
247
  }
247
248
  };
@@ -329,25 +330,25 @@ export function classifyTickError(err, state) {
329
330
  }
330
331
  return { kind: 'other', nextBackoffSec: Math.min(state.backoffSec * 2, 60) };
331
332
  }
332
- async function loadWatchSpriteLine() {
333
+ async function loadWatchHeader(onProgress) {
333
334
  try {
334
335
  const config = await loadConfig();
335
336
  const state = await loadState();
336
337
  const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
337
338
  let workingSet = 0;
339
+ let gameIndex;
338
340
  const kind = await detectProjectKind();
339
341
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
340
- const index = await indexGamePngs(kind, process.cwd(), config.outDir);
341
- const matched = matchConnectGlobs(index, config.connect);
342
- workingSet = matched.total;
342
+ gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
343
+ workingSet = matchConnectGlobs(gameIndex, config.connect).total;
343
344
  }
344
345
  else if (state.synced) {
345
346
  workingSet = Object.keys(state.synced).length;
346
347
  }
347
- return formatWatchSpriteLine({ workingSet, lastPulled });
348
+ return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
348
349
  }
349
350
  catch {
350
- return null;
351
+ return { line: null };
351
352
  }
352
353
  }
353
354
  async function runOnce(opts, runOpts = {}) {
@@ -406,9 +407,18 @@ async function runOnce(opts, runOpts = {}) {
406
407
  // magicpixel.json opts back into everything.
407
408
  onStatus?.('Looking through your game sprites…');
408
409
  const projectKind = await detectProjectKind();
409
- const gameIndex = isEngineKind(projectKind)
410
- ? await indexGamePngs(projectKind, process.cwd(), config.outDir)
411
- : { files: [], capped: false };
410
+ const gameIndex = runOpts.gameIndex
411
+ ?? (isEngineKind(projectKind)
412
+ ? await indexGamePngs(projectKind, process.cwd(), config.outDir, {
413
+ onProgress: (p) => {
414
+ const n = p.pngs.toLocaleString('en-US');
415
+ const f = p.folders.toLocaleString('en-US');
416
+ onStatus?.(p.folders > 0
417
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders`
418
+ : `Looking through your game sprites… ${n}`);
419
+ },
420
+ })
421
+ : { files: [], capped: false });
412
422
  const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
413
423
  const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
414
424
  if (verbose && gameIndex.capped) {
@@ -23,6 +23,10 @@ export const GAME_SCAN_SKIP_DIRS = new Set([
23
23
  'streamingassets',
24
24
  'node_modules',
25
25
  'magicpixel',
26
+ 'build',
27
+ 'builds',
28
+ 'recordings',
29
+ 'memorycaptures',
26
30
  ]);
27
31
  /**
28
32
  * Dot-directories we never walk. Other hidden folders (e.g. `.SpineRaw_*`)
@@ -94,8 +98,43 @@ function toEntry(abs, cwdAbs, assetRoot) {
94
98
  return null;
95
99
  return { abs, sourceRel, adoptRel, key: adopted.path.join('/') };
96
100
  }
101
+ function fmtCount(n, noun) {
102
+ return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`;
103
+ }
104
+ /** Spinner / status copy while walking the game tree. */
105
+ export function countingSpritesText(progress = 0) {
106
+ const pngs = typeof progress === 'number' ? progress : progress.pngs;
107
+ const folders = typeof progress === 'number' ? 0 : progress.folders;
108
+ if (pngs === 0 && folders === 0)
109
+ return 'Counting sprites in your game…';
110
+ const spriteBit = fmtCount(pngs, 'sprite');
111
+ if (folders > 0)
112
+ return `Counting sprites in your game… ${spriteBit} · ${fmtCount(folders, 'folder')}`;
113
+ return `Counting sprites in your game… ${spriteBit}`;
114
+ }
115
+ function bindScanProgress(onProgress) {
116
+ if (!onProgress)
117
+ return () => { };
118
+ let lastAt = 0;
119
+ let lastPngs = -1;
120
+ let lastFolders = -1;
121
+ return (pngs, folders) => {
122
+ if (pngs === lastPngs && folders === lastFolders)
123
+ return;
124
+ const now = Date.now();
125
+ if (lastPngs < 0 || now - lastAt >= 80) {
126
+ lastAt = now;
127
+ lastPngs = pngs;
128
+ lastFolders = folders;
129
+ onProgress({ pngs, folders });
130
+ }
131
+ };
132
+ }
133
+ function samePath(a, b) {
134
+ return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
135
+ }
97
136
  /** Walk the engine tree and return index entries. Does not copy files. */
98
- export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '') {
137
+ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts = {}) {
99
138
  const empty = { files: [], capped: false };
100
139
  if (!isEngineKind(kind))
101
140
  return empty;
@@ -106,8 +145,11 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '') {
106
145
  const namedRoot = gameScanRoot(kind);
107
146
  const assetRoot = namedRoot ? resolveChildDir(cwd, namedRoot) : null;
108
147
  const found = [];
148
+ const stats = { folders: 0 };
109
149
  const io = createLimit(SCAN_WALK_CONCURRENCY);
110
- await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io);
150
+ const report = bindScanProgress(opts.onProgress);
151
+ await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, assetRoot);
152
+ opts.onProgress?.({ pngs: found.length, folders: stats.folders });
111
153
  found.sort((a, b) => a.localeCompare(b));
112
154
  const capped = found.length > MAX_GAME_INDEX;
113
155
  if (capped)
@@ -136,9 +178,12 @@ export function searchGameIndex(index, query) {
136
178
  return [];
137
179
  return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
138
180
  }
139
- async function walkPngs(dir, root, destNorm, out, limit, io) {
181
+ async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
140
182
  if (out.length >= limit)
141
183
  return;
184
+ // Count the folder *before* readdir so a huge listing still shows movement.
185
+ stats.folders++;
186
+ onVisit(out.length, stats.folders);
142
187
  let entries;
143
188
  try {
144
189
  entries = await readdir(dir, { withFileTypes: true });
@@ -147,7 +192,8 @@ async function walkPngs(dir, root, destNorm, out, limit, io) {
147
192
  return;
148
193
  }
149
194
  entries.sort((a, b) => a.name.localeCompare(b.name));
150
- const subdirs = [];
195
+ const preferred = [];
196
+ const other = [];
151
197
  for (const ent of entries) {
152
198
  if (out.length >= limit)
153
199
  return;
@@ -165,13 +211,25 @@ async function walkPngs(dir, root, destNorm, out, limit, io) {
165
211
  continue;
166
212
  if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
167
213
  continue;
168
- subdirs.push(full);
214
+ if (preferFirst && samePath(full, preferFirst))
215
+ preferred.push(full);
216
+ else
217
+ other.push(full);
169
218
  }
170
219
  else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
171
220
  out.push(full);
221
+ onVisit(out.length, stats.folders);
172
222
  }
173
223
  }
174
- if (subdirs.length === 0 || out.length >= limit)
224
+ if (out.length >= limit)
225
+ return;
226
+ // Engine asset tree first (Unity `Assets/`) so sprite counts appear before
227
+ // sibling folders like Builds/Editor. Nested walks keep preferFirst so a
228
+ // misplaced `Assets` dir is still prioritized if we ever see it.
229
+ for (const d of preferred) {
230
+ await walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null);
231
+ }
232
+ if (other.length === 0 || out.length >= limit)
175
233
  return;
176
- await Promise.all(subdirs.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io))));
234
+ await Promise.all(other.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null))));
177
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {