@magicpixelart/cli 0.5.7 → 0.5.9

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,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.9] — 2026-08-26
11
+
12
+ ### Security
13
+
14
+ - **Write-back ignores tampered `state.json`.** Sync/push resolve game paths
15
+ from the live connect index only. `sourceRel` must be a relative `.png`
16
+ with no `..` / absolute segments; connect globs cannot contain `..`. Pull
17
+ and push refuse to follow symlinks, and prune still cannot delete files
18
+ outside `outDir`. Cloud composite keys that still point at an indexed PNG
19
+ are aliased onto that file so a slug collision does not write a second copy
20
+ under `outDir` or re-adopt a duplicate.
21
+
22
+ ### Changed
23
+
24
+ - **Working-set ingest cap is 10,000** (same as the game index), up from 500.
25
+ Hitting the cap now prints the real match count (`1847 PNGs match — ingesting
26
+ the first 10000`) on `connect`, `push`, and one-shot `sync`.
27
+
28
+ - **Push skips unchanged game files.** Ingest's cloud composite sha is not the
29
+ original PNG. `connect` / `push` / `sync --watch` remember the on-disk sha256
30
+ (and mtime/size) and skip re-uploading until the game file actually changes —
31
+ so a second `connect '**'` resumes leftover files instead of rewriting the
32
+ whole working set. Hashing is parallel and cached by mtime+size. The game-tree
33
+ walk reads directories in parallel.
34
+
35
+ - **Working set instead of bulk import.** `start` / `sync` / `push` no longer
36
+ copy every game PNG into the MagicPixel library. The CLI indexes sprites
37
+ locally; `magicpixel search <query>` finds them and `magicpixel connect
38
+ '<glob>'` adds a working set (`connect` in `magicpixel.json`). Sync writes
39
+ those sprites back to the original game path. New MagicPixel-only art still
40
+ lands in `outDir`.
41
+
42
+ ### Fixed
43
+
44
+ - **First-run import survived a 546.** A persistent manifest-worker recycle
45
+ used to abort `start` / `sync` before local sprites were pushed. Pull still
46
+ retries 546s for ~30s; if it still fails, `push` runs anyway (ingest is a
47
+ different host) and `start` still prints watch instructions.
48
+
49
+ - **Unity folders appear under Connected.** Game-tree PNGs in the working set
50
+ adopt as folder/doc/artboard (`sprites/hero/hero`) so they land in Connected,
51
+ not Files. Reused library folders get the Sync to Unity flag. Adopting an
52
+ artboard that already matches on disk is a no-op instead of a wall of
53
+ `cloud-changed` conflicts.
54
+
55
+ - **Unity packages and hidden folders.** `start` treated a UPM package
56
+ (`Editor`/`Runtime`/`assets`, `.meta` files, no `package.json`) as "not a
57
+ project" and asked for JavaScript. Those layouts detect as Unity. The game
58
+ index walks the whole package (not only `Assets/`), including hidden content
59
+ dirs like `.SpineRaw_*`, and uses the on-disk `assets`/`Assets` casing for
60
+ `outDir`. `.git` / `.magicpixel` / Library / Packages / nested `MagicPixel`
61
+ folders stay skipped. A generic Node `assets/` folder without Unity `.meta`
62
+ files is not treated as a Unity project.
63
+
64
+ ## [0.5.8] — 2026-08-26
65
+
66
+ ### Added
67
+
68
+ - **Connect imports game sprites.** `sync` (and `sync --watch`) now runs `push`
69
+ automatically so connecting a game project imports existing sprites into the
70
+ MagicPixel library, flagged Sync to Unity so they appear under Connected.
71
+ `"push": false` in `magicpixel.json` opts out.
72
+ - **First-connect import.** On Unity/Godot/GameMaker, PNGs under `Assets/` /
73
+ `assets/` / `datafiles/` are copied into `outDir` if they aren't there yet
74
+ (skipping Library/Temp/Packages, never overwriting) and then adopted.
75
+ Root-level PNGs (`hero.png`) adopt as a single-artboard document instead of
76
+ being skipped.
77
+
10
78
  ## [0.5.7] — 2026-08-26
11
79
 
12
80
  ### Added
package/README.md CHANGED
@@ -16,13 +16,22 @@ Requires Node.js ≥ 18.
16
16
 
17
17
  ## Quickstart
18
18
 
19
- One command, inside your project folder (the one with `package.json`):
19
+ One command, inside your game project — a JavaScript app (`package.json`), a
20
+ Unity project or UPM package (`Assets`/`assets`, `Editor`/`Runtime`), Godot
21
+ (`project.godot`), or GameMaker (`*.yyp`):
20
22
 
21
23
  ```bash
22
24
  npx @magicpixelart/cli start
23
25
  ```
24
26
 
25
- 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 your sprites, and tells you how to keep them fresh.
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:
28
+
29
+ ```bash
30
+ npx @magicpixelart/cli search hero
31
+ npx @magicpixelart/cli connect 'assets/Sprites/**'
32
+ ```
33
+
34
+ Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`.
26
35
 
27
36
  When it finishes:
28
37
 
@@ -101,7 +110,9 @@ No bundler config, no runtime, no extra package.
101
110
  | `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
102
111
  | `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
103
112
  | `sync [...flags]` | Fetch manifest, diff against disk, download changed assets, prune orphans. |
104
- | `push [--dry-run] [--flatten]` | Upload local PNG edits (and new sprites) back to MagicPixel. |
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. |
115
+ | `search <query>` | Search indexed game PNGs (no network). |
105
116
  | `add <glob>` / `remove <glob>` | Manage `include` patterns. |
106
117
  | `list` | Print matching manifest as a table. |
107
118
  | `status` | Config, last sync, diff vs remote. |
@@ -135,9 +146,11 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
135
146
  | `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
136
147
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
137
148
  | `exclude` | `string[]` | `[]` | Globs to exclude. |
149
+ | `connect` | `string[]` | `[]` | Working-set globs of game PNGs to adopt into Connected. Empty = index only. |
138
150
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
139
151
  | `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
140
152
  | `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
153
+ | `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
141
154
  | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
142
155
 
143
156
  State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
package/dist/api.js CHANGED
@@ -84,9 +84,9 @@ export async function fetchManifestPage(opts) {
84
84
  url.searchParams.set('cursor', opts.cursor);
85
85
  if (opts.limit)
86
86
  url.searchParams.set('limit', String(opts.limit));
87
- // Reuse the same transient-failure policy as asset downloads: 3 attempts,
88
- // exponential backoff, honor Retry-After on 429/5xx. Network blips and
89
- // brief edge restarts otherwise blow up the whole sync mid-pagination.
87
+ // Reuse the same transient-failure policy as asset downloads, with extra
88
+ // 546 attempts so first-run can ride out a worker recycle without stalling
89
+ // every PNG download for ~30s.
90
90
  return retryTransient(`manifest`, async () => {
91
91
  const { headers, requestId } = buildHeaders();
92
92
  const res = await safeFetch(url.href, { headers });
@@ -134,25 +134,27 @@ export async function fetchManifestPage(opts) {
134
134
  }
135
135
  const bodyText = await res.text();
136
136
  throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'manifest'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
137
- });
137
+ }, { max546Attempts: 7 });
138
138
  }
139
+ const defaultRetrySleep = (ms) => new Promise((r) => setTimeout(r, ms));
139
140
  /**
140
- * Run `fn` up to 3 times, backing off on network errors, 429, and 5xx.
141
+ * Run `fn` with exponential backoff on network errors, 429, and 5xx.
141
142
  * Non-retryable ApiErrors (4xx other than 429) bubble immediately.
142
143
  *
143
144
  * - `ApiError.retryAfterMs` (populated from a `Retry-After` header on 429/5xx)
144
145
  * wins over the default exponential backoff for the *next* attempt.
145
146
  * - Network/transport failures are wrapped once with a friendly hint and the
146
147
  * request-id of the most recent attempt.
148
+ *
149
+ * The 3rd argument may be a sleep function (tests) or an options bag.
147
150
  */
148
- export async function retryTransient(context, fn) {
149
- // 5 attempts × (500ms, 1s, 2s, 4s) = ~7.5s of coverage. Enough to ride out
150
- // a Supabase edge worker recycle (546) or a brief 5xx without surfacing to
151
- // the user; short enough that a real outage still fails within one sync tick.
151
+ export async function retryTransient(context, fn, opts = {}) {
152
+ const sleep = (typeof opts === 'function' ? opts : opts.sleep) ?? defaultRetrySleep;
152
153
  const maxAttempts = 5;
154
+ const max546Attempts = typeof opts === 'function' ? maxAttempts : (opts.max546Attempts ?? maxAttempts);
153
155
  let lastErr = null;
154
156
  let nextDelayMs = 0;
155
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
157
+ for (let attempt = 1;; attempt++) {
156
158
  try {
157
159
  return await fn();
158
160
  }
@@ -165,17 +167,15 @@ export async function retryTransient(context, fn) {
165
167
  nextDelayMs = err.retryAfterMs ?? 0;
166
168
  }
167
169
  else {
168
- // Network/transport failure — wrap once with the existing hint.
169
- // Preserve the original error as `cause` so `--verbose` consumers
170
- // and Node's default printer can still surface the underlying stack.
171
170
  lastErr = new Error(`${context}: network error (${err.message}).\n` +
172
171
  ` Fix: check your internet connection and that the MagicPixel API is reachable.`, { cause: err });
173
172
  nextDelayMs = 0;
174
173
  }
175
- if (attempt < maxAttempts) {
176
- const backoffMs = 500 * 2 ** (attempt - 1);
177
- await new Promise((r) => setTimeout(r, Math.max(nextDelayMs, backoffMs)));
178
- }
174
+ const allowed = lastErr instanceof ApiError && lastErr.status === 546 ? max546Attempts : maxAttempts;
175
+ if (attempt >= allowed)
176
+ break;
177
+ const backoffMs = Math.min(500 * 2 ** (attempt - 1), 16_000);
178
+ await sleep(Math.max(nextDelayMs, backoffMs));
179
179
  }
180
180
  }
181
181
  throw lastErr ?? new Error(`${context}: unknown error`);
@@ -0,0 +1,49 @@
1
+ import kleur from 'kleur';
2
+ import { loadConfig, saveConfig } from '../config.js';
3
+ import { assertSafeGlob } from '../util/security.js';
4
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
+ import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
6
+ import { cmd } from '../util/invoke.js';
7
+ import { runPush } from './push.js';
8
+ /**
9
+ * Add a working-set glob and ingest matching game PNGs into Connected.
10
+ * Sync writes edits back to the original files — not a copy under outDir.
11
+ */
12
+ export async function connectCommand(glob) {
13
+ const pattern = assertSafeGlob(glob);
14
+ const config = await loadConfig();
15
+ const already = config.connect.includes(pattern);
16
+ if (!already) {
17
+ config.connect.push(pattern);
18
+ await saveConfig(config);
19
+ console.log(kleur.green(`✓ added connect pattern: ${pattern}`));
20
+ }
21
+ else {
22
+ console.log(kleur.dim(` connect pattern already present: ${pattern}`));
23
+ }
24
+ const kind = await detectProjectKind();
25
+ if (!isEngineKind(kind)) {
26
+ console.log(kleur.yellow(' This folder is not a Unity/Godot/GameMaker project — nothing to ingest from disk.'));
27
+ return;
28
+ }
29
+ const index = await indexGamePngs(kind, process.cwd(), config.outDir);
30
+ if (index.capped) {
31
+ console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
32
+ }
33
+ const matched = matchConnectGlobs(index, config.connect);
34
+ if (matched.capped) {
35
+ console.log(kleur.yellow(`! ${connectCapMessage(matched.total, matched.entries.length)}`));
36
+ }
37
+ if (matched.entries.length === 0) {
38
+ console.log(kleur.yellow(` No PNGs matched. Try \`${cmd('search')} <name>\` to see indexed paths.`));
39
+ return;
40
+ }
41
+ const n = matched.entries.length;
42
+ console.log(kleur.dim(` ${n} sprite${n === 1 ? '' : 's'} in working set (of ${index.files.length} indexed` +
43
+ (matched.capped ? `, ${matched.total} matched` : '') +
44
+ `).`));
45
+ if (n >= 200) {
46
+ console.log(kleur.dim(` Already-imported sprites are skipped; only new or changed files upload.`));
47
+ }
48
+ await runPush({ gameIndex: index });
49
+ }
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { resolve, relative } from 'node:path';
5
5
  import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
6
6
  import { describeKeySource, readKeyForDisplay } from '../util/credentials.js';
7
- import { detectProjectKind, hasPackageJson } from '../util/framework.js';
7
+ import { detectProjectKind, hasPackageJson, isEngineKind } from '../util/framework.js';
8
8
  import { safeFetch } from '../util/security.js';
9
9
  import { authHeaders } from '../util/authHeaders.js';
10
10
  import { CLI_VERSION } from '../version.js';
@@ -104,6 +104,7 @@ export async function collectDoctorReport(opts = {}) {
104
104
  emitIndex: config.emitIndex !== false,
105
105
  include: config.include,
106
106
  exclude: config.exclude,
107
+ connect: config.connect,
107
108
  endpoint: config.endpoint ?? null,
108
109
  }
109
110
  : { found: false, error: configErr ?? 'unknown' },
@@ -178,13 +179,20 @@ export function renderDoctorReport(r) {
178
179
  push(`Platform: ${r.platform}`);
179
180
  push(`cwd: ${r.cwd}`);
180
181
  push();
181
- push(`package.json: ${r.hasPackageJson ? 'found' : kleur.yellow('missing')}`);
182
+ const pkgMissing = r.hasPackageJson
183
+ ? 'found'
184
+ : isEngineKind(r.framework)
185
+ ? kleur.dim('n/a (engine)')
186
+ : kleur.yellow('missing');
187
+ push(`package.json: ${pkgMissing}`);
182
188
  push(`Framework detected: ${r.framework ?? kleur.dim('none')}`);
183
189
  if (r.config.found) {
184
190
  push(`magicpixel.json: found`);
185
191
  push(` outDir: ${r.config.outDir}`);
186
192
  push(` emitIndex: ${r.config.emitIndex ? 'true' : 'false'}`);
187
193
  push(` include: ${r.config.include.join(', ')}`);
194
+ if (r.config.connect.length)
195
+ push(` connect: ${r.config.connect.join(', ')}`);
188
196
  if (r.config.exclude.length)
189
197
  push(` exclude: ${r.config.exclude.join(', ')}`);
190
198
  if (r.config.endpoint)
@@ -2,14 +2,9 @@
2
2
  * `magicpixel push` — disk → MagicPixel (the Unity → MagicPixel half of
3
3
  * two-way sync).
4
4
  *
5
- * Walks `outDir`, hashes every PNG, and sends the ones that changed (or are
6
- * brand new on disk) to `POST /api/public/integration/ingest`:
7
- *
8
- * - known key + changed sha → write back into that artboard, using the sha
9
- * the CLI last pulled as the conflict baseline. If the cloud moved on the
10
- * server refuses with `conflict` and nothing is lost.
11
- * - unknown key → adopt: the server mirrors the disk folders into
12
- * the library and appends/creates the document.
5
+ * Walks `outDir` plus the `connect` working set (original game PNGs), hashes
6
+ * every PNG, and sends the ones that changed (or are brand new on disk) to
7
+ * `POST /api/public/integration/ingest`.
13
8
  */
14
9
  import kleur from 'kleur';
15
10
  import ora from 'ora';
@@ -18,38 +13,122 @@ import { dirname } from 'node:path';
18
13
  import { relative, resolve } from 'node:path';
19
14
  import { loadConfig, loadState, saveState } from '../config.js';
20
15
  import { PUSH_BATCH_SIZE, pushSprites } from '../api.js';
21
- import { fileSha256 } from '../util/hash.js';
16
+ import { hashFile } from '../util/hash.js';
17
+ import { createLimit } from '../util/limit.js';
22
18
  import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
23
- import { planPush } from '../util/pushPlan.js';
19
+ import { applyDiskFingerprints, indexSyncedBySourceRel, planPush, resolveSyncedKey } from '../util/pushPlan.js';
24
20
  import { cmd } from '../util/invoke.js';
25
- export async function pushCommand(opts) {
21
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
22
+ import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage } from '../util/gameScan.js';
23
+ import { collectSourceRelMap, remapAbsToCloudKeys } from '../util/syncPath.js';
24
+ import { assertSafeIoPath } from '../util/security.js';
25
+ /** Parallel hashing / path checks — enough to saturate disk, low enough to stay polite. */
26
+ const PUSH_SCAN_CONCURRENCY = 8;
27
+ /** Paths already verified this process; hashFile still refuses a later symlink swap. */
28
+ const verifiedIoPaths = new Set();
29
+ export async function pushCommand(opts = {}) {
30
+ await runPush(opts);
31
+ }
32
+ /**
33
+ * Scan local PNGs and upload new/changed ones into the MagicPixel library.
34
+ * Used by `magicpixel push` and by `sync` (so a committed `connect` working
35
+ * set is ingested without a second command).
36
+ */
37
+ export async function runPush(opts = {}) {
26
38
  const config = await loadConfig();
27
39
  const state = await loadState();
28
- const spinner = ora('Scanning local sprites…').start();
40
+ return runPushWith(config, state, opts);
41
+ }
42
+ export async function runPushWith(config, state, opts = {}) {
43
+ const quiet = opts.quiet === true;
44
+ const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
45
+ const spinner = quiet ? null : ora('Scanning local sprites…').start();
46
+ const kind = await detectProjectKind();
47
+ const index = opts.gameIndex
48
+ ?? (isEngineKind(kind)
49
+ ? await indexGamePngs(kind, process.cwd(), config.outDir)
50
+ : { files: [], capped: false });
51
+ const matched = matchConnectGlobs(index, config.connect ?? []);
52
+ const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
29
53
  const disk = await walkOutDirPngs(config.outDir);
54
+ const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
55
+ const entryByKey = new Map(matched.entries.map((e) => [e.key, e]));
56
+ for (const e of matched.entries) {
57
+ absByKey.set(e.key, e.abs);
58
+ }
59
+ remapAbsToCloudKeys(absByKey, matched.entries, state.synced);
60
+ const entryBySourceRel = new Map(matched.entries.map((e) => [e.sourceRel, e]));
61
+ for (const [k, rel] of sourceByKey) {
62
+ const e = entryBySourceRel.get(rel);
63
+ if (e)
64
+ entryByKey.set(k, e);
65
+ }
30
66
  const candidates = [];
31
- for (const asset of disk) {
32
- const sha = await fileSha256(asset.abs);
33
- if (!sha)
34
- continue;
67
+ const cwdAbs = resolve(process.cwd());
68
+ let skippedUnsafe = 0;
69
+ const synced = state.synced ?? {};
70
+ const byRel = indexSyncedBySourceRel(synced);
71
+ const scan = createLimit(PUSH_SCAN_CONCURRENCY);
72
+ await Promise.all([...absByKey.entries()].map(([key, abs]) => scan(async () => {
73
+ try {
74
+ if (!verifiedIoPaths.has(abs)) {
75
+ await assertSafeIoPath(abs, cwdAbs);
76
+ verifiedIoPaths.add(abs);
77
+ }
78
+ }
79
+ catch {
80
+ skippedUnsafe++;
81
+ return;
82
+ }
83
+ const entry = entryByKey.get(key);
84
+ const sourceRel = entry?.sourceRel ?? sourceByKey.get(key);
85
+ const stateKey = resolveSyncedKey({ key, sourceRel }, synced, byRel);
86
+ const known = stateKey ? synced[stateKey] : undefined;
87
+ const hint = known?.diskSha256 && known.diskMtimeMs != null && known.diskSize != null
88
+ ? { sha256: known.diskSha256, mtimeMs: known.diskMtimeMs, size: known.diskSize }
89
+ : undefined;
90
+ const hashed = await hashFile(abs, hint);
91
+ if (!hashed)
92
+ return;
93
+ const segments = entry
94
+ ? entry.adoptRel.replace(/\.png$/i, '').split('/')
95
+ : key.split('/');
35
96
  candidates.push({
36
- key: asset.key,
37
- segments: asset.key.split('/'),
38
- diskSha256: sha,
97
+ key,
98
+ segments,
99
+ diskSha256: hashed.sha256,
100
+ ...(sourceRel ? { sourceRel } : {}),
101
+ diskMtimeMs: hashed.mtimeMs,
102
+ diskSize: hashed.size,
39
103
  });
104
+ })));
105
+ const folderTreeKeys = new Set(matched.entries.map((e) => e.key));
106
+ for (const k of absByKey.keys()) {
107
+ if (sourceByKey.has(k))
108
+ folderTreeKeys.add(k);
40
109
  }
41
- const actions = planPush(candidates, state.synced, { flatten: opts.flatten });
110
+ const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys });
42
111
  const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
43
112
  const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
44
113
  const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
45
114
  const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
46
- spinner.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}`);
47
- if (legacy.length > 0) {
115
+ spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
116
+ (matched.entries.length ? ` · connected ${matched.entries.length}` : ''));
117
+ if (index.capped && !quiet) {
118
+ console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
119
+ }
120
+ if (matched.capped && !quiet) {
121
+ console.log(kleur.yellow(`! ${connectCapMessage(matched.total, matched.entries.length)}`));
122
+ }
123
+ if (skippedUnsafe > 0 && !quiet) {
124
+ console.log(kleur.yellow(`! skipped ${skippedUnsafe} path${skippedUnsafe === 1 ? '' : 's'} that were not regular files inside the project.`));
125
+ }
126
+ if (legacy.length > 0 && !quiet) {
48
127
  console.log();
49
128
  console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
50
129
  console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
51
130
  }
52
- if (flattenBlocked.length > 0) {
131
+ if (flattenBlocked.length > 0 && !quiet) {
53
132
  console.log();
54
133
  console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
55
134
  for (const a of flattenBlocked.slice(0, 10)) {
@@ -58,23 +137,31 @@ export async function pushCommand(opts) {
58
137
  console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
59
138
  }
60
139
  if (sendable.length === 0) {
61
- console.log(kleur.green('✓ nothing to push.'));
62
- return;
140
+ await persistDiskFingerprints(state, candidates);
141
+ if (!quiet)
142
+ console.log(kleur.green('✓ nothing to push.'));
143
+ return { ...empty, imported: matched.entries.length, unchanged: skipped };
63
144
  }
64
145
  if (opts.dryRun) {
65
- console.log();
66
- for (const a of sendable) {
67
- const verb = a.kind === 'update' ? kleur.yellow('~') : kleur.green('+');
68
- console.log(` ${verb} ${a.key}${a.kind === 'adopt' ? kleur.dim(' (new)') : ''}`);
146
+ if (!quiet) {
147
+ console.log();
148
+ for (const a of sendable) {
149
+ const verb = a.kind === 'update' ? kleur.yellow('~') : kleur.green('+');
150
+ console.log(` ${verb} ${a.key}${a.kind === 'adopt' ? kleur.dim(' (new)') : ''}`);
151
+ }
152
+ console.log(kleur.dim('--dry-run: nothing sent.'));
69
153
  }
70
- console.log(kleur.dim('--dry-run: nothing sent.'));
71
- return;
154
+ return { ...empty, imported: matched.entries.length };
72
155
  }
73
156
  const outRoot = resolve(process.cwd(), config.outDir);
74
157
  const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
158
+ const fpByKey = new Map(candidates.map((c) => [c.key, c]));
75
159
  const sprites = [];
76
160
  for (const a of sendable) {
77
- const abs = resolve(outRoot, `${a.key}.png`);
161
+ const abs = absByKey.get(a.key);
162
+ if (!abs)
163
+ continue;
164
+ await assertSafeIoPath(abs, cwdAbs);
78
165
  const pngBase64 = (await readFile(abs)).toString('base64');
79
166
  const diskSha256 = shaByKey.get(a.key);
80
167
  if (a.kind === 'update') {
@@ -92,37 +179,41 @@ export async function pushCommand(opts) {
92
179
  sprites.push({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
93
180
  }
94
181
  }
95
- const progress = ora(`Pushing 0/${sprites.length}…`).start();
182
+ const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
96
183
  const results = [];
97
184
  for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
98
185
  const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
99
186
  const batchResults = await pushSprites(batch);
100
187
  results.push(...batchResults);
101
- progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
188
+ if (progress)
189
+ progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
102
190
  }
103
191
  const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
104
- const nextSynced = { ...(state.synced ?? {}) };
192
+ const failedKeys = new Set(results.filter((r) => r.status === 'conflict' || r.status === 'error').map((r) => r.key));
193
+ const fingerprinted = applyDiskFingerprints(state.synced ?? {}, candidates.filter((c) => !failedKeys.has(c.key)));
194
+ const nextSynced = { ...fingerprinted.next };
105
195
  for (const r of results) {
106
196
  counts[r.status] = (counts[r.status] ?? 0) + 1;
107
- // The server may have stored the document under a different slug than the
108
- // disk folder (collision suffix, slugified name). Move the local files to
109
- // the key the manifest will report, or the next `sync` downloads a second
110
- // copy alongside this one.
111
197
  const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
198
+ const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
112
199
  if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
113
- await renameLocalSprite(config.outDir, r.key, stateKey);
200
+ if (!sourceRel)
201
+ await renameLocalSprite(config.outDir, r.key, stateKey);
114
202
  }
115
- // Store the sha the cloud now reports so the next `push` is a no-op and
116
- // the next `sync` doesn't see a phantom change.
117
203
  if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
118
204
  if (stateKey !== r.key)
119
205
  delete nextSynced[r.key];
206
+ const diskSha256 = shaByKey.get(r.key) ?? shaByKey.get(stateKey) ?? nextSynced[stateKey]?.diskSha256;
207
+ const fp = fpByKey.get(r.key) ?? fpByKey.get(stateKey);
120
208
  nextSynced[stateKey] = {
121
209
  assetId: r.assetId,
122
210
  layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
123
211
  sha256: r.sha256,
124
- // A push always leaves the artboard single-layer (the flat disk image).
125
212
  layers: 1,
213
+ ...(diskSha256 ? { diskSha256 } : {}),
214
+ ...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
215
+ ...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
216
+ ...(sourceRel ? { sourceRel } : {}),
126
217
  };
127
218
  }
128
219
  }
@@ -131,24 +222,36 @@ export async function pushCommand(opts) {
131
222
  const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
132
223
  (counts.conflict ? `, conflicts ${counts.conflict}` : '') +
133
224
  (counts.error ? `, errors ${counts.error}` : '');
134
- if (failed)
135
- progress.warn(`Push finished with issues. ${summary}`);
136
- else
137
- progress.succeed(`Pushed. ${summary}`);
138
- for (const r of results) {
139
- if (r.status === 'conflict') {
140
- console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
225
+ if (progress) {
226
+ if (failed)
227
+ progress.warn(`Push finished with issues. ${summary}`);
228
+ else
229
+ progress.succeed(`Pushed. ${summary}`);
230
+ }
231
+ if (!quiet) {
232
+ for (const r of results) {
233
+ if (r.status === 'conflict') {
234
+ console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
235
+ }
236
+ else if (r.status === 'error') {
237
+ console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
238
+ }
141
239
  }
142
- else if (r.status === 'error') {
143
- console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
240
+ if (counts.created > 0) {
241
+ console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
242
+ `(working set + ${relative(process.cwd(), outRoot)}). They appear in Connected as they import.`));
144
243
  }
145
244
  }
146
- if (counts.created > 0) {
147
- console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
148
- `(folders mirrored from ${relative(process.cwd(), outRoot)}).`));
149
- }
150
245
  if (failed)
151
246
  process.exitCode = 1;
247
+ return { ...counts, imported: matched.entries.length };
248
+ }
249
+ /** Persist disk sha256 onto existing synced rows so the next scan can skip. */
250
+ async function persistDiskFingerprints(state, candidates) {
251
+ const { next, changed } = applyDiskFingerprints(state.synced ?? {}, candidates);
252
+ if (!changed)
253
+ return;
254
+ await saveState({ ...state, synced: next });
152
255
  }
153
256
  function conflictHint(reason) {
154
257
  switch (reason) {
@@ -167,7 +270,7 @@ function conflictHint(reason) {
167
270
  /**
168
271
  * Move a pushed sprite (and its `.meta` sidecar) to the disk path matching the
169
272
  * key the cloud assigned. Best effort: a failed move only means the next sync
170
- * re-downloads the sprite under its cloud name.
273
+ * re-downloads the sprite under its cloud name. Never used for sourceRel files.
171
274
  */
172
275
  async function renameLocalSprite(outDir, fromKey, toKey) {
173
276
  const from = assetDiskPathFromKey(outDir, fromKey);
@@ -0,0 +1,45 @@
1
+ import kleur from 'kleur';
2
+ import { loadConfig } from '../config.js';
3
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
4
+ import { indexGamePngs, searchGameIndex, GAME_INDEX_CAP_HINT } from '../util/gameScan.js';
5
+ import { cmd } from '../util/invoke.js';
6
+ const PRINT_CAP = 50;
7
+ /**
8
+ * Substring search over the local game PNG index. No network.
9
+ */
10
+ export async function searchCommand(query) {
11
+ const q = query.trim();
12
+ if (!q) {
13
+ console.log(kleur.yellow(' Pass a search string, e.g. `magicpixel search hero`.'));
14
+ return;
15
+ }
16
+ const kind = await detectProjectKind();
17
+ if (!isEngineKind(kind)) {
18
+ console.log(kleur.yellow(' Game-sprite search is for Unity, Godot, and GameMaker projects.'));
19
+ return;
20
+ }
21
+ let outDir = '';
22
+ try {
23
+ outDir = (await loadConfig()).outDir;
24
+ }
25
+ catch {
26
+ // Config is optional for search — skip MagicPixel outDir when missing.
27
+ }
28
+ const index = await indexGamePngs(kind, process.cwd(), outDir);
29
+ if (index.capped) {
30
+ console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
31
+ }
32
+ const hits = searchGameIndex(index, q);
33
+ if (hits.length === 0) {
34
+ console.log(kleur.dim(` No indexed PNGs matching "${q}" (${index.files.length} scanned).`));
35
+ return;
36
+ }
37
+ const shown = hits.slice(0, PRINT_CAP);
38
+ console.log(kleur.dim(` ${hits.length} match${hits.length === 1 ? '' : 'es'} (of ${index.files.length} indexed). Connect with \`${cmd('connect')} '<glob>'\`.`));
39
+ for (const h of shown) {
40
+ console.log(` ${h.sourceRel} ${kleur.dim(`→ ${h.key}`)}`);
41
+ }
42
+ if (hits.length > shown.length) {
43
+ console.log(kleur.dim(` …and ${hits.length - shown.length} more. Narrow the query.`));
44
+ }
45
+ }