@magicpixelart/cli 0.5.38 → 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 +41 -0
- package/README.md +24 -1
- package/dist/commands/add.js +2 -1
- package/dist/commands/cleanStrays.js +92 -0
- package/dist/commands/connect.js +3 -3
- 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 +125 -60
- package/dist/commands/whoami.js +4 -3
- package/dist/commands/why.js +91 -0
- package/dist/config.js +3 -0
- package/dist/index.js +46 -4
- package/dist/util/framework.js +20 -2
- package/dist/util/gameScan.js +51 -8
- package/dist/util/prompt.js +15 -0
- package/dist/util/strayGuard.js +76 -0
- package/dist/util/ui.js +156 -0
- 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';
|
|
@@ -32,6 +32,7 @@ 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
34
|
import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
|
|
35
|
+
import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
|
|
35
36
|
/** How often the watcher asks for a library "Resync from game" request. */
|
|
36
37
|
const RESYNC_POLL_MS = 15_000;
|
|
37
38
|
let lastResyncPollAt = 0;
|
|
@@ -67,7 +68,7 @@ async function maybeRunQueuedResync(quiet) {
|
|
|
67
68
|
catch (e) {
|
|
68
69
|
await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
|
|
69
70
|
if (!quiet)
|
|
70
|
-
console.log(
|
|
71
|
+
console.log(ui.fail(`Resync failed: ${e.message}`));
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
@@ -88,7 +89,7 @@ export function pushWasActive(pushed) {
|
|
|
88
89
|
export function needsLocalPngWalk(since, toDownloadCount) {
|
|
89
90
|
return !since || toDownloadCount > 0;
|
|
90
91
|
}
|
|
91
|
-
export async function syncCommand(opts) {
|
|
92
|
+
export async function syncCommand(opts, reuse = {}) {
|
|
92
93
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
93
94
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
94
95
|
// (watch mode included) and only touches files older than 30s, so it can
|
|
@@ -110,7 +111,7 @@ export async function syncCommand(opts) {
|
|
|
110
111
|
await watchLoop(opts);
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
|
-
await runOnce(opts);
|
|
114
|
+
await runOnce(opts, { gameIndex: reuse.gameIndex });
|
|
114
115
|
}
|
|
115
116
|
async function watchLoop(opts) {
|
|
116
117
|
// Default 2s — matches the perceived-instant UX users expect when editing
|
|
@@ -122,7 +123,7 @@ async function watchLoop(opts) {
|
|
|
122
123
|
const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
|
|
123
124
|
// Header — print once on start. Counts are best-effort; if the first
|
|
124
125
|
// manifest fetch fails we still want the loop to come up and retry.
|
|
125
|
-
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'));
|
|
126
127
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
127
128
|
try {
|
|
128
129
|
const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
|
|
@@ -139,7 +140,7 @@ async function watchLoop(opts) {
|
|
|
139
140
|
const config = await loadConfig();
|
|
140
141
|
const kind = await detectProjectKind();
|
|
141
142
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
142
|
-
countSpinner =
|
|
143
|
+
countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
catch {
|
|
@@ -156,6 +157,8 @@ async function watchLoop(opts) {
|
|
|
156
157
|
else if (header.line && !opts.quiet) {
|
|
157
158
|
console.log(kleur.dim(header.line));
|
|
158
159
|
}
|
|
160
|
+
if (header.excluded && !opts.quiet)
|
|
161
|
+
console.log(kleur.yellow(` ! ${header.excluded}`));
|
|
159
162
|
console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
|
|
160
163
|
console.log();
|
|
161
164
|
let stopping = false;
|
|
@@ -189,7 +192,11 @@ async function watchLoop(opts) {
|
|
|
189
192
|
let lastChangeAt = Date.now();
|
|
190
193
|
// Re-walk the game tree on this cadence so new PNGs are ingested without
|
|
191
194
|
// listing thousands of folders on every 2s tick.
|
|
192
|
-
|
|
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 = '';
|
|
193
200
|
let gameIndexCache = header.gameIndex;
|
|
194
201
|
let gameIndexAt = Date.now();
|
|
195
202
|
// Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
|
|
@@ -226,9 +233,9 @@ async function watchLoop(opts) {
|
|
|
226
233
|
return;
|
|
227
234
|
inFlight = true;
|
|
228
235
|
const onStatus = (msg) => {
|
|
229
|
-
if (opts.quiet)
|
|
230
|
-
return;
|
|
231
|
-
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}
|
|
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))}`);
|
|
232
239
|
};
|
|
233
240
|
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
234
241
|
// link) must not look like the silent hang a timeout-less fetch used to
|
|
@@ -248,7 +255,7 @@ async function watchLoop(opts) {
|
|
|
248
255
|
}, SLOW_TICK_HEARTBEAT_MS);
|
|
249
256
|
heartbeat?.unref?.();
|
|
250
257
|
try {
|
|
251
|
-
wrappedStatus('Checking MagicPixel
|
|
258
|
+
wrappedStatus(`${icon('work')} Checking MagicPixel…`);
|
|
252
259
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
253
260
|
? gameIndexCache
|
|
254
261
|
: undefined;
|
|
@@ -276,15 +283,15 @@ async function watchLoop(opts) {
|
|
|
276
283
|
await maybeRunQueuedResync(opts.quiet === true);
|
|
277
284
|
if (wasPausedForAuth && !opts.quiet) {
|
|
278
285
|
process.stdout.write('\x1b[2K\r');
|
|
279
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
286
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
|
|
280
287
|
}
|
|
281
288
|
if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
|
|
282
289
|
process.stdout.write('\x1b[2K\r');
|
|
283
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
290
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
|
|
284
291
|
}
|
|
285
292
|
if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
|
|
286
293
|
process.stdout.write('\x1b[2K\r');
|
|
287
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
294
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Daily allowance available again — resuming.`);
|
|
288
295
|
}
|
|
289
296
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
290
297
|
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
@@ -300,15 +307,37 @@ async function watchLoop(opts) {
|
|
|
300
307
|
}
|
|
301
308
|
if (opts.quiet)
|
|
302
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;
|
|
303
330
|
if (changedCount > 0) {
|
|
304
331
|
process.stdout.write('\x1b[2K\r');
|
|
305
332
|
const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
|
|
306
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
333
|
+
console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
|
|
307
334
|
`change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
|
|
308
335
|
printChanges(r, /* indent */ ' ');
|
|
309
336
|
}
|
|
310
337
|
else {
|
|
311
|
-
|
|
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
|
+
}
|
|
312
341
|
}
|
|
313
342
|
}
|
|
314
343
|
catch (e) {
|
|
@@ -331,15 +360,15 @@ async function watchLoop(opts) {
|
|
|
331
360
|
// Surface the request id (from B1) so the user can paste it into a
|
|
332
361
|
// support thread and we can correlate against edge function logs.
|
|
333
362
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
334
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
335
|
-
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).`)}`);
|
|
336
365
|
}
|
|
337
366
|
pausedForAuth = true;
|
|
338
367
|
consecutiveAuthFailures = decision.consecutiveAuthFailures;
|
|
339
368
|
backoffSec = decision.nextBackoffSec;
|
|
340
369
|
if (decision.giveUp) {
|
|
341
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
342
|
-
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.`)}`);
|
|
343
372
|
// Surface this to /admin/errors — persistent watcher auth failure
|
|
344
373
|
// means a key is mass-rejected (revoked, project deleted, edge
|
|
345
374
|
// misconfig) and we want visibility without waiting for a support
|
|
@@ -352,7 +381,7 @@ async function watchLoop(opts) {
|
|
|
352
381
|
consecutiveAuthFailures = 0;
|
|
353
382
|
if (decision.printMessage) {
|
|
354
383
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
355
|
-
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. ` +
|
|
356
385
|
`Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
|
|
357
386
|
}
|
|
358
387
|
pausedForNetwork = true;
|
|
@@ -362,7 +391,7 @@ async function watchLoop(opts) {
|
|
|
362
391
|
consecutiveAuthFailures = 0;
|
|
363
392
|
if (decision.printMessage) {
|
|
364
393
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
365
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
394
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} Daily MagicPixel API allowance reached. ` +
|
|
366
395
|
`Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
|
|
367
396
|
`(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
|
|
368
397
|
}
|
|
@@ -372,7 +401,7 @@ async function watchLoop(opts) {
|
|
|
372
401
|
else {
|
|
373
402
|
consecutiveAuthFailures = 0;
|
|
374
403
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
375
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
404
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
|
|
376
405
|
backoffSec = decision.nextBackoffSec;
|
|
377
406
|
}
|
|
378
407
|
}
|
|
@@ -380,6 +409,10 @@ async function watchLoop(opts) {
|
|
|
380
409
|
if (heartbeat)
|
|
381
410
|
clearInterval(heartbeat);
|
|
382
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
|
+
}
|
|
383
416
|
}
|
|
384
417
|
};
|
|
385
418
|
await tick();
|
|
@@ -508,15 +541,18 @@ async function loadWatchHeader(onProgress) {
|
|
|
508
541
|
const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
|
|
509
542
|
let workingSet = 0;
|
|
510
543
|
let gameIndex;
|
|
544
|
+
let excluded = null;
|
|
511
545
|
const kind = await detectProjectKind();
|
|
512
546
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
513
547
|
gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
|
|
514
|
-
|
|
548
|
+
const matched = matchConnectGlobs(gameIndex, config.connect, config.exclude);
|
|
549
|
+
workingSet = matched.entries.length;
|
|
550
|
+
excluded = describeExcluded(matched);
|
|
515
551
|
}
|
|
516
552
|
else if (state.synced) {
|
|
517
553
|
workingSet = Object.keys(state.synced).length;
|
|
518
554
|
}
|
|
519
|
-
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
|
|
555
|
+
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
|
|
520
556
|
}
|
|
521
557
|
catch {
|
|
522
558
|
return { line: null };
|
|
@@ -537,7 +573,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
537
573
|
const onStatus = runOpts.onStatus;
|
|
538
574
|
const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
|
|
539
575
|
if (verbose && config.endpoint) {
|
|
540
|
-
console.log(
|
|
576
|
+
console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
|
|
541
577
|
}
|
|
542
578
|
// Incremental unless asked for a full run — or unless a full pass is due:
|
|
543
579
|
// permanently-deleted rows can't be reported incrementally (see reconcile.ts).
|
|
@@ -554,7 +590,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
554
590
|
since = undefined;
|
|
555
591
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
556
592
|
const spinner = verbose
|
|
557
|
-
?
|
|
593
|
+
? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
558
594
|
: null;
|
|
559
595
|
if (!spinner)
|
|
560
596
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
@@ -603,7 +639,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
603
639
|
if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
|
|
604
640
|
await saveState(state);
|
|
605
641
|
if (verbose)
|
|
606
|
-
console.log(
|
|
642
|
+
console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
|
|
607
643
|
}
|
|
608
644
|
const projectSuffix = projectInfo && verbose
|
|
609
645
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -615,7 +651,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
615
651
|
// so the user isn't left wondering why sync "did nothing".
|
|
616
652
|
if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
|
|
617
653
|
console.log();
|
|
618
|
-
console.log(
|
|
654
|
+
console.log(ui.warn(`${projectInfo.hint}`));
|
|
619
655
|
}
|
|
620
656
|
}
|
|
621
657
|
catch (e) {
|
|
@@ -627,7 +663,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
627
663
|
const pushed = await maybePushLocalSprites(config.push, verbose);
|
|
628
664
|
if (verbose && pushed && pushed.created > 0) {
|
|
629
665
|
console.log();
|
|
630
|
-
console.log(
|
|
666
|
+
console.log(ui.ok(`pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
|
|
631
667
|
console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
|
|
632
668
|
}
|
|
633
669
|
}
|
|
@@ -643,11 +679,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
643
679
|
onProgress: (p) => {
|
|
644
680
|
const n = p.pngs.toLocaleString('en-US');
|
|
645
681
|
const f = p.folders.toLocaleString('en-US');
|
|
646
|
-
onStatus?.(p.
|
|
647
|
-
? `Looking through your game sprites… ${n} sprites · ${f} folders
|
|
648
|
-
:
|
|
649
|
-
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
650
|
-
: `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}`);
|
|
651
685
|
},
|
|
652
686
|
})
|
|
653
687
|
: { files: [] });
|
|
@@ -687,6 +721,15 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
687
721
|
}
|
|
688
722
|
}
|
|
689
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
|
+
}
|
|
690
733
|
if (verbose && isEngineKind(projectKind)) {
|
|
691
734
|
console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
|
|
692
735
|
const skipped = describeExcluded(connected);
|
|
@@ -723,8 +766,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
723
766
|
const n = filtered.unknown.length;
|
|
724
767
|
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
|
|
725
768
|
if (verbose) {
|
|
726
|
-
console.log(
|
|
727
|
-
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\`.`));
|
|
728
771
|
}
|
|
729
772
|
else {
|
|
730
773
|
onStatus?.(msg);
|
|
@@ -792,7 +835,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
792
835
|
: 'Checking MagicPixel…');
|
|
793
836
|
const diffLimit = createLimit(concurrency);
|
|
794
837
|
const shaByEntryId = new Map();
|
|
795
|
-
|
|
838
|
+
let toDownload = [];
|
|
796
839
|
const conflicts = [];
|
|
797
840
|
const failedDownloadKeys = new Set();
|
|
798
841
|
let bytesSaved = 0;
|
|
@@ -886,6 +929,23 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
886
929
|
// Wrong-folder guard: sprites connected to another game folder on this
|
|
887
930
|
// machine would land here as stray copies under outDir. Stop before writing.
|
|
888
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
|
+
}
|
|
889
949
|
if (projectIdForRoots && !opts.here) {
|
|
890
950
|
const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
|
|
891
951
|
if (owner) {
|
|
@@ -1137,7 +1197,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1137
1197
|
}
|
|
1138
1198
|
if (opts.dryRun) {
|
|
1139
1199
|
if (verbose) {
|
|
1140
|
-
console.log(
|
|
1200
|
+
console.log(ui.dryRun());
|
|
1141
1201
|
if (renamed.length > 0)
|
|
1142
1202
|
printRenames(renamed, { withHints: false });
|
|
1143
1203
|
if (orphans.length > 0)
|
|
@@ -1171,7 +1231,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1171
1231
|
let progress = null;
|
|
1172
1232
|
if (live && toDownload.length > 0) {
|
|
1173
1233
|
onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
|
|
1174
|
-
progress =
|
|
1234
|
+
progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
|
|
1175
1235
|
}
|
|
1176
1236
|
const run = createLimit(concurrency);
|
|
1177
1237
|
let done = 0;
|
|
@@ -1241,12 +1301,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1241
1301
|
// so the user sees the fix hint. Single-line errors stay terse.
|
|
1242
1302
|
const msg = e.message ?? String(e);
|
|
1243
1303
|
if (msg.includes('\n')) {
|
|
1244
|
-
console.log(` ${
|
|
1304
|
+
console.log(` ${icon('fail')} ${entry.key}:`);
|
|
1245
1305
|
for (const line of msg.split('\n'))
|
|
1246
1306
|
console.log(` ${line}`);
|
|
1247
1307
|
}
|
|
1248
1308
|
else {
|
|
1249
|
-
console.log(` ${
|
|
1309
|
+
console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
|
|
1250
1310
|
}
|
|
1251
1311
|
progress?.start();
|
|
1252
1312
|
}
|
|
@@ -1303,7 +1363,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1303
1363
|
}
|
|
1304
1364
|
catch (e) {
|
|
1305
1365
|
if (verbose)
|
|
1306
|
-
console.log(` ${
|
|
1366
|
+
console.log(` ${icon('warn')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
|
|
1307
1367
|
}
|
|
1308
1368
|
}
|
|
1309
1369
|
await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
|
|
@@ -1331,7 +1391,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1331
1391
|
}
|
|
1332
1392
|
catch (e) {
|
|
1333
1393
|
if (verbose)
|
|
1334
|
-
console.log(` ${
|
|
1394
|
+
console.log(` ${icon('warn')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
|
|
1335
1395
|
}
|
|
1336
1396
|
}
|
|
1337
1397
|
// Always surface the import-update notice — even in --quiet/watch mode
|
|
@@ -1515,23 +1575,28 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1515
1575
|
const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1516
1576
|
if (verbose) {
|
|
1517
1577
|
console.log();
|
|
1518
|
-
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
|
1519
|
-
`unchanged ${result.unchanged}` +
|
|
1520
|
-
(result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
|
|
1521
|
-
(shouldPrune ? `, pruned ${result.removed.length}` : '') +
|
|
1522
|
-
(renamed.length ? `, renamed ${renamed.length}` : '') +
|
|
1523
|
-
(result.failed ? `, failed ${result.failed}` : '');
|
|
1524
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
|
+
];
|
|
1525
1590
|
console.log(result.failed || pushFailed
|
|
1526
|
-
?
|
|
1527
|
-
:
|
|
1591
|
+
? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
|
|
1592
|
+
: card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
|
|
1528
1593
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1529
1594
|
if (renamed.length > 0)
|
|
1530
1595
|
printRenames(renamed, { withHints: true });
|
|
1531
1596
|
}
|
|
1532
1597
|
if (result.failed)
|
|
1533
1598
|
process.exitCode = 1;
|
|
1534
|
-
return { ...result, pushed };
|
|
1599
|
+
return { ...result, pushed, ...(strays ? { strays } : {}) };
|
|
1535
1600
|
}
|
|
1536
1601
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1537
1602
|
if (pushEnabled === false)
|
|
@@ -1542,8 +1607,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
|
|
|
1542
1607
|
catch (e) {
|
|
1543
1608
|
if (announceErrors) {
|
|
1544
1609
|
console.log();
|
|
1545
|
-
console.log(
|
|
1546
|
-
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.`));
|
|
1547
1612
|
}
|
|
1548
1613
|
return null;
|
|
1549
1614
|
}
|
|
@@ -1587,11 +1652,11 @@ function printRenames(renamed, opts) {
|
|
|
1587
1652
|
}
|
|
1588
1653
|
}
|
|
1589
1654
|
function progressText(done, total, bytes) {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
return
|
|
1655
|
+
return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
|
|
1656
|
+
}
|
|
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);
|
|
1595
1660
|
}
|
|
1596
1661
|
function timestamp() {
|
|
1597
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
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -113,6 +113,9 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
113
113
|
push: parsed.push === false ? false : undefined,
|
|
114
114
|
connect,
|
|
115
115
|
...(parsed.syncOnly !== undefined ? { syncOnly: normalizeGlobList(parsed.syncOnly, 'syncOnly') } : {}),
|
|
116
|
+
...(parsed.workspace === true && Array.isArray(parsed.members)
|
|
117
|
+
? { workspace: true, members: normalizeGlobList(parsed.members, 'members') }
|
|
118
|
+
: {}),
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
121
|
const MAX_GLOBS = 64;
|