@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 +23 -0
- package/dist/commands/connect.js +10 -5
- package/dist/commands/push.js +1 -1
- package/dist/commands/search.js +1 -1
- package/dist/commands/sync.js +10 -2
- package/dist/util/gameScan.js +130 -48
- package/package.json +1 -1
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
|
package/dist/commands/connect.js
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(`✓
|
|
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: (
|
|
37
|
+
onProgress: (p) => { spinner.text = countingSpritesText(p); },
|
|
33
38
|
});
|
|
34
39
|
spinner.stop();
|
|
35
40
|
if (index.capped) {
|
package/dist/commands/push.js
CHANGED
|
@@ -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 ? (
|
|
50
|
+
onProgress: spinner ? (p) => { spinner.text = countingSpritesText(p); } : undefined,
|
|
51
51
|
})
|
|
52
52
|
: { files: [], capped: false });
|
|
53
53
|
const matched = matchConnectGlobs(index, config.connect ?? []);
|
package/dist/commands/search.js
CHANGED
|
@@ -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: (
|
|
31
|
+
onProgress: (p) => { spinner.text = countingSpritesText(p); },
|
|
32
32
|
});
|
|
33
33
|
spinner.stop();
|
|
34
34
|
if (index.capped) {
|
package/dist/commands/sync.js
CHANGED
|
@@ -68,7 +68,7 @@ async function watchLoop(opts) {
|
|
|
68
68
|
/* header is cosmetic */
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
const header = await loadWatchHeader(countSpinner ? (
|
|
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: (
|
|
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 ?? []);
|
package/dist/util/gameScan.js
CHANGED
|
@@ -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 {
|
|
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}
|
|
52
|
-
`
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
227
|
+
dh = await opendir(dir);
|
|
170
228
|
}
|
|
171
229
|
catch {
|
|
172
|
-
return;
|
|
230
|
+
return { preferred, other };
|
|
173
231
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
244
|
+
const full = resolve(dir, ent.name);
|
|
245
|
+
try {
|
|
246
|
+
assertPathInsideRoot(full, root, 'scan');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
192
249
|
continue;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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(
|
|
284
|
+
await Promise.all(other.map((d) => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null)));
|
|
203
285
|
}
|