@magicpixelart/cli 0.5.34 → 0.5.35

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,12 @@ 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.35] - 2026-10-07
9
+
10
+ ### Changed
11
+ - `exclude` now also stops uploads: game PNGs matching an exclude glob are never pushed, even when `connect` is `**`.
12
+ - Removed the 10,000-PNG game index / connect cap: every sprite in the project is now indexed, connected and pushed. The server's daily allowance still applies; re-running resumes where it stopped.
13
+
8
14
  ## [0.5.34] - 2026-10-06
9
15
 
10
16
  ### Fixed
package/README.md CHANGED
@@ -150,13 +150,23 @@ npx @magicpixelart/cli sync --watch --only Runtime/Sprites/Entities/decorations
150
150
 
151
151
  Only that folder is pushed, pulled, or pruned. Every other file stays exactly as it is. Repeat `--only` for more folders, or set `"syncOnly"` in `magicpixel.json`. Changing the folder list triggers one full check. `sync <folder>` without `--only` stops with an error instead of syncing everything.
152
152
 
153
+ ## Keep a folder out of MagicPixel
154
+
155
+ To leave game files on disk but out of your library, add them to `exclude` in `magicpixel.json`:
156
+
157
+ ```json
158
+ { "exclude": [".SpineRaw/rabbit_nft/**"] }
159
+ ```
160
+
161
+ 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.
162
+
153
163
  ## Config (`magicpixel.json`)
154
164
 
155
165
  | Field | Type | Default | Meaning |
156
166
  | ---------- | ---------- | ----------------------- | ------------------------------------------------ |
157
167
  | `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
158
168
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
159
- | `exclude` | `string[]` | `[]` | Globs to exclude. |
169
+ | `exclude` | `string[]` | `[]` | Globs to skip in both directions: never downloaded, and game PNGs matching them are never uploaded (wins over `connect`). |
160
170
  | `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
161
171
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
162
172
  | `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
@@ -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, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
6
+ import { 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';
@@ -36,23 +36,15 @@ export async function connectCommand(glob) {
36
36
  onProgress: (p) => { spinner.text = countingSpritesText(p); },
37
37
  });
38
38
  spinner.stop();
39
- if (index.capped) {
40
- console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
41
- }
42
- const matched = matchConnectGlobs(index, config.connect);
43
- if (matched.capped) {
44
- console.log(kleur.yellow(`! ${connectCapMessage(matched.total, matched.entries.length)}`));
45
- }
39
+ const matched = matchConnectGlobs(index, config.connect, config.exclude);
46
40
  if (matched.entries.length === 0) {
47
41
  console.log(kleur.yellow(` No PNGs matched. Try \`${cmd('search')} <name>\` to see indexed paths.`));
48
42
  return;
49
43
  }
50
44
  const n = matched.entries.length;
51
- console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} to sync (of ${index.files.length} in your game` +
52
- (matched.capped ? `, ${matched.total} matched` : '') +
53
- `).`));
45
+ console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} to sync (of ${index.files.length} in your game).`));
54
46
  if (n >= 200) {
55
- console.log(kleur.dim(` Already-imported sprites are skipped; only new or changed files upload.`));
47
+ console.log(kleur.dim(` Found ${n} sprites. Only new or changed files upload.`));
56
48
  }
57
49
  await runPush({ gameIndex: index });
58
50
  }
@@ -19,7 +19,7 @@ import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
19
19
  import { applyDiskFingerprints, indexSyncedBySourceRel, planPush, resolveSyncedKey } from '../util/pushPlan.js';
20
20
  import { cmd } from '../util/invoke.js';
21
21
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
22
- import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
22
+ import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
23
23
  import { collectSourceRelMap, remapAbsToCloudKeys } from '../util/syncPath.js';
24
24
  import { assertSafeIoPath } from '../util/security.js';
25
25
  /** Parallel hashing / path checks — enough to saturate disk, low enough to stay polite. */
@@ -64,8 +64,8 @@ export async function runPushWith(config, state, opts = {}) {
64
64
  ? await indexGamePngs(kind, process.cwd(), config.outDir, {
65
65
  onProgress: spinner ? (p) => { spinner.text = countingSpritesText(p); } : undefined,
66
66
  })
67
- : { files: [], capped: false });
68
- const matched = matchConnectGlobs(index, config.connect ?? []);
67
+ : { files: [] });
68
+ const matched = matchConnectGlobs(index, config.connect ?? [], config.exclude);
69
69
  const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
70
70
  const disk = await walkOutDirPngs(config.outDir);
71
71
  const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
@@ -151,12 +151,6 @@ export async function runPushWith(config, state, opts = {}) {
151
151
  const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
152
152
  spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
153
153
  (matched.entries.length ? ` · connected ${matched.entries.length}` : ''));
154
- if (index.capped && !quiet) {
155
- console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
156
- }
157
- if (matched.capped && !quiet) {
158
- console.log(kleur.yellow(`! ${connectCapMessage(matched.total, matched.entries.length)}`));
159
- }
160
154
  if (skippedUnsafe > 0 && !quiet) {
161
155
  console.log(kleur.yellow(`! skipped ${skippedUnsafe} path${skippedUnsafe === 1 ? '' : 's'} that were not regular files inside the project.`));
162
156
  }
@@ -77,8 +77,7 @@ export async function runResync(segments, opts = {}) {
77
77
  if (plan.scanned === 0) {
78
78
  throw new Error(`No local sprites found in "${label}". Nothing was changed. Check the folder name with \`magicpixel search\`.`);
79
79
  }
80
- const pruneAllowed = !gameIndex.capped;
81
- const preview = pruneAllowed && plan.keptAssetIds.length > 0
80
+ const preview = plan.keptAssetIds.length > 0
82
81
  ? await pruneResyncFolder(segments, plan.keptAssetIds, true)
83
82
  : null;
84
83
  log(kleur.bold(`Resync ${label} from your game`));
@@ -90,8 +89,6 @@ export async function runResync(segments, opts = {}) {
90
89
  if (preview.wouldTrash.length > 10)
91
90
  log(kleur.dim(` …and ${preview.wouldTrash.length - 10} more`));
92
91
  }
93
- if (!pruneAllowed)
94
- log(kleur.yellow(' ! game scan hit its file cap — nothing will be trashed.'));
95
92
  log(kleur.dim(' Local files win: MagicPixel copies are overwritten and resized to match.'));
96
93
  if (opts.dryRun) {
97
94
  log(kleur.dim('--dry-run: nothing sent.'));
@@ -105,9 +102,7 @@ export async function runResync(segments, opts = {}) {
105
102
  });
106
103
  let pruneSkipped = null;
107
104
  let trashed = 0;
108
- if (!pruneAllowed)
109
- pruneSkipped = 'game scan capped';
110
- else if (push.error > 0 || push.conflict > 0)
105
+ if (push.error > 0 || push.conflict > 0)
111
106
  pruneSkipped = 'some sprites failed to upload';
112
107
  else if (push.keptAssetIds.length === 0)
113
108
  pruneSkipped = 'no files confirmed';
@@ -2,7 +2,7 @@ import kleur from 'kleur';
2
2
  import ora from 'ora';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
- import { indexGamePngs, searchGameIndex, GAME_INDEX_CAP_HINT, countingSpritesText } from '../util/gameScan.js';
5
+ import { indexGamePngs, searchGameIndex, countingSpritesText } from '../util/gameScan.js';
6
6
  import { cmd } from '../util/invoke.js';
7
7
  const PRINT_CAP = 50;
8
8
  /**
@@ -31,9 +31,6 @@ export async function searchCommand(query) {
31
31
  onProgress: (p) => { spinner.text = countingSpritesText(p); },
32
32
  });
33
33
  spinner.stop();
34
- if (index.capped) {
35
- console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
36
- }
37
34
  const hits = searchGameIndex(index, q);
38
35
  if (hits.length === 0) {
39
36
  console.log(kleur.dim(` No indexed PNGs matching "${q}" (${index.files.length} scanned).`));
@@ -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, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
15
+ import { 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';
@@ -510,7 +510,7 @@ async function loadWatchHeader(onProgress) {
510
510
  const kind = await detectProjectKind();
511
511
  if (isEngineKind(kind) && (config.connect?.length ?? 0) > 0) {
512
512
  gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, { onProgress });
513
- workingSet = matchConnectGlobs(gameIndex, config.connect).total;
513
+ workingSet = matchConnectGlobs(gameIndex, config.connect, config.exclude).entries.length;
514
514
  }
515
515
  else if (state.synced) {
516
516
  workingSet = Object.keys(state.synced).length;
@@ -649,14 +649,14 @@ async function runOnce(opts, runOpts = {}) {
649
649
  : `Looking through your game sprites… ${n}`);
650
650
  },
651
651
  })
652
- : { files: [], capped: false });
652
+ : { files: [] });
653
653
  if (!runOpts.gameIndex)
654
654
  runOpts.onGameIndex?.(gameIndex);
655
655
  const fullGameIndex = gameIndex;
656
656
  const scopedGameIndex = scope
657
657
  ? { ...fullGameIndex, files: fullGameIndex.files.filter((f) => pathInScope(scope, f.sourceRel)) }
658
658
  : fullGameIndex;
659
- const connected = matchConnectGlobs(scopedGameIndex, config.connect ?? []);
659
+ const connected = matchConnectGlobs(scopedGameIndex, config.connect ?? [], config.exclude);
660
660
  const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
661
661
  if (scope) {
662
662
  // Out-of-scope rows become hold-only: never pulled, and their folders are
@@ -686,15 +686,8 @@ async function runOnce(opts, runOpts = {}) {
686
686
  }
687
687
  }
688
688
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
689
- if (verbose && gameIndex.capped) {
690
- console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
691
- }
692
- if (verbose && connected.capped) {
693
- console.log(kleur.yellow(`! ${connectCapMessage(connected.total, connected.entries.length)}`));
694
- }
695
689
  if (verbose && isEngineKind(projectKind)) {
696
- console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}` +
697
- (connected.capped ? ` of ${connected.total} matched` : '')));
690
+ console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
698
691
  }
699
692
  const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
700
693
  // Every folder on disk that belongs to a withheld document. Anything already
@@ -47,15 +47,6 @@ export const GAME_SCAN_SKIP_HIDDEN = new Set([
47
47
  ]);
48
48
  /** Parallel directory reads while walking the game tree. */
49
49
  const SCAN_WALK_CONCURRENCY = 8;
50
- /** Cap on the search index (whole tree). Connect uses the same ceiling. */
51
- export const MAX_GAME_INDEX = 10_000;
52
- /** Cap on PNGs ingested for one working-set connect / push. */
53
- export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
54
- export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
55
- export function connectCapMessage(total, ingested) {
56
- return (`${total} sprites match — syncing the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
57
- `Connect a smaller folder if you don't need all of them.`);
58
- }
59
50
  /** True for a full Unity game project (not an embedded UPM package). */
60
51
  function isUnityGameProject(cwd, assetRoot) {
61
52
  return Boolean(assetRoot && existsSync(resolve(cwd, 'ProjectSettings')));
@@ -175,7 +166,7 @@ function samePath(a, b) {
175
166
  }
176
167
  /** Walk the engine tree and return index entries. Does not copy files. */
177
168
  export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts = {}) {
178
- const empty = { files: [], capped: false };
169
+ const empty = { files: [] };
179
170
  if (!isEngineKind(kind))
180
171
  return empty;
181
172
  const scanRoot = resolve(cwd);
@@ -192,30 +183,27 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
192
183
  // Library/Packages/cloud placeholders and looks hung at "1 folder".
193
184
  const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
194
185
  const preferFirst = startAt === scanRoot ? assetRoot : null;
195
- await walkPngs(startAt, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, preferFirst);
186
+ await walkPngs(startAt, scanRoot, destNorm, found, Infinity, io, report, stats, preferFirst);
196
187
  report.flush?.();
197
188
  opts.onProgress?.({ pngs: found.length, folders: stats.folders });
198
189
  found.sort((a, b) => a.localeCompare(b));
199
- const capped = found.length > MAX_GAME_INDEX;
200
- if (capped)
201
- found.length = MAX_GAME_INDEX;
202
190
  const files = [];
203
191
  for (const abs of found) {
204
192
  const entry = toEntry(abs, scanRoot, assetRoot);
205
193
  if (entry)
206
194
  files.push(entry);
207
195
  }
208
- return { files, capped };
196
+ return { files };
209
197
  }
210
- /** Filter the index by connect globs (cwd-relative or asset-root-relative). */
211
- export function matchConnectGlobs(index, globs, max = MAX_GAME_CONNECT) {
198
+ /** Filter the index by connect globs minus exclude globs (cwd-, asset-root-relative or manifest key). */
199
+ export function matchConnectGlobs(index, globs, exclude = []) {
212
200
  if (globs.length === 0)
213
- return { entries: [], total: 0, capped: false };
214
- const matched = index.files.filter((e) => globs.some((g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g)));
201
+ return { entries: [] };
202
+ const hit = (e, g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g) || matchGlob(e.key, g);
203
+ // `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)));
215
205
  matched.sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
216
- const total = matched.length;
217
- const capped = total > max;
218
- return { entries: capped ? matched.slice(0, max) : matched, total, capped };
206
+ return { entries: matched };
219
207
  }
220
208
  export function searchGameIndex(index, query) {
221
209
  const q = query.trim().toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.34",
3
+ "version": "0.5.35",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {