@magicpixelart/cli 0.5.32 → 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,25 @@ 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
+
14
+ ## [0.5.34] - 2026-10-06
15
+
16
+ ### Fixed
17
+ - `push --force` no longer fails with "Too many sprites (max 20 per request)" when more than 20 files are retried; the retry round is now sent in small batches.
18
+
19
+ ## [0.5.33] - 2026-10-06
20
+
21
+ ### Fixed
22
+ - 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.
23
+
24
+ ### Changed
25
+ - Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
26
+
8
27
  ## [0.5.32] - 2026-10-05
9
28
 
10
29
  ### 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`. |
package/dist/api.js CHANGED
@@ -423,6 +423,15 @@ function isTransientPushError(e) {
423
423
  export async function pushSpritesAdaptive(sprites, retryOpts) {
424
424
  if (sprites.length === 0)
425
425
  return [];
426
+ // Never send more than one batch per request — callers (e.g. the --force
427
+ // retry round) may hand over every refused sprite at once.
428
+ if (sprites.length > PUSH_BATCH_SIZE) {
429
+ const out = [];
430
+ for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
431
+ out.push(...(await pushSpritesAdaptive(sprites.slice(i, i + PUSH_BATCH_SIZE), retryOpts)));
432
+ }
433
+ return out;
434
+ }
426
435
  try {
427
436
  return await pushSprites(sprites, retryOpts);
428
437
  }
@@ -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
  }
@@ -215,6 +209,7 @@ export async function runPushWith(config, state, opts = {}) {
215
209
  assetId: a.assetId,
216
210
  layerIdx: a.layerIdx,
217
211
  baseSha256: a.baseSha256,
212
+ ...(a.baseUpdatedAt ? { baseUpdatedAt: a.baseUpdatedAt } : {}),
218
213
  flatten: opts.flatten === true,
219
214
  ...(opts.replace ? { replace: true } : {}),
220
215
  });
@@ -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
@@ -1377,12 +1370,14 @@ async function runOnce(opts, runOpts = {}) {
1377
1370
  // After a write, the actual downloaded bytes are the disk/cloud baseline.
1378
1371
  // The manifest hash may be null or represent a cached composite while the
1379
1372
  // download endpoint serves storage bytes for a single-artboard document.
1373
+ const prev = nextSynced[entry.key];
1374
+ // Never let the local game file stand in for MagicPixel's composite when
1375
+ // the manifest has no cached hash: keep the last real cloud baseline.
1380
1376
  const sha = writtenKeys.has(entry.key)
1381
1377
  ? await fileSha256(pathFor(entry))
1382
- : (entry.sha256 ?? (await fileSha256(pathFor(entry))));
1378
+ : (entry.sha256 ?? prev?.sha256 ?? (await fileSha256(pathFor(entry))));
1383
1379
  if (!sha)
1384
1380
  continue;
1385
- const prev = nextSynced[entry.key];
1386
1381
  const diskSha256 = writtenKeys.has(entry.key)
1387
1382
  ? sha
1388
1383
  : (shaByEntryId.get(entry.id) ?? prev?.diskSha256 ?? sha);
@@ -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();
@@ -151,6 +151,7 @@ export function planPush(candidates, synced, opts = {}) {
151
151
  // A later explicit `push` can then safely keep the local copy without
152
152
  // bypassing the server's optimistic-concurrency check.
153
153
  baseSha256: known.pendingCloudSha256 ?? known.sha256,
154
+ ...(known.cloudUpdatedAt && !known.pendingCloudSha256 ? { baseUpdatedAt: known.cloudUpdatedAt } : {}),
154
155
  });
155
156
  continue;
156
157
  }
@@ -13,10 +13,10 @@ export function formatWatchSpriteLine(c) {
13
13
  if (set === 0 && pulled === 0)
14
14
  return null;
15
15
  if (set > 0 && pulled > 0 && set !== pulled) {
16
- return ` Sprites: ${fmt(set)} in your game · ${fmt(pulled)} last pulled from MagicPixel`;
16
+ return ` Sprites: ${fmt(set)} game sprites connected · ${fmt(pulled)} last pulled from MagicPixel`;
17
17
  }
18
18
  if (set > 0)
19
- return ` Sprites: ${fmt(set)} in your game`;
19
+ return ` Sprites: ${fmt(set)} game sprites connected`;
20
20
  return ` Sprites: ${fmt(pulled)}`;
21
21
  }
22
22
  /** How long a single watch tick may run before the heartbeat starts printing. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.32",
3
+ "version": "0.5.35",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {