@magicpixelart/cli 0.5.7 → 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 +68 -0
- package/README.md +16 -3
- 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 +160 -57
- package/dist/commands/search.js +45 -0
- package/dist/commands/start.js +28 -15
- package/dist/commands/sync.js +109 -34
- package/dist/config.js +31 -2
- package/dist/index.js +12 -0
- package/dist/util/framework.js +57 -5
- package/dist/util/gameScan.js +177 -0
- package/dist/util/globMatch.js +50 -0
- package/dist/util/hash.js +37 -5
- package/dist/util/pushPlan.js +115 -5
- package/dist/util/security.js +91 -1
- package/dist/util/syncPath.js +76 -0
- package/package.json +1 -1
package/dist/commands/start.js
CHANGED
|
@@ -14,25 +14,24 @@ import { cmd } from '../util/invoke.js';
|
|
|
14
14
|
/**
|
|
15
15
|
* One-command bootstrap for new users. Runs init → key prompt → first sync →
|
|
16
16
|
* prints copy-pasteable watch instructions. Designed so a non-technical user
|
|
17
|
-
* can paste `npx @magicpixelart/cli start` and end up with sprites
|
|
18
|
-
*
|
|
17
|
+
* can paste `npx @magicpixelart/cli start` and end up with game sprites in
|
|
18
|
+
* MagicPixel (and flagged sprites back on disk) plus a watch script.
|
|
19
19
|
*/
|
|
20
20
|
export async function startCommand(opts = {}) {
|
|
21
21
|
console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
|
|
22
|
-
console.log(kleur.dim(' This
|
|
22
|
+
console.log(kleur.dim(' This links your game project. Sprites are indexed locally; nothing is imported until you connect a working set.'));
|
|
23
23
|
console.log();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
|
|
33
|
-
' Run this inside your project folder — the one with package.json.'));
|
|
24
|
+
const kind = await detectProjectKind();
|
|
25
|
+
// Engine projects (including Unity UPM packages) have no package.json
|
|
26
|
+
// requirement. JS projects still need one.
|
|
27
|
+
if (!hasPackageJson() && !isEngineKind(kind)) {
|
|
28
|
+
console.log(kleur.yellow(' MagicPixel needs a game project to sync into.\n' +
|
|
29
|
+
' Run this inside your Unity, Godot, GameMaker, or JavaScript folder\n' +
|
|
30
|
+
' (Unity package, project with Assets/, or a folder with package.json).'));
|
|
34
31
|
return;
|
|
35
32
|
}
|
|
33
|
+
if (kind)
|
|
34
|
+
console.log(kleur.dim(` Detected: ${kind}`));
|
|
36
35
|
// 2. Run init non-interactively unless config already exists AND is valid.
|
|
37
36
|
// A broken `magicpixel.json` (hand-edited / truncated) used to slip past
|
|
38
37
|
// the existsSync check here and then explode deep inside `syncCommand`.
|
|
@@ -73,10 +72,21 @@ export async function startCommand(opts = {}) {
|
|
|
73
72
|
// 5. First sync. syncCommand sets `process.exitCode = 1` on download
|
|
74
73
|
// failures without throwing — snapshot around the call so we don't
|
|
75
74
|
// print a misleading green "you're set up" over a half-failed run.
|
|
75
|
+
// Manifest 546s throw after still importing local sprites; catch so
|
|
76
|
+
// the watch instructions below aren't swallowed.
|
|
76
77
|
console.log();
|
|
77
|
-
console.log(kleur.bold('Step:
|
|
78
|
+
console.log(kleur.bold('Step: sync'));
|
|
78
79
|
const exitBefore = process.exitCode ?? 0;
|
|
79
|
-
|
|
80
|
+
try {
|
|
81
|
+
await syncCommand({ full: true });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
const err = e;
|
|
85
|
+
console.error(kleur.red(err.message ?? String(e)));
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
const { reportCliError } = await import('../util/telemetry.js');
|
|
88
|
+
await reportCliError(err, { command: 'start' });
|
|
89
|
+
}
|
|
80
90
|
const firstSyncFailed = (process.exitCode ?? 0) > exitBefore;
|
|
81
91
|
// 6. Tell the user how to run the watch loop. If `init` couldn't patch
|
|
82
92
|
// package.json (non-standard layout, write-protected, etc.) the
|
|
@@ -99,6 +109,9 @@ export async function startCommand(opts = {}) {
|
|
|
99
109
|
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
100
110
|
}
|
|
101
111
|
console.log();
|
|
112
|
+
console.log(kleur.dim(` Find sprites with \`${cmd('search')} hero\`, then \`${cmd('connect')} 'assets/Sprites/**'\` to add a working set.`));
|
|
113
|
+
console.log(kleur.dim(' Connected sprites write back to their original game path. New MagicPixel art lands in outDir.'));
|
|
114
|
+
console.log();
|
|
102
115
|
if (await hasDevScript()) {
|
|
103
116
|
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
|
|
104
117
|
console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
|
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 { runPush } from './push.js';
|
|
24
27
|
export async function syncCommand(opts) {
|
|
25
28
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
26
29
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
@@ -340,15 +343,43 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
340
343
|
}
|
|
341
344
|
catch (e) {
|
|
342
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
|
+
}
|
|
343
357
|
throw e;
|
|
344
358
|
}
|
|
345
359
|
// Unity projects sync only the artboards flagged "Sync to Unity" in the
|
|
346
360
|
// editor (parity with the in-app sync button). `unitySyncAll: true` in
|
|
347
361
|
// magicpixel.json opts back into everything.
|
|
348
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);
|
|
349
379
|
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
350
380
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
351
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.
|
|
352
383
|
const deselectedPaths = [];
|
|
353
384
|
// Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
|
|
354
385
|
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
@@ -364,25 +395,26 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
364
395
|
for (const entry of manifest) {
|
|
365
396
|
if (filtered.entries.includes(entry))
|
|
366
397
|
continue;
|
|
367
|
-
const p =
|
|
398
|
+
const p = pathForKey(entry.key);
|
|
368
399
|
if (unknownSet.has(entry)) {
|
|
369
400
|
unknownFlagPaths.add(p);
|
|
370
401
|
continue;
|
|
371
402
|
}
|
|
403
|
+
if (sourceByKey.has(entry.key))
|
|
404
|
+
continue;
|
|
372
405
|
if (existsSync(p))
|
|
373
406
|
deselectedPaths.push(p);
|
|
374
407
|
}
|
|
375
408
|
if (filtered.noneFlagged) {
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// out with a hint instead.
|
|
409
|
+
// Nothing flagged for pull — don't dump the library, and don't prune.
|
|
410
|
+
// Local sprites still go up so they appear in MagicPixel on connect.
|
|
379
411
|
if (verbose) {
|
|
380
412
|
console.log();
|
|
381
|
-
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to
|
|
382
|
-
console.log(kleur.dim(
|
|
383
|
-
console.log(kleur.dim('
|
|
413
|
+
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
|
|
414
|
+
console.log(kleur.dim(` Working-set sprites (\`connect\` in magicpixel.json) still push into MagicPixel.`));
|
|
415
|
+
console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
|
|
384
416
|
}
|
|
385
|
-
|
|
417
|
+
const empty = {
|
|
386
418
|
added: [],
|
|
387
419
|
modified: [],
|
|
388
420
|
removed: [],
|
|
@@ -392,6 +424,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
392
424
|
bytesSaved: 0,
|
|
393
425
|
renamed: [],
|
|
394
426
|
};
|
|
427
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex);
|
|
428
|
+
return empty;
|
|
395
429
|
}
|
|
396
430
|
const skipped = manifest.length - filtered.entries.length;
|
|
397
431
|
manifest = filtered.entries;
|
|
@@ -407,13 +441,18 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
407
441
|
renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
|
|
408
442
|
}
|
|
409
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
|
+
}
|
|
410
449
|
// Compute the disk path once per entry and reuse it across the diff /
|
|
411
450
|
// orphan / download loops — saves three resolves + two security asserts
|
|
412
451
|
// per asset on large projects. Every entry in `manifest` is pre-seeded, so
|
|
413
452
|
// the lookup never misses; non-null assertion is safe.
|
|
414
453
|
const diskPathById = new Map();
|
|
415
454
|
for (const entry of manifest) {
|
|
416
|
-
diskPathById.set(entry.id,
|
|
455
|
+
diskPathById.set(entry.id, pathForKey(entry.key));
|
|
417
456
|
}
|
|
418
457
|
const pathFor = (entry) => diskPathById.get(entry.id);
|
|
419
458
|
// Diff against disk. SHA pre-check runs through the same concurrency pool
|
|
@@ -448,14 +487,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
448
487
|
// delete files outside what the current manifest delta touches).
|
|
449
488
|
const localPngs = await walkOutDirPngs(config.outDir);
|
|
450
489
|
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
451
|
-
//
|
|
452
|
-
let pendingPush = [];
|
|
490
|
+
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
453
491
|
if (!since) {
|
|
454
492
|
const trackedPaths = new Set(Object.keys(state.synced ?? {})
|
|
455
493
|
.concat(Object.values(previousAssets))
|
|
456
494
|
.map((key) => {
|
|
457
495
|
try {
|
|
458
|
-
return
|
|
496
|
+
return pathForKey(key);
|
|
459
497
|
}
|
|
460
498
|
catch {
|
|
461
499
|
return '';
|
|
@@ -469,7 +507,6 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
469
507
|
isTracked: (p) => trackedPaths.has(p),
|
|
470
508
|
});
|
|
471
509
|
orphans = policy.orphans;
|
|
472
|
-
pendingPush = policy.pendingPush;
|
|
473
510
|
}
|
|
474
511
|
// Heuristic-rename fallback for incremental mode.
|
|
475
512
|
//
|
|
@@ -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 };
|
|
@@ -917,16 +981,27 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
917
981
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
918
982
|
if (renamed.length > 0)
|
|
919
983
|
printRenames(renamed, { withHints: true });
|
|
920
|
-
if (pendingPush.length > 0) {
|
|
921
|
-
console.log();
|
|
922
|
-
console.log(kleur.yellow(`! ${pendingPush.length} local sprite${pendingPush.length === 1 ? '' : 's'} ${pendingPush.length === 1 ? 'is' : 'are'} not in MagicPixel yet — kept on disk.`));
|
|
923
|
-
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` to add them to your library.`));
|
|
924
|
-
}
|
|
925
984
|
}
|
|
985
|
+
await maybePushLocalSprites(config.push, verbose, gameIndex);
|
|
926
986
|
if (result.failed)
|
|
927
987
|
process.exitCode = 1;
|
|
928
988
|
return result;
|
|
929
989
|
}
|
|
990
|
+
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex) {
|
|
991
|
+
if (pushEnabled === false)
|
|
992
|
+
return null;
|
|
993
|
+
try {
|
|
994
|
+
return await runPush({ quiet: !verbose, gameIndex });
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
if (verbose) {
|
|
998
|
+
console.log();
|
|
999
|
+
console.log(kleur.yellow(`! could not push working-set sprites into MagicPixel: ${e.message}`));
|
|
1000
|
+
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
930
1005
|
const CHANGE_PRINT_CAP = 50;
|
|
931
1006
|
function printChanges(r, indent, opts = { includeRenames: true }) {
|
|
932
1007
|
const lines = [];
|
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 {
|
|
@@ -86,6 +88,8 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
86
88
|
emitIndex,
|
|
87
89
|
unityPpu,
|
|
88
90
|
unitySyncAll: parsed.unitySyncAll === true ? true : undefined,
|
|
91
|
+
push: parsed.push === false ? false : undefined,
|
|
92
|
+
connect,
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
const MAX_GLOBS = 64;
|
|
@@ -109,6 +113,10 @@ function normalizeGlobList(value, field) {
|
|
|
109
113
|
throw new Error(`${CONFIG_FILENAME}: "${field}" entry is too long (>${MAX_GLOB_LEN} chars) or contains a null byte.\n` +
|
|
110
114
|
` Fix: shorten the pattern.`);
|
|
111
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
|
+
}
|
|
112
120
|
out.push(g);
|
|
113
121
|
}
|
|
114
122
|
return out;
|
|
@@ -142,7 +150,7 @@ export async function loadState(cwd = process.cwd()) {
|
|
|
142
150
|
return {};
|
|
143
151
|
}
|
|
144
152
|
try {
|
|
145
|
-
return JSON.parse(raw);
|
|
153
|
+
return scrubLoadedState(JSON.parse(raw));
|
|
146
154
|
}
|
|
147
155
|
catch (e) {
|
|
148
156
|
// Corrupt state file (truncated by a crash, hand-edited, disk full, etc.)
|
|
@@ -161,6 +169,27 @@ export async function loadState(cwd = process.cwd()) {
|
|
|
161
169
|
return {};
|
|
162
170
|
}
|
|
163
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
|
+
}
|
|
164
193
|
export async function saveState(state, cwd = process.cwd()) {
|
|
165
194
|
const path = statePath(cwd);
|
|
166
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
|
}
|