@magicpixelart/cli 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -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 +91 -26
- 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/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';
|
|
@@ -341,15 +343,43 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
341
343
|
}
|
|
342
344
|
catch (e) {
|
|
343
345
|
spinner?.fail('Manifest fetch failed');
|
|
346
|
+
// One-shot sync / first-run: still import local sprites (ingest is a
|
|
347
|
+
// different host). Watch ticks skip this — otherwise a 546 would POST
|
|
348
|
+
// every PNG every poll.
|
|
349
|
+
if (!runOpts.watchMode) {
|
|
350
|
+
const pushed = await maybePushLocalSprites(config.push, verbose);
|
|
351
|
+
if (verbose && pushed && pushed.created > 0) {
|
|
352
|
+
console.log();
|
|
353
|
+
console.log(kleur.green(`✓ pushed ${pushed.created} working-set sprite${pushed.created === 1 ? '' : 's'} into MagicPixel anyway.`));
|
|
354
|
+
console.log(kleur.dim(' Refresh Connected in the library to see them, then re-run sync to pull.'));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
344
357
|
throw e;
|
|
345
358
|
}
|
|
346
359
|
// Unity projects sync only the artboards flagged "Sync to Unity" in the
|
|
347
360
|
// editor (parity with the in-app sync button). `unitySyncAll: true` in
|
|
348
361
|
// magicpixel.json opts back into everything.
|
|
349
362
|
const projectKind = await detectProjectKind();
|
|
363
|
+
const gameIndex = isEngineKind(projectKind)
|
|
364
|
+
? await indexGamePngs(projectKind, process.cwd(), config.outDir)
|
|
365
|
+
: { files: [], capped: false };
|
|
366
|
+
const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
|
|
367
|
+
const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
|
|
368
|
+
if (verbose && gameIndex.capped) {
|
|
369
|
+
console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
|
|
370
|
+
}
|
|
371
|
+
if (verbose && connected.capped) {
|
|
372
|
+
console.log(kleur.yellow(`! ${connectCapMessage(connected.total, connected.entries.length)}`));
|
|
373
|
+
}
|
|
374
|
+
if (verbose && isEngineKind(projectKind)) {
|
|
375
|
+
console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}` +
|
|
376
|
+
(connected.capped ? ` of ${connected.total} matched` : '')));
|
|
377
|
+
}
|
|
378
|
+
const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
|
|
350
379
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
351
380
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
352
381
|
// the sprite from the game project, not leave a stale copy behind.
|
|
382
|
+
// Working-set (sourceRel) files are never deleted — they're the user's art.
|
|
353
383
|
const deselectedPaths = [];
|
|
354
384
|
// Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
|
|
355
385
|
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
@@ -365,11 +395,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
365
395
|
for (const entry of manifest) {
|
|
366
396
|
if (filtered.entries.includes(entry))
|
|
367
397
|
continue;
|
|
368
|
-
const p =
|
|
398
|
+
const p = pathForKey(entry.key);
|
|
369
399
|
if (unknownSet.has(entry)) {
|
|
370
400
|
unknownFlagPaths.add(p);
|
|
371
401
|
continue;
|
|
372
402
|
}
|
|
403
|
+
if (sourceByKey.has(entry.key))
|
|
404
|
+
continue;
|
|
373
405
|
if (existsSync(p))
|
|
374
406
|
deselectedPaths.push(p);
|
|
375
407
|
}
|
|
@@ -379,7 +411,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
379
411
|
if (verbose) {
|
|
380
412
|
console.log();
|
|
381
413
|
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
|
|
382
|
-
console.log(kleur.dim(
|
|
414
|
+
console.log(kleur.dim(` Working-set sprites (\`connect\` in magicpixel.json) still push into MagicPixel.`));
|
|
383
415
|
console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
|
|
384
416
|
}
|
|
385
417
|
const empty = {
|
|
@@ -392,7 +424,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
392
424
|
bytesSaved: 0,
|
|
393
425
|
renamed: [],
|
|
394
426
|
};
|
|
395
|
-
await maybePushLocalSprites(config.push, verbose);
|
|
427
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex);
|
|
396
428
|
return empty;
|
|
397
429
|
}
|
|
398
430
|
const skipped = manifest.length - filtered.entries.length;
|
|
@@ -409,13 +441,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
409
441
|
renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
|
|
410
442
|
}
|
|
411
443
|
}
|
|
444
|
+
for (const r of renamed) {
|
|
445
|
+
const rel = sourceByKey.get(r.oldKey);
|
|
446
|
+
if (rel && !sourceByKey.has(r.newKey))
|
|
447
|
+
sourceByKey.set(r.newKey, rel);
|
|
448
|
+
}
|
|
412
449
|
// Compute the disk path once per entry and reuse it across the diff /
|
|
413
450
|
// orphan / download loops — saves three resolves + two security asserts
|
|
414
451
|
// per asset on large projects. Every entry in `manifest` is pre-seeded, so
|
|
415
452
|
// the lookup never misses; non-null assertion is safe.
|
|
416
453
|
const diskPathById = new Map();
|
|
417
454
|
for (const entry of manifest) {
|
|
418
|
-
diskPathById.set(entry.id,
|
|
455
|
+
diskPathById.set(entry.id, pathForKey(entry.key));
|
|
419
456
|
}
|
|
420
457
|
const pathFor = (entry) => diskPathById.get(entry.id);
|
|
421
458
|
// Diff against disk. SHA pre-check runs through the same concurrency pool
|
|
@@ -456,7 +493,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
456
493
|
.concat(Object.values(previousAssets))
|
|
457
494
|
.map((key) => {
|
|
458
495
|
try {
|
|
459
|
-
return
|
|
496
|
+
return pathForKey(key);
|
|
460
497
|
}
|
|
461
498
|
catch {
|
|
462
499
|
return '';
|
|
@@ -516,15 +553,24 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
516
553
|
}
|
|
517
554
|
// Stale paths from detected renames (always pruned, even in incremental mode —
|
|
518
555
|
// otherwise the old PNG silently lingers next to the renamed copy).
|
|
556
|
+
const outRoot = resolve(process.cwd(), config.outDir);
|
|
519
557
|
const renameStalePaths = renamed
|
|
520
|
-
.map((r) =>
|
|
521
|
-
|
|
558
|
+
.map((r) => {
|
|
559
|
+
try {
|
|
560
|
+
return pathForKey(r.oldKey);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return '';
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
.filter((p) => p && existsSync(p) && isPathInside(p, outRoot));
|
|
522
567
|
// De-duplicate vs the full-sync orphan list.
|
|
523
568
|
const orphanSet = new Set(orphans);
|
|
524
569
|
for (const p of renameStalePaths)
|
|
525
570
|
orphanSet.add(p);
|
|
526
571
|
// Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
|
|
527
|
-
// Unity project on the next sync, incremental or not.
|
|
572
|
+
// Unity project on the next sync, incremental or not. Working-set originals
|
|
573
|
+
// are never in this list.
|
|
528
574
|
for (const p of deselectedPaths)
|
|
529
575
|
orphanSet.add(p);
|
|
530
576
|
// Server-side rename history. Each manifest entry carries the composite
|
|
@@ -536,11 +582,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
536
582
|
const prevKeyResult = computePreviousKeyOrphans({
|
|
537
583
|
manifest,
|
|
538
584
|
remoteDiskPaths,
|
|
539
|
-
resolveDiskPath: (key) =>
|
|
585
|
+
resolveDiskPath: (key) => pathForKey(key),
|
|
540
586
|
fileExists: existsSync,
|
|
541
587
|
});
|
|
542
|
-
for (const p of prevKeyResult.orphanPaths)
|
|
543
|
-
|
|
588
|
+
for (const p of prevKeyResult.orphanPaths) {
|
|
589
|
+
if (isPathInside(p, outRoot))
|
|
590
|
+
orphanSet.add(p);
|
|
591
|
+
}
|
|
544
592
|
for (const r of prevKeyResult.renames) {
|
|
545
593
|
if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
|
|
546
594
|
renamed.push(r);
|
|
@@ -679,6 +727,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
679
727
|
// "fix" this back to atomicWrite without also changing the
|
|
680
728
|
// tmp-name pattern AND verifying HMR end-to-end in a consumer app.
|
|
681
729
|
try {
|
|
730
|
+
await assertSafeIoPath(diskPath, process.cwd(), { forWrite: true });
|
|
682
731
|
await mkdir(dirname(diskPath), { recursive: true });
|
|
683
732
|
await writeFile(diskPath, bytes);
|
|
684
733
|
}
|
|
@@ -686,7 +735,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
686
735
|
throw friendlyFsError(fsErr, {
|
|
687
736
|
operation: `Writing asset`,
|
|
688
737
|
path: diskPath,
|
|
689
|
-
hint:
|
|
738
|
+
hint: sourceByKey.has(entry.key)
|
|
739
|
+
? 'Sync can\'t continue until the original game file is writable.'
|
|
740
|
+
: `Sync can't continue until outDir (${config.outDir}) is writable.`,
|
|
690
741
|
});
|
|
691
742
|
}
|
|
692
743
|
if (existedBefore)
|
|
@@ -730,7 +781,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
730
781
|
// parity with the in-app "Sync to Unity" button. Only missing sidecars are
|
|
731
782
|
// written, so importer settings the user tweaked in Unity are preserved.
|
|
732
783
|
if (projectKind === 'Unity') {
|
|
733
|
-
const meta = await writeMissingUnityMetas(manifest
|
|
784
|
+
const meta = await writeMissingUnityMetas(manifest
|
|
785
|
+
.filter((entry) => !sourceByKey.has(entry.key))
|
|
786
|
+
.map((entry) => ({ id: entry.id, pngPath: pathFor(entry) })), config.unityPpu ?? DEFAULT_UNITY_PPU);
|
|
734
787
|
if (verbose && meta.written > 0) {
|
|
735
788
|
console.log(kleur.dim(` unity → ${meta.written} .meta file${meta.written === 1 ? '' : 's'} written`));
|
|
736
789
|
}
|
|
@@ -740,15 +793,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
740
793
|
}
|
|
741
794
|
// Prune (now default — pass --no-prune to opt out).
|
|
742
795
|
if (shouldPrune && orphans.length > 0) {
|
|
743
|
-
const
|
|
796
|
+
const pruneRoot = resolve(process.cwd(), config.outDir);
|
|
744
797
|
for (const p of orphans) {
|
|
745
|
-
|
|
798
|
+
if (!isPathInside(p, pruneRoot))
|
|
799
|
+
continue;
|
|
800
|
+
assertPathInsideRoot(p, pruneRoot, 'outDir');
|
|
746
801
|
try {
|
|
747
802
|
await unlink(p);
|
|
748
803
|
// Unity sidecar follows its PNG — a stranded .meta shows up in Unity
|
|
749
804
|
// as a missing-asset warning.
|
|
750
805
|
await unlink(`${p}.meta`).catch(() => { });
|
|
751
|
-
const relPath = relative(
|
|
806
|
+
const relPath = relative(pruneRoot, p).replace(/\\/g, '/');
|
|
752
807
|
const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
|
|
753
808
|
result.removed.push(key);
|
|
754
809
|
if (verbose)
|
|
@@ -816,28 +871,37 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
816
871
|
// without re-fetching the manifest. Renamed/pruned keys are dropped so a
|
|
817
872
|
// stale entry can never send an edit to the wrong artboard.
|
|
818
873
|
const nextSynced = { ...(state.synced ?? {}) };
|
|
819
|
-
for (const r of renamed)
|
|
874
|
+
for (const r of renamed) {
|
|
820
875
|
delete nextSynced[r.oldKey];
|
|
876
|
+
}
|
|
821
877
|
for (const key of result.removed)
|
|
822
878
|
delete nextSynced[key];
|
|
879
|
+
const writtenKeys = new Set([...result.added, ...result.modified]);
|
|
823
880
|
for (const entry of manifest) {
|
|
824
881
|
if (!entry.asset_id)
|
|
825
882
|
continue;
|
|
826
883
|
const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
|
|
827
884
|
if (!sha)
|
|
828
885
|
continue;
|
|
886
|
+
const prev = nextSynced[entry.key];
|
|
887
|
+
const diskSha256 = writtenKeys.has(entry.key)
|
|
888
|
+
? sha
|
|
889
|
+
: (shaByEntryId.get(entry.id) ?? prev?.diskSha256 ?? sha);
|
|
890
|
+
const sourceRel = sourceByKey.get(entry.key);
|
|
829
891
|
// Legacy single-PNG rows have no artboard address (`layer_idx: -1`). Record
|
|
830
892
|
// them anyway, flagged, so `push` skips them with a hint instead of
|
|
831
893
|
// adopting the same art as a second document.
|
|
832
894
|
if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
|
|
833
|
-
nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, legacy: true };
|
|
895
|
+
nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, diskSha256, legacy: true, ...(sourceRel ? { sourceRel } : {}) };
|
|
834
896
|
continue;
|
|
835
897
|
}
|
|
836
898
|
nextSynced[entry.key] = {
|
|
837
899
|
assetId: entry.asset_id,
|
|
838
900
|
layerIdx: entry.layer_idx,
|
|
839
901
|
sha256: sha,
|
|
902
|
+
diskSha256,
|
|
840
903
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
904
|
+
...(sourceRel ? { sourceRel } : {}),
|
|
841
905
|
};
|
|
842
906
|
}
|
|
843
907
|
const nextState = { ...state, assets: nextAssets, synced: nextSynced };
|
|
@@ -918,23 +982,24 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
918
982
|
if (renamed.length > 0)
|
|
919
983
|
printRenames(renamed, { withHints: true });
|
|
920
984
|
}
|
|
921
|
-
await maybePushLocalSprites(config.push, verbose);
|
|
985
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex);
|
|
922
986
|
if (result.failed)
|
|
923
987
|
process.exitCode = 1;
|
|
924
988
|
return result;
|
|
925
989
|
}
|
|
926
|
-
async function maybePushLocalSprites(pushEnabled, verbose) {
|
|
990
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex) {
|
|
927
991
|
if (pushEnabled === false)
|
|
928
|
-
return;
|
|
992
|
+
return null;
|
|
929
993
|
try {
|
|
930
|
-
await runPush({ quiet: !verbose });
|
|
994
|
+
return await runPush({ quiet: !verbose, gameIndex });
|
|
931
995
|
}
|
|
932
996
|
catch (e) {
|
|
933
997
|
if (verbose) {
|
|
934
998
|
console.log();
|
|
935
|
-
console.log(kleur.yellow(`! could not
|
|
999
|
+
console.log(kleur.yellow(`! could not push working-set sprites into MagicPixel: ${e.message}`));
|
|
936
1000
|
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
|
|
937
1001
|
}
|
|
1002
|
+
return null;
|
|
938
1003
|
}
|
|
939
1004
|
}
|
|
940
1005
|
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
|
}
|