@magicpixelart/cli 0.5.36 → 0.6.1
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 +54 -5
- package/README.md +26 -0
- package/dist/commands/add.js +2 -1
- package/dist/commands/cleanStrays.js +92 -0
- package/dist/commands/connect.js +7 -4
- package/dist/commands/consolidate.js +308 -0
- package/dist/commands/doctor.js +3 -2
- package/dist/commands/init.js +6 -5
- package/dist/commands/login.js +2 -1
- package/dist/commands/logout.js +2 -1
- package/dist/commands/push.js +51 -17
- package/dist/commands/remove.js +2 -1
- package/dist/commands/repair.js +13 -23
- package/dist/commands/resync.js +11 -20
- package/dist/commands/search.js +2 -2
- package/dist/commands/start.js +97 -16
- package/dist/commands/sync.js +149 -66
- package/dist/commands/whoami.js +4 -3
- package/dist/commands/why.js +91 -0
- package/dist/config.js +3 -0
- package/dist/index.js +47 -4
- package/dist/util/framework.js +20 -2
- package/dist/util/gameScan.js +94 -11
- package/dist/util/projectRoots.js +73 -0
- package/dist/util/prompt.js +15 -0
- package/dist/util/strayGuard.js +76 -0
- package/dist/util/ui.js +156 -0
- package/dist/util/watchCopy.js +0 -16
- package/dist/util/workspace.js +82 -0
- package/package.json +1 -1
package/dist/commands/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
-
import
|
|
2
|
+
import { canRedraw, card, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
|
|
3
3
|
import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
@@ -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, countingSpritesText } from '../util/gameScan.js';
|
|
15
|
+
import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
|
|
16
16
|
import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
17
17
|
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
18
18
|
import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, workingSetPullKeys } from '../util/unityFilter.js';
|
|
@@ -23,7 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
|
|
|
23
23
|
import { formatBytes } from '../util/format.js';
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
|
-
import {
|
|
26
|
+
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
27
|
import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
@@ -31,6 +31,8 @@ import { runPush } from './push.js';
|
|
|
31
31
|
import { claimResyncRequest, reportResyncRequest } from '../api.js';
|
|
32
32
|
import { runResync } from './resync.js';
|
|
33
33
|
import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
|
|
34
|
+
import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
|
|
35
|
+
import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
|
|
34
36
|
/** How often the watcher asks for a library "Resync from game" request. */
|
|
35
37
|
const RESYNC_POLL_MS = 15_000;
|
|
36
38
|
let lastResyncPollAt = 0;
|
|
@@ -66,7 +68,7 @@ async function maybeRunQueuedResync(quiet) {
|
|
|
66
68
|
catch (e) {
|
|
67
69
|
await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
|
|
68
70
|
if (!quiet)
|
|
69
|
-
console.log(
|
|
71
|
+
console.log(ui.fail(`Resync failed: ${e.message}`));
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
@@ -87,7 +89,7 @@ export function pushWasActive(pushed) {
|
|
|
87
89
|
export function needsLocalPngWalk(since, toDownloadCount) {
|
|
88
90
|
return !since || toDownloadCount > 0;
|
|
89
91
|
}
|
|
90
|
-
export async function syncCommand(opts) {
|
|
92
|
+
export async function syncCommand(opts, reuse = {}) {
|
|
91
93
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
92
94
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
93
95
|
// (watch mode included) and only touches files older than 30s, so it can
|
|
@@ -109,7 +111,7 @@ export async function syncCommand(opts) {
|
|
|
109
111
|
await watchLoop(opts);
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
112
|
-
await runOnce(opts);
|
|
114
|
+
await runOnce(opts, { gameIndex: reuse.gameIndex });
|
|
113
115
|
}
|
|
114
116
|
async function watchLoop(opts) {
|
|
115
117
|
// Default 2s — matches the perceived-instant UX users expect when editing
|
|
@@ -121,7 +123,7 @@ async function watchLoop(opts) {
|
|
|
121
123
|
const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
|
|
122
124
|
// Header — print once on start. Counts are best-effort; if the first
|
|
123
125
|
// manifest fetch fails we still want the loop to come up and retry.
|
|
124
|
-
console.log(
|
|
126
|
+
console.log(card(`${icon('watch')} MagicPixel is watching for changes`, [`Edit at ${kleur.cyan('https://magicpixel.art')}`, kleur.dim('Changes show up here as they happen · Stop: Ctrl+C')], 'info'));
|
|
125
127
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
126
128
|
try {
|
|
127
129
|
const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
|
|
@@ -138,7 +140,7 @@ async function watchLoop(opts) {
|
|
|
138
140
|
const config = await loadConfig();
|
|
139
141
|
const kind = await detectProjectKind();
|
|
140
142
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
141
|
-
countSpinner =
|
|
143
|
+
countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
catch {
|
|
@@ -155,6 +157,8 @@ async function watchLoop(opts) {
|
|
|
155
157
|
else if (header.line && !opts.quiet) {
|
|
156
158
|
console.log(kleur.dim(header.line));
|
|
157
159
|
}
|
|
160
|
+
if (header.excluded && !opts.quiet)
|
|
161
|
+
console.log(kleur.yellow(` ! ${header.excluded}`));
|
|
158
162
|
console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
|
|
159
163
|
console.log();
|
|
160
164
|
let stopping = false;
|
|
@@ -188,7 +192,11 @@ async function watchLoop(opts) {
|
|
|
188
192
|
let lastChangeAt = Date.now();
|
|
189
193
|
// Re-walk the game tree on this cadence so new PNGs are ingested without
|
|
190
194
|
// listing thousands of folders on every 2s tick.
|
|
191
|
-
|
|
195
|
+
// Edits to already-indexed files are picked up every tick (push re-hashes
|
|
196
|
+
// them); the full tree walk only finds brand-new PNGs, so it runs rarely.
|
|
197
|
+
const GAME_INDEX_TTL_MS = 5 * 60_000;
|
|
198
|
+
const reportedIssues = new Set();
|
|
199
|
+
let reportedStrays = '';
|
|
192
200
|
let gameIndexCache = header.gameIndex;
|
|
193
201
|
let gameIndexAt = Date.now();
|
|
194
202
|
// Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
|
|
@@ -225,9 +233,9 @@ async function watchLoop(opts) {
|
|
|
225
233
|
return;
|
|
226
234
|
inFlight = true;
|
|
227
235
|
const onStatus = (msg) => {
|
|
228
|
-
if (opts.quiet)
|
|
229
|
-
return;
|
|
230
|
-
|
|
236
|
+
if (opts.quiet || !canRedraw())
|
|
237
|
+
return; // in-place updates only on a live terminal
|
|
238
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns))}`);
|
|
231
239
|
};
|
|
232
240
|
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
233
241
|
// link) must not look like the silent hang a timeout-less fetch used to
|
|
@@ -247,7 +255,7 @@ async function watchLoop(opts) {
|
|
|
247
255
|
}, SLOW_TICK_HEARTBEAT_MS);
|
|
248
256
|
heartbeat?.unref?.();
|
|
249
257
|
try {
|
|
250
|
-
wrappedStatus('Checking MagicPixel
|
|
258
|
+
wrappedStatus(`${icon('work')} Checking MagicPixel…`);
|
|
251
259
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
252
260
|
? gameIndexCache
|
|
253
261
|
: undefined;
|
|
@@ -275,15 +283,15 @@ async function watchLoop(opts) {
|
|
|
275
283
|
await maybeRunQueuedResync(opts.quiet === true);
|
|
276
284
|
if (wasPausedForAuth && !opts.quiet) {
|
|
277
285
|
process.stdout.write('\x1b[2K\r');
|
|
278
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
286
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
|
|
279
287
|
}
|
|
280
288
|
if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
|
|
281
289
|
process.stdout.write('\x1b[2K\r');
|
|
282
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
290
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
|
|
283
291
|
}
|
|
284
292
|
if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
|
|
285
293
|
process.stdout.write('\x1b[2K\r');
|
|
286
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
294
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Daily allowance available again — resuming.`);
|
|
287
295
|
}
|
|
288
296
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
289
297
|
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
@@ -299,15 +307,37 @@ async function watchLoop(opts) {
|
|
|
299
307
|
}
|
|
300
308
|
if (opts.quiet)
|
|
301
309
|
return;
|
|
310
|
+
const sent = (r.pushed?.created ?? 0) + (r.pushed?.updated ?? 0);
|
|
311
|
+
const freshIssues = (r.pushed?.issues ?? []).filter((i) => !reportedIssues.has(`${i.key}\0${i.text}`));
|
|
312
|
+
const strayLine = r.strays ? `${r.strays.count}\0${r.strays.owner ?? ''}` : '';
|
|
313
|
+
if (sent > 0 || freshIssues.length > 0 || (strayLine && strayLine !== reportedStrays)) {
|
|
314
|
+
process.stdout.write('\x1b[2K\r');
|
|
315
|
+
}
|
|
316
|
+
if (sent > 0) {
|
|
317
|
+
console.log(`${kleur.dim(timestamp())} ${icon('up')} Sent ${ui.count(sent)} edited sprite${sent === 1 ? '' : 's'} to MagicPixel.`);
|
|
318
|
+
}
|
|
319
|
+
for (const i of freshIssues.slice(0, 10)) {
|
|
320
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${ui.name(i.key)}: ${i.text}`);
|
|
321
|
+
}
|
|
322
|
+
if (freshIssues.length > 10)
|
|
323
|
+
console.log(kleur.dim(` …and ${freshIssues.length - 10} more not sent.`));
|
|
324
|
+
for (const i of freshIssues)
|
|
325
|
+
reportedIssues.add(`${i.key}\0${i.text}`);
|
|
326
|
+
if (strayLine && strayLine !== reportedStrays && r.strays) {
|
|
327
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${straySkipMessage(r.strays.count, r.strays.owner, `restart with ${kleur.cyan('--here')}`)}`);
|
|
328
|
+
}
|
|
329
|
+
reportedStrays = strayLine;
|
|
302
330
|
if (changedCount > 0) {
|
|
303
331
|
process.stdout.write('\x1b[2K\r');
|
|
304
332
|
const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
|
|
305
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
333
|
+
console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
|
|
306
334
|
`change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
|
|
307
335
|
printChanges(r, /* indent */ ' ');
|
|
308
336
|
}
|
|
309
337
|
else {
|
|
310
|
-
|
|
338
|
+
if (canRedraw()) {
|
|
339
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${icon('watch')} Watching · all in sync (${r.unchanged.toLocaleString('en-US')} up to date)`, process.stdout.columns))}`);
|
|
340
|
+
}
|
|
311
341
|
}
|
|
312
342
|
}
|
|
313
343
|
catch (e) {
|
|
@@ -330,15 +360,15 @@ async function watchLoop(opts) {
|
|
|
330
360
|
// Surface the request id (from B1) so the user can paste it into a
|
|
331
361
|
// support thread and we can correlate against edge function logs.
|
|
332
362
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
333
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
334
|
-
console.log(
|
|
363
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} Your key looks invalid or rotated.${idSuffix}`);
|
|
364
|
+
console.log(`${ui.fix(cmd('login'))}${kleur.dim(` (this watcher will keep retrying every 30s).`)}`);
|
|
335
365
|
}
|
|
336
366
|
pausedForAuth = true;
|
|
337
367
|
consecutiveAuthFailures = decision.consecutiveAuthFailures;
|
|
338
368
|
backoffSec = decision.nextBackoffSec;
|
|
339
369
|
if (decision.giveUp) {
|
|
340
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
341
|
-
console.log(
|
|
370
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
|
|
371
|
+
console.log(`${ui.fix(cmd('login'))}${kleur.dim(` with a fresh key, then restart the watcher.`)}`);
|
|
342
372
|
// Surface this to /admin/errors — persistent watcher auth failure
|
|
343
373
|
// means a key is mass-rejected (revoked, project deleted, edge
|
|
344
374
|
// misconfig) and we want visibility without waiting for a support
|
|
@@ -351,7 +381,7 @@ async function watchLoop(opts) {
|
|
|
351
381
|
consecutiveAuthFailures = 0;
|
|
352
382
|
if (decision.printMessage) {
|
|
353
383
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
354
|
-
console.log(`${kleur.dim(timestamp())} ${kleur.yellow('
|
|
384
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${kleur.yellow('Paused')} — MagicPixel is offline or your internet is. ` +
|
|
355
385
|
`Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
|
|
356
386
|
}
|
|
357
387
|
pausedForNetwork = true;
|
|
@@ -361,7 +391,7 @@ async function watchLoop(opts) {
|
|
|
361
391
|
consecutiveAuthFailures = 0;
|
|
362
392
|
if (decision.printMessage) {
|
|
363
393
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
364
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
394
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} Daily MagicPixel API allowance reached. ` +
|
|
365
395
|
`Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
|
|
366
396
|
`(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
|
|
367
397
|
}
|
|
@@ -371,7 +401,7 @@ async function watchLoop(opts) {
|
|
|
371
401
|
else {
|
|
372
402
|
consecutiveAuthFailures = 0;
|
|
373
403
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
374
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
404
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
|
|
375
405
|
backoffSec = decision.nextBackoffSec;
|
|
376
406
|
}
|
|
377
407
|
}
|
|
@@ -379,6 +409,10 @@ async function watchLoop(opts) {
|
|
|
379
409
|
if (heartbeat)
|
|
380
410
|
clearInterval(heartbeat);
|
|
381
411
|
inFlight = false;
|
|
412
|
+
if ((pausedForAuth || pausedForNetwork || pausedForQuota) && !opts.quiet && canRedraw()) {
|
|
413
|
+
const why = pausedForAuth ? 'waiting for a valid key' : pausedForQuota ? 'daily allowance reached' : 'offline';
|
|
414
|
+
process.stdout.write(`\r\x1b[2K${kleur.yellow(fitStatusLine(`${timestamp()} ${icon('paused')} Paused (${why}) · retrying in ${formatDuration(backoffSec * 1000)}`, process.stdout.columns))}`);
|
|
415
|
+
}
|
|
382
416
|
}
|
|
383
417
|
};
|
|
384
418
|
await tick();
|
|
@@ -507,15 +541,18 @@ async function loadWatchHeader(onProgress) {
|
|
|
507
541
|
const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
|
|
508
542
|
let workingSet = 0;
|
|
509
543
|
let gameIndex;
|
|
544
|
+
let excluded = null;
|
|
510
545
|
const kind = await detectProjectKind();
|
|
511
546
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
512
547
|
gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
|
|
513
|
-
|
|
548
|
+
const matched = matchConnectGlobs(gameIndex, config.connect, config.exclude);
|
|
549
|
+
workingSet = matched.entries.length;
|
|
550
|
+
excluded = describeExcluded(matched);
|
|
514
551
|
}
|
|
515
552
|
else if (state.synced) {
|
|
516
553
|
workingSet = Object.keys(state.synced).length;
|
|
517
554
|
}
|
|
518
|
-
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
|
|
555
|
+
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
|
|
519
556
|
}
|
|
520
557
|
catch {
|
|
521
558
|
return { line: null };
|
|
@@ -536,7 +573,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
536
573
|
const onStatus = runOpts.onStatus;
|
|
537
574
|
const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
|
|
538
575
|
if (verbose && config.endpoint) {
|
|
539
|
-
console.log(
|
|
576
|
+
console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
|
|
540
577
|
}
|
|
541
578
|
// Incremental unless asked for a full run — or unless a full pass is due:
|
|
542
579
|
// permanently-deleted rows can't be reported incrementally (see reconcile.ts).
|
|
@@ -553,7 +590,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
553
590
|
since = undefined;
|
|
554
591
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
555
592
|
const spinner = verbose
|
|
556
|
-
?
|
|
593
|
+
? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
557
594
|
: null;
|
|
558
595
|
if (!spinner)
|
|
559
596
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
@@ -602,7 +639,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
602
639
|
if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
|
|
603
640
|
await saveState(state);
|
|
604
641
|
if (verbose)
|
|
605
|
-
console.log(
|
|
642
|
+
console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
|
|
606
643
|
}
|
|
607
644
|
const projectSuffix = projectInfo && verbose
|
|
608
645
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -614,7 +651,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
614
651
|
// so the user isn't left wondering why sync "did nothing".
|
|
615
652
|
if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
|
|
616
653
|
console.log();
|
|
617
|
-
console.log(
|
|
654
|
+
console.log(ui.warn(`${projectInfo.hint}`));
|
|
618
655
|
}
|
|
619
656
|
}
|
|
620
657
|
catch (e) {
|
|
@@ -626,7 +663,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
626
663
|
const pushed = await maybePushLocalSprites(config.push, verbose);
|
|
627
664
|
if (verbose && pushed && pushed.created > 0) {
|
|
628
665
|
console.log();
|
|
629
|
-
console.log(
|
|
666
|
+
console.log(ui.ok(`pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
|
|
630
667
|
console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
|
|
631
668
|
}
|
|
632
669
|
}
|
|
@@ -642,11 +679,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
642
679
|
onProgress: (p) => {
|
|
643
680
|
const n = p.pngs.toLocaleString('en-US');
|
|
644
681
|
const f = p.folders.toLocaleString('en-US');
|
|
645
|
-
onStatus?.(p.
|
|
646
|
-
? `Looking through your game sprites… ${n} sprites · ${f} folders
|
|
647
|
-
:
|
|
648
|
-
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
649
|
-
: `Looking through your game sprites… ${n}`);
|
|
682
|
+
onStatus?.(p.folders > 0
|
|
683
|
+
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
684
|
+
: `Looking through your game sprites… ${n}`);
|
|
650
685
|
},
|
|
651
686
|
})
|
|
652
687
|
: { files: [] });
|
|
@@ -686,8 +721,20 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
686
721
|
}
|
|
687
722
|
}
|
|
688
723
|
aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
|
|
724
|
+
if (isEngineKind(projectKind)) {
|
|
725
|
+
placeBesideSiblings(sourceByKey, manifest, new Set(fullGameIndex.files.map((f) => f.sourceRel)));
|
|
726
|
+
}
|
|
727
|
+
if (verbose && gameIndex.keyClashes?.length) {
|
|
728
|
+
console.log(ui.warn(`${gameIndex.keyClashes.length} file${gameIndex.keyClashes.length === 1 ? '' : 's'} share a name with a file in another game folder — skipped:`));
|
|
729
|
+
for (const r of gameIndex.keyClashes.slice(0, 5))
|
|
730
|
+
console.log(kleur.dim(` ${r}`));
|
|
731
|
+
console.log(ui.fixText('rename one of them so each sprite has its own name.'));
|
|
732
|
+
}
|
|
689
733
|
if (verbose && isEngineKind(projectKind)) {
|
|
690
734
|
console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
|
|
735
|
+
const skipped = describeExcluded(connected);
|
|
736
|
+
if (skipped)
|
|
737
|
+
console.log(kleur.dim(` ${skipped}`));
|
|
691
738
|
}
|
|
692
739
|
const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
|
|
693
740
|
// Every folder on disk that belongs to a withheld document. Anything already
|
|
@@ -719,8 +766,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
719
766
|
const n = filtered.unknown.length;
|
|
720
767
|
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
|
|
721
768
|
if (verbose) {
|
|
722
|
-
console.log(
|
|
723
|
-
console.log(
|
|
769
|
+
console.log(ui.warn(`${msg}`));
|
|
770
|
+
console.log(ui.fixText(`re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
724
771
|
}
|
|
725
772
|
else {
|
|
726
773
|
onStatus?.(msg);
|
|
@@ -788,7 +835,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
788
835
|
: 'Checking MagicPixel…');
|
|
789
836
|
const diffLimit = createLimit(concurrency);
|
|
790
837
|
const shaByEntryId = new Map();
|
|
791
|
-
|
|
838
|
+
let toDownload = [];
|
|
792
839
|
const conflicts = [];
|
|
793
840
|
const failedDownloadKeys = new Set();
|
|
794
841
|
let bytesSaved = 0;
|
|
@@ -879,6 +926,37 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
879
926
|
const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
|
|
880
927
|
const remoteKeySet = new Set(manifest.map((e) => e.key));
|
|
881
928
|
const previousKeySet = new Set(Object.values(previousAssets));
|
|
929
|
+
// Wrong-folder guard: sprites connected to another game folder on this
|
|
930
|
+
// machine would land here as stray copies under outDir. Stop before writing.
|
|
931
|
+
const projectIdForRoots = getLastProjectInfo()?.id;
|
|
932
|
+
// Game-owned sprites (server flag) with no home in this folder are skipped —
|
|
933
|
+
// this works even on a machine where the owning folder never synced.
|
|
934
|
+
let strays;
|
|
935
|
+
if (!opts.here) {
|
|
936
|
+
const split = splitStrayDownloads(toDownload, [...sourceByKey.keys(), ...connected.entries.map((e) => e.key)]);
|
|
937
|
+
if (split.foreign.length > 0) {
|
|
938
|
+
toDownload = split.keep;
|
|
939
|
+
const owner = projectIdForRoots
|
|
940
|
+
? await findForeignOwner(projectIdForRoots, process.cwd(), split.foreign.map((e) => e.key))
|
|
941
|
+
: null;
|
|
942
|
+
strays = { count: split.foreign.length, owner: owner?.root ?? null };
|
|
943
|
+
if (verbose) {
|
|
944
|
+
console.log();
|
|
945
|
+
console.log(ui.warn(straySkipMessage(strays.count, strays.owner, `add ${kleur.cyan('--here')}`)));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (projectIdForRoots && !opts.here) {
|
|
950
|
+
const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
|
|
951
|
+
if (owner) {
|
|
952
|
+
const msg = foreignOwnerMessage(owner, process.cwd(), `add ${kleur.cyan('--here')}`);
|
|
953
|
+
if (runOpts.watchMode) {
|
|
954
|
+
console.error(kleur.red(msg));
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
throw new Error(msg);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
882
960
|
const localPngs = needsLocalPngWalk(since, toDownload.length)
|
|
883
961
|
? await walkOutDirPngs(config.outDir)
|
|
884
962
|
: [];
|
|
@@ -1119,7 +1197,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1119
1197
|
}
|
|
1120
1198
|
if (opts.dryRun) {
|
|
1121
1199
|
if (verbose) {
|
|
1122
|
-
console.log(
|
|
1200
|
+
console.log(ui.dryRun());
|
|
1123
1201
|
if (renamed.length > 0)
|
|
1124
1202
|
printRenames(renamed, { withHints: false });
|
|
1125
1203
|
if (orphans.length > 0)
|
|
@@ -1153,7 +1231,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1153
1231
|
let progress = null;
|
|
1154
1232
|
if (live && toDownload.length > 0) {
|
|
1155
1233
|
onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
|
|
1156
|
-
progress =
|
|
1234
|
+
progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
|
|
1157
1235
|
}
|
|
1158
1236
|
const run = createLimit(concurrency);
|
|
1159
1237
|
let done = 0;
|
|
@@ -1223,12 +1301,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1223
1301
|
// so the user sees the fix hint. Single-line errors stay terse.
|
|
1224
1302
|
const msg = e.message ?? String(e);
|
|
1225
1303
|
if (msg.includes('\n')) {
|
|
1226
|
-
console.log(` ${
|
|
1304
|
+
console.log(` ${icon('fail')} ${entry.key}:`);
|
|
1227
1305
|
for (const line of msg.split('\n'))
|
|
1228
1306
|
console.log(` ${line}`);
|
|
1229
1307
|
}
|
|
1230
1308
|
else {
|
|
1231
|
-
console.log(` ${
|
|
1309
|
+
console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
|
|
1232
1310
|
}
|
|
1233
1311
|
progress?.start();
|
|
1234
1312
|
}
|
|
@@ -1285,7 +1363,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1285
1363
|
}
|
|
1286
1364
|
catch (e) {
|
|
1287
1365
|
if (verbose)
|
|
1288
|
-
console.log(` ${
|
|
1366
|
+
console.log(` ${icon('warn')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
|
|
1289
1367
|
}
|
|
1290
1368
|
}
|
|
1291
1369
|
await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
|
|
@@ -1313,7 +1391,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1313
1391
|
}
|
|
1314
1392
|
catch (e) {
|
|
1315
1393
|
if (verbose)
|
|
1316
|
-
console.log(` ${
|
|
1394
|
+
console.log(` ${icon('warn')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
|
|
1317
1395
|
}
|
|
1318
1396
|
}
|
|
1319
1397
|
// Always surface the import-update notice — even in --quiet/watch mode
|
|
@@ -1485,6 +1563,11 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1485
1563
|
// Never let an AGENTS.md write failure break sync.
|
|
1486
1564
|
}
|
|
1487
1565
|
await saveState(nextState);
|
|
1566
|
+
// This folder owns real game paths → remember it so a sync elsewhere can
|
|
1567
|
+
// point back here instead of writing stray copies.
|
|
1568
|
+
if (projectIdForRoots && sourceByKey.size > 0) {
|
|
1569
|
+
await recordProjectRoot(projectIdForRoots, process.cwd());
|
|
1570
|
+
}
|
|
1488
1571
|
if (result.failed > 0 && verbose) {
|
|
1489
1572
|
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
1490
1573
|
}
|
|
@@ -1492,23 +1575,28 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1492
1575
|
const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1493
1576
|
if (verbose) {
|
|
1494
1577
|
console.log();
|
|
1495
|
-
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
|
1496
|
-
`unchanged ${result.unchanged}` +
|
|
1497
|
-
(result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
|
|
1498
|
-
(shouldPrune ? `, pruned ${result.removed.length}` : '') +
|
|
1499
|
-
(renamed.length ? `, renamed ${renamed.length}` : '') +
|
|
1500
|
-
(result.failed ? `, failed ${result.failed}` : '');
|
|
1501
1578
|
const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
|
|
1579
|
+
const skipped = pushed?.issues.length ?? 0;
|
|
1580
|
+
const sent = (pushed?.created ?? 0) + (pushed?.updated ?? 0);
|
|
1581
|
+
const rows = [
|
|
1582
|
+
`${icon('down')} ${ui.count(downloaded)} downloaded ${icon('up')} ${ui.count(sent)} uploaded`,
|
|
1583
|
+
`${icon('ok')} ${ui.count(result.unchanged)} unchanged` +
|
|
1584
|
+
(shouldPrune && result.removed.length ? ` ${icon('removed')} ${ui.count(result.removed.length)} removed` : '') +
|
|
1585
|
+
(skipped ? ` ${icon('warn')} ${ui.count(skipped)} skipped` : ''),
|
|
1586
|
+
...(renamed.length ? [`↪ ${ui.count(renamed.length)} renamed — see below`] : []),
|
|
1587
|
+
...(result.failed || pushFailed ? [`${icon('fail')} ${ui.count(result.failed + pushFailed)} failed — re-run to retry`] : []),
|
|
1588
|
+
kleur.dim(`${icon('time')} ${formatDuration(Date.now() - Date.parse(startedAt))}${result.bytesSaved ? ` · ~${formatBytes(result.bytesSaved)} saved` : ''}`),
|
|
1589
|
+
];
|
|
1502
1590
|
console.log(result.failed || pushFailed
|
|
1503
|
-
?
|
|
1504
|
-
:
|
|
1591
|
+
? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
|
|
1592
|
+
: card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
|
|
1505
1593
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1506
1594
|
if (renamed.length > 0)
|
|
1507
1595
|
printRenames(renamed, { withHints: true });
|
|
1508
1596
|
}
|
|
1509
1597
|
if (result.failed)
|
|
1510
1598
|
process.exitCode = 1;
|
|
1511
|
-
return { ...result, pushed };
|
|
1599
|
+
return { ...result, pushed, ...(strays ? { strays } : {}) };
|
|
1512
1600
|
}
|
|
1513
1601
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1514
1602
|
if (pushEnabled === false)
|
|
@@ -1519,8 +1607,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
|
|
|
1519
1607
|
catch (e) {
|
|
1520
1608
|
if (announceErrors) {
|
|
1521
1609
|
console.log();
|
|
1522
|
-
console.log(
|
|
1523
|
-
console.log(
|
|
1610
|
+
console.log(ui.warn(`could not update MagicPixel from your game files: ${e.message}`));
|
|
1611
|
+
console.log(ui.fixText(`run \`${cmd('push')}\` once the connection is healthy.`));
|
|
1524
1612
|
}
|
|
1525
1613
|
return null;
|
|
1526
1614
|
}
|
|
@@ -1564,16 +1652,11 @@ function printRenames(renamed, opts) {
|
|
|
1564
1652
|
}
|
|
1565
1653
|
}
|
|
1566
1654
|
function progressText(done, total, bytes) {
|
|
1567
|
-
|
|
1568
|
-
const barWidth = 24;
|
|
1569
|
-
const filled = Math.round((pct / 100) * barWidth);
|
|
1570
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1571
|
-
return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
|
|
1655
|
+
return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
|
|
1572
1656
|
}
|
|
1573
|
-
/**
|
|
1574
|
-
function
|
|
1575
|
-
|
|
1576
|
-
process.stdout.write(`\r\x1b[2K${kleur.dim(line)}`);
|
|
1657
|
+
/** Cut a status line to the terminal width so `\r` redraws never wrap. */
|
|
1658
|
+
export function fitStatusLine(line, columns) {
|
|
1659
|
+
return fitLine(line, columns);
|
|
1577
1660
|
}
|
|
1578
1661
|
function timestamp() {
|
|
1579
1662
|
const d = new Date();
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
+
import { ui } from '../util/ui.js';
|
|
2
3
|
import { loadConfig, defaultConfig, resolveEndpoint, getApiKey } from '../config.js';
|
|
3
4
|
import { ApiError, errorCodeFromResponse, friendlyApiError, retryTransient, retryAfterMsFromResponse, getLastProjectInfo } from '../api.js';
|
|
4
5
|
import { safeFetch } from '../util/security.js';
|
|
@@ -71,21 +72,21 @@ export async function whoamiCommand() {
|
|
|
71
72
|
return { kind: 'ok', body, project, serverRequestId };
|
|
72
73
|
});
|
|
73
74
|
if (result.kind === 'rejected') {
|
|
74
|
-
console.log(
|
|
75
|
+
console.log(ui.fail(`API key rejected (${result.status}).`));
|
|
75
76
|
console.log(kleur.dim(' Generate a new key at MagicPixel → Settings → API Keys.'));
|
|
76
77
|
console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
|
|
77
78
|
process.exitCode = 1;
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
if (result.kind === 'error') {
|
|
81
|
-
console.log(
|
|
82
|
+
console.log(ui.fail(`${result.status}: ${result.bodyText.slice(0, 200)}`));
|
|
82
83
|
console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
|
|
83
84
|
process.exitCode = 1;
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
87
|
const { body, project } = result;
|
|
87
88
|
const more = body.nextCursor ? '+' : '';
|
|
88
|
-
console.log(
|
|
89
|
+
console.log(ui.ok('key valid'));
|
|
89
90
|
console.log(` endpoint: ${resolveEndpoint(config)}`);
|
|
90
91
|
if (project) {
|
|
91
92
|
const label = project.name ?? '(unnamed)';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { spinner as makeSpinner } from '../util/ui.js';
|
|
3
|
+
import { loadConfig, loadState } from '../config.js';
|
|
4
|
+
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
5
|
+
import { countingSpritesText, entryMatchesGlob, indexGamePngs, rootSpriteDotDirs } from '../util/gameScan.js';
|
|
6
|
+
import { hashFile } from '../util/hash.js';
|
|
7
|
+
import { indexSyncedBySourceRel, resolveSyncedKey } from '../util/pushPlan.js';
|
|
8
|
+
import { normalizeScopeFolder } from '../util/syncScope.js';
|
|
9
|
+
import { cmd } from '../util/invoke.js';
|
|
10
|
+
import { resolveInvokedPath } from '../util/workspace.js';
|
|
11
|
+
import { relative } from 'node:path';
|
|
12
|
+
/** What the next sync does with one game PNG — no network, local facts only. */
|
|
13
|
+
export function explainSprite(entry, config, synced, currentSha) {
|
|
14
|
+
const hit = (g) => entryMatchesGlob(entry, g);
|
|
15
|
+
if (!(config.connect ?? []).some(hit))
|
|
16
|
+
return { kind: 'not-watched' };
|
|
17
|
+
const rule = (config.exclude ?? []).find(hit);
|
|
18
|
+
if (rule !== undefined)
|
|
19
|
+
return { kind: 'excluded', rule };
|
|
20
|
+
const stateKey = resolveSyncedKey({ key: entry.key, sourceRel: entry.sourceRel }, synced, indexSyncedBySourceRel(synced));
|
|
21
|
+
const known = stateKey ? synced[stateKey] : undefined;
|
|
22
|
+
if (!known)
|
|
23
|
+
return { kind: 'new' };
|
|
24
|
+
if (known.legacy)
|
|
25
|
+
return { kind: 'legacy' };
|
|
26
|
+
if (currentSha && known.diskSha256 === currentSha)
|
|
27
|
+
return { kind: 'unchanged' };
|
|
28
|
+
return { kind: 'changed', ...(typeof known.layers === 'number' ? { layers: known.layers } : {}) };
|
|
29
|
+
}
|
|
30
|
+
export function describeVerdict(v, config) {
|
|
31
|
+
switch (v.kind) {
|
|
32
|
+
case 'not-watched':
|
|
33
|
+
return `not watched — no "connect" pattern matches it (${(config.connect ?? []).join(', ') || 'none'}). Fix: ${cmd('connect')} '<folder>/**'`;
|
|
34
|
+
case 'excluded':
|
|
35
|
+
return `skipped by exclude rule "${v.rule}" — remove it from "exclude" in magicpixel.json to sync this file`;
|
|
36
|
+
case 'new':
|
|
37
|
+
return 'new — the next sync adds it to MagicPixel';
|
|
38
|
+
case 'changed':
|
|
39
|
+
return v.layers && v.layers > 1
|
|
40
|
+
? `changed locally, but the MagicPixel artboard has ${v.layers} layers — run \`${cmd('push')} --flatten\` to replace it`
|
|
41
|
+
: 'changed locally — the next sync uploads it';
|
|
42
|
+
case 'unchanged':
|
|
43
|
+
return 'unchanged since the last sync';
|
|
44
|
+
case 'legacy':
|
|
45
|
+
return 'linked to a legacy single-image file — open it in MagicPixel and save once, then sync again';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function whyCommand(pathArg) {
|
|
49
|
+
const config = await loadConfig();
|
|
50
|
+
const state = await loadState();
|
|
51
|
+
const kind = await detectProjectKind();
|
|
52
|
+
if (!isEngineKind(kind)) {
|
|
53
|
+
console.log(kleur.yellow('This folder is not a Unity/Godot/GameMaker project — sync only downloads here, it never uploads.'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const target = normalizeScopeFolder(resolveInvokedPath(pathArg)).toLowerCase();
|
|
57
|
+
const spinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
|
|
58
|
+
const index = await indexGamePngs(kind, process.cwd(), config.outDir, {
|
|
59
|
+
onProgress: (p) => { spinner.text = countingSpritesText(p); },
|
|
60
|
+
});
|
|
61
|
+
spinner.stop();
|
|
62
|
+
const files = index.files.filter((e) => {
|
|
63
|
+
const rel = e.sourceRel.toLowerCase();
|
|
64
|
+
return rel === target || rel.startsWith(`${target}/`);
|
|
65
|
+
});
|
|
66
|
+
if (files.length === 0) {
|
|
67
|
+
const dots = (await rootSpriteDotDirs(process.cwd())).map((d) => relative(process.cwd(), d));
|
|
68
|
+
console.log(kleur.yellow(`No PNGs found under ${pathArg} (scanned ${index.files.length.toLocaleString('en-US')} PNGs).`));
|
|
69
|
+
const scanned = config.members?.length ? config.members.map((m) => `${m}/`).join(', ') : `Assets/${dots.length ? `, ${dots.join(', ')}` : ''}`;
|
|
70
|
+
console.log(kleur.dim(` Paths are relative to ${process.cwd()}. Scanned: ${scanned}.`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const synced = state.synced ?? {};
|
|
74
|
+
const counts = new Map();
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const e of files) {
|
|
77
|
+
const sha = (await hashFile(e.abs))?.sha256 ?? null;
|
|
78
|
+
const text = describeVerdict(explainSprite(e, config, synced, sha), config);
|
|
79
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
80
|
+
if (lines.length < 20)
|
|
81
|
+
lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
|
|
82
|
+
}
|
|
83
|
+
console.log(kleur.bold(`${files.length.toLocaleString('en-US')} PNG${files.length === 1 ? '' : 's'} under ${pathArg}:`));
|
|
84
|
+
for (const [text, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
85
|
+
console.log(` ${kleur.cyan(String(n).padStart(5))} ${text}`);
|
|
86
|
+
}
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(lines.join('\n'));
|
|
89
|
+
if (files.length > lines.length)
|
|
90
|
+
console.log(kleur.dim(` …and ${files.length - lines.length} more`));
|
|
91
|
+
}
|