@magicpixelart/cli 0.5.6 → 0.5.8

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.
@@ -11,11 +11,17 @@ import { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } f
11
11
  import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
13
  import { assertPathInsideRoot } from '../util/security.js';
14
+ import { detectProjectKind } from '../util/framework.js';
15
+ import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
16
+ import { filterUnityManifest } from '../util/unityFilter.js';
17
+ import { selectFullSyncOrphans } from '../util/prunePolicy.js';
14
18
  import { runTmpJanitor } from '../util/tmpJanitor.js';
15
19
  import { friendlyFsError } from '../util/errors.js';
16
20
  import { maxIsoTimestamp } from '../util/iso.js';
17
21
  import { formatBytes } from '../util/format.js';
18
22
  import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
23
+ import { cmd } from '../util/invoke.js';
24
+ import { runPush } from './push.js';
19
25
  export async function syncCommand(opts) {
20
26
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
21
27
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
@@ -180,14 +186,14 @@ async function watchLoop(opts) {
180
186
  // support thread and we can correlate against edge function logs.
181
187
  const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
182
188
  console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
183
- console.log(kleur.dim(' Fix: run `magicpixel login` (this watcher will keep retrying every 30s).'));
189
+ console.log(kleur.dim(` Fix: run \`${cmd('login')}\` (this watcher will keep retrying every 30s).`));
184
190
  }
185
191
  pausedForAuth = true;
186
192
  consecutiveAuthFailures = decision.consecutiveAuthFailures;
187
193
  backoffSec = decision.nextBackoffSec;
188
194
  if (decision.giveUp) {
189
195
  console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
190
- console.log(kleur.dim(' Fix: run `magicpixel login` with a fresh key, then restart the watcher.'));
196
+ console.log(kleur.dim(` Fix: run \`${cmd('login')}\` with a fresh key, then restart the watcher.`));
191
197
  // Surface this to /admin/errors — persistent watcher auth failure
192
198
  // means a key is mass-rejected (revoked, project deleted, edge
193
199
  // misconfig) and we want visibility without waiting for a support
@@ -337,6 +343,64 @@ async function runOnce(opts, runOpts = {}) {
337
343
  spinner?.fail('Manifest fetch failed');
338
344
  throw e;
339
345
  }
346
+ // Unity projects sync only the artboards flagged "Sync to Unity" in the
347
+ // editor (parity with the in-app sync button). `unitySyncAll: true` in
348
+ // magicpixel.json opts back into everything.
349
+ const projectKind = await detectProjectKind();
350
+ // Files whose artboard is no longer flagged for Unity. Deleted even in
351
+ // incremental mode: un-checking the box in the editor must actually remove
352
+ // the sprite from the game project, not leave a stale copy behind.
353
+ const deselectedPaths = [];
354
+ // Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
355
+ // (strict opt-in) but explicitly shielded from pruning: a transient server
356
+ // response must never delete art from someone's game project.
357
+ const unknownFlagPaths = new Set();
358
+ if (projectKind === 'Unity') {
359
+ const filtered = filterUnityManifest(manifest, { syncAll: config.unitySyncAll });
360
+ if (filtered.unknown.length > 0) {
361
+ console.log(kleur.yellow(`! ${filtered.unknown.length} artboard${filtered.unknown.length === 1 ? '' : 's'} came back without a "Sync to Unity" flag — skipped (existing files kept).`));
362
+ console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
363
+ }
364
+ const unknownSet = new Set(filtered.unknown);
365
+ for (const entry of manifest) {
366
+ if (filtered.entries.includes(entry))
367
+ continue;
368
+ const p = assetDiskPath(config.outDir, entry);
369
+ if (unknownSet.has(entry)) {
370
+ unknownFlagPaths.add(p);
371
+ continue;
372
+ }
373
+ if (existsSync(p))
374
+ deselectedPaths.push(p);
375
+ }
376
+ if (filtered.noneFlagged) {
377
+ // Nothing flagged for pull — don't dump the library, and don't prune.
378
+ // Local sprites still go up so they appear in MagicPixel on connect.
379
+ if (verbose) {
380
+ console.log();
381
+ 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.'));
383
+ console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
384
+ }
385
+ const empty = {
386
+ added: [],
387
+ modified: [],
388
+ removed: [],
389
+ unchanged: 0,
390
+ failed: 0,
391
+ bytesIn: 0,
392
+ bytesSaved: 0,
393
+ renamed: [],
394
+ };
395
+ await maybePushLocalSprites(config.push, verbose);
396
+ return empty;
397
+ }
398
+ const skipped = manifest.length - filtered.entries.length;
399
+ manifest = filtered.entries;
400
+ if (verbose && skipped > 0) {
401
+ console.log(kleur.dim(` unity → ${skipped} artboard${skipped === 1 ? '' : 's'} not flagged for sync (skipped)`));
402
+ }
403
+ }
340
404
  // Detect renames: same id, different key vs prior snapshot.
341
405
  const renamed = [];
342
406
  for (const entry of manifest) {
@@ -385,8 +449,27 @@ async function runOnce(opts, runOpts = {}) {
385
449
  // still gated on `!since` to preserve incremental-mode safety (we never
386
450
  // delete files outside what the current manifest delta touches).
387
451
  const localPngs = await walkOutDirPngs(config.outDir);
452
+ // Sprites created in Unity (or any PNG we never pulled) are pending a
453
+ // push into MagicPixel, not orphans — see prunePolicy.ts.
388
454
  if (!since) {
389
- orphans = localPngs.map((a) => a.abs).filter((p) => !remoteDiskPaths.has(p));
455
+ const trackedPaths = new Set(Object.keys(state.synced ?? {})
456
+ .concat(Object.values(previousAssets))
457
+ .map((key) => {
458
+ try {
459
+ return assetDiskPathFromKey(config.outDir, key);
460
+ }
461
+ catch {
462
+ return '';
463
+ }
464
+ })
465
+ .filter(Boolean));
466
+ const policy = selectFullSyncOrphans({
467
+ localPaths: localPngs.map((a) => a.abs),
468
+ remoteDiskPaths,
469
+ protectedPaths: unknownFlagPaths,
470
+ isTracked: (p) => trackedPaths.has(p),
471
+ });
472
+ orphans = policy.orphans;
390
473
  }
391
474
  // Heuristic-rename fallback for incremental mode.
392
475
  //
@@ -440,6 +523,10 @@ async function runOnce(opts, runOpts = {}) {
440
523
  const orphanSet = new Set(orphans);
441
524
  for (const p of renameStalePaths)
442
525
  orphanSet.add(p);
526
+ // Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
527
+ // Unity project on the next sync, incremental or not.
528
+ for (const p of deselectedPaths)
529
+ orphanSet.add(p);
443
530
  // Server-side rename history. Each manifest entry carries the composite
444
531
  // keys this row was previously emitted under (populated by the editor when
445
532
  // a doc/artboard is renamed). For each prior key whose file is still on
@@ -638,6 +725,19 @@ async function runOnce(opts, runOpts = {}) {
638
725
  else
639
726
  progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
640
727
  }
728
+ // Unity: write pixel-art-correct `.meta` sidecars so sprites import with
729
+ // point filtering / no compression instead of Unity's blurry defaults —
730
+ // parity with the in-app "Sync to Unity" button. Only missing sidecars are
731
+ // written, so importer settings the user tweaked in Unity are preserved.
732
+ if (projectKind === 'Unity') {
733
+ const meta = await writeMissingUnityMetas(manifest.map((entry) => ({ id: entry.id, pngPath: pathFor(entry) })), config.unityPpu ?? DEFAULT_UNITY_PPU);
734
+ if (verbose && meta.written > 0) {
735
+ console.log(kleur.dim(` unity → ${meta.written} .meta file${meta.written === 1 ? '' : 's'} written`));
736
+ }
737
+ if (verbose && meta.failed > 0) {
738
+ console.log(kleur.yellow(` ! ${meta.failed} .meta file${meta.failed === 1 ? '' : 's'} could not be written — Unity will use default import settings.`));
739
+ }
740
+ }
641
741
  // Prune (now default — pass --no-prune to opt out).
642
742
  if (shouldPrune && orphans.length > 0) {
643
743
  const outRoot = resolve(process.cwd(), config.outDir);
@@ -645,6 +745,9 @@ async function runOnce(opts, runOpts = {}) {
645
745
  assertPathInsideRoot(p, outRoot, 'outDir');
646
746
  try {
647
747
  await unlink(p);
748
+ // Unity sidecar follows its PNG — a stranded .meta shows up in Unity
749
+ // as a missing-asset warning.
750
+ await unlink(`${p}.meta`).catch(() => { });
648
751
  const relPath = relative(outRoot, p).replace(/\\/g, '/');
649
752
  const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
650
753
  result.removed.push(key);
@@ -708,7 +811,36 @@ async function runOnce(opts, runOpts = {}) {
708
811
  delete nextAssets[id];
709
812
  }
710
813
  }
711
- const nextState = { ...state, assets: nextAssets };
814
+ // Address book for `magicpixel push`: composite key → artboard + the sha we
815
+ // just put on disk. Keyed by key (not id) so a disk walk can look it up
816
+ // without re-fetching the manifest. Renamed/pruned keys are dropped so a
817
+ // stale entry can never send an edit to the wrong artboard.
818
+ const nextSynced = { ...(state.synced ?? {}) };
819
+ for (const r of renamed)
820
+ delete nextSynced[r.oldKey];
821
+ for (const key of result.removed)
822
+ delete nextSynced[key];
823
+ for (const entry of manifest) {
824
+ if (!entry.asset_id)
825
+ continue;
826
+ const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
827
+ if (!sha)
828
+ continue;
829
+ // Legacy single-PNG rows have no artboard address (`layer_idx: -1`). Record
830
+ // them anyway, flagged, so `push` skips them with a hint instead of
831
+ // adopting the same art as a second document.
832
+ if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
833
+ nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, legacy: true };
834
+ continue;
835
+ }
836
+ nextSynced[entry.key] = {
837
+ assetId: entry.asset_id,
838
+ layerIdx: entry.layer_idx,
839
+ sha256: sha,
840
+ ...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
841
+ };
842
+ }
843
+ const nextState = { ...state, assets: nextAssets, synced: nextSynced };
712
844
  if (result.failed === 0) {
713
845
  // Advance the cursor to the newest row we actually observed, NOT the
714
846
  // wall-clock time the sync started. Using startedAt silently skipped
@@ -786,10 +918,25 @@ async function runOnce(opts, runOpts = {}) {
786
918
  if (renamed.length > 0)
787
919
  printRenames(renamed, { withHints: true });
788
920
  }
921
+ await maybePushLocalSprites(config.push, verbose);
789
922
  if (result.failed)
790
923
  process.exitCode = 1;
791
924
  return result;
792
925
  }
926
+ async function maybePushLocalSprites(pushEnabled, verbose) {
927
+ if (pushEnabled === false)
928
+ return;
929
+ try {
930
+ await runPush({ quiet: !verbose });
931
+ }
932
+ catch (e) {
933
+ if (verbose) {
934
+ console.log();
935
+ console.log(kleur.yellow(`! could not import local sprites into MagicPixel: ${e.message}`));
936
+ console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
937
+ }
938
+ }
939
+ }
793
940
  const CHANGE_PRINT_CAP = 50;
794
941
  function printChanges(r, indent, opts = { includeRenames: true }) {
795
942
  const lines = [];
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { assertSafeOutDir, MAX_GLOB_LEN, validateEndpointUrl } from './util/secu
5
5
  import { readCredentialsSync } from './util/credentials.js';
6
6
  import { friendlyFsError } from './util/errors.js';
7
7
  import { atomicWrite } from './util/atomicWrite.js';
8
+ import { cmd } from './util/invoke.js';
8
9
  const CONFIG_FILENAME = 'magicpixel.json';
9
10
  const STATE_DIR = '.magicpixel';
10
11
  const STATE_FILENAME = 'state.json';
@@ -25,7 +26,7 @@ export async function loadConfig(cwd = process.cwd()) {
25
26
  const path = configPath(cwd);
26
27
  if (!existsSync(path)) {
27
28
  throw new Error(`No ${CONFIG_FILENAME} found in ${cwd}.\n` +
28
- ` Fix: run \`npx magicpixel init\` to create one.`);
29
+ ` Fix: run \`${cmd('start')}\` — it creates the config, logs you in, and runs the first sync.`);
29
30
  }
30
31
  const raw = await readFile(path, 'utf8');
31
32
  let parsed;
@@ -66,12 +67,26 @@ export async function loadConfig(cwd = process.cwd()) {
66
67
  }
67
68
  emitIndex = parsed.emitIndex;
68
69
  }
70
+ let unityPpu;
71
+ if (parsed.unityPpu !== undefined) {
72
+ if (typeof parsed.unityPpu !== 'number' ||
73
+ !Number.isFinite(parsed.unityPpu) ||
74
+ parsed.unityPpu <= 0 ||
75
+ parsed.unityPpu > 4096) {
76
+ throw new Error(`${CONFIG_FILENAME}: "unityPpu" must be a number between 1 and 4096.\n` +
77
+ ` Fix: set "unityPpu": 32 (or remove the field to use the default).`);
78
+ }
79
+ unityPpu = Math.round(parsed.unityPpu);
80
+ }
69
81
  return {
70
82
  outDir,
71
83
  include,
72
84
  exclude,
73
85
  endpoint,
74
86
  emitIndex,
87
+ unityPpu,
88
+ unitySyncAll: parsed.unitySyncAll === true ? true : undefined,
89
+ push: parsed.push === false ? false : undefined,
75
90
  };
76
91
  }
77
92
  const MAX_GLOBS = 64;
@@ -112,7 +127,7 @@ export async function saveConfig(config, cwd = process.cwd()) {
112
127
  throw friendlyFsError(e, {
113
128
  operation: 'Saving magicpixel.json',
114
129
  path,
115
- hint: 'magicpixel.json holds your sync config — without it `magicpixel sync` can\'t run.',
130
+ hint: `magicpixel.json holds your sync config — without it \`${cmd('sync')}\` can't run.`,
116
131
  });
117
132
  }
118
133
  }
@@ -196,7 +211,7 @@ export function getApiKey() {
196
211
  throw new Error('No MagicPixel API key found.\n' +
197
212
  ' Fix:\n' +
198
213
  ' 1. Get a key at https://magicpixel.art/settings (API Keys).\n' +
199
- ' 2. Run `magicpixel login` (or `export MAGICPIXEL_API_KEY=mp_live_...`).\n' +
214
+ ` 2. Run \`${cmd('login')}\` (or \`export MAGICPIXEL_API_KEY=mp_live_...\`).\n` +
200
215
  ' 3. Re-run the command.');
201
216
  }
202
217
  // Env vars from shells routinely smuggle in stray quotes / whitespace; the
@@ -209,7 +224,7 @@ export function getApiKey() {
209
224
  }
210
225
  if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(trimmed)) {
211
226
  throw new Error(`MagicPixel API key does not look right (expected mp_live_… or mp_test_…).\n` +
212
- ` Fix: run \`magicpixel login\` and paste a fresh key from https://magicpixel.art/settings.`);
227
+ ` Fix: run \`${cmd('login')}\` and paste a fresh key from https://magicpixel.art/settings.`);
213
228
  }
214
229
  return trimmed;
215
230
  }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import kleur from 'kleur';
4
4
  import { initCommand } from './commands/init.js';
5
5
  import { syncCommand } from './commands/sync.js';
6
+ import { pushCommand } from './commands/push.js';
6
7
  import { addCommand } from './commands/add.js';
7
8
  import { removeCommand } from './commands/remove.js';
8
9
  import { listCommand } from './commands/list.js';
@@ -89,6 +90,13 @@ program
89
90
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
90
91
  .addHelpText('after', '\nExamples:\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
91
92
  .action(wrap("sync", async (opts) => syncCommand(opts)));
93
+ program
94
+ .command('push')
95
+ .description('Upload local sprite edits back to MagicPixel (two-way sync)')
96
+ .option('--dry-run', 'Print what would be pushed without sending anything')
97
+ .option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
98
+ .addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n')
99
+ .action(wrap("push", async (opts) => pushCommand(opts)));
92
100
  program
93
101
  .command('add <glob>')
94
102
  .description('Append a glob pattern to include')
@@ -5,6 +5,7 @@ import { assertSafeAssetSegments } from './security.js';
5
5
  import { atomicWrite } from './atomicWrite.js';
6
6
  import { walkOutDirPngs } from './paths.js';
7
7
  import { isStaticOutDir } from './framework.js';
8
+ import { cmd } from './invoke.js';
8
9
  const AGENT_HEADER = [
9
10
  '// 👋 AI agents: import sprites by key, e.g.',
10
11
  '// import { MagicPixelAssets } from "<this-dir>";',
@@ -57,7 +58,7 @@ export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
57
58
  }
58
59
  const lines = [
59
60
  '// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
60
- '// Re-runs of `magicpixel sync` overwrite this file.',
61
+ `// Re-runs of \`${cmd('sync')}\` overwrite this file.`,
61
62
  '//',
62
63
  AGENT_HEADER,
63
64
  '',
@@ -185,7 +186,7 @@ function renderAgentsSection(outDir) {
185
186
  `<img src={MagicPixelAssetsById["<asset-uuid>"]} />`,
186
187
  '```',
187
188
  '',
188
- 'When you rename or move an asset, `magicpixel sync` prints both the old → new key',
189
+ `When you rename or move an asset, \`${cmd('sync')}\` prints both the old → new key`,
189
190
  'and the matching `MagicPixelAssetsById[...]` hint so you can pick whichever you prefer.',
190
191
  ];
191
192
  return [
@@ -0,0 +1,119 @@
1
+ /**
2
+ * First-connect import: when `outDir` has no PNGs yet, copy nearby game
3
+ * sprites into it so `push` can adopt them into the MagicPixel library.
4
+ *
5
+ * Only engine projects. Never walks Library/Temp/Packages. Never overwrites
6
+ * a file already in outDir. Capped so a huge Assets/ tree can't stampede
7
+ * the library.
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { copyFile, mkdir, readdir } from 'node:fs/promises';
11
+ import { dirname, relative, resolve } from 'node:path';
12
+ import { isEngineKind } from './framework.js';
13
+ import { assertPathInsideRoot } from './security.js';
14
+ /** Unity/engine folders that are never sprite sources. */
15
+ export const GAME_SCAN_SKIP_DIRS = new Set([
16
+ 'library',
17
+ 'temp',
18
+ 'obj',
19
+ 'logs',
20
+ 'packages',
21
+ 'projectsettings',
22
+ 'usersettings',
23
+ 'streamingassets',
24
+ ]);
25
+ export const MAX_GAME_IMPORT = 200;
26
+ export function gameScanRoot(kind) {
27
+ switch (kind) {
28
+ case 'Unity':
29
+ return 'Assets';
30
+ case 'Godot':
31
+ return 'assets';
32
+ case 'GameMaker':
33
+ return 'datafiles';
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Copy PNGs from the engine asset root into `outDir`, preserving relative
40
+ * paths. No-op when `outDir` already has PNGs, when the project isn't an
41
+ * engine, or when the scan root is missing.
42
+ */
43
+ export async function importNearbyGamePngs(outDir, kind, cwd = process.cwd()) {
44
+ const result = { copied: 0, skipped: 0, capped: false };
45
+ if (!isEngineKind(kind))
46
+ return result;
47
+ const scanRel = gameScanRoot(kind);
48
+ if (!scanRel)
49
+ return result;
50
+ const scanRoot = resolve(cwd, scanRel);
51
+ const destRoot = resolve(cwd, outDir);
52
+ if (!existsSync(scanRoot))
53
+ return result;
54
+ const destNorm = destRoot.replace(/\\/g, '/').toLowerCase();
55
+ const found = [];
56
+ await walkPngs(scanRoot, scanRoot, destNorm, found);
57
+ if (found.length >= MAX_GAME_IMPORT)
58
+ result.capped = true;
59
+ if (found.length > MAX_GAME_IMPORT)
60
+ found.length = MAX_GAME_IMPORT;
61
+ for (const abs of found) {
62
+ const rel = relative(scanRoot, abs).replace(/\\/g, '/');
63
+ const dest = resolve(destRoot, rel);
64
+ try {
65
+ assertPathInsideRoot(dest, destRoot, 'outDir');
66
+ }
67
+ catch {
68
+ result.skipped++;
69
+ continue;
70
+ }
71
+ if (existsSync(dest)) {
72
+ result.skipped++;
73
+ continue;
74
+ }
75
+ await mkdir(dirname(dest), { recursive: true });
76
+ await copyFile(abs, dest);
77
+ result.copied++;
78
+ }
79
+ return result;
80
+ }
81
+ async function walkPngs(dir, root, destNorm, out) {
82
+ if (out.length >= MAX_GAME_IMPORT)
83
+ return;
84
+ let entries;
85
+ try {
86
+ entries = await readdir(dir, { withFileTypes: true });
87
+ }
88
+ catch {
89
+ return;
90
+ }
91
+ for (const ent of entries) {
92
+ if (out.length >= MAX_GAME_IMPORT)
93
+ return;
94
+ if (ent.isSymbolicLink())
95
+ continue;
96
+ const full = resolve(dir, ent.name);
97
+ try {
98
+ assertPathInsideRoot(full, root, 'scan');
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ if (ent.isDirectory()) {
104
+ const name = ent.name.toLowerCase();
105
+ if (name.startsWith('.'))
106
+ continue;
107
+ if (GAME_SCAN_SKIP_DIRS.has(name))
108
+ continue;
109
+ // Don't re-import from outDir if it sits inside the scan root
110
+ // (Assets/MagicPixel under Assets/).
111
+ if (full.replace(/\\/g, '/').toLowerCase() === destNorm)
112
+ continue;
113
+ await walkPngs(full, root, destNorm, out);
114
+ }
115
+ else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
116
+ out.push(full);
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * How should we tell the user to run a subcommand?
3
+ *
4
+ * `magicpixel` is only the BIN name; the published package is
5
+ * `@magicpixelart/cli`. So npx-ing the bin name directly (e.g. `magicpixel init` via npx) 404s in a
6
+ * project with no local install ("'magicpixel@*' is not in this registry"), which is a dead end
7
+ * for anyone driving the CLI purely through `npx @magicpixelart/cli …`.
8
+ *
9
+ * Rule: if the package resolves from the project's node_modules, the bin is on
10
+ * the PATH for `npx`/scripts and the short `magicpixel <cmd>` form works.
11
+ * Otherwise we print the always-correct `npx @magicpixelart/cli <cmd>` form.
12
+ * When in doubt we prefer the long form — it works in both worlds.
13
+ */
14
+ import { createRequire } from 'node:module';
15
+ import { resolve } from 'node:path';
16
+ export const PKG_NAME = '@magicpixelart/cli';
17
+ let cachedLocal = null;
18
+ /** True when `@magicpixelart/cli` is installed in the current project. */
19
+ export function hasLocalInstall(cwd = process.cwd()) {
20
+ if (cachedLocal !== null)
21
+ return cachedLocal;
22
+ let local = false;
23
+ try {
24
+ // Resolve as if requiring from <cwd>/index.js so only the project's
25
+ // node_modules chain counts — not the npx cache this process runs from.
26
+ const req = createRequire(resolve(cwd, 'index.js'));
27
+ req.resolve(`${PKG_NAME}/package.json`);
28
+ local = true;
29
+ }
30
+ catch {
31
+ local = false;
32
+ }
33
+ cachedLocal = local;
34
+ return local;
35
+ }
36
+ /** Reset the memoized probe (tests only). */
37
+ export function resetInvokeCache() {
38
+ cachedLocal = null;
39
+ }
40
+ /**
41
+ * Format a runnable command hint, e.g. `cmd('login')` →
42
+ * "npx @magicpixelart/cli login" or "magicpixel login".
43
+ */
44
+ export function cmd(subcommand, cwd) {
45
+ const suffix = subcommand ? ` ${subcommand}` : '';
46
+ return hasLocalInstall(cwd) ? `magicpixel${suffix}` : `npx ${PKG_NAME}${suffix}`;
47
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which local PNGs a full sync is allowed to delete.
3
+ *
4
+ * Pruning used to be "anything on disk the manifest didn't mention". Two-way
5
+ * Unity sync breaks that assumption in two directions:
6
+ *
7
+ * - Strict Unity opt-in excludes entries whose `unity` flag the server
8
+ * omitted (older edge deploy, work-budget-starved manifest backfill). Those
9
+ * are "unknown", not "unwanted" — deleting their sprite because of a
10
+ * transient server response would be data loss inside someone's game
11
+ * project. They're protected instead.
12
+ * - Sprites authored in Unity have no manifest entry until `magicpixel push`
13
+ * adopts them. They're pending, not orphaned.
14
+ *
15
+ * Everything we HAVE synced before (it's in `state.synced`) and that the
16
+ * manifest no longer references is a genuine orphan and still prunes: renames,
17
+ * deletions, and un-checked "Sync to Unity" boxes all keep working.
18
+ */
19
+ export function selectFullSyncOrphans({ localPaths, remoteDiskPaths, protectedPaths, isTracked, }) {
20
+ const orphans = [];
21
+ const pendingPush = [];
22
+ for (const p of localPaths) {
23
+ if (remoteDiskPaths.has(p))
24
+ continue;
25
+ if (protectedPaths?.has(p))
26
+ continue;
27
+ if (isTracked(p))
28
+ orphans.push(p);
29
+ else
30
+ pendingPush.push(p);
31
+ }
32
+ return { orphans, pendingPush };
33
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Plan a disk → MagicPixel push.
3
+ *
4
+ * Pure so the decision table (skip / update / adopt / needs-flatten) is unit
5
+ * testable without touching the network or the filesystem.
6
+ *
7
+ * Two shapes reach the server:
8
+ * - UPDATE: the key is in the sync state, so we know the exact artboard and
9
+ * the composite sha we last wrote. A changed disk sha means a local edit.
10
+ * - ADOPT: the key is new on disk (a sprite someone created in Unity), so
11
+ * the server mirrors the folders into the library and appends/creates the
12
+ * document.
13
+ */
14
+ /**
15
+ * Slug rule mirrored from the server (`slugify` + `SEGMENT_RE` in
16
+ * `src/lib/integration/artboardComposite.server.ts`). The server is the
17
+ * authority; this copy only exists so the CLI can pre-validate a path before
18
+ * spending a request, and `pushPlan.test.ts` pins the two together.
19
+ */
20
+ export function pushSlug(raw) {
21
+ return raw
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, '-')
24
+ .replace(/^-+|-+$/g, '')
25
+ .slice(0, 64)
26
+ .replace(/-+$/g, '');
27
+ }
28
+ export const MAX_PUSH_PATH_SEGMENTS = 8;
29
+ export function planPush(candidates, synced, opts = {}) {
30
+ const state = synced ?? {};
31
+ const out = [];
32
+ for (const c of candidates) {
33
+ const known = state[c.key];
34
+ if (known) {
35
+ if (known.sha256 === c.diskSha256) {
36
+ out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
37
+ continue;
38
+ }
39
+ // Legacy row: the cloud has no addressable artboard, and adopting would
40
+ // duplicate art that already exists in the library.
41
+ if (known.legacy) {
42
+ out.push({ kind: 'skip', key: c.key, reason: 'legacy' });
43
+ continue;
44
+ }
45
+ if (!opts.flatten && typeof known.layers === 'number' && known.layers > 1) {
46
+ out.push({ kind: 'needs-flatten', key: c.key, layers: known.layers });
47
+ continue;
48
+ }
49
+ out.push({
50
+ kind: 'update',
51
+ key: c.key,
52
+ assetId: known.assetId,
53
+ layerIdx: known.layerIdx,
54
+ baseSha256: known.sha256,
55
+ });
56
+ continue;
57
+ }
58
+ // New on disk → adopt. A single segment (outDir/hero.png) is a document
59
+ // with one artboard of the same name — the server already accepts that.
60
+ const rawNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
61
+ let pathNames = rawNames;
62
+ let path = pathNames.map(pushSlug);
63
+ if (path.length === 1 && path[0]) {
64
+ pathNames = [pathNames[0], pathNames[0]];
65
+ path = [path[0], path[0]];
66
+ }
67
+ if (path.length < 2 || path.some((s) => !s)) {
68
+ out.push({ kind: 'skip', key: c.key, reason: 'unusable-path' });
69
+ continue;
70
+ }
71
+ out.push({
72
+ kind: 'adopt',
73
+ key: c.key,
74
+ path,
75
+ pathNames,
76
+ name: pathNames[pathNames.length - 1],
77
+ });
78
+ }
79
+ return out;
80
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-artboard Unity sync opt-in (strict).
3
+ *
4
+ * In the editor, each artboard has a "Sync to Unity" checkbox (and library
5
+ * folders have a folder-level switch that cascades). A project may hold 100
6
+ * drafts but only 3 artboards that belong in the Unity project, so the CLI
7
+ * mirrors the in-app sync button and pulls ONLY the flagged ones.
8
+ *
9
+ * Strict opt-in: `unity === true` syncs, anything else does not. A missing
10
+ * flag means the server didn't say (older edge deploy, or a budget-starved
11
+ * manifest backfill) — those entries are excluded and reported via
12
+ * `unknownFlags` so the caller can warn instead of silently syncing the whole
13
+ * library into someone's game project.
14
+ */
15
+ export function filterUnityManifest(manifest, opts = {}) {
16
+ if (opts.syncAll)
17
+ return { entries: manifest, noneFlagged: false, unknown: [] };
18
+ const entries = manifest.filter((e) => e.unity === true);
19
+ const unknown = manifest.filter((e) => typeof e.unity !== 'boolean');
20
+ return {
21
+ entries,
22
+ noneFlagged: manifest.length > 0 && entries.length === 0,
23
+ unknown,
24
+ };
25
+ }