@magicpixelart/cli 0.5.36 → 0.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to `@magicpixelart/cli` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.5.38] - 2026-10-10
9
+
10
+ ### Fixed
11
+ - Full Unity projects also scan sprite dot-folders at the project root (e.g. `.SpineRaw`), not only `Assets/`. VCS/editor folders stay skipped.
12
+ - `sync` and `connect` report how many PNGs an `exclude` rule skipped, naming the rule.
13
+
14
+ ## [0.5.37] - 2026-10-09
15
+
16
+ ### Fixed
17
+ - `sync` stops before writing when the sprites it would download are connected to another game folder on this machine (e.g. run from the wrong repo), naming the right folder. `--here` overrides; README documents it.
18
+
8
19
  ## [0.5.36] - 2026-10-07
9
20
 
10
21
  ### Changed
@@ -26,13 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
26
37
  ### Fixed
27
38
  - Local edits to game files that were never changed in MagicPixel no longer fail as "also changed in MagicPixel". Push now sends the cloud timestamp it last saw, and sync no longer records the local file as MagicPixel's copy when the server has no cached hash.
28
39
 
29
- ### Changed
30
- - Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
31
-
32
40
  ## [0.5.32] - 2026-10-05
33
41
 
34
- ### Fixed
35
- - `sync --watch` status lines are clipped to the terminal width, so a long sprite path no longer wraps and leaves the previous status piled on screen.
42
+ ### Changed
43
+ - Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
36
44
 
37
45
  ## [0.5.31] - 2026-10-01
38
46
 
package/README.md CHANGED
@@ -131,6 +131,7 @@ No bundler config, no runtime, no extra package.
131
131
  | `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
132
132
  | `-q, --quiet` | Minimal output (for CI). |
133
133
  | `--only <folder...>` | Only push, pull and prune inside these game folders (repeatable). Everything else is left untouched. |
134
+ | `--here` | Download even if these sprites are connected to another game folder on this computer (sync otherwise stops and names that folder). Run sync from your game project folder. |
134
135
 
135
136
  Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
136
137
 
@@ -162,6 +163,8 @@ To leave game files on disk but out of your library, add them to `exclude` in `m
162
163
  { "exclude": [".SpineRaw/rabbit_nft/**"] }
163
164
  ```
164
165
 
166
+ Sync prints how many PNGs your `exclude` rules skipped, so a forgotten rule is easy to spot. In full Unity projects sync scans `Assets/` plus sprite dot-folders at the project root (like `.SpineRaw`).
167
+
165
168
  Excluded files are never uploaded, even when `connect` is `**`, and the CLI never deletes them from your game. Then delete the folder in the MagicPixel library (it goes to Trash) and it stays gone.
166
169
 
167
170
  ## Config (`magicpixel.json`)
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import { loadConfig, saveConfig } from '../config.js';
4
4
  import { assertSafeGlob } from '../util/security.js';
5
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
6
- import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
6
+ import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
7
7
  import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
8
8
  import { cmd } from '../util/invoke.js';
9
9
  import { runPush } from './push.js';
@@ -37,6 +37,9 @@ export async function connectCommand(glob) {
37
37
  });
38
38
  spinner.stop();
39
39
  const matched = matchConnectGlobs(index, config.connect, config.exclude);
40
+ const skipped = describeExcluded(matched);
41
+ if (skipped)
42
+ console.log(kleur.dim(` ${skipped}`));
40
43
  if (matched.entries.length === 0) {
41
44
  console.log(kleur.yellow(` No PNGs matched. Try \`${cmd('search')} <name>\` to see indexed paths.`));
42
45
  return;
@@ -12,7 +12,7 @@ import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
13
  import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
14
14
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
15
- import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
15
+ import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
16
16
  import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
17
17
  import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
18
18
  import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, workingSetPullKeys } from '../util/unityFilter.js';
@@ -23,7 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
23
23
  import { formatBytes } from '../util/format.js';
24
24
  import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
25
25
  import { cmd } from '../util/invoke.js';
26
- import { fitStatusLine, formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
26
+ import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
27
27
  import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
28
28
  import { hasUnpushedLocalEdit } from '../util/localEdit.js';
29
29
  import { shouldReconcile } from '../util/reconcile.js';
@@ -31,6 +31,7 @@ import { runPush } from './push.js';
31
31
  import { claimResyncRequest, reportResyncRequest } from '../api.js';
32
32
  import { runResync } from './resync.js';
33
33
  import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
34
+ import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
34
35
  /** How often the watcher asks for a library "Resync from game" request. */
35
36
  const RESYNC_POLL_MS = 15_000;
36
37
  let lastResyncPollAt = 0;
@@ -227,7 +228,7 @@ async function watchLoop(opts) {
227
228
  const onStatus = (msg) => {
228
229
  if (opts.quiet)
229
230
  return;
230
- writeStatusLine(msg);
231
+ process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
231
232
  };
232
233
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
233
234
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -307,7 +308,7 @@ async function watchLoop(opts) {
307
308
  printChanges(r, /* indent */ ' ');
308
309
  }
309
310
  else {
310
- writeStatusLine(`Waiting for edits… (${r.unchanged} up to date)`);
311
+ process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} Waiting for edits… (${r.unchanged} up to date)`)}`);
311
312
  }
312
313
  }
313
314
  catch (e) {
@@ -688,6 +689,9 @@ async function runOnce(opts, runOpts = {}) {
688
689
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
689
690
  if (verbose && isEngineKind(projectKind)) {
690
691
  console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
692
+ const skipped = describeExcluded(connected);
693
+ if (skipped)
694
+ console.log(kleur.dim(` ${skipped}`));
691
695
  }
692
696
  const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
693
697
  // Every folder on disk that belongs to a withheld document. Anything already
@@ -879,6 +883,20 @@ async function runOnce(opts, runOpts = {}) {
879
883
  const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
880
884
  const remoteKeySet = new Set(manifest.map((e) => e.key));
881
885
  const previousKeySet = new Set(Object.values(previousAssets));
886
+ // Wrong-folder guard: sprites connected to another game folder on this
887
+ // machine would land here as stray copies under outDir. Stop before writing.
888
+ const projectIdForRoots = getLastProjectInfo()?.id;
889
+ if (projectIdForRoots && !opts.here) {
890
+ const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
891
+ if (owner) {
892
+ const msg = foreignOwnerMessage(owner, process.cwd(), `add ${kleur.cyan('--here')}`);
893
+ if (runOpts.watchMode) {
894
+ console.error(kleur.red(msg));
895
+ process.exit(1);
896
+ }
897
+ throw new Error(msg);
898
+ }
899
+ }
882
900
  const localPngs = needsLocalPngWalk(since, toDownload.length)
883
901
  ? await walkOutDirPngs(config.outDir)
884
902
  : [];
@@ -1485,6 +1503,11 @@ async function runOnce(opts, runOpts = {}) {
1485
1503
  // Never let an AGENTS.md write failure break sync.
1486
1504
  }
1487
1505
  await saveState(nextState);
1506
+ // This folder owns real game paths → remember it so a sync elsewhere can
1507
+ // point back here instead of writing stray copies.
1508
+ if (projectIdForRoots && sourceByKey.size > 0) {
1509
+ await recordProjectRoot(projectIdForRoots, process.cwd());
1510
+ }
1488
1511
  if (result.failed > 0 && verbose) {
1489
1512
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
1490
1513
  }
@@ -1570,11 +1593,6 @@ function progressText(done, total, bytes) {
1570
1593
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1571
1594
  return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
1572
1595
  }
1573
- /** Rewrite the watcher's single status row, clipped so it never wraps. */
1574
- function writeStatusLine(msg) {
1575
- const line = fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns);
1576
- process.stdout.write(`\r\x1b[2K${kleur.dim(line)}`);
1577
- }
1578
1596
  function timestamp() {
1579
1597
  const d = new Date();
1580
1598
  return `[${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}]`;
package/dist/index.js CHANGED
@@ -94,6 +94,7 @@ program
94
94
  .option('-q, --quiet', 'Minimal output (for CI)')
95
95
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
96
96
  .option('--only <folder...>', 'Only sync these game folders (repeatable), e.g. Runtime/Sprites/Entities/decorations')
97
+ .option('--here', 'Download here even if these sprites are connected to another game folder')
97
98
  .argument('[extra...]')
98
99
  .addHelpText('after', '\nExamples:\n $ magicpixel sync --watch --only Runtime/Sprites/Enemies # one folder\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')
99
100
  .action(wrap("sync", async (extra, opts) => {
@@ -4,7 +4,7 @@
4
4
  * globs select a working set; sync writes back to those original paths.
5
5
  */
6
6
  import { existsSync } from 'node:fs';
7
- import { opendir } from 'node:fs/promises';
7
+ import { opendir, readdir } from 'node:fs/promises';
8
8
  import { relative, resolve } from 'node:path';
9
9
  import { isEngineKind, resolveChildDir } from './framework.js';
10
10
  import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
@@ -184,6 +184,13 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
184
184
  const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
185
185
  const preferFirst = startAt === scanRoot ? assetRoot : null;
186
186
  await walkPngs(startAt, scanRoot, destNorm, found, Infinity, io, report, stats, preferFirst);
187
+ // Full Unity games also keep sprite sources in root dot-folders next to
188
+ // Assets/ (e.g. `.SpineRaw`). Walk those too; Unity's own folders stay out.
189
+ if (startAt !== scanRoot) {
190
+ for (const dir of await rootSpriteDotDirs(scanRoot)) {
191
+ await walkPngs(dir, scanRoot, destNorm, found, Infinity, io, report, stats, null);
192
+ }
193
+ }
187
194
  report.flush?.();
188
195
  opts.onProgress?.({ pngs: found.length, folders: stats.folders });
189
196
  found.sort((a, b) => a.localeCompare(b));
@@ -195,15 +202,48 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
195
202
  }
196
203
  return { files };
197
204
  }
205
+ /** One-line summary of exclude-dropped files, or null when none. */
206
+ export function describeExcluded(r) {
207
+ if (!r.excluded)
208
+ return null;
209
+ return `${r.excluded.toLocaleString('en-US')} PNG${r.excluded === 1 ? '' : 's'} skipped by exclude rules (${r.topExclude}) — edit "exclude" in magicpixel.json to include them`;
210
+ }
211
+ /** Root-level dot-folders that may hold sprites (not VCS/editor/tool dirs). */
212
+ export async function rootSpriteDotDirs(root) {
213
+ try {
214
+ const ents = await readdir(root, { withFileTypes: true });
215
+ return ents
216
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && e.name.startsWith('.') && !shouldSkipDir(e.name))
217
+ .map((e) => resolve(root, e.name))
218
+ .sort();
219
+ }
220
+ catch {
221
+ return [];
222
+ }
223
+ }
198
224
  /** Filter the index by connect globs minus exclude globs (cwd-, asset-root-relative or manifest key). */
199
225
  export function matchConnectGlobs(index, globs, exclude = []) {
200
226
  if (globs.length === 0)
201
227
  return { entries: [] };
202
228
  const hit = (e, g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g) || matchGlob(e.key, g);
203
229
  // `exclude` wins over `connect`: excluded game files are never uploaded.
204
- const matched = index.files.filter((e) => globs.some((g) => hit(e, g)) && !exclude.some((g) => hit(e, g)));
230
+ const matched = [];
231
+ const byRule = new Map();
232
+ let excluded = 0;
233
+ for (const e of index.files) {
234
+ if (!globs.some((g) => hit(e, g)))
235
+ continue;
236
+ const rule = exclude.find((g) => hit(e, g));
237
+ if (rule === undefined)
238
+ matched.push(e);
239
+ else {
240
+ excluded++;
241
+ byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
242
+ }
243
+ }
205
244
  matched.sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
206
- return { entries: matched };
245
+ const topExclude = [...byRule.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
246
+ return { entries: matched, ...(excluded ? { excluded, topExclude } : {}) };
207
247
  }
208
248
  export function searchGameIndex(index, query) {
209
249
  const q = query.trim().toLowerCase();
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { atomicWrite } from './atomicWrite.js';
6
+ export function registryPath(home = homedir()) {
7
+ return resolve(home, '.magicpixel', 'project-roots.json');
8
+ }
9
+ async function readRegistry(home) {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(registryPath(home), 'utf8'));
12
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ /** Remember `root` as a home for this project's connected sprites. Best-effort. */
19
+ const recorded = new Set();
20
+ export async function recordProjectRoot(projectId, root, home) {
21
+ const memo = `${home ?? ''}\0${projectId}\0${resolve(root)}`;
22
+ if (recorded.has(memo))
23
+ return; // watch mode: once per run
24
+ recorded.add(memo);
25
+ try {
26
+ const reg = await readRegistry(home);
27
+ const abs = resolve(root);
28
+ const list = reg[projectId] ?? [];
29
+ if (list.includes(abs))
30
+ return;
31
+ reg[projectId] = [...list, abs].slice(-20);
32
+ const path = registryPath(home);
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await atomicWrite(path, JSON.stringify(reg, null, 2));
35
+ }
36
+ catch {
37
+ /* registry is advisory */
38
+ }
39
+ }
40
+ /** Keys another folder already syncs to a real game path. */
41
+ function connectedKeysAt(root) {
42
+ try {
43
+ const state = JSON.parse(readFileSync(resolve(root, '.magicpixel', 'state.json'), 'utf8'));
44
+ return new Set(Object.entries(state.synced ?? {}).filter(([, v]) => !!v?.sourceRel).map(([k]) => k));
45
+ }
46
+ catch {
47
+ return new Set();
48
+ }
49
+ }
50
+ /**
51
+ * Of the keys this run would download WITHOUT a local game path, how many
52
+ * belong to another registered folder. Returns the folder owning the most, or
53
+ * null when the download is safe.
54
+ */
55
+ export async function findForeignOwner(projectId, cwd, downloadKeysWithoutLocalPath, home) {
56
+ if (downloadKeysWithoutLocalPath.length === 0)
57
+ return null;
58
+ const here = resolve(cwd);
59
+ const roots = ((await readRegistry(home))[projectId] ?? []).filter((r) => r !== here && existsSync(r));
60
+ let best = null;
61
+ for (const root of roots) {
62
+ const owned = connectedKeysAt(root);
63
+ const count = downloadKeysWithoutLocalPath.filter((k) => owned.has(k)).length;
64
+ if (count > 0 && (!best || count > best.count))
65
+ best = { root, count };
66
+ }
67
+ return best;
68
+ }
69
+ export function foreignOwnerMessage(owner, cwd, rerun) {
70
+ return (`This folder isn't the game these sprites are connected to.\n` +
71
+ ` ${owner.count} sprite${owner.count === 1 ? '' : 's'} live in ${owner.root}, not ${resolve(cwd)}.\n` +
72
+ ` Fix: cd "${owner.root}" and run sync there — or ${rerun} to download copies here anyway.`);
73
+ }
@@ -39,19 +39,3 @@ export function formatSlowTickLine(elapsedSec, lastStatus) {
39
39
  ? `${what} — still working (${elapsed})`
40
40
  : `Still working (${elapsed})`;
41
41
  }
42
- /**
43
- * Fit a carriage-return status line to one terminal row. `\r\x1b[2K` only
44
- * clears the row the cursor is on, so a line that wraps leaves its first rows
45
- * behind and the next status prints after them (long "Looking through your
46
- * game sprites… <folder>" lines piled up this way). No width (not a TTY) →
47
- * unchanged.
48
- */
49
- export function fitStatusLine(line, columns) {
50
- if (!columns || columns < 2)
51
- return line;
52
- const max = columns - 1; // leave the last cell free so the cursor can't wrap
53
- const chars = Array.from(line);
54
- if (chars.length <= max)
55
- return line;
56
- return `${chars.slice(0, max - 1).join('')}…`;
57
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {