@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.
@@ -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 on disk and a watch
18
- * script ready to run.
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 walks you through getting your sprites onto disk.'));
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
- // 1. `start` is JS-only — engine projects use init + login + sync --watch.
25
- if (!hasPackageJson()) {
26
- const kind = await detectProjectKind();
27
- if (isEngineKind(kind)) {
28
- console.log(kleur.yellow(` Detected ${kind}. \`${cmd('start')}\` is JS-only; run ` +
29
- `\`${cmd('init')} && ${cmd('login')} && ${cmd('sync')} --watch\` instead.`));
30
- return;
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: first sync'));
78
+ console.log(kleur.bold('Step: sync'));
78
79
  const exitBefore = process.exitCode ?? 0;
79
- await syncCommand({ full: true });
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'));
@@ -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 { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
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 = assetDiskPath(config.outDir, entry);
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
- // Every artboard is explicitly opted out. Pruning here would delete the
377
- // user's whole sprite folder off the back of an unchecked box, so bail
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 sync.'));
382
- console.log(kleur.dim(' Fix: right-click an artboard in MagicPixel → "Sync to Unity",'));
383
- console.log(kleur.dim(' or set "unitySyncAll": true in magicpixel.json to sync everything.'));
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
- return {
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, assetDiskPath(config.outDir, entry));
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
- // `magicpixel push`, not orphans — see prunePolicy.ts.
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 assetDiskPathFromKey(config.outDir, key);
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) => assetDiskPathFromKey(config.outDir, r.oldKey))
521
- .filter((p) => existsSync(p));
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) => assetDiskPathFromKey(config.outDir, key),
585
+ resolveDiskPath: (key) => pathForKey(key),
540
586
  fileExists: existsSync,
541
587
  });
542
- for (const p of prevKeyResult.orphanPaths)
543
- orphanSet.add(p);
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: `Sync can't continue until outDir (${config.outDir}) is writable.`,
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.map((entry) => ({ id: entry.id, pngPath: pathFor(entry) })), config.unityPpu ?? DEFAULT_UNITY_PPU);
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 outRoot = resolve(process.cwd(), config.outDir);
796
+ const pruneRoot = resolve(process.cwd(), config.outDir);
744
797
  for (const p of orphans) {
745
- assertPathInsideRoot(p, outRoot, 'outDir');
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(outRoot, p).replace(/\\/g, '/');
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')
@@ -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
- if (existsSync(resolve(cwd, 'ProjectSettings', 'ProjectVersion.txt')))
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/MagicPixel';
129
+ return suggestEngineOutDir(cwd, 'Assets', 'MagicPixel');
83
130
  case 'Godot':
84
- return 'assets/magicpixel';
131
+ return suggestEngineOutDir(cwd, 'assets', 'magicpixel');
85
132
  case 'GameMaker':
86
- return 'datafiles/magicpixel';
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
  }