@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 +67 -0
- package/README.md +16 -4
- package/dist/api.js +17 -17
- package/dist/commands/connect.js +49 -0
- package/dist/commands/doctor.js +10 -2
- package/dist/commands/push.js +111 -32
- package/dist/commands/search.js +45 -0
- package/dist/commands/start.js +24 -6
- package/dist/commands/sync.js +168 -47
- package/dist/config.js +30 -2
- package/dist/index.js +12 -0
- package/dist/util/framework.js +57 -5
- package/dist/util/gameScan.js +117 -59
- package/dist/util/globMatch.js +50 -0
- package/dist/util/hash.js +37 -5
- package/dist/util/pushPlan.js +108 -4
- package/dist/util/security.js +91 -1
- package/dist/util/syncPath.js +76 -0
- package/dist/util/watchCopy.js +21 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/commands/push.js
CHANGED
|
@@ -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
|
|
6
|
-
* brand new on disk) to
|
|
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 {
|
|
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 {
|
|
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
|
|
33
|
-
*
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
56
|
-
segments
|
|
57
|
-
diskSha256:
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
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
|
-
`(
|
|
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
|
+
}
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
28
|
-
' Run this inside your
|
|
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:
|
|
78
|
+
console.log(kleur.bold('Step: sync'));
|
|
73
79
|
const exitBefore = process.exitCode ?? 0;
|
|
74
|
-
|
|
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'));
|