@magicpixelart/cli 0.5.38 → 0.6.2
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 +53 -0
- package/README.md +26 -1
- package/dist/commands/add.js +2 -1
- package/dist/commands/cleanStrays.js +92 -0
- package/dist/commands/connect.js +9 -6
- package/dist/commands/consolidate.js +309 -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 +64 -19
- 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 +100 -16
- package/dist/commands/sync.js +128 -60
- package/dist/commands/whoami.js +4 -3
- package/dist/commands/why.js +105 -0
- package/dist/config.js +3 -0
- package/dist/index.js +49 -5
- package/dist/util/commandTips.js +33 -0
- package/dist/util/engineConnect.js +39 -0
- 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 +175 -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, isPlain, 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,8 @@ 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';
|
|
36
|
+
import { syncFooterHint } from '../util/commandTips.js';
|
|
35
37
|
/** How often the watcher asks for a library "Resync from game" request. */
|
|
36
38
|
const RESYNC_POLL_MS = 15_000;
|
|
37
39
|
let lastResyncPollAt = 0;
|
|
@@ -67,7 +69,7 @@ async function maybeRunQueuedResync(quiet) {
|
|
|
67
69
|
catch (e) {
|
|
68
70
|
await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
|
|
69
71
|
if (!quiet)
|
|
70
|
-
console.log(
|
|
72
|
+
console.log(ui.fail(`Resync failed: ${e.message}`));
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
@@ -88,7 +90,7 @@ export function pushWasActive(pushed) {
|
|
|
88
90
|
export function needsLocalPngWalk(since, toDownloadCount) {
|
|
89
91
|
return !since || toDownloadCount > 0;
|
|
90
92
|
}
|
|
91
|
-
export async function syncCommand(opts) {
|
|
93
|
+
export async function syncCommand(opts, reuse = {}) {
|
|
92
94
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
93
95
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
94
96
|
// (watch mode included) and only touches files older than 30s, so it can
|
|
@@ -110,7 +112,7 @@ export async function syncCommand(opts) {
|
|
|
110
112
|
await watchLoop(opts);
|
|
111
113
|
return;
|
|
112
114
|
}
|
|
113
|
-
await runOnce(opts);
|
|
115
|
+
await runOnce(opts, { gameIndex: reuse.gameIndex });
|
|
114
116
|
}
|
|
115
117
|
async function watchLoop(opts) {
|
|
116
118
|
// Default 2s — matches the perceived-instant UX users expect when editing
|
|
@@ -122,7 +124,7 @@ async function watchLoop(opts) {
|
|
|
122
124
|
const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
|
|
123
125
|
// Header — print once on start. Counts are best-effort; if the first
|
|
124
126
|
// manifest fetch fails we still want the loop to come up and retry.
|
|
125
|
-
console.log(
|
|
127
|
+
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
128
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
127
129
|
try {
|
|
128
130
|
const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
|
|
@@ -139,7 +141,7 @@ async function watchLoop(opts) {
|
|
|
139
141
|
const config = await loadConfig();
|
|
140
142
|
const kind = await detectProjectKind();
|
|
141
143
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
142
|
-
countSpinner =
|
|
144
|
+
countSpinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
catch {
|
|
@@ -156,6 +158,8 @@ async function watchLoop(opts) {
|
|
|
156
158
|
else if (header.line && !opts.quiet) {
|
|
157
159
|
console.log(kleur.dim(header.line));
|
|
158
160
|
}
|
|
161
|
+
if (header.excluded && !opts.quiet)
|
|
162
|
+
console.log(kleur.yellow(` ! ${header.excluded}`));
|
|
159
163
|
console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
|
|
160
164
|
console.log();
|
|
161
165
|
let stopping = false;
|
|
@@ -189,7 +193,11 @@ async function watchLoop(opts) {
|
|
|
189
193
|
let lastChangeAt = Date.now();
|
|
190
194
|
// Re-walk the game tree on this cadence so new PNGs are ingested without
|
|
191
195
|
// listing thousands of folders on every 2s tick.
|
|
192
|
-
|
|
196
|
+
// Edits to already-indexed files are picked up every tick (push re-hashes
|
|
197
|
+
// them); the full tree walk only finds brand-new PNGs, so it runs rarely.
|
|
198
|
+
const GAME_INDEX_TTL_MS = 5 * 60_000;
|
|
199
|
+
const reportedIssues = new Set();
|
|
200
|
+
let reportedStrays = '';
|
|
193
201
|
let gameIndexCache = header.gameIndex;
|
|
194
202
|
let gameIndexAt = Date.now();
|
|
195
203
|
// Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
|
|
@@ -226,9 +234,9 @@ async function watchLoop(opts) {
|
|
|
226
234
|
return;
|
|
227
235
|
inFlight = true;
|
|
228
236
|
const onStatus = (msg) => {
|
|
229
|
-
if (opts.quiet)
|
|
230
|
-
return;
|
|
231
|
-
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}
|
|
237
|
+
if (opts.quiet || !canRedraw())
|
|
238
|
+
return; // in-place updates only on a live terminal
|
|
239
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns))}`);
|
|
232
240
|
};
|
|
233
241
|
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
234
242
|
// link) must not look like the silent hang a timeout-less fetch used to
|
|
@@ -248,7 +256,7 @@ async function watchLoop(opts) {
|
|
|
248
256
|
}, SLOW_TICK_HEARTBEAT_MS);
|
|
249
257
|
heartbeat?.unref?.();
|
|
250
258
|
try {
|
|
251
|
-
wrappedStatus('Checking MagicPixel
|
|
259
|
+
wrappedStatus(`${icon('work')} Checking MagicPixel…`);
|
|
252
260
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
253
261
|
? gameIndexCache
|
|
254
262
|
: undefined;
|
|
@@ -276,15 +284,15 @@ async function watchLoop(opts) {
|
|
|
276
284
|
await maybeRunQueuedResync(opts.quiet === true);
|
|
277
285
|
if (wasPausedForAuth && !opts.quiet) {
|
|
278
286
|
process.stdout.write('\x1b[2K\r');
|
|
279
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
287
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Key accepted again — resuming.`);
|
|
280
288
|
}
|
|
281
289
|
if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
|
|
282
290
|
process.stdout.write('\x1b[2K\r');
|
|
283
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
291
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Back online — resuming.`);
|
|
284
292
|
}
|
|
285
293
|
if (wasPausedForQuota && !wasPausedForAuth && !wasPausedForNetwork && !opts.quiet) {
|
|
286
294
|
process.stdout.write('\x1b[2K\r');
|
|
287
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
295
|
+
console.log(`${kleur.dim(timestamp())} ${icon('ok')} Daily allowance available again — resuming.`);
|
|
288
296
|
}
|
|
289
297
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
290
298
|
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
@@ -300,15 +308,37 @@ async function watchLoop(opts) {
|
|
|
300
308
|
}
|
|
301
309
|
if (opts.quiet)
|
|
302
310
|
return;
|
|
311
|
+
const sent = (r.pushed?.created ?? 0) + (r.pushed?.updated ?? 0);
|
|
312
|
+
const freshIssues = (r.pushed?.issues ?? []).filter((i) => !reportedIssues.has(`${i.key}\0${i.text}`));
|
|
313
|
+
const strayLine = r.strays ? `${r.strays.count}\0${r.strays.owner ?? ''}` : '';
|
|
314
|
+
if (sent > 0 || freshIssues.length > 0 || (strayLine && strayLine !== reportedStrays)) {
|
|
315
|
+
process.stdout.write('\x1b[2K\r');
|
|
316
|
+
}
|
|
317
|
+
if (sent > 0) {
|
|
318
|
+
console.log(`${kleur.dim(timestamp())} ${icon('up')} Sent ${ui.count(sent)} edited sprite${sent === 1 ? '' : 's'} to MagicPixel.`);
|
|
319
|
+
}
|
|
320
|
+
for (const i of freshIssues.slice(0, 10)) {
|
|
321
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${ui.name(i.key)}: ${i.text}`);
|
|
322
|
+
}
|
|
323
|
+
if (freshIssues.length > 10)
|
|
324
|
+
console.log(kleur.dim(` …and ${freshIssues.length - 10} more not sent.`));
|
|
325
|
+
for (const i of freshIssues)
|
|
326
|
+
reportedIssues.add(`${i.key}\0${i.text}`);
|
|
327
|
+
if (strayLine && strayLine !== reportedStrays && r.strays) {
|
|
328
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${straySkipMessage(r.strays.count, r.strays.owner, `restart with ${kleur.cyan('--here')}`)}`);
|
|
329
|
+
}
|
|
330
|
+
reportedStrays = strayLine;
|
|
303
331
|
if (changedCount > 0) {
|
|
304
332
|
process.stdout.write('\x1b[2K\r');
|
|
305
333
|
const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
|
|
306
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
334
|
+
console.log(`${kleur.dim(timestamp())} ${icon(verb === 'Removed' ? 'removed' : 'down')} ${verb} ${ui.count(changedCount)} ` +
|
|
307
335
|
`change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
|
|
308
336
|
printChanges(r, /* indent */ ' ');
|
|
309
337
|
}
|
|
310
338
|
else {
|
|
311
|
-
|
|
339
|
+
if (canRedraw()) {
|
|
340
|
+
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))}`);
|
|
341
|
+
}
|
|
312
342
|
}
|
|
313
343
|
}
|
|
314
344
|
catch (e) {
|
|
@@ -331,15 +361,15 @@ async function watchLoop(opts) {
|
|
|
331
361
|
// Surface the request id (from B1) so the user can paste it into a
|
|
332
362
|
// support thread and we can correlate against edge function logs.
|
|
333
363
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
334
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
335
|
-
console.log(
|
|
364
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} Your key looks invalid or rotated.${idSuffix}`);
|
|
365
|
+
console.log(`${ui.fix(cmd('login'))}${kleur.dim(` (this watcher will keep retrying every 30s).`)}`);
|
|
336
366
|
}
|
|
337
367
|
pausedForAuth = true;
|
|
338
368
|
consecutiveAuthFailures = decision.consecutiveAuthFailures;
|
|
339
369
|
backoffSec = decision.nextBackoffSec;
|
|
340
370
|
if (decision.giveUp) {
|
|
341
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
342
|
-
console.log(
|
|
371
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
|
|
372
|
+
console.log(`${ui.fix(cmd('login'))}${kleur.dim(` with a fresh key, then restart the watcher.`)}`);
|
|
343
373
|
// Surface this to /admin/errors — persistent watcher auth failure
|
|
344
374
|
// means a key is mass-rejected (revoked, project deleted, edge
|
|
345
375
|
// misconfig) and we want visibility without waiting for a support
|
|
@@ -352,7 +382,7 @@ async function watchLoop(opts) {
|
|
|
352
382
|
consecutiveAuthFailures = 0;
|
|
353
383
|
if (decision.printMessage) {
|
|
354
384
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
355
|
-
console.log(`${kleur.dim(timestamp())} ${kleur.yellow('
|
|
385
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} ${kleur.yellow('Paused')} — MagicPixel is offline or your internet is. ` +
|
|
356
386
|
`Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
|
|
357
387
|
}
|
|
358
388
|
pausedForNetwork = true;
|
|
@@ -362,7 +392,7 @@ async function watchLoop(opts) {
|
|
|
362
392
|
consecutiveAuthFailures = 0;
|
|
363
393
|
if (decision.printMessage) {
|
|
364
394
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
365
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
395
|
+
console.log(`${kleur.dim(timestamp())} ${icon('warn')} Daily MagicPixel API allowance reached. ` +
|
|
366
396
|
`Sprites you already have still work. Resets in ${formatQuotaReset(decision.resetSec)} ` +
|
|
367
397
|
`(00:00 UTC); checking again every ${Math.max(1, Math.round(decision.nextBackoffSec / 60))} min.${idSuffix}`);
|
|
368
398
|
}
|
|
@@ -372,7 +402,7 @@ async function watchLoop(opts) {
|
|
|
372
402
|
else {
|
|
373
403
|
consecutiveAuthFailures = 0;
|
|
374
404
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
375
|
-
console.log(`${kleur.dim(timestamp())} ${
|
|
405
|
+
console.log(`${kleur.dim(timestamp())} ${icon('fail')} ${firstLine}${idSuffix}`);
|
|
376
406
|
backoffSec = decision.nextBackoffSec;
|
|
377
407
|
}
|
|
378
408
|
}
|
|
@@ -380,6 +410,10 @@ async function watchLoop(opts) {
|
|
|
380
410
|
if (heartbeat)
|
|
381
411
|
clearInterval(heartbeat);
|
|
382
412
|
inFlight = false;
|
|
413
|
+
if ((pausedForAuth || pausedForNetwork || pausedForQuota) && !opts.quiet && canRedraw()) {
|
|
414
|
+
const why = pausedForAuth ? 'waiting for a valid key' : pausedForQuota ? 'daily allowance reached' : 'offline';
|
|
415
|
+
process.stdout.write(`\r\x1b[2K${kleur.yellow(fitStatusLine(`${timestamp()} ${icon('paused')} Paused (${why}) · retrying in ${formatDuration(backoffSec * 1000)}`, process.stdout.columns))}`);
|
|
416
|
+
}
|
|
383
417
|
}
|
|
384
418
|
};
|
|
385
419
|
await tick();
|
|
@@ -508,15 +542,18 @@ async function loadWatchHeader(onProgress) {
|
|
|
508
542
|
const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
|
|
509
543
|
let workingSet = 0;
|
|
510
544
|
let gameIndex;
|
|
545
|
+
let excluded = null;
|
|
511
546
|
const kind = await detectProjectKind();
|
|
512
547
|
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
513
548
|
gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
|
|
514
|
-
|
|
549
|
+
const matched = matchConnectGlobs(gameIndex, config.connect, config.exclude);
|
|
550
|
+
workingSet = matched.entries.length;
|
|
551
|
+
excluded = describeExcluded(matched);
|
|
515
552
|
}
|
|
516
553
|
else if (state.synced) {
|
|
517
554
|
workingSet = Object.keys(state.synced).length;
|
|
518
555
|
}
|
|
519
|
-
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), gameIndex };
|
|
556
|
+
return { line: formatWatchSpriteLine({ workingSet, lastPulled }), excluded, gameIndex };
|
|
520
557
|
}
|
|
521
558
|
catch {
|
|
522
559
|
return { line: null };
|
|
@@ -537,7 +574,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
537
574
|
const onStatus = runOpts.onStatus;
|
|
538
575
|
const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
|
|
539
576
|
if (verbose && config.endpoint) {
|
|
540
|
-
console.log(
|
|
577
|
+
console.log(ui.warn(`using custom endpoint: ${config.endpoint}`));
|
|
541
578
|
}
|
|
542
579
|
// Incremental unless asked for a full run — or unless a full pass is due:
|
|
543
580
|
// permanently-deleted rows can't be reported incrementally (see reconcile.ts).
|
|
@@ -554,7 +591,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
554
591
|
since = undefined;
|
|
555
592
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
556
593
|
const spinner = verbose
|
|
557
|
-
?
|
|
594
|
+
? makeSpinner(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
558
595
|
: null;
|
|
559
596
|
if (!spinner)
|
|
560
597
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
@@ -603,7 +640,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
603
640
|
if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
|
|
604
641
|
await saveState(state);
|
|
605
642
|
if (verbose)
|
|
606
|
-
console.log(
|
|
643
|
+
console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
|
|
607
644
|
}
|
|
608
645
|
const projectSuffix = projectInfo && verbose
|
|
609
646
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
@@ -615,7 +652,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
615
652
|
// so the user isn't left wondering why sync "did nothing".
|
|
616
653
|
if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
|
|
617
654
|
console.log();
|
|
618
|
-
console.log(
|
|
655
|
+
console.log(ui.warn(`${projectInfo.hint}`));
|
|
619
656
|
}
|
|
620
657
|
}
|
|
621
658
|
catch (e) {
|
|
@@ -627,7 +664,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
627
664
|
const pushed = await maybePushLocalSprites(config.push, verbose);
|
|
628
665
|
if (verbose && pushed && pushed.created > 0) {
|
|
629
666
|
console.log();
|
|
630
|
-
console.log(
|
|
667
|
+
console.log(ui.ok(`pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
|
|
631
668
|
console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
|
|
632
669
|
}
|
|
633
670
|
}
|
|
@@ -643,11 +680,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
643
680
|
onProgress: (p) => {
|
|
644
681
|
const n = p.pngs.toLocaleString('en-US');
|
|
645
682
|
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}`);
|
|
683
|
+
onStatus?.(p.folders > 0
|
|
684
|
+
? `Looking through your game sprites… ${n} sprites · ${f} folders`
|
|
685
|
+
: `Looking through your game sprites… ${n}`);
|
|
651
686
|
},
|
|
652
687
|
})
|
|
653
688
|
: { files: [] });
|
|
@@ -687,6 +722,15 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
687
722
|
}
|
|
688
723
|
}
|
|
689
724
|
aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
|
|
725
|
+
if (isEngineKind(projectKind)) {
|
|
726
|
+
placeBesideSiblings(sourceByKey, manifest, new Set(fullGameIndex.files.map((f) => f.sourceRel)));
|
|
727
|
+
}
|
|
728
|
+
if (verbose && gameIndex.keyClashes?.length) {
|
|
729
|
+
console.log(ui.warn(`${gameIndex.keyClashes.length} file${gameIndex.keyClashes.length === 1 ? '' : 's'} share a name with a file in another game folder — skipped:`));
|
|
730
|
+
for (const r of gameIndex.keyClashes.slice(0, 5))
|
|
731
|
+
console.log(kleur.dim(` ${r}`));
|
|
732
|
+
console.log(ui.fixText('rename one of them so each sprite has its own name.'));
|
|
733
|
+
}
|
|
690
734
|
if (verbose && isEngineKind(projectKind)) {
|
|
691
735
|
console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
|
|
692
736
|
const skipped = describeExcluded(connected);
|
|
@@ -723,8 +767,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
723
767
|
const n = filtered.unknown.length;
|
|
724
768
|
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
|
|
725
769
|
if (verbose) {
|
|
726
|
-
console.log(
|
|
727
|
-
console.log(
|
|
770
|
+
console.log(ui.warn(`${msg}`));
|
|
771
|
+
console.log(ui.fixText(`re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
728
772
|
}
|
|
729
773
|
else {
|
|
730
774
|
onStatus?.(msg);
|
|
@@ -792,7 +836,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
792
836
|
: 'Checking MagicPixel…');
|
|
793
837
|
const diffLimit = createLimit(concurrency);
|
|
794
838
|
const shaByEntryId = new Map();
|
|
795
|
-
|
|
839
|
+
let toDownload = [];
|
|
796
840
|
const conflicts = [];
|
|
797
841
|
const failedDownloadKeys = new Set();
|
|
798
842
|
let bytesSaved = 0;
|
|
@@ -886,6 +930,23 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
886
930
|
// Wrong-folder guard: sprites connected to another game folder on this
|
|
887
931
|
// machine would land here as stray copies under outDir. Stop before writing.
|
|
888
932
|
const projectIdForRoots = getLastProjectInfo()?.id;
|
|
933
|
+
// Game-owned sprites (server flag) with no home in this folder are skipped —
|
|
934
|
+
// this works even on a machine where the owning folder never synced.
|
|
935
|
+
let strays;
|
|
936
|
+
if (!opts.here) {
|
|
937
|
+
const split = splitStrayDownloads(toDownload, [...sourceByKey.keys(), ...connected.entries.map((e) => e.key)]);
|
|
938
|
+
if (split.foreign.length > 0) {
|
|
939
|
+
toDownload = split.keep;
|
|
940
|
+
const owner = projectIdForRoots
|
|
941
|
+
? await findForeignOwner(projectIdForRoots, process.cwd(), split.foreign.map((e) => e.key))
|
|
942
|
+
: null;
|
|
943
|
+
strays = { count: split.foreign.length, owner: owner?.root ?? null };
|
|
944
|
+
if (verbose) {
|
|
945
|
+
console.log();
|
|
946
|
+
console.log(ui.warn(straySkipMessage(strays.count, strays.owner, `add ${kleur.cyan('--here')}`)));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
889
950
|
if (projectIdForRoots && !opts.here) {
|
|
890
951
|
const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
|
|
891
952
|
if (owner) {
|
|
@@ -1137,7 +1198,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1137
1198
|
}
|
|
1138
1199
|
if (opts.dryRun) {
|
|
1139
1200
|
if (verbose) {
|
|
1140
|
-
console.log(
|
|
1201
|
+
console.log(ui.dryRun());
|
|
1141
1202
|
if (renamed.length > 0)
|
|
1142
1203
|
printRenames(renamed, { withHints: false });
|
|
1143
1204
|
if (orphans.length > 0)
|
|
@@ -1171,7 +1232,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1171
1232
|
let progress = null;
|
|
1172
1233
|
if (live && toDownload.length > 0) {
|
|
1173
1234
|
onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
|
|
1174
|
-
progress =
|
|
1235
|
+
progress = makeSpinner({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
|
|
1175
1236
|
}
|
|
1176
1237
|
const run = createLimit(concurrency);
|
|
1177
1238
|
let done = 0;
|
|
@@ -1241,12 +1302,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1241
1302
|
// so the user sees the fix hint. Single-line errors stay terse.
|
|
1242
1303
|
const msg = e.message ?? String(e);
|
|
1243
1304
|
if (msg.includes('\n')) {
|
|
1244
|
-
console.log(` ${
|
|
1305
|
+
console.log(` ${icon('fail')} ${entry.key}:`);
|
|
1245
1306
|
for (const line of msg.split('\n'))
|
|
1246
1307
|
console.log(` ${line}`);
|
|
1247
1308
|
}
|
|
1248
1309
|
else {
|
|
1249
|
-
console.log(` ${
|
|
1310
|
+
console.log(` ${icon('fail')} ${entry.key}: ${msg}`);
|
|
1250
1311
|
}
|
|
1251
1312
|
progress?.start();
|
|
1252
1313
|
}
|
|
@@ -1303,7 +1364,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1303
1364
|
}
|
|
1304
1365
|
catch (e) {
|
|
1305
1366
|
if (verbose)
|
|
1306
|
-
console.log(` ${
|
|
1367
|
+
console.log(` ${icon('warn')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
|
|
1307
1368
|
}
|
|
1308
1369
|
}
|
|
1309
1370
|
await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
|
|
@@ -1331,7 +1392,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1331
1392
|
}
|
|
1332
1393
|
catch (e) {
|
|
1333
1394
|
if (verbose)
|
|
1334
|
-
console.log(` ${
|
|
1395
|
+
console.log(` ${icon('warn')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
|
|
1335
1396
|
}
|
|
1336
1397
|
}
|
|
1337
1398
|
// Always surface the import-update notice — even in --quiet/watch mode
|
|
@@ -1515,23 +1576,30 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1515
1576
|
const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1516
1577
|
if (verbose) {
|
|
1517
1578
|
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
1579
|
const pushFailed = (pushed?.conflict ?? 0) + (pushed?.error ?? 0);
|
|
1580
|
+
const skipped = pushed?.issues.length ?? 0;
|
|
1581
|
+
const sent = (pushed?.created ?? 0) + (pushed?.updated ?? 0);
|
|
1582
|
+
const rows = [
|
|
1583
|
+
`${icon('down')} ${ui.count(downloaded)} downloaded ${icon('up')} ${ui.count(sent)} uploaded`,
|
|
1584
|
+
`${icon('ok')} ${ui.count(result.unchanged)} unchanged` +
|
|
1585
|
+
(shouldPrune && result.removed.length ? ` ${icon('removed')} ${ui.count(result.removed.length)} removed` : '') +
|
|
1586
|
+
(skipped ? ` ${icon('warn')} ${ui.count(skipped)} skipped` : ''),
|
|
1587
|
+
...(renamed.length ? [`↪ ${ui.count(renamed.length)} renamed — see below`] : []),
|
|
1588
|
+
...(result.failed || pushFailed ? [`${icon('fail')} ${ui.count(result.failed + pushFailed)} failed — re-run to retry`] : []),
|
|
1589
|
+
kleur.dim(`${icon('time')} ${formatDuration(Date.now() - Date.parse(startedAt))}${result.bytesSaved ? ` · ~${formatBytes(result.bytesSaved)} saved` : ''}`),
|
|
1590
|
+
];
|
|
1525
1591
|
console.log(result.failed || pushFailed
|
|
1526
|
-
?
|
|
1527
|
-
:
|
|
1592
|
+
? card(`${icon('warn')} Sync finished with issues`, rows, 'warn')
|
|
1593
|
+
: card(`${icon('sparkle')} Sync complete`, rows, 'ok'));
|
|
1528
1594
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
1529
1595
|
if (renamed.length > 0)
|
|
1530
1596
|
printRenames(renamed, { withHints: true });
|
|
1597
|
+
if (!runOpts.watchMode && !isPlain())
|
|
1598
|
+
console.log(kleur.dim(syncFooterHint()));
|
|
1531
1599
|
}
|
|
1532
1600
|
if (result.failed)
|
|
1533
1601
|
process.exitCode = 1;
|
|
1534
|
-
return { ...result, pushed };
|
|
1602
|
+
return { ...result, pushed, ...(strays ? { strays } : {}) };
|
|
1535
1603
|
}
|
|
1536
1604
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus, excludeKeys) {
|
|
1537
1605
|
if (pushEnabled === false)
|
|
@@ -1542,8 +1610,8 @@ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceEr
|
|
|
1542
1610
|
catch (e) {
|
|
1543
1611
|
if (announceErrors) {
|
|
1544
1612
|
console.log();
|
|
1545
|
-
console.log(
|
|
1546
|
-
console.log(
|
|
1613
|
+
console.log(ui.warn(`could not update MagicPixel from your game files: ${e.message}`));
|
|
1614
|
+
console.log(ui.fixText(`run \`${cmd('push')}\` once the connection is healthy.`));
|
|
1547
1615
|
}
|
|
1548
1616
|
return null;
|
|
1549
1617
|
}
|
|
@@ -1587,11 +1655,11 @@ function printRenames(renamed, opts) {
|
|
|
1587
1655
|
}
|
|
1588
1656
|
}
|
|
1589
1657
|
function progressText(done, total, bytes) {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
return
|
|
1658
|
+
return `${icon('down')} ${progressBar(done, total)} ${kleur.dim(formatBytes(bytes))}`;
|
|
1659
|
+
}
|
|
1660
|
+
/** Cut a status line to the terminal width so `\r` redraws never wrap. */
|
|
1661
|
+
export function fitStatusLine(line, columns) {
|
|
1662
|
+
return fitLine(line, columns);
|
|
1595
1663
|
}
|
|
1596
1664
|
function timestamp() {
|
|
1597
1665
|
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,105 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { spinner as makeSpinner, ui } 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) {
|
|
31
|
+
switch (v.kind) {
|
|
32
|
+
case 'not-watched':
|
|
33
|
+
return 'not watched — no folder pattern covers it';
|
|
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
|
+
let unwatched = 0;
|
|
77
|
+
for (const e of files) {
|
|
78
|
+
const sha = (await hashFile(e.abs))?.sha256 ?? null;
|
|
79
|
+
const verdict = explainSprite(e, config, synced, sha);
|
|
80
|
+
if (verdict.kind === 'not-watched')
|
|
81
|
+
unwatched++;
|
|
82
|
+
const text = describeVerdict(verdict);
|
|
83
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
84
|
+
if (lines.length < 20)
|
|
85
|
+
lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
|
|
86
|
+
}
|
|
87
|
+
console.log(kleur.bold(`${files.length.toLocaleString('en-US')} PNG${files.length === 1 ? '' : 's'} under ${pathArg}:`));
|
|
88
|
+
for (const [text, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
89
|
+
console.log(` ${kleur.cyan(String(n).padStart(5))} ${text}`);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(lines.join('\n'));
|
|
93
|
+
if (files.length > lines.length)
|
|
94
|
+
console.log(kleur.dim(` …and ${files.length - lines.length} more`));
|
|
95
|
+
if (unwatched > 0) {
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(kleur.dim(` Watching: ${(config.connect ?? []).join(', ') || 'nothing'}`));
|
|
98
|
+
console.log(ui.fix(whyConnectCommand(files[0].sourceRel, target), ' '));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Ready-to-run connect command for the folder the user asked about. */
|
|
102
|
+
export function whyConnectCommand(firstRel, target) {
|
|
103
|
+
const folder = firstRel.toLowerCase() === target ? firstRel.split('/').slice(0, -1).join('/') : firstRel.slice(0, target.length);
|
|
104
|
+
return `${cmd('connect')} '${folder ? `${folder}/**` : '**'}'`;
|
|
105
|
+
}
|
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;
|