@magicpixelart/cli 0.5.8 → 0.5.10

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