@magicpixelart/cli 0.5.8 → 0.5.10
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 +67 -0
- package/README.md +16 -4
- package/dist/api.js +17 -17
- package/dist/commands/connect.js +49 -0
- package/dist/commands/doctor.js +10 -2
- package/dist/commands/push.js +111 -32
- package/dist/commands/search.js +45 -0
- package/dist/commands/start.js +24 -6
- package/dist/commands/sync.js +168 -47
- package/dist/config.js +30 -2
- package/dist/index.js +12 -0
- package/dist/util/framework.js +57 -5
- package/dist/util/gameScan.js +117 -59
- package/dist/util/globMatch.js +50 -0
- package/dist/util/hash.js +37 -5
- package/dist/util/pushPlan.js +108 -4
- package/dist/util/security.js +91 -1
- package/dist/util/syncPath.js +76 -0
- package/dist/util/watchCopy.js +21 -0
- package/package.json +1 -1
package/dist/commands/sync.js
CHANGED
|
@@ -7,11 +7,13 @@ import { dirname, relative, resolve } from 'node:path';
|
|
|
7
7
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
8
8
|
import { fetchAllManifest, fetchAssetBytes, ApiError, getLastProjectInfo } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
10
|
-
import {
|
|
10
|
+
import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
|
|
11
11
|
import { createLimit } from '../util/limit.js';
|
|
12
12
|
import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
|
|
13
|
-
import { assertPathInsideRoot } from '../util/security.js';
|
|
14
|
-
import { detectProjectKind } from '../util/framework.js';
|
|
13
|
+
import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
|
|
14
|
+
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
15
|
+
import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
|
|
16
|
+
import { collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
15
17
|
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
16
18
|
import { filterUnityManifest } from '../util/unityFilter.js';
|
|
17
19
|
import { selectFullSyncOrphans } from '../util/prunePolicy.js';
|
|
@@ -21,6 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
|
|
|
21
23
|
import { formatBytes } from '../util/format.js';
|
|
22
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
23
25
|
import { cmd } from '../util/invoke.js';
|
|
26
|
+
import { formatWatchSpriteLine } from '../util/watchCopy.js';
|
|
24
27
|
import { runPush } from './push.js';
|
|
25
28
|
export async function syncCommand(opts) {
|
|
26
29
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
@@ -48,20 +51,33 @@ async function watchLoop(opts) {
|
|
|
48
51
|
// Commander already validated the numeric value (2–3600); the `?? 2`
|
|
49
52
|
// covers the bare boolean `-w` form.
|
|
50
53
|
const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
|
|
51
|
-
// Header — print once on start.
|
|
52
|
-
//
|
|
53
|
-
let headerCount = null;
|
|
54
|
-
try {
|
|
55
|
-
const state = await loadState();
|
|
56
|
-
headerCount = state.assets ? Object.keys(state.assets).length : null;
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
// ignore — header is cosmetic
|
|
60
|
-
}
|
|
54
|
+
// Header — print once on start. Counts are best-effort; if the first
|
|
55
|
+
// manifest fetch fails we still want the loop to come up and retry.
|
|
61
56
|
console.log(kleur.bold('👀 MagicPixel watching for changes…'));
|
|
62
57
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
let countSpinner = null;
|
|
59
|
+
if (!opts.quiet) {
|
|
60
|
+
try {
|
|
61
|
+
const config = await loadConfig();
|
|
62
|
+
const kind = await detectProjectKind();
|
|
63
|
+
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
64
|
+
countSpinner = ora({ text: 'Counting sprites in your game…', spinner: 'dots' }).start();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* header is cosmetic */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const spriteLine = await loadWatchSpriteLine();
|
|
72
|
+
if (countSpinner) {
|
|
73
|
+
if (spriteLine)
|
|
74
|
+
countSpinner.succeed(spriteLine.trim());
|
|
75
|
+
else
|
|
76
|
+
countSpinner.stop();
|
|
77
|
+
}
|
|
78
|
+
else if (spriteLine && !opts.quiet) {
|
|
79
|
+
console.log(kleur.dim(spriteLine));
|
|
80
|
+
}
|
|
65
81
|
console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
|
|
66
82
|
console.log();
|
|
67
83
|
let stopping = false;
|
|
@@ -104,12 +120,12 @@ async function watchLoop(opts) {
|
|
|
104
120
|
wakeStop();
|
|
105
121
|
process.stdout.write('\x1b[2K\r');
|
|
106
122
|
if (inFlight) {
|
|
107
|
-
console.log(kleur.dim(`
|
|
123
|
+
console.log(kleur.dim(`Still finishing this check… (${signal} again to stop now)`));
|
|
108
124
|
process.once(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
109
125
|
}
|
|
110
126
|
else {
|
|
111
127
|
if (!opts.quiet)
|
|
112
|
-
console.log(kleur.dim('
|
|
128
|
+
console.log(kleur.dim('Stopped watching.'));
|
|
113
129
|
// Preserve any exit code already set by a prior failed tick.
|
|
114
130
|
process.exit(process.exitCode ?? 0);
|
|
115
131
|
}
|
|
@@ -120,8 +136,14 @@ async function watchLoop(opts) {
|
|
|
120
136
|
if (inFlight || stopping)
|
|
121
137
|
return;
|
|
122
138
|
inFlight = true;
|
|
139
|
+
const onStatus = (msg) => {
|
|
140
|
+
if (opts.quiet)
|
|
141
|
+
return;
|
|
142
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
|
|
143
|
+
};
|
|
123
144
|
try {
|
|
124
|
-
|
|
145
|
+
onStatus('Checking MagicPixel…');
|
|
146
|
+
const r = await runOnce({ ...opts, watch: false }, { watchMode: true, onStatus });
|
|
125
147
|
// Reset backoff on any successful tick. Resume message fires once when
|
|
126
148
|
// we recover from an auth pause — `getApiKey()` re-reads
|
|
127
149
|
// .magicpixel/credentials on every call, so a `magicpixel login` in
|
|
@@ -163,7 +185,7 @@ async function watchLoop(opts) {
|
|
|
163
185
|
printChanges(r, /* indent */ ' ');
|
|
164
186
|
}
|
|
165
187
|
else {
|
|
166
|
-
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()}
|
|
188
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} Waiting for edits… (${r.unchanged} up to date)`)}`);
|
|
167
189
|
}
|
|
168
190
|
}
|
|
169
191
|
catch (e) {
|
|
@@ -246,7 +268,7 @@ async function watchLoop(opts) {
|
|
|
246
268
|
await tick();
|
|
247
269
|
}
|
|
248
270
|
if (!opts.quiet)
|
|
249
|
-
console.log(kleur.dim('
|
|
271
|
+
console.log(kleur.dim('Stopped watching.'));
|
|
250
272
|
// Preserve any non-zero exit code set by a failed tick — don't mask a
|
|
251
273
|
// download failure with a clean exit just because the user pressed Ctrl+C.
|
|
252
274
|
process.exit(process.exitCode ?? 0);
|
|
@@ -307,12 +329,35 @@ export function classifyTickError(err, state) {
|
|
|
307
329
|
}
|
|
308
330
|
return { kind: 'other', nextBackoffSec: Math.min(state.backoffSec * 2, 60) };
|
|
309
331
|
}
|
|
332
|
+
async function loadWatchSpriteLine() {
|
|
333
|
+
try {
|
|
334
|
+
const config = await loadConfig();
|
|
335
|
+
const state = await loadState();
|
|
336
|
+
const lastPulled = state.assets ? Object.keys(state.assets).length : 0;
|
|
337
|
+
let workingSet = 0;
|
|
338
|
+
const kind = await detectProjectKind();
|
|
339
|
+
if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
|
|
340
|
+
const index = await indexGamePngs(kind, process.cwd(), config.outDir);
|
|
341
|
+
const matched = matchConnectGlobs(index, config.connect);
|
|
342
|
+
workingSet = matched.total;
|
|
343
|
+
}
|
|
344
|
+
else if (state.synced) {
|
|
345
|
+
workingSet = Object.keys(state.synced).length;
|
|
346
|
+
}
|
|
347
|
+
return formatWatchSpriteLine({ workingSet, lastPulled });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
310
353
|
async function runOnce(opts, runOpts = {}) {
|
|
311
354
|
const config = await loadConfig();
|
|
312
355
|
const state = await loadState();
|
|
313
356
|
const startedAt = new Date().toISOString();
|
|
314
357
|
const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
|
|
315
358
|
const verbose = !opts.quiet && !runOpts.watchMode;
|
|
359
|
+
const live = !opts.quiet;
|
|
360
|
+
const onStatus = runOpts.onStatus;
|
|
316
361
|
const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
|
|
317
362
|
if (verbose && config.endpoint) {
|
|
318
363
|
console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
|
|
@@ -322,6 +367,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
322
367
|
const spinner = verbose
|
|
323
368
|
? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
324
369
|
: null;
|
|
370
|
+
if (!spinner)
|
|
371
|
+
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
325
372
|
let manifest;
|
|
326
373
|
try {
|
|
327
374
|
manifest = await fetchAllManifest(config, since);
|
|
@@ -341,15 +388,44 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
341
388
|
}
|
|
342
389
|
catch (e) {
|
|
343
390
|
spinner?.fail('Manifest fetch failed');
|
|
391
|
+
// One-shot sync / first-run: still import local sprites (ingest is a
|
|
392
|
+
// different host). Watch ticks skip this — otherwise a 546 would POST
|
|
393
|
+
// every PNG every poll.
|
|
394
|
+
if (!runOpts.watchMode) {
|
|
395
|
+
const pushed = await maybePushLocalSprites(config.push, verbose);
|
|
396
|
+
if (verbose && pushed && pushed.created > 0) {
|
|
397
|
+
console.log();
|
|
398
|
+
console.log(kleur.green(`✓ pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
|
|
399
|
+
console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
344
402
|
throw e;
|
|
345
403
|
}
|
|
346
404
|
// Unity projects sync only the artboards flagged "Sync to Unity" in the
|
|
347
405
|
// editor (parity with the in-app sync button). `unitySyncAll: true` in
|
|
348
406
|
// magicpixel.json opts back into everything.
|
|
407
|
+
onStatus?.('Looking through your game sprites…');
|
|
349
408
|
const projectKind = await detectProjectKind();
|
|
409
|
+
const gameIndex = isEngineKind(projectKind)
|
|
410
|
+
? await indexGamePngs(projectKind, process.cwd(), config.outDir)
|
|
411
|
+
: { files: [], capped: false };
|
|
412
|
+
const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
|
|
413
|
+
const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
|
|
414
|
+
if (verbose && gameIndex.capped) {
|
|
415
|
+
console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
|
|
416
|
+
}
|
|
417
|
+
if (verbose && connected.capped) {
|
|
418
|
+
console.log(kleur.yellow(`! ${connectCapMessage(connected.total, connected.entries.length)}`));
|
|
419
|
+
}
|
|
420
|
+
if (verbose && isEngineKind(projectKind)) {
|
|
421
|
+
console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}` +
|
|
422
|
+
(connected.capped ? ` of ${connected.total} matched` : '')));
|
|
423
|
+
}
|
|
424
|
+
const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
|
|
350
425
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
351
426
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
352
427
|
// the sprite from the game project, not leave a stale copy behind.
|
|
428
|
+
// Working-set (sourceRel) files are never deleted — they're the user's art.
|
|
353
429
|
const deselectedPaths = [];
|
|
354
430
|
// Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
|
|
355
431
|
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
@@ -365,11 +441,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
365
441
|
for (const entry of manifest) {
|
|
366
442
|
if (filtered.entries.includes(entry))
|
|
367
443
|
continue;
|
|
368
|
-
const p =
|
|
444
|
+
const p = pathForKey(entry.key);
|
|
369
445
|
if (unknownSet.has(entry)) {
|
|
370
446
|
unknownFlagPaths.add(p);
|
|
371
447
|
continue;
|
|
372
448
|
}
|
|
449
|
+
if (sourceByKey.has(entry.key))
|
|
450
|
+
continue;
|
|
373
451
|
if (existsSync(p))
|
|
374
452
|
deselectedPaths.push(p);
|
|
375
453
|
}
|
|
@@ -379,7 +457,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
379
457
|
if (verbose) {
|
|
380
458
|
console.log();
|
|
381
459
|
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
|
|
382
|
-
console.log(kleur.dim(
|
|
460
|
+
console.log(kleur.dim(` Working-set sprites (\`connect\` in magicpixel.json) still push into MagicPixel.`));
|
|
383
461
|
console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
|
|
384
462
|
}
|
|
385
463
|
const empty = {
|
|
@@ -392,7 +470,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
392
470
|
bytesSaved: 0,
|
|
393
471
|
renamed: [],
|
|
394
472
|
};
|
|
395
|
-
await maybePushLocalSprites(config.push, verbose);
|
|
473
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode);
|
|
396
474
|
return empty;
|
|
397
475
|
}
|
|
398
476
|
const skipped = manifest.length - filtered.entries.length;
|
|
@@ -409,13 +487,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
409
487
|
renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
|
|
410
488
|
}
|
|
411
489
|
}
|
|
490
|
+
for (const r of renamed) {
|
|
491
|
+
const rel = sourceByKey.get(r.oldKey);
|
|
492
|
+
if (rel && !sourceByKey.has(r.newKey))
|
|
493
|
+
sourceByKey.set(r.newKey, rel);
|
|
494
|
+
}
|
|
412
495
|
// Compute the disk path once per entry and reuse it across the diff /
|
|
413
496
|
// orphan / download loops — saves three resolves + two security asserts
|
|
414
497
|
// per asset on large projects. Every entry in `manifest` is pre-seeded, so
|
|
415
498
|
// the lookup never misses; non-null assertion is safe.
|
|
416
499
|
const diskPathById = new Map();
|
|
417
500
|
for (const entry of manifest) {
|
|
418
|
-
diskPathById.set(entry.id,
|
|
501
|
+
diskPathById.set(entry.id, pathForKey(entry.key));
|
|
419
502
|
}
|
|
420
503
|
const pathFor = (entry) => diskPathById.get(entry.id);
|
|
421
504
|
// Diff against disk. SHA pre-check runs through the same concurrency pool
|
|
@@ -423,6 +506,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
423
506
|
// the dominant wall-clock cost of an incremental sync. We cache each result
|
|
424
507
|
// so the download loop can reuse it for `If-None-Match` ETags instead of
|
|
425
508
|
// re-hashing the same file moments later.
|
|
509
|
+
onStatus?.(manifest.length > 0
|
|
510
|
+
? `Comparing ${manifest.length.toLocaleString('en-US')} sprite${manifest.length === 1 ? '' : 's'} with files on disk…`
|
|
511
|
+
: 'Checking MagicPixel…');
|
|
426
512
|
const diffLimit = createLimit(concurrency);
|
|
427
513
|
const shaByEntryId = new Map();
|
|
428
514
|
const toDownload = [];
|
|
@@ -456,7 +542,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
456
542
|
.concat(Object.values(previousAssets))
|
|
457
543
|
.map((key) => {
|
|
458
544
|
try {
|
|
459
|
-
return
|
|
545
|
+
return pathForKey(key);
|
|
460
546
|
}
|
|
461
547
|
catch {
|
|
462
548
|
return '';
|
|
@@ -516,15 +602,24 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
516
602
|
}
|
|
517
603
|
// Stale paths from detected renames (always pruned, even in incremental mode —
|
|
518
604
|
// otherwise the old PNG silently lingers next to the renamed copy).
|
|
605
|
+
const outRoot = resolve(process.cwd(), config.outDir);
|
|
519
606
|
const renameStalePaths = renamed
|
|
520
|
-
.map((r) =>
|
|
521
|
-
|
|
607
|
+
.map((r) => {
|
|
608
|
+
try {
|
|
609
|
+
return pathForKey(r.oldKey);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return '';
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
.filter((p) => p && existsSync(p) && isPathInside(p, outRoot));
|
|
522
616
|
// De-duplicate vs the full-sync orphan list.
|
|
523
617
|
const orphanSet = new Set(orphans);
|
|
524
618
|
for (const p of renameStalePaths)
|
|
525
619
|
orphanSet.add(p);
|
|
526
620
|
// Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
|
|
527
|
-
// Unity project on the next sync, incremental or not.
|
|
621
|
+
// Unity project on the next sync, incremental or not. Working-set originals
|
|
622
|
+
// are never in this list.
|
|
528
623
|
for (const p of deselectedPaths)
|
|
529
624
|
orphanSet.add(p);
|
|
530
625
|
// Server-side rename history. Each manifest entry carries the composite
|
|
@@ -536,11 +631,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
536
631
|
const prevKeyResult = computePreviousKeyOrphans({
|
|
537
632
|
manifest,
|
|
538
633
|
remoteDiskPaths,
|
|
539
|
-
resolveDiskPath: (key) =>
|
|
634
|
+
resolveDiskPath: (key) => pathForKey(key),
|
|
540
635
|
fileExists: existsSync,
|
|
541
636
|
});
|
|
542
|
-
for (const p of prevKeyResult.orphanPaths)
|
|
543
|
-
|
|
637
|
+
for (const p of prevKeyResult.orphanPaths) {
|
|
638
|
+
if (isPathInside(p, outRoot))
|
|
639
|
+
orphanSet.add(p);
|
|
640
|
+
}
|
|
544
641
|
for (const r of prevKeyResult.renames) {
|
|
545
642
|
if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
|
|
546
643
|
renamed.push(r);
|
|
@@ -631,7 +728,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
631
728
|
bytesSaved,
|
|
632
729
|
};
|
|
633
730
|
let progress = null;
|
|
634
|
-
if (
|
|
731
|
+
if (live && toDownload.length > 0) {
|
|
732
|
+
onStatus?.(`Pulling ${toDownload.length.toLocaleString('en-US')} updated sprite${toDownload.length === 1 ? '' : 's'}…`);
|
|
635
733
|
progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
|
|
636
734
|
}
|
|
637
735
|
const run = createLimit(concurrency);
|
|
@@ -679,6 +777,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
679
777
|
// "fix" this back to atomicWrite without also changing the
|
|
680
778
|
// tmp-name pattern AND verifying HMR end-to-end in a consumer app.
|
|
681
779
|
try {
|
|
780
|
+
await assertSafeIoPath(diskPath, process.cwd(), { forWrite: true });
|
|
682
781
|
await mkdir(dirname(diskPath), { recursive: true });
|
|
683
782
|
await writeFile(diskPath, bytes);
|
|
684
783
|
}
|
|
@@ -686,7 +785,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
686
785
|
throw friendlyFsError(fsErr, {
|
|
687
786
|
operation: `Writing asset`,
|
|
688
787
|
path: diskPath,
|
|
689
|
-
hint:
|
|
788
|
+
hint: sourceByKey.has(entry.key)
|
|
789
|
+
? 'Sync can\'t continue until the original game file is writable.'
|
|
790
|
+
: `Sync can't continue until outDir (${config.outDir}) is writable.`,
|
|
690
791
|
});
|
|
691
792
|
}
|
|
692
793
|
if (existedBefore)
|
|
@@ -720,17 +821,24 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
720
821
|
})));
|
|
721
822
|
const downloaded = result.added.length + result.modified.length;
|
|
722
823
|
if (progress) {
|
|
723
|
-
if (
|
|
824
|
+
if (runOpts.watchMode) {
|
|
825
|
+
progress.stop();
|
|
826
|
+
}
|
|
827
|
+
else if (result.failed === 0) {
|
|
724
828
|
progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
|
|
725
|
-
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
726
831
|
progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
|
|
832
|
+
}
|
|
727
833
|
}
|
|
728
834
|
// Unity: write pixel-art-correct `.meta` sidecars so sprites import with
|
|
729
835
|
// point filtering / no compression instead of Unity's blurry defaults —
|
|
730
836
|
// parity with the in-app "Sync to Unity" button. Only missing sidecars are
|
|
731
837
|
// written, so importer settings the user tweaked in Unity are preserved.
|
|
732
838
|
if (projectKind === 'Unity') {
|
|
733
|
-
const meta = await writeMissingUnityMetas(manifest
|
|
839
|
+
const meta = await writeMissingUnityMetas(manifest
|
|
840
|
+
.filter((entry) => !sourceByKey.has(entry.key))
|
|
841
|
+
.map((entry) => ({ id: entry.id, pngPath: pathFor(entry) })), config.unityPpu ?? DEFAULT_UNITY_PPU);
|
|
734
842
|
if (verbose && meta.written > 0) {
|
|
735
843
|
console.log(kleur.dim(` unity → ${meta.written} .meta file${meta.written === 1 ? '' : 's'} written`));
|
|
736
844
|
}
|
|
@@ -740,15 +848,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
740
848
|
}
|
|
741
849
|
// Prune (now default — pass --no-prune to opt out).
|
|
742
850
|
if (shouldPrune && orphans.length > 0) {
|
|
743
|
-
const
|
|
851
|
+
const pruneRoot = resolve(process.cwd(), config.outDir);
|
|
744
852
|
for (const p of orphans) {
|
|
745
|
-
|
|
853
|
+
if (!isPathInside(p, pruneRoot))
|
|
854
|
+
continue;
|
|
855
|
+
assertPathInsideRoot(p, pruneRoot, 'outDir');
|
|
746
856
|
try {
|
|
747
857
|
await unlink(p);
|
|
748
858
|
// Unity sidecar follows its PNG — a stranded .meta shows up in Unity
|
|
749
859
|
// as a missing-asset warning.
|
|
750
860
|
await unlink(`${p}.meta`).catch(() => { });
|
|
751
|
-
const relPath = relative(
|
|
861
|
+
const relPath = relative(pruneRoot, p).replace(/\\/g, '/');
|
|
752
862
|
const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
|
|
753
863
|
result.removed.push(key);
|
|
754
864
|
if (verbose)
|
|
@@ -816,28 +926,37 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
816
926
|
// without re-fetching the manifest. Renamed/pruned keys are dropped so a
|
|
817
927
|
// stale entry can never send an edit to the wrong artboard.
|
|
818
928
|
const nextSynced = { ...(state.synced ?? {}) };
|
|
819
|
-
for (const r of renamed)
|
|
929
|
+
for (const r of renamed) {
|
|
820
930
|
delete nextSynced[r.oldKey];
|
|
931
|
+
}
|
|
821
932
|
for (const key of result.removed)
|
|
822
933
|
delete nextSynced[key];
|
|
934
|
+
const writtenKeys = new Set([...result.added, ...result.modified]);
|
|
823
935
|
for (const entry of manifest) {
|
|
824
936
|
if (!entry.asset_id)
|
|
825
937
|
continue;
|
|
826
938
|
const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
|
|
827
939
|
if (!sha)
|
|
828
940
|
continue;
|
|
941
|
+
const prev = nextSynced[entry.key];
|
|
942
|
+
const diskSha256 = writtenKeys.has(entry.key)
|
|
943
|
+
? sha
|
|
944
|
+
: (shaByEntryId.get(entry.id) ?? prev?.diskSha256 ?? sha);
|
|
945
|
+
const sourceRel = sourceByKey.get(entry.key);
|
|
829
946
|
// Legacy single-PNG rows have no artboard address (`layer_idx: -1`). Record
|
|
830
947
|
// them anyway, flagged, so `push` skips them with a hint instead of
|
|
831
948
|
// adopting the same art as a second document.
|
|
832
949
|
if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
|
|
833
|
-
nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, legacy: true };
|
|
950
|
+
nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, diskSha256, legacy: true, ...(sourceRel ? { sourceRel } : {}) };
|
|
834
951
|
continue;
|
|
835
952
|
}
|
|
836
953
|
nextSynced[entry.key] = {
|
|
837
954
|
assetId: entry.asset_id,
|
|
838
955
|
layerIdx: entry.layer_idx,
|
|
839
956
|
sha256: sha,
|
|
957
|
+
diskSha256,
|
|
840
958
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
959
|
+
...(sourceRel ? { sourceRel } : {}),
|
|
841
960
|
};
|
|
842
961
|
}
|
|
843
962
|
const nextState = { ...state, assets: nextAssets, synced: nextSynced };
|
|
@@ -918,23 +1037,25 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
918
1037
|
if (renamed.length > 0)
|
|
919
1038
|
printRenames(renamed, { withHints: true });
|
|
920
1039
|
}
|
|
921
|
-
|
|
1040
|
+
onStatus?.('Checking your game files…');
|
|
1041
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode);
|
|
922
1042
|
if (result.failed)
|
|
923
1043
|
process.exitCode = 1;
|
|
924
1044
|
return result;
|
|
925
1045
|
}
|
|
926
|
-
async function maybePushLocalSprites(pushEnabled, verbose) {
|
|
1046
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose) {
|
|
927
1047
|
if (pushEnabled === false)
|
|
928
|
-
return;
|
|
1048
|
+
return null;
|
|
929
1049
|
try {
|
|
930
|
-
await runPush({ quiet: !verbose });
|
|
1050
|
+
return await runPush({ quiet: !verbose, gameIndex });
|
|
931
1051
|
}
|
|
932
1052
|
catch (e) {
|
|
933
|
-
if (
|
|
1053
|
+
if (announceErrors) {
|
|
934
1054
|
console.log();
|
|
935
|
-
console.log(kleur.yellow(`! could not
|
|
1055
|
+
console.log(kleur.yellow(`! could not update MagicPixel from your game files: ${e.message}`));
|
|
936
1056
|
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
|
|
937
1057
|
}
|
|
1058
|
+
return null;
|
|
938
1059
|
}
|
|
939
1060
|
}
|
|
940
1061
|
const CHANGE_PRINT_CAP = 50;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, mkdir, rename, writeFile, chmod } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
|
-
import { assertSafeOutDir, MAX_GLOB_LEN, validateEndpointUrl } from './util/security.js';
|
|
4
|
+
import { assertSafeOutDir, MAX_GLOB_LEN, sanitizeSourceRel, validateEndpointUrl } from './util/security.js';
|
|
5
5
|
import { readCredentialsSync } from './util/credentials.js';
|
|
6
6
|
import { friendlyFsError } from './util/errors.js';
|
|
7
7
|
import { atomicWrite } from './util/atomicWrite.js';
|
|
@@ -14,6 +14,7 @@ export const defaultConfig = {
|
|
|
14
14
|
outDir: 'src/assets/magicpixel',
|
|
15
15
|
include: ['**/*'],
|
|
16
16
|
exclude: [],
|
|
17
|
+
connect: [],
|
|
17
18
|
emitIndex: true,
|
|
18
19
|
};
|
|
19
20
|
export function configPath(cwd = process.cwd()) {
|
|
@@ -43,6 +44,7 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
43
44
|
}
|
|
44
45
|
const include = normalizeGlobList(parsed.include ?? defaultConfig.include, 'include');
|
|
45
46
|
const exclude = normalizeGlobList(parsed.exclude ?? defaultConfig.exclude, 'exclude');
|
|
47
|
+
const connect = normalizeGlobList(parsed.connect ?? defaultConfig.connect, 'connect');
|
|
46
48
|
const rawOutDir = typeof parsed.outDir === 'string' && parsed.outDir.trim() ? parsed.outDir : defaultConfig.outDir;
|
|
47
49
|
let outDir;
|
|
48
50
|
try {
|
|
@@ -87,6 +89,7 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
87
89
|
unityPpu,
|
|
88
90
|
unitySyncAll: parsed.unitySyncAll === true ? true : undefined,
|
|
89
91
|
push: parsed.push === false ? false : undefined,
|
|
92
|
+
connect,
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
const MAX_GLOBS = 64;
|
|
@@ -110,6 +113,10 @@ function normalizeGlobList(value, field) {
|
|
|
110
113
|
throw new Error(`${CONFIG_FILENAME}: "${field}" entry is too long (>${MAX_GLOB_LEN} chars) or contains a null byte.\n` +
|
|
111
114
|
` Fix: shorten the pattern.`);
|
|
112
115
|
}
|
|
116
|
+
if (g.split(/[/\\]/).some((seg) => seg === '..')) {
|
|
117
|
+
throw new Error(`${CONFIG_FILENAME}: "${field}" must not contain ".." segments.\n` +
|
|
118
|
+
` Fix: use a project-relative glob such as "Assets/Sprites/**".`);
|
|
119
|
+
}
|
|
113
120
|
out.push(g);
|
|
114
121
|
}
|
|
115
122
|
return out;
|
|
@@ -143,7 +150,7 @@ export async function loadState(cwd = process.cwd()) {
|
|
|
143
150
|
return {};
|
|
144
151
|
}
|
|
145
152
|
try {
|
|
146
|
-
return JSON.parse(raw);
|
|
153
|
+
return scrubLoadedState(JSON.parse(raw));
|
|
147
154
|
}
|
|
148
155
|
catch (e) {
|
|
149
156
|
// Corrupt state file (truncated by a crash, hand-edited, disk full, etc.)
|
|
@@ -162,6 +169,27 @@ export async function loadState(cwd = process.cwd()) {
|
|
|
162
169
|
return {};
|
|
163
170
|
}
|
|
164
171
|
}
|
|
172
|
+
/** Drop untrusted persisted sourceRel so a hand-edited state.json cannot leak into later saves. */
|
|
173
|
+
function scrubLoadedState(state) {
|
|
174
|
+
const synced = state.synced;
|
|
175
|
+
if (!synced || typeof synced !== 'object' || Array.isArray(synced))
|
|
176
|
+
return state;
|
|
177
|
+
const next = {};
|
|
178
|
+
for (const [key, val] of Object.entries(synced)) {
|
|
179
|
+
if (!val || typeof val !== 'object')
|
|
180
|
+
continue;
|
|
181
|
+
const sprite = { ...val };
|
|
182
|
+
if (sprite.sourceRel !== undefined) {
|
|
183
|
+
const safe = sanitizeSourceRel(sprite.sourceRel);
|
|
184
|
+
if (safe)
|
|
185
|
+
sprite.sourceRel = safe;
|
|
186
|
+
else
|
|
187
|
+
delete sprite.sourceRel;
|
|
188
|
+
}
|
|
189
|
+
next[key] = sprite;
|
|
190
|
+
}
|
|
191
|
+
return { ...state, synced: next };
|
|
192
|
+
}
|
|
165
193
|
export async function saveState(state, cwd = process.cwd()) {
|
|
166
194
|
const path = statePath(cwd);
|
|
167
195
|
try {
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { logoutCommand } from './commands/logout.js';
|
|
|
14
14
|
import { doctorCommand } from './commands/doctor.js';
|
|
15
15
|
import { repairCommand } from './commands/repair.js';
|
|
16
16
|
import { startCommand } from './commands/start.js';
|
|
17
|
+
import { connectCommand } from './commands/connect.js';
|
|
18
|
+
import { searchCommand } from './commands/search.js';
|
|
17
19
|
import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
|
|
18
20
|
import { CLI_VERSION } from './version.js';
|
|
19
21
|
// Node version guard
|
|
@@ -97,6 +99,16 @@ program
|
|
|
97
99
|
.option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
|
|
98
100
|
.addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n')
|
|
99
101
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
102
|
+
program
|
|
103
|
+
.command('connect <glob>')
|
|
104
|
+
.description('Add a game-sprite glob to the working set and ingest matching PNGs')
|
|
105
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
|
|
106
|
+
.action(wrap("connect", async (glob) => connectCommand(glob)));
|
|
107
|
+
program
|
|
108
|
+
.command('search <query>')
|
|
109
|
+
.description('Search indexed game PNGs (no network)')
|
|
110
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel search hero\n')
|
|
111
|
+
.action(wrap("search", async (query) => searchCommand(query)));
|
|
100
112
|
program
|
|
101
113
|
.command('add <glob>')
|
|
102
114
|
.description('Append a glob pattern to include')
|
package/dist/util/framework.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
3
|
+
import { relative, resolve } from 'node:path';
|
|
4
4
|
const ENGINE_KINDS = new Set(['Unity', 'Godot', 'GameMaker']);
|
|
5
5
|
export function isEngineKind(kind) {
|
|
6
6
|
return kind !== null && ENGINE_KINDS.has(kind);
|
|
@@ -38,11 +38,58 @@ async function detectJsKind(cwd) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Case-insensitive child lookup, preserving the on-disk name. `existsSync` of
|
|
43
|
+
* a guessed `Assets` path is wrong on macOS (matches `assets` but keeps the
|
|
44
|
+
* wrong casing).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveChild(cwd, logicalName, opts = {}) {
|
|
47
|
+
try {
|
|
48
|
+
const want = logicalName.toLowerCase();
|
|
49
|
+
for (const ent of readdirSync(cwd, { withFileTypes: true })) {
|
|
50
|
+
if (ent.name.toLowerCase() !== want)
|
|
51
|
+
continue;
|
|
52
|
+
if (opts.directory && !ent.isDirectory())
|
|
53
|
+
continue;
|
|
54
|
+
return resolve(cwd, ent.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* unreadable cwd */
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** Case-insensitive child directory, preserving the on-disk name. */
|
|
63
|
+
export function resolveChildDir(cwd, logicalName) {
|
|
64
|
+
return resolveChild(cwd, logicalName, { directory: true });
|
|
65
|
+
}
|
|
66
|
+
function hasMetaSidecar(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(dir).some((name) => name.endsWith('.meta'));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
41
74
|
function detectEngineKind(cwd) {
|
|
42
75
|
if (existsSync(resolve(cwd, 'project.godot')))
|
|
43
76
|
return 'Godot';
|
|
44
|
-
|
|
77
|
+
const projectSettings = resolveChildDir(cwd, 'ProjectSettings');
|
|
78
|
+
if (projectSettings && resolveChild(projectSettings, 'ProjectVersion.txt'))
|
|
79
|
+
return 'Unity';
|
|
80
|
+
const packages = resolveChildDir(cwd, 'Packages');
|
|
81
|
+
if (packages && resolveChild(packages, 'manifest.json'))
|
|
45
82
|
return 'Unity';
|
|
83
|
+
// Assets/assets only counts with Unity .meta sidecars — a generic `assets/`
|
|
84
|
+
// folder in a Node repo is not a Unity project.
|
|
85
|
+
const assets = resolveChildDir(cwd, 'Assets');
|
|
86
|
+
if (assets && hasMetaSidecar(assets))
|
|
87
|
+
return 'Unity';
|
|
88
|
+
// Embedded UPM package: Editor/Runtime plus root .meta (kr-core has no
|
|
89
|
+
// ProjectSettings and often only a lowercase `assets` dump).
|
|
90
|
+
if (hasMetaSidecar(cwd) && (resolveChildDir(cwd, 'Editor') || resolveChildDir(cwd, 'Runtime'))) {
|
|
91
|
+
return 'Unity';
|
|
92
|
+
}
|
|
46
93
|
try {
|
|
47
94
|
const yyp = readdirSync(cwd).find((name) => name.endsWith('.yyp'));
|
|
48
95
|
if (yyp)
|
|
@@ -79,17 +126,22 @@ export function suggestOutDir(kind, cwd = process.cwd()) {
|
|
|
79
126
|
case 'TanStack Start':
|
|
80
127
|
return 'src/assets/magicpixel';
|
|
81
128
|
case 'Unity':
|
|
82
|
-
return 'Assets
|
|
129
|
+
return suggestEngineOutDir(cwd, 'Assets', 'MagicPixel');
|
|
83
130
|
case 'Godot':
|
|
84
|
-
return 'assets
|
|
131
|
+
return suggestEngineOutDir(cwd, 'assets', 'magicpixel');
|
|
85
132
|
case 'GameMaker':
|
|
86
|
-
return 'datafiles
|
|
133
|
+
return suggestEngineOutDir(cwd, 'datafiles', 'magicpixel');
|
|
87
134
|
default:
|
|
88
135
|
// No kind detected — prefer src/ if present so the typed index is
|
|
89
136
|
// importable; otherwise fall back to a top-level assets/ dir.
|
|
90
137
|
return existsSync(resolve(cwd, 'src')) ? 'src/assets/magicpixel' : 'assets/magicpixel';
|
|
91
138
|
}
|
|
92
139
|
}
|
|
140
|
+
function suggestEngineOutDir(cwd, folder, destName) {
|
|
141
|
+
const found = resolveChildDir(cwd, folder);
|
|
142
|
+
const rel = found ? relative(cwd, found).replace(/\\/g, '/') : folder;
|
|
143
|
+
return `${rel || folder}/${destName}`;
|
|
144
|
+
}
|
|
93
145
|
export function hasPackageJson(cwd = process.cwd()) {
|
|
94
146
|
return existsSync(resolve(cwd, 'package.json'));
|
|
95
147
|
}
|