@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 +18 -0
- package/dist/commands/connect.js +7 -2
- package/dist/commands/push.js +5 -3
- package/dist/commands/search.js +7 -2
- package/dist/commands/sync.js +27 -17
- package/dist/util/gameScan.js +65 -7
- package/package.json +1 -1
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
|
package/dist/commands/connect.js
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
|
|
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
|
}
|
package/dist/commands/push.js
CHANGED
|
@@ -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(
|
|
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);
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -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:
|
|
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
|
|
71
|
+
const header = await loadWatchHeader(countSpinner ? (p) => { countSpinner.text = countingSpritesText(p); } : undefined);
|
|
72
72
|
if (countSpinner) {
|
|
73
|
-
if (
|
|
74
|
-
countSpinner.succeed(
|
|
73
|
+
if (header.line)
|
|
74
|
+
countSpinner.succeed(header.line.trim());
|
|
75
75
|
else
|
|
76
76
|
countSpinner.stop();
|
|
77
77
|
}
|
|
78
|
-
else if (
|
|
79
|
-
console.log(kleur.dim(
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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 =
|
|
410
|
-
|
|
411
|
-
|
|
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) {
|
package/dist/util/gameScan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
234
|
+
await Promise.all(other.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null))));
|
|
177
235
|
}
|