@magicpixelart/cli 0.5.8 → 0.5.10

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,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.10] — 2026-08-26
11
+
12
+ ### Changed
13
+
14
+ - **Watch header shows the working-set size**, not only the last successful
15
+ pull. Connecting 2,000+ game sprites no longer prints `Sprites: 469` when
16
+ `state.json` hasn't caught up yet (`2,147 in your game · 469 last pulled
17
+ from MagicPixel`).
18
+ - **Watch prints live status** while a tick is in flight (`Checking
19
+ MagicPixel…`, `Comparing N sprites…`, download progress, `Checking your
20
+ game files…`) instead of sitting silent on the header until the tick
21
+ finishes. Idle ticks say `Waiting for edits…`.
22
+
23
+ ## [0.5.9] — 2026-08-26
24
+
25
+ ### Security
26
+
27
+ - **Write-back ignores tampered `state.json`.** Sync/push resolve game paths
28
+ from the live connect index only. `sourceRel` must be a relative `.png`
29
+ with no `..` / absolute segments; connect globs cannot contain `..`. Pull
30
+ and push refuse to follow symlinks, and prune still cannot delete files
31
+ outside `outDir`. Cloud composite keys that still point at an indexed PNG
32
+ are aliased onto that file so a slug collision does not write a second copy
33
+ under `outDir` or re-adopt a duplicate.
34
+
35
+ ### Changed
36
+
37
+ - **Working-set ingest cap is 10,000** (same as the game index), up from 500.
38
+ Hitting the cap now prints the real match count (`1847 PNGs match — ingesting
39
+ the first 10000`) on `connect`, `push`, and one-shot `sync`.
40
+
41
+ - **Push skips unchanged game files.** Ingest's cloud composite sha is not the
42
+ original PNG. `connect` / `push` / `sync --watch` remember the on-disk sha256
43
+ (and mtime/size) and skip re-uploading until the game file actually changes —
44
+ so a second `connect '**'` resumes leftover files instead of rewriting the
45
+ whole working set. Hashing is parallel and cached by mtime+size. The game-tree
46
+ walk reads directories in parallel.
47
+
48
+ - **Working set instead of bulk import.** `start` / `sync` / `push` no longer
49
+ copy every game PNG into the MagicPixel library. The CLI indexes sprites
50
+ locally; `magicpixel search <query>` finds them and `magicpixel connect
51
+ '<glob>'` adds a working set (`connect` in `magicpixel.json`). Sync writes
52
+ those sprites back to the original game path. New MagicPixel-only art still
53
+ lands in `outDir`.
54
+
55
+ ### Fixed
56
+
57
+ - **First-run import survived a 546.** A persistent manifest-worker recycle
58
+ used to abort `start` / `sync` before local sprites were pushed. Pull still
59
+ retries 546s for ~30s; if it still fails, `push` runs anyway (ingest is a
60
+ different host) and `start` still prints watch instructions.
61
+
62
+ - **Unity folders appear under Connected.** Game-tree PNGs in the working set
63
+ adopt as folder/doc/artboard (`sprites/hero/hero`) so they land in Connected,
64
+ not Files. Reused library folders get the Sync to Unity flag. Adopting an
65
+ artboard that already matches on disk is a no-op instead of a wall of
66
+ `cloud-changed` conflicts.
67
+
68
+ - **Unity packages and hidden folders.** `start` treated a UPM package
69
+ (`Editor`/`Runtime`/`assets`, `.meta` files, no `package.json`) as "not a
70
+ project" and asked for JavaScript. Those layouts detect as Unity. The game
71
+ index walks the whole package (not only `Assets/`), including hidden content
72
+ dirs like `.SpineRaw_*`, and uses the on-disk `assets`/`Assets` casing for
73
+ `outDir`. `.git` / `.magicpixel` / Library / Packages / nested `MagicPixel`
74
+ folders stay skipped. A generic Node `assets/` folder without Unity `.meta`
75
+ files is not treated as a Unity project.
76
+
10
77
  ## [0.5.8] — 2026-08-26
11
78
 
12
79
  ### 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,10 +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. |
141
- | `push` | `boolean?` | `true` | Upload local PNG changes to MagicPixel on every `sync`. Set `false` for pull-only. |
153
+ | `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
142
154
  | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
143
155
 
144
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,19 +13,26 @@ 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
- import { detectProjectKind } from '../util/framework.js';
26
- import { importNearbyGamePngs } from '../util/gameScan.js';
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();
27
29
  export async function pushCommand(opts = {}) {
28
30
  await runPush(opts);
29
31
  }
30
32
  /**
31
33
  * Scan local PNGs and upload new/changed ones into the MagicPixel library.
32
- * Used by `magicpixel push` and by `sync` (so connect/watch imports game
33
- * sprites without a second command).
34
+ * Used by `magicpixel push` and by `sync` (so a committed `connect` working
35
+ * set is ingested without a second command).
34
36
  */
35
37
  export async function runPush(opts = {}) {
36
38
  const config = await loadConfig();
@@ -42,28 +44,85 @@ export async function runPushWith(config, state, opts = {}) {
42
44
  const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
43
45
  const spinner = quiet ? null : ora('Scanning local sprites…').start();
44
46
  const kind = await detectProjectKind();
45
- const scan = await importNearbyGamePngs(config.outDir, kind);
46
- const imported = scan.copied;
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);
47
53
  const disk = await walkOutDirPngs(config.outDir);
48
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
+ }
49
66
  const candidates = [];
50
- for (const asset of disk) {
51
- const sha = await fileSha256(asset.abs);
52
- if (!sha)
53
- 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('/');
54
96
  candidates.push({
55
- key: asset.key,
56
- segments: asset.key.split('/'),
57
- diskSha256: sha,
97
+ key,
98
+ segments,
99
+ diskSha256: hashed.sha256,
100
+ ...(sourceRel ? { sourceRel } : {}),
101
+ diskMtimeMs: hashed.mtimeMs,
102
+ diskSize: hashed.size,
58
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);
59
109
  }
60
- const actions = planPush(candidates, state.synced, { flatten: opts.flatten });
110
+ const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys });
61
111
  const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
62
112
  const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
63
113
  const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
64
114
  const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
65
115
  spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
66
- (imported ? ` · imported ${imported}` : ''));
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
+ }
67
126
  if (legacy.length > 0 && !quiet) {
68
127
  console.log();
69
128
  console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
@@ -78,9 +137,10 @@ export async function runPushWith(config, state, opts = {}) {
78
137
  console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
79
138
  }
80
139
  if (sendable.length === 0) {
140
+ await persistDiskFingerprints(state, candidates);
81
141
  if (!quiet)
82
142
  console.log(kleur.green('✓ nothing to push.'));
83
- return { ...empty, imported, unchanged: skipped };
143
+ return { ...empty, imported: matched.entries.length, unchanged: skipped };
84
144
  }
85
145
  if (opts.dryRun) {
86
146
  if (!quiet) {
@@ -91,15 +151,17 @@ export async function runPushWith(config, state, opts = {}) {
91
151
  }
92
152
  console.log(kleur.dim('--dry-run: nothing sent.'));
93
153
  }
94
- return { ...empty, imported };
154
+ return { ...empty, imported: matched.entries.length };
95
155
  }
96
156
  const outRoot = resolve(process.cwd(), config.outDir);
97
157
  const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
158
+ const fpByKey = new Map(candidates.map((c) => [c.key, c]));
98
159
  const sprites = [];
99
160
  for (const a of sendable) {
100
161
  const abs = absByKey.get(a.key);
101
162
  if (!abs)
102
163
  continue;
164
+ await assertSafeIoPath(abs, cwdAbs);
103
165
  const pngBase64 = (await readFile(abs)).toString('base64');
104
166
  const diskSha256 = shaByKey.get(a.key);
105
167
  if (a.kind === 'update') {
@@ -127,21 +189,31 @@ export async function runPushWith(config, state, opts = {}) {
127
189
  progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
128
190
  }
129
191
  const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
130
- 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 };
131
195
  for (const r of results) {
132
196
  counts[r.status] = (counts[r.status] ?? 0) + 1;
133
197
  const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
198
+ const sourceRel = sourceByKey.get(r.key) ?? sourceByKey.get(stateKey);
134
199
  if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
135
- await renameLocalSprite(config.outDir, r.key, stateKey);
200
+ if (!sourceRel)
201
+ await renameLocalSprite(config.outDir, r.key, stateKey);
136
202
  }
137
203
  if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
138
204
  if (stateKey !== r.key)
139
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);
140
208
  nextSynced[stateKey] = {
141
209
  assetId: r.assetId,
142
210
  layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
143
211
  sha256: r.sha256,
144
212
  layers: 1,
213
+ ...(diskSha256 ? { diskSha256 } : {}),
214
+ ...(fp?.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
215
+ ...(fp?.diskSize != null ? { diskSize: fp.diskSize } : {}),
216
+ ...(sourceRel ? { sourceRel } : {}),
145
217
  };
146
218
  }
147
219
  }
@@ -167,12 +239,19 @@ export async function runPushWith(config, state, opts = {}) {
167
239
  }
168
240
  if (counts.created > 0) {
169
241
  console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
170
- `(folders mirrored from ${relative(process.cwd(), outRoot)}). Refresh the library to see them.`));
242
+ `(working set + ${relative(process.cwd(), outRoot)}). They appear in Connected as they import.`));
171
243
  }
172
244
  }
173
245
  if (failed)
174
246
  process.exitCode = 1;
175
- return { ...counts, imported };
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 });
176
255
  }
177
256
  function conflictHint(reason) {
178
257
  switch (reason) {
@@ -191,7 +270,7 @@ function conflictHint(reason) {
191
270
  /**
192
271
  * Move a pushed sprite (and its `.meta` sidecar) to the disk path matching the
193
272
  * key the cloud assigned. Best effort: a failed move only means the next sync
194
- * re-downloads the sprite under its cloud name.
273
+ * re-downloads the sprite under its cloud name. Never used for sourceRel files.
195
274
  */
196
275
  async function renameLocalSprite(outDir, fromKey, toKey) {
197
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
+ }
@@ -19,15 +19,19 @@ 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 connects your game project so existing sprites show up in MagicPixel.'));
22
+ console.log(kleur.dim(' This links your game project. Sprites are indexed locally; nothing is imported until you connect a working set.'));
23
23
  console.log();
24
24
  const kind = await detectProjectKind();
25
- // Engine projects have no package.json requirement. JS projects still need one.
25
+ // Engine projects (including Unity UPM packages) have no package.json
26
+ // requirement. JS projects still need one.
26
27
  if (!hasPackageJson() && !isEngineKind(kind)) {
27
- console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
28
- ' Run this inside your project folder — the one with package.json.'));
28
+ console.log(kleur.yellow(' MagicPixel needs a game project to sync into.\n' +
29
+ ' Run this inside your Unity, Godot, GameMaker, or JavaScript folder\n' +
30
+ ' (Unity package, project with Assets/, or a folder with package.json).'));
29
31
  return;
30
32
  }
33
+ if (kind)
34
+ console.log(kleur.dim(` Detected: ${kind}`));
31
35
  // 2. Run init non-interactively unless config already exists AND is valid.
32
36
  // A broken `magicpixel.json` (hand-edited / truncated) used to slip past
33
37
  // the existsSync check here and then explode deep inside `syncCommand`.
@@ -68,10 +72,21 @@ export async function startCommand(opts = {}) {
68
72
  // 5. First sync. syncCommand sets `process.exitCode = 1` on download
69
73
  // failures without throwing — snapshot around the call so we don't
70
74
  // print a misleading green "you're set up" over a half-failed run.
75
+ // Manifest 546s throw after still importing local sprites; catch so
76
+ // the watch instructions below aren't swallowed.
71
77
  console.log();
72
- console.log(kleur.bold('Step: import + sync'));
78
+ console.log(kleur.bold('Step: sync'));
73
79
  const exitBefore = process.exitCode ?? 0;
74
- await syncCommand({ full: true });
80
+ try {
81
+ await syncCommand({ full: true });
82
+ }
83
+ catch (e) {
84
+ const err = e;
85
+ console.error(kleur.red(err.message ?? String(e)));
86
+ process.exitCode = 1;
87
+ const { reportCliError } = await import('../util/telemetry.js');
88
+ await reportCliError(err, { command: 'start' });
89
+ }
75
90
  const firstSyncFailed = (process.exitCode ?? 0) > exitBefore;
76
91
  // 6. Tell the user how to run the watch loop. If `init` couldn't patch
77
92
  // package.json (non-standard layout, write-protected, etc.) the
@@ -94,6 +109,9 @@ export async function startCommand(opts = {}) {
94
109
  console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
95
110
  }
96
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.'));
114
+ console.log();
97
115
  if (await hasDevScript()) {
98
116
  const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
99
117
  console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));