@magicpixelart/cli 0.5.11 → 0.5.13

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,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.13] — 2026-08-26
11
+
12
+ ### Fixed
13
+
14
+ - **Connect no longer hangs at `0 sprites · 1 folder`.** Full Unity games
15
+ (`ProjectSettings` present) scan `Assets/` only — listing Library and the
16
+ rest of the project root blocked the spinner before any sprite was found.
17
+ Directory reads stream so the path keeps updating (`Assets/Sprites`), and
18
+ nested walks no longer deadlock on the folder limiter.
19
+ - **`connect` copy.** Re-running `connect '**'` no longer prints
20
+ `connect pattern already present: **`. First-time connect says
21
+ `now syncing all sprites in your game`.
22
+
23
+ ## [0.5.12] — 2026-08-26
24
+
25
+ ### Fixed
26
+
27
+ - **Watch counting no longer looks hung.** Sprite counting used to stay on
28
+ `Counting sprites in your game…` with no numbers until the first PNG, which
29
+ on a large Unity tree can take a long time. The spinner now updates as
30
+ folders are visited (`0 sprites · 128 folders`) and walks `Assets/` before
31
+ sibling project folders so the sprite count appears sooner.
32
+
10
33
  ## [0.5.11] — 2026-08-26
11
34
 
12
35
  ### Changed
@@ -6,6 +6,14 @@ import { detectProjectKind, isEngineKind } from '../util/framework.js';
6
6
  import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
7
7
  import { cmd } from '../util/invoke.js';
8
8
  import { runPush } from './push.js';
9
+ /** Plain-language label for a connect glob (`**` is not meaningful to players). */
10
+ export function describeWorkingSet(glob) {
11
+ const g = glob.trim();
12
+ if (g === '**' || g === '**/*' || g === '**/**' || g === '**/*.png' || g === '**/**/*.png') {
13
+ return 'all sprites in your game';
14
+ }
15
+ return g;
16
+ }
9
17
  /**
10
18
  * Add a working-set glob and ingest matching game PNGs into Connected.
11
19
  * Sync writes edits back to the original files — not a copy under outDir.
@@ -17,10 +25,7 @@ export async function connectCommand(glob) {
17
25
  if (!already) {
18
26
  config.connect.push(pattern);
19
27
  await saveConfig(config);
20
- console.log(kleur.green(`✓ added connect pattern: ${pattern}`));
21
- }
22
- else {
23
- console.log(kleur.dim(` connect pattern already present: ${pattern}`));
28
+ console.log(kleur.green(`✓ now syncing ${describeWorkingSet(pattern)}`));
24
29
  }
25
30
  const kind = await detectProjectKind();
26
31
  if (!isEngineKind(kind)) {
@@ -29,7 +34,7 @@ export async function connectCommand(glob) {
29
34
  }
30
35
  const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
31
36
  const index = await indexGamePngs(kind, process.cwd(), config.outDir, {
32
- onProgress: (n) => { spinner.text = countingSpritesText(n); },
37
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
33
38
  });
34
39
  spinner.stop();
35
40
  if (index.capped) {
@@ -47,7 +47,7 @@ export async function runPushWith(config, state, opts = {}) {
47
47
  const index = opts.gameIndex
48
48
  ?? (isEngineKind(kind)
49
49
  ? await indexGamePngs(kind, process.cwd(), config.outDir, {
50
- onProgress: spinner ? (n) => { spinner.text = countingSpritesText(n); } : undefined,
50
+ onProgress: spinner ? (p) => { spinner.text = countingSpritesText(p); } : undefined,
51
51
  })
52
52
  : { files: [], capped: false });
53
53
  const matched = matchConnectGlobs(index, config.connect ?? []);
@@ -28,7 +28,7 @@ export async function searchCommand(query) {
28
28
  }
29
29
  const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
30
30
  const index = await indexGamePngs(kind, process.cwd(), outDir, {
31
- onProgress: (n) => { spinner.text = countingSpritesText(n); },
31
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
32
32
  });
33
33
  spinner.stop();
34
34
  if (index.capped) {
@@ -68,7 +68,7 @@ async function watchLoop(opts) {
68
68
  /* header is cosmetic */
69
69
  }
70
70
  }
71
- const header = await loadWatchHeader(countSpinner ? (n) => { countSpinner.text = countingSpritesText(n); } : undefined);
71
+ const header = await loadWatchHeader(countSpinner ? (p) => { countSpinner.text = countingSpritesText(p); } : undefined);
72
72
  if (countSpinner) {
73
73
  if (header.line)
74
74
  countSpinner.succeed(header.line.trim());
@@ -410,7 +410,15 @@ async function runOnce(opts, runOpts = {}) {
410
410
  const gameIndex = runOpts.gameIndex
411
411
  ?? (isEngineKind(projectKind)
412
412
  ? await indexGamePngs(projectKind, process.cwd(), config.outDir, {
413
- onProgress: (n) => onStatus?.(`Looking through your game sprites… ${n.toLocaleString('en-US')}`),
413
+ onProgress: (p) => {
414
+ const n = p.pngs.toLocaleString('en-US');
415
+ const f = p.folders.toLocaleString('en-US');
416
+ onStatus?.(p.current
417
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders · ${p.current}`
418
+ : p.folders > 0
419
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders`
420
+ : `Looking through your game sprites… ${n}`);
421
+ },
414
422
  })
415
423
  : { files: [], capped: false });
416
424
  const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
@@ -4,7 +4,7 @@
4
4
  * globs select a working set; sync writes back to those original paths.
5
5
  */
6
6
  import { existsSync } from 'node:fs';
7
- import { readdir } from 'node:fs/promises';
7
+ import { opendir } from 'node:fs/promises';
8
8
  import { relative, resolve } from 'node:path';
9
9
  import { isEngineKind, resolveChildDir } from './framework.js';
10
10
  import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
@@ -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_*`)
@@ -48,8 +52,12 @@ export const MAX_GAME_INDEX = 10_000;
48
52
  export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
49
53
  export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
50
54
  export function connectCapMessage(total, ingested) {
51
- return (`${total} PNGs match the working set — ingesting the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
52
- `Narrow the glob, or add another connect pattern for the rest.`);
55
+ return (`${total} sprites match — syncing the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
56
+ `Connect a smaller folder if you don't need all of them.`);
57
+ }
58
+ /** True for a full Unity game project (not an embedded UPM package). */
59
+ function isUnityGameProject(cwd, assetRoot) {
60
+ return Boolean(assetRoot && existsSync(resolve(cwd, 'ProjectSettings')));
53
61
  }
54
62
  export function gameScanRoot(kind) {
55
63
  switch (kind) {
@@ -94,28 +102,67 @@ function toEntry(abs, cwdAbs, assetRoot) {
94
102
  return null;
95
103
  return { abs, sourceRel, adoptRel, key: adopted.path.join('/') };
96
104
  }
105
+ function fmtCount(n, noun) {
106
+ return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`;
107
+ }
108
+ function shortPath(rel) {
109
+ const n = rel.replace(/\\/g, '/');
110
+ if (!n || n === '.')
111
+ return '';
112
+ return n.length <= 48 ? n : `…${n.slice(-47)}`;
113
+ }
97
114
  /** Spinner / status copy while walking the game tree. */
98
- export function countingSpritesText(found) {
99
- return found > 0
100
- ? `Counting sprites in your game… ${found.toLocaleString('en-US')}`
101
- : 'Counting sprites in your game…';
115
+ export function countingSpritesText(progress = 0) {
116
+ const pngs = typeof progress === 'number' ? progress : progress.pngs;
117
+ const folders = typeof progress === 'number' ? 0 : progress.folders;
118
+ const current = typeof progress === 'number' ? undefined : progress.current;
119
+ if (pngs === 0 && folders === 0 && !current)
120
+ return 'Counting sprites in your game…';
121
+ const spriteBit = fmtCount(pngs, 'sprite');
122
+ const parts = [spriteBit];
123
+ if (folders > 0)
124
+ parts.push(fmtCount(folders, 'folder'));
125
+ const where = current ? shortPath(current) : '';
126
+ if (where)
127
+ parts.push(where);
128
+ return `Counting sprites in your game… ${parts.join(' · ')}`;
102
129
  }
103
130
  function bindScanProgress(onProgress) {
104
131
  if (!onProgress)
105
132
  return () => { };
106
133
  let lastAt = 0;
107
- let lastN = 0;
108
- return (n) => {
109
- if (n === lastN)
110
- return;
134
+ let pending = null;
135
+ let timer = null;
136
+ const flush = (p) => {
137
+ pending = null;
138
+ lastAt = Date.now();
139
+ onProgress(p);
140
+ };
141
+ return (pngs, folders, current) => {
142
+ const next = { pngs, folders, current };
111
143
  const now = Date.now();
112
- if (lastN === 0 || now - lastAt >= 80) {
113
- lastAt = now;
114
- lastN = n;
115
- onProgress(n);
144
+ if (lastAt === 0 || now - lastAt >= 80) {
145
+ if (timer) {
146
+ clearTimeout(timer);
147
+ timer = null;
148
+ }
149
+ flush(next);
150
+ return;
151
+ }
152
+ pending = next;
153
+ if (!timer) {
154
+ timer = setTimeout(() => {
155
+ timer = null;
156
+ if (pending)
157
+ flush(pending);
158
+ }, 80 - (now - lastAt));
159
+ timer.unref();
116
160
  }
117
161
  };
118
162
  }
163
+ function samePath(a, b) {
164
+ return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
165
+ }
119
166
  /** Walk the engine tree and return index entries. Does not copy files. */
120
167
  export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts = {}) {
121
168
  const empty = { files: [], capped: false };
@@ -128,11 +175,15 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
128
175
  const namedRoot = gameScanRoot(kind);
129
176
  const assetRoot = namedRoot ? resolveChildDir(cwd, namedRoot) : null;
130
177
  const found = [];
178
+ const stats = { folders: 0 };
131
179
  const io = createLimit(SCAN_WALK_CONCURRENCY);
132
180
  const report = bindScanProgress(opts.onProgress);
133
- await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report);
134
- if (found.length > 0)
135
- opts.onProgress?.(found.length);
181
+ // Full Unity games: walk Assets/ only. Listing the project root waits on
182
+ // Library/Packages/cloud placeholders and looks hung at "1 folder".
183
+ const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
184
+ const preferFirst = startAt === scanRoot ? assetRoot : null;
185
+ await walkPngs(startAt, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, preferFirst);
186
+ opts.onProgress?.({ pngs: found.length, folders: stats.folders });
136
187
  found.sort((a, b) => a.localeCompare(b));
137
188
  const capped = found.length > MAX_GAME_INDEX;
138
189
  if (capped)
@@ -161,43 +212,74 @@ export function searchGameIndex(index, query) {
161
212
  return [];
162
213
  return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
163
214
  }
164
- async function walkPngs(dir, root, destNorm, out, limit, io, onFound) {
165
- if (out.length >= limit)
166
- return;
167
- let entries;
215
+ function folderLabel(dir, root) {
216
+ return relative(root, dir).replace(/\\/g, '/') || '.';
217
+ }
218
+ /**
219
+ * List one directory. The limiter wraps *only* this listing so a parent does
220
+ * not hold a slot while waiting on children (that deadlocks wide Unity trees).
221
+ */
222
+ async function listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current) {
223
+ const preferred = [];
224
+ const other = [];
225
+ let dh;
168
226
  try {
169
- entries = await readdir(dir, { withFileTypes: true });
227
+ dh = await opendir(dir);
170
228
  }
171
229
  catch {
172
- return;
230
+ return { preferred, other };
173
231
  }
174
- entries.sort((a, b) => a.name.localeCompare(b.name));
175
- const subdirs = [];
176
- for (const ent of entries) {
177
- if (out.length >= limit)
178
- return;
179
- if (ent.isSymbolicLink())
180
- continue;
181
- const full = resolve(dir, ent.name);
182
- try {
183
- assertPathInsideRoot(full, root, 'scan');
184
- }
185
- catch {
186
- continue;
187
- }
188
- if (ent.isDirectory()) {
189
- if (shouldSkipDir(ent.name))
232
+ let lastBeat = Date.now();
233
+ try {
234
+ for await (const ent of dh) {
235
+ if (out.length >= limit)
236
+ break;
237
+ const now = Date.now();
238
+ if (now - lastBeat >= 80) {
239
+ lastBeat = now;
240
+ onVisit(out.length, stats.folders, current);
241
+ }
242
+ if (shouldSkipDir(ent.name) || ent.isSymbolicLink())
190
243
  continue;
191
- if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
244
+ const full = resolve(dir, ent.name);
245
+ try {
246
+ assertPathInsideRoot(full, root, 'scan');
247
+ }
248
+ catch {
192
249
  continue;
193
- subdirs.push(full);
194
- }
195
- else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
196
- out.push(full);
197
- onFound?.(out.length);
250
+ }
251
+ if (ent.isDirectory()) {
252
+ if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
253
+ continue;
254
+ if (preferFirst && samePath(full, preferFirst))
255
+ preferred.push(full);
256
+ else
257
+ other.push(full);
258
+ }
259
+ else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
260
+ out.push(full);
261
+ onVisit(out.length, stats.folders, current);
262
+ }
198
263
  }
199
264
  }
200
- if (subdirs.length === 0 || out.length >= limit)
265
+ catch {
266
+ /* unreadable mid-listing */
267
+ }
268
+ return { preferred, other };
269
+ }
270
+ async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
271
+ if (out.length >= limit)
272
+ return;
273
+ stats.folders++;
274
+ const current = folderLabel(dir, root);
275
+ onVisit(out.length, stats.folders, current);
276
+ const { preferred, other } = await io(() => listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current));
277
+ if (out.length >= limit)
278
+ return;
279
+ for (const d of preferred) {
280
+ await walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null);
281
+ }
282
+ if (other.length === 0 || out.length >= limit)
201
283
  return;
202
- await Promise.all(subdirs.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io, onFound))));
284
+ await Promise.all(other.map((d) => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null)));
203
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {