@magicpixelart/cli 0.5.34 → 0.5.36

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.36] - 2026-10-07
9
+
10
+ ### Changed
11
+ - `push --force` no longer replaces files whose newer copy was saved in the MagicPixel editor; they are listed as "edited in MagicPixel — skipped". Add `--overwrite-editor-changes` to replace them too.
12
+
13
+ ## [0.5.35] - 2026-10-07
14
+
15
+ ### Changed
16
+ - `exclude` now also stops uploads: game PNGs matching an exclude glob are never pushed, even when `connect` is `**`.
17
+ - 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.
18
+
8
19
  ## [0.5.34] - 2026-10-06
9
20
 
10
21
  ### Fixed
package/README.md CHANGED
@@ -150,13 +150,27 @@ 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
+ ## Forced pushes and editor edits
154
+
155
+ `push --force` keeps your local copy when a sprite changed on both sides, except for files last saved in the MagicPixel editor: those are skipped and listed, so a forced push never silently undoes editor work. Add `--overwrite-editor-changes` to replace them as well.
156
+
157
+ ## Keep a folder out of MagicPixel
158
+
159
+ To leave game files on disk but out of your library, add them to `exclude` in `magicpixel.json`:
160
+
161
+ ```json
162
+ { "exclude": [".SpineRaw/rabbit_nft/**"] }
163
+ ```
164
+
165
+ 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
+
153
167
  ## Config (`magicpixel.json`)
154
168
 
155
169
  | Field | Type | Default | Meaning |
156
170
  | ---------- | ---------- | ----------------------- | ------------------------------------------------ |
157
171
  | `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
158
172
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
159
- | `exclude` | `string[]` | `[]` | Globs to exclude. |
173
+ | `exclude` | `string[]` | `[]` | Globs to skip in both directions: never downloaded, and game PNGs matching them are never uploaded (wins over `connect`). |
160
174
  | `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
161
175
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
162
176
  | `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
  }
@@ -261,7 +255,7 @@ export async function runPushWith(config, state, opts = {}) {
261
255
  // without a live sha, and the retry as an update is what makes the server
262
256
  // composite the artboard and report one.
263
257
  for (let round = 0; round < 2; round++) {
264
- const retry = forcedRetrySprites(results, spriteByKey, opts.flatten === true);
258
+ const retry = forcedRetrySprites(results, spriteByKey, opts.flatten === true, opts.overwriteEditorChanges === true);
265
259
  if (retry.length === 0)
266
260
  break;
267
261
  const retryResults = await pushSpritesAdaptive(retry);
@@ -306,7 +300,7 @@ export async function runPushWith(config, state, opts = {}) {
306
300
  for (const r of results) {
307
301
  if (r.status === 'conflict') {
308
302
  if (printedIssues++ < 10)
309
- console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
303
+ console.log(` ${kleur.yellow('!')} ${r.key}: ${opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges ? `edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.` : conflictHint(r.reason)}`);
310
304
  }
311
305
  else if (r.status === 'error') {
312
306
  if (printedIssues++ < 10)
@@ -350,11 +344,14 @@ export function collectKeptAssetIds(candidates, synced, results) {
350
344
  * reported. Not a bypass — the server still compares, so a save that lands in
351
345
  * between refuses again.
352
346
  */
353
- export function forcedRetrySprites(results, spriteByKey, flatten) {
347
+ export function forcedRetrySprites(results, spriteByKey, flatten, overwriteEditorChanges = false) {
354
348
  const out = [];
355
349
  for (const r of results) {
356
350
  if (r.status !== 'conflict' || r.reason !== 'cloud-changed' || !r.assetId)
357
351
  continue;
352
+ // A newer save made in the MagicPixel editor is never silently undone.
353
+ if (r.lastWriteSource === 'editor' && !overwriteEditorChanges)
354
+ continue;
358
355
  const original = spriteByKey.get(r.key);
359
356
  if (!original)
360
357
  continue;
@@ -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
package/dist/index.js CHANGED
@@ -108,7 +108,8 @@ program
108
108
  .description('Upload local sprite edits back to MagicPixel (two-way sync)')
109
109
  .option('--dry-run', 'Print what would be pushed without sending anything')
110
110
  .option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
111
- .option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
111
+ .option('--force', 'Keep your local copy when a sprite also changed in MagicPixel (skips files saved in the MagicPixel editor)')
112
+ .option('--overwrite-editor-changes', 'With --force: also replace files saved in the MagicPixel editor')
112
113
  .addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n $ magicpixel push --force # your local copy wins on both-sides-changed\n')
113
114
  .action(wrap("push", async (opts) => pushCommand(opts)));
114
115
  program
@@ -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.36",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {