@magicpixelart/cli 0.5.12 → 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,19 @@ 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
+
10
23
  ## [0.5.12] — 2026-08-26
11
24
 
12
25
  ### Fixed
@@ -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)) {
@@ -413,9 +413,11 @@ async function runOnce(opts, runOpts = {}) {
413
413
  onProgress: (p) => {
414
414
  const n = p.pngs.toLocaleString('en-US');
415
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}`);
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}`);
419
421
  },
420
422
  })
421
423
  : { files: [], capped: false });
@@ -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';
@@ -52,8 +52,12 @@ export const MAX_GAME_INDEX = 10_000;
52
52
  export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
53
53
  export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
54
54
  export function connectCapMessage(total, ingested) {
55
- return (`${total} PNGs match the working set — ingesting the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
56
- `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')));
57
61
  }
58
62
  export function gameScanRoot(kind) {
59
63
  switch (kind) {
@@ -101,32 +105,58 @@ function toEntry(abs, cwdAbs, assetRoot) {
101
105
  function fmtCount(n, noun) {
102
106
  return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`;
103
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
+ }
104
114
  /** Spinner / status copy while walking the game tree. */
105
115
  export function countingSpritesText(progress = 0) {
106
116
  const pngs = typeof progress === 'number' ? progress : progress.pngs;
107
117
  const folders = typeof progress === 'number' ? 0 : progress.folders;
108
- if (pngs === 0 && folders === 0)
118
+ const current = typeof progress === 'number' ? undefined : progress.current;
119
+ if (pngs === 0 && folders === 0 && !current)
109
120
  return 'Counting sprites in your game…';
110
121
  const spriteBit = fmtCount(pngs, 'sprite');
122
+ const parts = [spriteBit];
111
123
  if (folders > 0)
112
- return `Counting sprites in your game… ${spriteBit} · ${fmtCount(folders, 'folder')}`;
113
- return `Counting sprites in your game… ${spriteBit}`;
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(' · ')}`;
114
129
  }
115
130
  function bindScanProgress(onProgress) {
116
131
  if (!onProgress)
117
132
  return () => { };
118
133
  let lastAt = 0;
119
- let lastPngs = -1;
120
- let lastFolders = -1;
121
- return (pngs, folders) => {
122
- if (pngs === lastPngs && folders === lastFolders)
123
- 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 };
124
143
  const now = Date.now();
125
- if (lastPngs < 0 || now - lastAt >= 80) {
126
- lastAt = now;
127
- lastPngs = pngs;
128
- lastFolders = folders;
129
- onProgress({ pngs, folders });
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();
130
160
  }
131
161
  };
132
162
  }
@@ -148,7 +178,11 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
148
178
  const stats = { folders: 0 };
149
179
  const io = createLimit(SCAN_WALK_CONCURRENCY);
150
180
  const report = bindScanProgress(opts.onProgress);
151
- await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, assetRoot);
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);
152
186
  opts.onProgress?.({ pngs: found.length, folders: stats.folders });
153
187
  found.sort((a, b) => a.localeCompare(b));
154
188
  const capped = found.length > MAX_GAME_INDEX;
@@ -178,58 +212,74 @@ export function searchGameIndex(index, query) {
178
212
  return [];
179
213
  return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
180
214
  }
181
- async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
182
- if (out.length >= limit)
183
- return;
184
- // Count the folder *before* readdir so a huge listing still shows movement.
185
- stats.folders++;
186
- onVisit(out.length, stats.folders);
187
- 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;
188
226
  try {
189
- entries = await readdir(dir, { withFileTypes: true });
227
+ dh = await opendir(dir);
190
228
  }
191
229
  catch {
192
- return;
230
+ return { preferred, other };
193
231
  }
194
- entries.sort((a, b) => a.name.localeCompare(b.name));
195
- const preferred = [];
196
- const other = [];
197
- for (const ent of entries) {
198
- if (out.length >= limit)
199
- return;
200
- if (ent.isSymbolicLink())
201
- continue;
202
- const full = resolve(dir, ent.name);
203
- try {
204
- assertPathInsideRoot(full, root, 'scan');
205
- }
206
- catch {
207
- continue;
208
- }
209
- if (ent.isDirectory()) {
210
- 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())
211
243
  continue;
212
- if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
244
+ const full = resolve(dir, ent.name);
245
+ try {
246
+ assertPathInsideRoot(full, root, 'scan');
247
+ }
248
+ catch {
213
249
  continue;
214
- if (preferFirst && samePath(full, preferFirst))
215
- preferred.push(full);
216
- else
217
- other.push(full);
218
- }
219
- else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
220
- out.push(full);
221
- onVisit(out.length, stats.folders);
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
+ }
222
263
  }
223
264
  }
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));
224
277
  if (out.length >= limit)
225
278
  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
279
  for (const d of preferred) {
230
280
  await walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null);
231
281
  }
232
282
  if (other.length === 0 || out.length >= limit)
233
283
  return;
234
- await Promise.all(other.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null))));
284
+ await Promise.all(other.map((d) => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null)));
235
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {