@magicpixelart/cli 0.5.12 → 0.5.14

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
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.14] — 2026-08-26
11
+
12
+ ### Fixed
13
+
14
+ - **Watch no longer re-downloads the whole game on catch-up.** Connect
15
+ originals almost never match MagicPixel's re-encoded composite hash, so
16
+ comparing those bytes queued ~every PNG (`492/3116…`) and would overwrite
17
+ the Unity files. Pull now skips a connected sprite unless the cloud
18
+ composite actually changed (or an editor save cleared the cached hash).
19
+ Ingest collision slugs (`ground-ice-snow-4`) alias onto the original PNG
20
+ instead of dumping copies into `Assets/MagicPixel`.
21
+ - **`connect <folder>` actually narrows the default `**` working set.** Adding
22
+ a folder used to OR with `**` and keep every sprite. `connect '**'` restores
23
+ all sprites. Godot's `.godot/` import cache is not ingested.
24
+ - **Editor saves of connected sprites still pull when the Unity flag is
25
+ missing.** A save nulls `artboard_index`; the next manifest often omits
26
+ `unity`. Watch writes those pixels back to the original game PNG. `lastSync`
27
+ advances past every row the fetch saw, including skipped drafts.
28
+ - **Watch no longer sits silent on `Checking your game files…`.** After a
29
+ pull, the upload half hashes local PNGs; the status line now shows
30
+ `1,200 / 3,126` instead of a frozen timestamp. The game-tree walk is reused
31
+ for 30s so each 2s poll does not re-list thousands of folders.
32
+ - **Ctrl+C drain message** says `Ctrl+C again` instead of `undefined`.
33
+ - **Single-artboard downloads no longer composite in the edge worker.**
34
+ Unity ingest sheets (including 2048/4096 tiles) are served from storage
35
+ so a catch-up download does not 546 the isolate. The CLI writes those
36
+ bytes even when they differ from a cached composite hash.
37
+
38
+ ## [0.5.13] — 2026-08-26
39
+
40
+ ### Fixed
41
+
42
+ - **Connect no longer hangs at `0 sprites · 1 folder`.** Full Unity games
43
+ (`ProjectSettings` present) scan `Assets/` only — listing Library and the
44
+ rest of the project root blocked the spinner before any sprite was found.
45
+ Directory reads stream so the path keeps updating (`Assets/Sprites`), and
46
+ nested walks no longer deadlock on the folder limiter.
47
+ - **Push no longer aborts the rest of a connect on a 502.** Batches of 8
48
+ (down from 20) persist after each success. A gateway error splits the
49
+ batch instead of crashing; already-uploaded sprites are remembered so a
50
+ re-run skips them.
51
+ - **`connect` copy.** Re-running `connect '**'` no longer prints
52
+ `connect pattern already present: **`. First-time connect says
53
+ `now syncing all sprites in your game`.
54
+
55
+ ### Changed
56
+
57
+ - **Unity/Godot/GameMaker default to all game sprites.** `start` and `sync`
58
+ ingest the game tree without a separate `connect '**'`. Use `connect` only
59
+ to narrow folders. Daily command is `sync --watch`.
60
+
10
61
  ## [0.5.12] — 2026-08-26
11
62
 
12
63
  ### Fixed
package/README.md CHANGED
@@ -24,14 +24,15 @@ Unity project or UPM package (`Assets`/`assets`, `Editor`/`Runtime`), Godot
24
24
  npx @magicpixelart/cli start
25
25
  ```
26
26
 
27
- That walks you through everything: detects your framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), pulls flagged sprites, and indexes game PNGs locally. Nothing is imported into the library until you connect a working set:
27
+ That walks you through everything: detects your engine or framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), and syncs sprites both ways. Unity, Godot, and GameMaker default to all game PNGs.
28
+
29
+ Then leave this running while you work:
28
30
 
29
31
  ```bash
30
- npx @magicpixelart/cli search hero
31
- npx @magicpixelart/cli connect 'assets/Sprites/**'
32
+ npx @magicpixelart/cli sync --watch
32
33
  ```
33
34
 
34
- Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`.
35
+ After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`.
35
36
 
36
37
  When it finishes:
37
38
 
@@ -109,9 +110,9 @@ No bundler config, no runtime, no extra package.
109
110
  | `logout` | Remove the stored API key. |
110
111
  | `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
111
112
  | `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
112
- | `sync [...flags]` | Fetch manifest, diff against disk, download changed assets, prune orphans. |
113
- | `push [--dry-run] [--flatten]` | Upload local PNG edits (and the `connect` working set) back to MagicPixel. |
114
- | `connect <glob>` | Add a game-sprite glob to the working set and ingest matching PNGs. |
113
+ | `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
114
+ | `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
115
+ | `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
115
116
  | `search <query>` | Search indexed game PNGs (no network). |
116
117
  | `add <glob>` / `remove <glob>` | Manage `include` patterns. |
117
118
  | `list` | Print matching manifest as a table. |
@@ -146,7 +147,7 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
146
147
  | `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
147
148
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
148
149
  | `exclude` | `string[]` | `[]` | Globs to exclude. |
149
- | `connect` | `string[]` | `[]` | Working-set globs of game PNGs to adopt into Connected. Empty = index only. |
150
+ | `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
150
151
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
151
152
  | `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
152
153
  | `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
package/dist/api.js CHANGED
@@ -280,15 +280,15 @@ export function retryAfterMsFromResponse(res) {
280
280
  }
281
281
  return 0;
282
282
  }
283
- /** Max sprites the server accepts per request (mirrors MAX_SPRITES_PER_REQUEST). */
284
- export const PUSH_BATCH_SIZE = 20;
283
+ /** CLI send size. The server allows 20; smaller batches survive Worker 502s. */
284
+ export const PUSH_BATCH_SIZE = 8;
285
285
  const DEFAULT_INGEST_ENDPOINT = 'https://magicpixel.art/api/public/integration/ingest';
286
286
  export function resolveIngestEndpoint() {
287
287
  const override = process.env.MAGICPIXEL_INGEST_ENDPOINT?.trim();
288
288
  return override ? validateEndpointUrl(override) : DEFAULT_INGEST_ENDPOINT;
289
289
  }
290
290
  /** Post one batch of sprites. Shares the transient-retry policy with pulls. */
291
- export async function pushSprites(sprites) {
291
+ export async function pushSprites(sprites, retryOpts) {
292
292
  const url = resolveIngestEndpoint();
293
293
  return retryTransient('push', async () => {
294
294
  const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
@@ -308,5 +308,41 @@ export async function pushSprites(sprites) {
308
308
  }
309
309
  const bodyText = await res.text();
310
310
  throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'push'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
311
- });
311
+ }, retryOpts);
312
+ }
313
+ function isTransientPushError(e) {
314
+ return e instanceof ApiError && (e.status >= 500 || e.status === 429);
315
+ }
316
+ /**
317
+ * Push a batch, splitting in half on persistent 502/5xx so one fat request
318
+ * cannot abort the rest of a connect/sync.
319
+ */
320
+ export async function pushSpritesAdaptive(sprites, retryOpts) {
321
+ if (sprites.length === 0)
322
+ return [];
323
+ try {
324
+ return await pushSprites(sprites, retryOpts);
325
+ }
326
+ catch (e) {
327
+ if (!isTransientPushError(e))
328
+ throw e;
329
+ if (sprites.length === 1) {
330
+ return [{ key: sprites[0].key, status: 'error', message: e.message }];
331
+ }
332
+ const mid = Math.ceil(sprites.length / 2);
333
+ const left = await pushSpritesAdaptive(sprites.slice(0, mid), retryOpts);
334
+ try {
335
+ const right = await pushSpritesAdaptive(sprites.slice(mid), retryOpts);
336
+ return [...left, ...right];
337
+ }
338
+ catch (rightErr) {
339
+ // Left half already landed — don't drop those results when the rest fails.
340
+ const right = sprites.slice(mid).map((s) => ({
341
+ key: s.key,
342
+ status: 'error',
343
+ message: rightErr.message,
344
+ }));
345
+ return [...left, ...right];
346
+ }
347
+ }
312
348
  }
@@ -4,23 +4,27 @@ import { loadConfig, saveConfig } from '../config.js';
4
4
  import { assertSafeGlob } from '../util/security.js';
5
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
6
6
  import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
7
+ import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
7
8
  import { cmd } from '../util/invoke.js';
8
9
  import { runPush } from './push.js';
10
+ /** Plain-language label for a connect glob (`**` is not meaningful to players). */
11
+ export function describeWorkingSet(glob) {
12
+ return isAllSpritesGlob(glob) ? 'all sprites in your game' : glob.trim();
13
+ }
9
14
  /**
10
- * Add a working-set glob and ingest matching game PNGs into Connected.
11
- * Sync writes edits back to the original files — not a copy under outDir.
15
+ * Add or narrow a working-set glob, then ingest matching game PNGs.
16
+ * A specific folder replaces the default `**`. Sync writes edits back to
17
+ * the original files — not a copy under outDir.
12
18
  */
13
19
  export async function connectCommand(glob) {
14
20
  const pattern = assertSafeGlob(glob);
15
21
  const config = await loadConfig();
16
- const already = config.connect.includes(pattern);
17
- if (!already) {
18
- config.connect.push(pattern);
22
+ const next = nextConnectGlobs(config.connect, pattern);
23
+ const changed = next.length !== config.connect.length || next.some((g, i) => g !== config.connect[i]);
24
+ if (changed) {
25
+ config.connect = next;
19
26
  await saveConfig(config);
20
- console.log(kleur.green(`✓ added connect pattern: ${pattern}`));
21
- }
22
- else {
23
- console.log(kleur.dim(` connect pattern already present: ${pattern}`));
27
+ console.log(kleur.green(`✓ now syncing ${describeWorkingSet(pattern)}`));
24
28
  }
25
29
  const kind = await detectProjectKind();
26
30
  if (!isEngineKind(kind)) {
@@ -44,7 +48,7 @@ export async function connectCommand(glob) {
44
48
  return;
45
49
  }
46
50
  const n = matched.entries.length;
47
- console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} in working set (of ${index.files.length} indexed` +
51
+ console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} to sync (of ${index.files.length} in your game` +
48
52
  (matched.capped ? `, ${matched.total} matched` : '') +
49
53
  `).`));
50
54
  if (n >= 200) {
@@ -9,6 +9,7 @@ import { detectProjectKind, suggestOutDir, hasPackageJson, isStaticOutDir, suppo
9
9
  import { assertSafeOutDir } from '../util/security.js';
10
10
  import { atomicWrite } from '../util/atomicWrite.js';
11
11
  import { cmd } from '../util/invoke.js';
12
+ import { DEFAULT_ENGINE_CONNECT } from '../util/engineConnect.js';
12
13
  const WATCH_SCRIPT_NAME = 'magicpixel:watch';
13
14
  const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
14
15
  export async function initCommand(opts) {
@@ -23,6 +24,8 @@ export async function initCommand(opts) {
23
24
  const kind = await detectProjectKind();
24
25
  const suggestedOutDir = suggestOutDir(kind);
25
26
  config.outDir = suggestedOutDir;
27
+ if (isEngineKind(kind))
28
+ config.connect = [...DEFAULT_ENGINE_CONNECT];
26
29
  const pkgExists = hasPackageJson();
27
30
  const pkgPath = resolve(process.cwd(), 'package.json');
28
31
  let addWatchScript = pkgExists && !isEngineKind(kind);
@@ -9,10 +9,10 @@
9
9
  import kleur from 'kleur';
10
10
  import ora from 'ora';
11
11
  import { mkdir, readFile, rename } from 'node:fs/promises';
12
- import { dirname } from 'node:path';
13
- import { relative, resolve } from 'node:path';
12
+ import { dirname, relative, resolve } from 'node:path';
14
13
  import { loadConfig, loadState, saveState } from '../config.js';
15
- import { PUSH_BATCH_SIZE, pushSprites } from '../api.js';
14
+ import { ensureEngineConnect } from '../util/engineConnect.js';
15
+ import { PUSH_BATCH_SIZE, pushSpritesAdaptive } from '../api.js';
16
16
  import { hashFile } from '../util/hash.js';
17
17
  import { createLimit } from '../util/limit.js';
18
18
  import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
@@ -35,7 +35,7 @@ export async function pushCommand(opts = {}) {
35
35
  * set is ingested without a second command).
36
36
  */
37
37
  export async function runPush(opts = {}) {
38
- const config = await loadConfig();
38
+ const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
39
39
  const state = await loadState();
40
40
  return runPushWith(config, state, opts);
41
41
  }
@@ -68,10 +68,22 @@ export async function runPushWith(config, state, opts = {}) {
68
68
  const candidates = [];
69
69
  const cwdAbs = resolve(process.cwd());
70
70
  let skippedUnsafe = 0;
71
- const synced = state.synced ?? {};
72
- const byRel = indexSyncedBySourceRel(synced);
71
+ const knownSynced = state.synced ?? {};
72
+ const byRel = indexSyncedBySourceRel(knownSynced);
73
73
  const scan = createLimit(PUSH_SCAN_CONCURRENCY);
74
- await Promise.all([...absByKey.entries()].map(([key, abs]) => scan(async () => {
74
+ const toHash = [...absByKey.entries()];
75
+ let hashed = 0;
76
+ const reportHash = () => {
77
+ hashed++;
78
+ const n = hashed.toLocaleString('en-US');
79
+ const t = toHash.length.toLocaleString('en-US');
80
+ if (spinner)
81
+ spinner.text = `Checking your game files… ${n} / ${t}`;
82
+ else if (opts.onStatus && (hashed === 1 || hashed === toHash.length || hashed % 25 === 0)) {
83
+ opts.onStatus(`Checking your game files… ${n} / ${t}`);
84
+ }
85
+ };
86
+ await Promise.all(toHash.map(([key, abs]) => scan(async () => {
75
87
  try {
76
88
  if (!verifiedIoPaths.has(abs)) {
77
89
  await assertSafeIoPath(abs, cwdAbs);
@@ -80,29 +92,33 @@ export async function runPushWith(config, state, opts = {}) {
80
92
  }
81
93
  catch {
82
94
  skippedUnsafe++;
95
+ reportHash();
83
96
  return;
84
97
  }
85
98
  const entry = entryByKey.get(key);
86
99
  const sourceRel = entry?.sourceRel ?? sourceByKey.get(key);
87
- const stateKey = resolveSyncedKey({ key, sourceRel }, synced, byRel);
88
- const known = stateKey ? synced[stateKey] : undefined;
100
+ const stateKey = resolveSyncedKey({ key, sourceRel }, knownSynced, byRel);
101
+ const known = stateKey ? knownSynced[stateKey] : undefined;
89
102
  const hint = known?.diskSha256 && known.diskMtimeMs != null && known.diskSize != null
90
103
  ? { sha256: known.diskSha256, mtimeMs: known.diskMtimeMs, size: known.diskSize }
91
104
  : undefined;
92
- const hashed = await hashFile(abs, hint);
93
- if (!hashed)
105
+ const hashedFile = await hashFile(abs, hint);
106
+ if (!hashedFile) {
107
+ reportHash();
94
108
  return;
109
+ }
95
110
  const segments = entry
96
111
  ? entry.adoptRel.replace(/\.png$/i, '').split('/')
97
112
  : key.split('/');
98
113
  candidates.push({
99
114
  key,
100
115
  segments,
101
- diskSha256: hashed.sha256,
116
+ diskSha256: hashedFile.sha256,
102
117
  ...(sourceRel ? { sourceRel } : {}),
103
- diskMtimeMs: hashed.mtimeMs,
104
- diskSize: hashed.size,
118
+ diskMtimeMs: hashedFile.mtimeMs,
119
+ diskSize: hashedFile.size,
105
120
  });
121
+ reportHash();
106
122
  })));
107
123
  const folderTreeKeys = new Set(matched.entries.map((e) => e.key));
108
124
  for (const k of absByKey.keys()) {
@@ -183,43 +199,37 @@ export async function runPushWith(config, state, opts = {}) {
183
199
  }
184
200
  const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
185
201
  const results = [];
186
- for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
187
- const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
188
- const batchResults = await pushSprites(batch);
189
- results.push(...batchResults);
190
- if (progress)
191
- progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
202
+ let synced = { ...(state.synced ?? {}) };
203
+ try {
204
+ for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
205
+ const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
206
+ const batchResults = await pushSpritesAdaptive(batch);
207
+ results.push(...batchResults);
208
+ synced = await commitPushResults({
209
+ synced,
210
+ batchResults,
211
+ candidates,
212
+ sourceByKey,
213
+ shaByKey,
214
+ fpByKey,
215
+ outDir: config.outDir,
216
+ });
217
+ await saveState({ ...state, synced });
218
+ if (progress)
219
+ progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
220
+ }
221
+ }
222
+ catch (e) {
223
+ progress?.fail(`Push stopped after ${results.length}/${sprites.length}. ${e.message}`);
224
+ if (results.length > 0)
225
+ await saveState({ ...state, synced });
226
+ throw e;
192
227
  }
193
228
  const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
194
- const failedKeys = new Set(results.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
195
- const fingerprinted = applyDiskFingerprints(state.synced ?? {}, candidates.filter((c) => !failedKeys.has(c.key)));
196
- const nextSynced = { ...fingerprinted.next };
197
229
  for (const r of results) {
198
- counts[r.status] = (counts[r.status] ?? 0) + 1;
199
- const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
200
- const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
201
- if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
202
- if (!sourceRel)
203
- await renameLocalSprite(config.outDir, r.key, stateKey);
204
- }
205
- if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
206
- if (stateKey !== r.key)
207
- delete nextSynced[r.key];
208
- const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
209
- const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
210
- nextSynced[stateKey] = {
211
- assetId: r.assetId,
212
- layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
213
- sha256: r.sha256,
214
- layers: 1,
215
- ...(diskSha256 ? { diskSha256 } : {}),
216
- ...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
217
- ...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
218
- ...(sourceRel ? { sourceRel } : {}),
219
- };
220
- }
230
+ if (r.status in counts)
231
+ counts[r.status]++;
221
232
  }
222
- await saveState({ ...state, synced: nextSynced });
223
233
  const failed = counts.conflict + counts.error;
224
234
  const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
225
235
  (counts.conflict ? `, conflicts ${counts.conflict}` : '') +
@@ -248,6 +258,38 @@ export async function runPushWith(config, state, opts = {}) {
248
258
  process.exitCode = 1;
249
259
  return { ...counts, imported: matched.entries.length };
250
260
  }
261
+ async function commitPushResults(opts) {
262
+ const { batchResults, candidates, sourceByKey, shaByKey, fpByKey, outDir } = opts;
263
+ const failedKeys = new Set(batchResults.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
264
+ const batchKeys = new Set(batchResults.map((r) => r.key));
265
+ const fingerprinted = applyDiskFingerprints(opts.synced, candidates.filter((c) => batchKeys.has(c.key) && !failedKeys.has(c.key)));
266
+ const nextSynced = { ...fingerprinted.next };
267
+ for (const r of batchResults) {
268
+ const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
269
+ const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
270
+ if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
271
+ if (!sourceRel)
272
+ await renameLocalSprite(outDir, r.key, stateKey);
273
+ }
274
+ if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
275
+ if (stateKey !== r.key)
276
+ delete nextSynced[r.key];
277
+ const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
278
+ const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
279
+ nextSynced[stateKey] = {
280
+ assetId: r.assetId,
281
+ layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
282
+ sha256: r.sha256,
283
+ layers: 1,
284
+ ...(diskSha256 ? { diskSha256 } : {}),
285
+ ...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
286
+ ...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
287
+ ...(sourceRel ? { sourceRel } : {}),
288
+ };
289
+ }
290
+ }
291
+ return nextSynced;
292
+ }
251
293
  /** Persist disk sha256 onto existing synced rows so the next scan can skip. */
252
294
  async function persistDiskFingerprints(state, candidates) {
253
295
  const { next, changed } = applyDiskFingerprints(state.synced ?? {}, candidates);
@@ -19,7 +19,7 @@ import { cmd } from '../util/invoke.js';
19
19
  */
20
20
  export async function startCommand(opts = {}) {
21
21
  console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
22
- console.log(kleur.dim(' This links your game project. Sprites are indexed locally; nothing is imported until you connect a working set.'));
22
+ console.log(kleur.dim(' This links your game project and syncs sprites both ways.'));
23
23
  console.log();
24
24
  const kind = await detectProjectKind();
25
25
  // Engine projects (including Unity UPM packages) have no package.json
@@ -109,8 +109,7 @@ export async function startCommand(opts = {}) {
109
109
  console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
110
110
  }
111
111
  console.log();
112
- console.log(kleur.dim(` Find sprites with \`${cmd('search')} hero\`, then \`${cmd('connect')} 'assets/Sprites/**'\` to add a working set.`));
113
- console.log(kleur.dim(' Connected sprites write back to their original game path. New MagicPixel art lands in outDir.'));
112
+ console.log(kleur.dim(' Game sprites write back to their original path. New MagicPixel art lands in outDir.'));
114
113
  console.log();
115
114
  if (await hasDevScript()) {
116
115
  const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
@@ -1,10 +1,10 @@
1
1
  import kleur from 'kleur';
2
2
  import ora from 'ora';
3
- import { createHash } from 'node:crypto';
4
3
  import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
5
4
  import { existsSync } from 'node:fs';
6
5
  import { dirname, relative, resolve } from 'node:path';
7
6
  import { loadConfig, loadState, saveState } from '../config.js';
7
+ import { ensureEngineConnect } from '../util/engineConnect.js';
8
8
  import { fetchAllManifest, fetchAssetBytes, ApiError, getLastProjectInfo } from '../api.js';
9
9
  import { fileSha256 } from '../util/hash.js';
10
10
  import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
@@ -13,9 +13,9 @@ import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitInd
13
13
  import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
14
14
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
15
15
  import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
16
- import { collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
16
+ import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
17
17
  import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
18
- import { filterUnityManifest } from '../util/unityFilter.js';
18
+ import { applyUnityPullPolicy, isWorkingSetEntry, workingSetPullKeys } from '../util/unityFilter.js';
19
19
  import { selectFullSyncOrphans } from '../util/prunePolicy.js';
20
20
  import { runTmpJanitor } from '../util/tmpJanitor.js';
21
21
  import { friendlyFsError } from '../util/errors.js';
@@ -24,6 +24,7 @@ import { formatBytes } from '../util/format.js';
24
24
  import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
25
25
  import { cmd } from '../util/invoke.js';
26
26
  import { formatWatchSpriteLine } from '../util/watchCopy.js';
27
+ import { shouldPullEntry } from '../util/pullDecision.js';
27
28
  import { runPush } from './push.js';
28
29
  export async function syncCommand(opts) {
29
30
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
@@ -32,10 +33,16 @@ export async function syncCommand(opts) {
32
33
  // never race a concurrent in-flight write. See util/tmpJanitor.ts.
33
34
  try {
34
35
  const cfg = await loadConfig();
35
- await runTmpJanitor(cfg.outDir);
36
+ try {
37
+ await runTmpJanitor(cfg.outDir);
38
+ }
39
+ catch {
40
+ /* janitor is best-effort — never let it block a sync */
41
+ }
42
+ await ensureEngineConnect(cfg, process.cwd(), !opts.dryRun);
36
43
  }
37
44
  catch {
38
- /* janitor is best-effort — never let it block a sync */
45
+ /* missing/invalid config is reported by runOnce / watchLoop */
39
46
  }
40
47
  if (opts.watch) {
41
48
  await watchLoop(opts);
@@ -105,6 +112,11 @@ async function watchLoop(opts) {
105
112
  // tick counts (90 / 300) which made the slowdown timing scale with intervalSec.
106
113
  const IDLE_SOFT_BACKOFF_SECONDS = 180;
107
114
  const IDLE_HARD_BACKOFF_SECONDS = 900;
115
+ // Re-walk the game tree on this cadence so new PNGs are ingested without
116
+ // listing thousands of folders on every 2s tick.
117
+ const GAME_INDEX_TTL_MS = 30_000;
118
+ let gameIndexCache = header.gameIndex;
119
+ let gameIndexAt = Date.now();
108
120
  // Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
109
121
  // tick exits immediately instead of waiting out the full backoff (which can
110
122
  // be 60s at the error ceiling). Initialised to a no-op so the first
@@ -119,9 +131,11 @@ async function watchLoop(opts) {
119
131
  stopping = true;
120
132
  wakeStop();
121
133
  process.stdout.write('\x1b[2K\r');
134
+ const sig = signal === 'SIGTERM' ? 'SIGTERM' : 'SIGINT';
122
135
  if (inFlight) {
123
- console.log(kleur.dim(`Still finishing this check… (${signal} again to stop now)`));
124
- process.once(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
136
+ const again = sig === 'SIGINT' ? 'Ctrl+C' : 'SIGTERM';
137
+ console.log(kleur.dim(`Still finishing this check… (${again} again to stop now)`));
138
+ process.once(sig, () => process.exit(sig === 'SIGINT' ? 130 : 143));
125
139
  }
126
140
  else {
127
141
  if (!opts.quiet)
@@ -143,7 +157,18 @@ async function watchLoop(opts) {
143
157
  };
144
158
  try {
145
159
  onStatus('Checking MagicPixel…');
146
- const r = await runOnce({ ...opts, watch: false }, { watchMode: true, onStatus, gameIndex: header.gameIndex });
160
+ const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
161
+ ? gameIndexCache
162
+ : undefined;
163
+ const r = await runOnce({ ...opts, watch: false }, {
164
+ watchMode: true,
165
+ onStatus,
166
+ gameIndex: reuseIndex,
167
+ onGameIndex: (idx) => {
168
+ gameIndexCache = idx;
169
+ gameIndexAt = Date.now();
170
+ },
171
+ });
147
172
  // Reset backoff on any successful tick. Resume message fires once when
148
173
  // we recover from an auth pause — `getApiKey()` re-reads
149
174
  // .magicpixel/credentials on every call, so a `magicpixel login` in
@@ -242,7 +267,6 @@ async function watchLoop(opts) {
242
267
  }
243
268
  }
244
269
  finally {
245
- header.gameIndex = undefined;
246
270
  inFlight = false;
247
271
  }
248
272
  };
@@ -371,8 +395,12 @@ async function runOnce(opts, runOpts = {}) {
371
395
  if (!spinner)
372
396
  onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
373
397
  let manifest;
398
+ // Capture before the Unity filter drops rows — lastSync must not jump past
399
+ // an editor save whose `unity` flag was omitted on this tick.
400
+ let observedUpdatedAt = null;
374
401
  try {
375
402
  manifest = await fetchAllManifest(config, since);
403
+ observedUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
376
404
  const projectInfo = getLastProjectInfo();
377
405
  const projectSuffix = projectInfo && verbose
378
406
  ? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
@@ -413,14 +441,19 @@ async function runOnce(opts, runOpts = {}) {
413
441
  onProgress: (p) => {
414
442
  const n = p.pngs.toLocaleString('en-US');
415
443
  const f = p.folders.toLocaleString('en-US');
416
- onStatus?.(p.folders > 0
417
- ? `Looking through your game sprites… ${n} sprites · ${f} folders`
418
- : `Looking through your game sprites… ${n}`);
444
+ onStatus?.(p.current
445
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders · ${p.current}`
446
+ : p.folders > 0
447
+ ? `Looking through your game sprites… ${n} sprites · ${f} folders`
448
+ : `Looking through your game sprites… ${n}`);
419
449
  },
420
450
  })
421
451
  : { files: [], capped: false });
452
+ if (!runOpts.gameIndex)
453
+ runOpts.onGameIndex?.(gameIndex);
422
454
  const connected = matchConnectGlobs(gameIndex, config.connect ?? []);
423
455
  const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
456
+ aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
424
457
  if (verbose && gameIndex.capped) {
425
458
  console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
426
459
  }
@@ -432,6 +465,7 @@ async function runOnce(opts, runOpts = {}) {
432
465
  (connected.capped ? ` of ${connected.total} matched` : '')));
433
466
  }
434
467
  const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
468
+ const workingSet = workingSetPullKeys(sourceByKey, state.synced);
435
469
  // Files whose artboard is no longer flagged for Unity. Deleted even in
436
470
  // incremental mode: un-checking the box in the editor must actually remove
437
471
  // the sprite from the game project, not leave a stale copy behind.
@@ -442,14 +476,25 @@ async function runOnce(opts, runOpts = {}) {
442
476
  // response must never delete art from someone's game project.
443
477
  const unknownFlagPaths = new Set();
444
478
  if (projectKind === 'Unity') {
445
- const filtered = filterUnityManifest(manifest, { syncAll: config.unitySyncAll });
479
+ const filtered = applyUnityPullPolicy(manifest, {
480
+ syncAll: config.unitySyncAll,
481
+ alwaysPull: (entry) => isWorkingSetEntry(entry, workingSet),
482
+ });
446
483
  if (filtered.unknown.length > 0) {
447
- 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).`));
448
- console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
484
+ const n = filtered.unknown.length;
485
+ const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a "Sync to Unity" flag — skipped (existing files kept).`;
486
+ if (verbose) {
487
+ console.log(kleur.yellow(`! ${msg}`));
488
+ console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
489
+ }
490
+ else {
491
+ onStatus?.(msg);
492
+ }
449
493
  }
450
494
  const unknownSet = new Set(filtered.unknown);
495
+ const pullSet = new Set(filtered.entries);
451
496
  for (const entry of manifest) {
452
- if (filtered.entries.includes(entry))
497
+ if (pullSet.has(entry))
453
498
  continue;
454
499
  const p = pathForKey(entry.key);
455
500
  if (unknownSet.has(entry)) {
@@ -480,7 +525,11 @@ async function runOnce(opts, runOpts = {}) {
480
525
  bytesSaved: 0,
481
526
  renamed: [],
482
527
  };
483
- await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode);
528
+ if (observedUpdatedAt) {
529
+ const lastSync = state.lastSync && state.lastSync > observedUpdatedAt ? state.lastSync : observedUpdatedAt;
530
+ await saveState({ ...state, lastSync });
531
+ }
532
+ await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
484
533
  return empty;
485
534
  }
486
535
  const skipped = manifest.length - filtered.entries.length;
@@ -524,10 +573,54 @@ async function runOnce(opts, runOpts = {}) {
524
573
  const toDownload = [];
525
574
  let bytesSaved = 0;
526
575
  let unchanged = 0;
576
+ const synced = state.synced ?? {};
577
+ const cloudShaByAssetId = new Map();
578
+ for (const s of Object.values(synced)) {
579
+ if (!s.assetId || !s.sha256)
580
+ continue;
581
+ const prev = cloudShaByAssetId.get(s.assetId);
582
+ if (prev && prev !== s.sha256)
583
+ cloudShaByAssetId.set(s.assetId, 'ambiguous');
584
+ else if (!prev)
585
+ cloudShaByAssetId.set(s.assetId, s.sha256);
586
+ }
587
+ const previousCloudSha = (entry) => {
588
+ const direct = synced[entry.key]?.sha256;
589
+ if (direct)
590
+ return direct;
591
+ for (const k of entry.previous_keys ?? []) {
592
+ const sha = synced[k]?.sha256;
593
+ if (sha)
594
+ return sha;
595
+ }
596
+ if (entry.asset_id) {
597
+ const sha = cloudShaByAssetId.get(entry.asset_id);
598
+ if (sha && sha !== 'ambiguous')
599
+ return sha;
600
+ }
601
+ return undefined;
602
+ };
527
603
  await Promise.all(manifest.map((entry) => diffLimit(async () => {
604
+ const previousCloudSha256 = previousCloudSha(entry);
605
+ const inWorkingSet = isWorkingSetEntry(entry, workingSet);
606
+ // Unchanged cloud composite: skip hashing the original PNG (it almost
607
+ // never matches) unless the file is gone and we need to restore it.
608
+ if (entry.sha256 && previousCloudSha256 === entry.sha256 && existsSync(pathFor(entry))) {
609
+ shaByEntryId.set(entry.id, null);
610
+ unchanged++;
611
+ if (entry.size_bytes)
612
+ bytesSaved += entry.size_bytes;
613
+ return;
614
+ }
528
615
  const localSha = await fileSha256(pathFor(entry));
529
616
  shaByEntryId.set(entry.id, localSha);
530
- if (entry.sha256 && localSha && entry.sha256 === localSha) {
617
+ const pull = shouldPullEntry({
618
+ cloudSha256: entry.sha256,
619
+ localSha256: localSha,
620
+ previousCloudSha256,
621
+ inWorkingSet,
622
+ });
623
+ if (!pull) {
531
624
  unchanged++;
532
625
  if (entry.size_bytes)
533
626
  bytesSaved += entry.size_bytes;
@@ -766,14 +859,9 @@ async function runOnce(opts, runOpts = {}) {
766
859
  result.bytesSaved += entry.size_bytes;
767
860
  }
768
861
  else {
769
- if (entry.sha256) {
770
- const actual = createHash('sha256').update(bytes).digest('hex');
771
- if (actual !== entry.sha256) {
772
- throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
773
- }
774
- }
775
- // Wrap any of mkdir/writeFile so the user sees a friendly
776
- // multi-line diagnostic instead of a raw `EACCES: permission denied`.
862
+ // Do not require bytes to match `entry.sha256`. Single-artboard
863
+ // downloads serve storage_path (so 2048/4096 Unity tiles do not
864
+ // 546 the isolate); that PNG can differ from a cached composite hash.
777
865
  //
778
866
  // NOTE: Asset PNGs deliberately use a direct writeFile rather than
779
867
  // atomicWrite. Vite's chokidar watcher only suppresses atomic-rename
@@ -976,7 +1064,7 @@ async function runOnce(opts, runOpts = {}) {
976
1064
  // rows whose `updated_at` lived in the gap between the manifest snapshot
977
1065
  // and the next poll (notably when only metadata changed — e.g. artboard
978
1066
  // renames — and the row updated between fetch start and save).
979
- const maxUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
1067
+ const maxUpdatedAt = observedUpdatedAt ?? maxIsoTimestamp(manifest.map((e) => e.updated_at));
980
1068
  if (maxUpdatedAt) {
981
1069
  // Take the later of: newest row we saw, or the prior cursor. Never
982
1070
  // rewind — an incremental sync that returned 0 rows must keep the
@@ -1048,16 +1136,16 @@ async function runOnce(opts, runOpts = {}) {
1048
1136
  printRenames(renamed, { withHints: true });
1049
1137
  }
1050
1138
  onStatus?.('Checking your game files…');
1051
- await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode);
1139
+ await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
1052
1140
  if (result.failed)
1053
1141
  process.exitCode = 1;
1054
1142
  return result;
1055
1143
  }
1056
- async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose) {
1144
+ async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
1057
1145
  if (pushEnabled === false)
1058
1146
  return null;
1059
1147
  try {
1060
- return await runPush({ quiet: !verbose, gameIndex });
1148
+ return await runPush({ quiet: !verbose, gameIndex, onStatus });
1061
1149
  }
1062
1150
  catch (e) {
1063
1151
  if (announceErrors) {
package/dist/index.js CHANGED
@@ -83,7 +83,7 @@ program
83
83
  .action(wrap("repair", async (opts) => repairCommand(opts)));
84
84
  program
85
85
  .command('sync')
86
- .description('Download changed assets from MagicPixel')
86
+ .description('Two-way sync: pull MagicPixel edits, push your game sprites')
87
87
  .option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
88
88
  .option('--dry-run', 'Print the plan without writing files')
89
89
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
@@ -101,7 +101,7 @@ program
101
101
  .action(wrap("push", async (opts) => pushCommand(opts)));
102
102
  program
103
103
  .command('connect <glob>')
104
- .description('Add a game-sprite glob to the working set and ingest matching PNGs')
104
+ .description('Limit which game folders sync (default is all sprites)')
105
105
  .addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
106
106
  .action(wrap("connect", async (glob) => connectCommand(glob)));
107
107
  program
@@ -0,0 +1,32 @@
1
+ import { saveConfig } from '../config.js';
2
+ import { detectProjectKind, isEngineKind } from './framework.js';
3
+ export const DEFAULT_ENGINE_CONNECT = ['**'];
4
+ const ALL_SPRITES_GLOBS = new Set(['**', '**/*', '**/**', '**/*.png', '**/**/*.png']);
5
+ export function isAllSpritesGlob(glob) {
6
+ return ALL_SPRITES_GLOBS.has(glob.trim());
7
+ }
8
+ /**
9
+ * Next `connect` list after the user adds a glob.
10
+ * A specific folder replaces a default `**` (narrowing). `**` restores all
11
+ * sprites. Other globs accumulate. Stray `**` left over from an earlier
12
+ * default is dropped when narrowing so OR-matching cannot keep everything.
13
+ */
14
+ export function nextConnectGlobs(current, pattern) {
15
+ if (isAllSpritesGlob(pattern))
16
+ return [...DEFAULT_ENGINE_CONNECT];
17
+ const withoutAll = current.filter((g) => !isAllSpritesGlob(g));
18
+ if (withoutAll.includes(pattern))
19
+ return withoutAll;
20
+ return [...withoutAll, pattern];
21
+ }
22
+ export async function ensureEngineConnect(config, cwd = process.cwd(), persist = true) {
23
+ if (config.connect.length > 0)
24
+ return config;
25
+ const kind = await detectProjectKind(cwd);
26
+ if (!isEngineKind(kind))
27
+ return config;
28
+ const next = { ...config, connect: [...DEFAULT_ENGINE_CONNECT] };
29
+ if (persist)
30
+ await saveConfig(next, cwd);
31
+ return next;
32
+ }
@@ -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 { readdir } from 'node:fs/promises';
7
+ import { opendir } 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';
@@ -36,6 +36,7 @@ export const GAME_SCAN_SKIP_HIDDEN = new Set([
36
36
  '.git',
37
37
  '.svn',
38
38
  '.hg',
39
+ '.godot',
39
40
  '.magicpixel',
40
41
  '.vs',
41
42
  '.vscode',
@@ -52,8 +53,12 @@ export const MAX_GAME_INDEX = 10_000;
52
53
  export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
53
54
  export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
54
55
  export function connectCapMessage(total, ingested) {
55
- return (`${total} PNGs match the working set — ingesting the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
56
- `Narrow the glob, or add another connect pattern for the rest.`);
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
+ /** True for a full Unity game project (not an embedded UPM package). */
60
+ function isUnityGameProject(cwd, assetRoot) {
61
+ return Boolean(assetRoot && existsSync(resolve(cwd, 'ProjectSettings')));
57
62
  }
58
63
  export function gameScanRoot(kind) {
59
64
  switch (kind) {
@@ -101,34 +106,69 @@ function toEntry(abs, cwdAbs, assetRoot) {
101
106
  function fmtCount(n, noun) {
102
107
  return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`;
103
108
  }
109
+ function shortPath(rel) {
110
+ const n = rel.replace(/\\/g, '/');
111
+ if (!n || n === '.')
112
+ return '';
113
+ return n.length <= 48 ? n : `…${n.slice(-47)}`;
114
+ }
104
115
  /** Spinner / status copy while walking the game tree. */
105
116
  export function countingSpritesText(progress = 0) {
106
117
  const pngs = typeof progress === 'number' ? progress : progress.pngs;
107
118
  const folders = typeof progress === 'number' ? 0 : progress.folders;
108
- if (pngs === 0 && folders === 0)
119
+ const current = typeof progress === 'number' ? undefined : progress.current;
120
+ if (pngs === 0 && folders === 0 && !current)
109
121
  return 'Counting sprites in your game…';
110
122
  const spriteBit = fmtCount(pngs, 'sprite');
123
+ const parts = [spriteBit];
111
124
  if (folders > 0)
112
- return `Counting sprites in your game… ${spriteBit} · ${fmtCount(folders, 'folder')}`;
113
- return `Counting sprites in your game… ${spriteBit}`;
125
+ parts.push(fmtCount(folders, 'folder'));
126
+ const where = current ? shortPath(current) : '';
127
+ if (where)
128
+ parts.push(where);
129
+ return `Counting sprites in your game… ${parts.join(' · ')}`;
114
130
  }
115
131
  function bindScanProgress(onProgress) {
116
132
  if (!onProgress)
117
133
  return () => { };
118
134
  let lastAt = 0;
119
- let lastPngs = -1;
120
- let lastFolders = -1;
121
- return (pngs, folders) => {
122
- if (pngs === lastPngs && folders === lastFolders)
123
- return;
135
+ let pending = null;
136
+ let timer = null;
137
+ const flush = (p) => {
138
+ pending = null;
139
+ lastAt = Date.now();
140
+ onProgress(p);
141
+ };
142
+ const visit = (pngs, folders, current) => {
143
+ const next = { pngs, folders, current };
124
144
  const now = Date.now();
125
- if (lastPngs < 0 || now - lastAt >= 80) {
126
- lastAt = now;
127
- lastPngs = pngs;
128
- lastFolders = folders;
129
- onProgress({ pngs, folders });
145
+ if (lastAt === 0 || now - lastAt >= 80) {
146
+ if (timer) {
147
+ clearTimeout(timer);
148
+ timer = null;
149
+ }
150
+ flush(next);
151
+ return;
152
+ }
153
+ pending = next;
154
+ if (!timer) {
155
+ timer = setTimeout(() => {
156
+ timer = null;
157
+ if (pending)
158
+ flush(pending);
159
+ }, 80 - (now - lastAt));
160
+ timer.unref();
161
+ }
162
+ };
163
+ visit.flush = () => {
164
+ if (timer) {
165
+ clearTimeout(timer);
166
+ timer = null;
130
167
  }
168
+ if (pending)
169
+ flush(pending);
131
170
  };
171
+ return visit;
132
172
  }
133
173
  function samePath(a, b) {
134
174
  return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
@@ -148,7 +188,12 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
148
188
  const stats = { folders: 0 };
149
189
  const io = createLimit(SCAN_WALK_CONCURRENCY);
150
190
  const report = bindScanProgress(opts.onProgress);
151
- await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, assetRoot);
191
+ // Full Unity games: walk Assets/ only. Listing the project root waits on
192
+ // Library/Packages/cloud placeholders and looks hung at "1 folder".
193
+ const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
194
+ const preferFirst = startAt === scanRoot ? assetRoot : null;
195
+ await walkPngs(startAt, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, preferFirst);
196
+ report.flush?.();
152
197
  opts.onProgress?.({ pngs: found.length, folders: stats.folders });
153
198
  found.sort((a, b) => a.localeCompare(b));
154
199
  const capped = found.length > MAX_GAME_INDEX;
@@ -178,58 +223,74 @@ export function searchGameIndex(index, query) {
178
223
  return [];
179
224
  return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
180
225
  }
181
- async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
182
- if (out.length >= limit)
183
- return;
184
- // Count the folder *before* readdir so a huge listing still shows movement.
185
- stats.folders++;
186
- onVisit(out.length, stats.folders);
187
- let entries;
226
+ function folderLabel(dir, root) {
227
+ return relative(root, dir).replace(/\\/g, '/') || '.';
228
+ }
229
+ /**
230
+ * List one directory. The limiter wraps *only* this listing so a parent does
231
+ * not hold a slot while waiting on children (that deadlocks wide Unity trees).
232
+ */
233
+ async function listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current) {
234
+ const preferred = [];
235
+ const other = [];
236
+ let dh;
188
237
  try {
189
- entries = await readdir(dir, { withFileTypes: true });
238
+ dh = await opendir(dir);
190
239
  }
191
240
  catch {
192
- return;
241
+ return { preferred, other };
193
242
  }
194
- entries.sort((a, b) => a.name.localeCompare(b.name));
195
- const preferred = [];
196
- const other = [];
197
- for (const ent of entries) {
198
- if (out.length >= limit)
199
- return;
200
- if (ent.isSymbolicLink())
201
- continue;
202
- const full = resolve(dir, ent.name);
203
- try {
204
- assertPathInsideRoot(full, root, 'scan');
205
- }
206
- catch {
207
- continue;
208
- }
209
- if (ent.isDirectory()) {
210
- if (shouldSkipDir(ent.name))
243
+ let lastBeat = Date.now();
244
+ try {
245
+ for await (const ent of dh) {
246
+ if (out.length >= limit)
247
+ break;
248
+ const now = Date.now();
249
+ if (now - lastBeat >= 80) {
250
+ lastBeat = now;
251
+ onVisit(out.length, stats.folders, current);
252
+ }
253
+ if (shouldSkipDir(ent.name) || ent.isSymbolicLink())
211
254
  continue;
212
- if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
255
+ const full = resolve(dir, ent.name);
256
+ try {
257
+ assertPathInsideRoot(full, root, 'scan');
258
+ }
259
+ catch {
213
260
  continue;
214
- if (preferFirst && samePath(full, preferFirst))
215
- preferred.push(full);
216
- else
217
- other.push(full);
218
- }
219
- else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
220
- out.push(full);
221
- onVisit(out.length, stats.folders);
261
+ }
262
+ if (ent.isDirectory()) {
263
+ if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
264
+ continue;
265
+ if (preferFirst && samePath(full, preferFirst))
266
+ preferred.push(full);
267
+ else
268
+ other.push(full);
269
+ }
270
+ else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
271
+ out.push(full);
272
+ onVisit(out.length, stats.folders, current);
273
+ }
222
274
  }
223
275
  }
276
+ catch {
277
+ /* unreadable mid-listing */
278
+ }
279
+ return { preferred, other };
280
+ }
281
+ async function walkPngs(dir, root, destNorm, out, limit, io, onVisit, stats, preferFirst) {
282
+ if (out.length >= limit)
283
+ return;
284
+ stats.folders++;
285
+ const current = folderLabel(dir, root);
286
+ onVisit(out.length, stats.folders, current);
287
+ const { preferred, other } = await io(() => listDir(dir, root, destNorm, out, limit, onVisit, stats, preferFirst, current));
224
288
  if (out.length >= limit)
225
289
  return;
226
- // Engine asset tree first (Unity `Assets/`) so sprite counts appear before
227
- // sibling folders like Builds/Editor. Nested walks keep preferFirst so a
228
- // misplaced `Assets` dir is still prioritized if we ever see it.
229
290
  for (const d of preferred) {
230
291
  await walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null);
231
292
  }
232
293
  if (other.length === 0 || out.length >= limit)
233
294
  return;
234
- await Promise.all(other.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null))));
295
+ await Promise.all(other.map((d) => walkPngs(d, root, destNorm, out, limit, io, onVisit, stats, null)));
235
296
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether a manifest entry should be written to disk.
3
+ *
4
+ * Connected game PNGs are usually *not* the same bytes as MagicPixel's
5
+ * re-encoded composite. Comparing those hashes would re-download the whole
6
+ * library on every catch-up and overwrite the originals. Pull only when the
7
+ * cloud composite actually changed since we last recorded it, or when there
8
+ * is no local file to protect.
9
+ */
10
+ export function shouldPullEntry(opts) {
11
+ const cloud = opts.cloudSha256 ?? null;
12
+ const local = opts.localSha256;
13
+ const prev = opts.previousCloudSha256;
14
+ // Missing on disk → restore from cloud (MagicPixel-only art and deletions).
15
+ if (!local)
16
+ return true;
17
+ if (cloud && cloud === local)
18
+ return false;
19
+ // Cloud composite unchanged since last pull/push — keep the original PNG.
20
+ if (cloud && prev && cloud === prev)
21
+ return false;
22
+ if (opts.inWorkingSet) {
23
+ // Never recorded a cloud hash: disk is the source of truth (connect/ingest).
24
+ if (!prev)
25
+ return false;
26
+ // Editor save often nulls the cached hash; treat that as a cloud change.
27
+ return true;
28
+ }
29
+ return true;
30
+ }
@@ -37,6 +37,37 @@ export function collectSourceRelMap(connected, synced) {
37
37
  }
38
38
  return map;
39
39
  }
40
+ /**
41
+ * Ingest appends `-2`, `-3`, … to the *document* slug when the library already
42
+ * has that name. The live connect index still uses the unsuffixed key, so a
43
+ * catch-up pull would treat `ground-ice-snow-4/…` as new MagicPixel art and
44
+ * dump thousands of copies into outDir. Alias those keys onto the original
45
+ * PNG when the unsuffixed key is already in the working set.
46
+ */
47
+ export function stripDocCollisionSuffix(key) {
48
+ const parts = key.split('/');
49
+ if (parts.length < 2)
50
+ return key;
51
+ const docIdx = parts.length - 2;
52
+ const stripped = parts[docIdx].replace(/-(\d+)$/, '');
53
+ if (stripped === parts[docIdx])
54
+ return key;
55
+ const next = parts.slice();
56
+ next[docIdx] = stripped;
57
+ return next.join('/');
58
+ }
59
+ export function aliasCollisionKeys(sourceByKey, manifestKeys) {
60
+ for (const key of manifestKeys) {
61
+ if (sourceByKey.has(key))
62
+ continue;
63
+ const base = stripDocCollisionSuffix(key);
64
+ if (base === key)
65
+ continue;
66
+ const rel = sourceByKey.get(base);
67
+ if (rel)
68
+ sourceByKey.set(key, rel);
69
+ }
70
+ }
40
71
  /**
41
72
  * When the cloud assigned a different composite key than the index key,
42
73
  * push under the cloud key so we update instead of adopting a duplicate.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-artboard Unity sync opt-in (strict).
2
+ * Per-artboard Unity sync opt-in (strict), with a working-set override.
3
3
  *
4
4
  * In the editor, each artboard has a "Sync to Unity" checkbox (and library
5
5
  * folders have a folder-level switch that cascades). A project may hold 100
@@ -9,8 +9,14 @@
9
9
  * Strict opt-in: `unity === true` syncs, anything else does not. A missing
10
10
  * flag means the server didn't say (older edge deploy, or a budget-starved
11
11
  * manifest backfill) — those entries are excluded and reported via
12
- * `unknownFlags` so the caller can warn instead of silently syncing the whole
12
+ * `unknown` so the caller can warn instead of silently syncing the whole
13
13
  * library into someone's game project.
14
+ *
15
+ * Exception: sprites already in the game (connect working set / previously
16
+ * synced keys) always pull. An editor save nulls `artboard_index` and the
17
+ * next manifest often omits `unity` (or rebuilds it as false because the
18
+ * layer JSON dropped `syncToUnity`). Those saves must still write back to
19
+ * the original PNG.
14
20
  */
15
21
  export function filterUnityManifest(manifest, opts = {}) {
16
22
  if (opts.syncAll)
@@ -23,3 +29,49 @@ export function filterUnityManifest(manifest, opts = {}) {
23
29
  unknown,
24
30
  };
25
31
  }
32
+ /** Keys we already write to on disk — connect globs plus those sprites' cloud aliases. */
33
+ export function workingSetPullKeys(sourceByKey, synced) {
34
+ const keys = new Set(sourceByKey.keys());
35
+ const assetIds = new Set();
36
+ if (synced) {
37
+ for (const [key, sprite] of Object.entries(synced)) {
38
+ if (!keys.has(key))
39
+ continue;
40
+ if (sprite.assetId)
41
+ assetIds.add(sprite.assetId);
42
+ }
43
+ }
44
+ return { keys, assetIds };
45
+ }
46
+ export function isWorkingSetEntry(entry, pull) {
47
+ if (entry.key && pull.keys.has(entry.key))
48
+ return true;
49
+ if (entry.previous_keys?.some((k) => pull.keys.has(k)))
50
+ return true;
51
+ if (entry.asset_id && pull.assetIds.has(entry.asset_id)) {
52
+ // Same document as a connected sprite. Post-save fallback (flag omitted)
53
+ // must still pull; explicitly unflagged sibling artboards must not dump
54
+ // into outDir.
55
+ return entry.unity !== false;
56
+ }
57
+ return false;
58
+ }
59
+ /**
60
+ * Flagged artboards, plus any working-set sprite whose Unity flag is missing
61
+ * or false after an editor save / index rebuild.
62
+ */
63
+ export function applyUnityPullPolicy(manifest, opts = {}) {
64
+ const filtered = filterUnityManifest(manifest, { syncAll: opts.syncAll });
65
+ if (opts.syncAll || !opts.alwaysPull)
66
+ return filtered;
67
+ const already = new Set(filtered.entries);
68
+ const extra = manifest.filter((e) => !already.has(e) && opts.alwaysPull(e));
69
+ if (extra.length === 0)
70
+ return filtered;
71
+ const extraSet = new Set(extra);
72
+ return {
73
+ entries: filtered.entries.concat(extra),
74
+ unknown: filtered.unknown.filter((e) => !extraSet.has(e)),
75
+ noneFlagged: false,
76
+ };
77
+ }
@@ -16,6 +16,6 @@ export function formatWatchSpriteLine(c) {
16
16
  return ` Sprites: ${fmt(set)} in your game · ${fmt(pulled)} last pulled from MagicPixel`;
17
17
  }
18
18
  if (set > 0)
19
- return ` Sprites: ${fmt(set)} in your working set`;
19
+ return ` Sprites: ${fmt(set)} in your game`;
20
20
  return ` Sprites: ${fmt(pulled)}`;
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {