@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.
@@ -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';
@@ -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 = assetDiskPath(config.outDir, entry);
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(' Existing game sprites will still be imported into MagicPixel.'));
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, assetDiskPath(config.outDir, entry));
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 assetDiskPathFromKey(config.outDir, key);
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) => 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 };
@@ -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 import local sprites into MagicPixel: ${e.message}`));
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')
@@ -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
  }