@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 +68 -0
- package/README.md +16 -3
- 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 +160 -57
- package/dist/commands/search.js +45 -0
- package/dist/commands/start.js +28 -15
- package/dist/commands/sync.js +109 -34
- package/dist/config.js +31 -2
- package/dist/index.js +12 -0
- package/dist/util/framework.js +57 -5
- package/dist/util/gameScan.js +177 -0
- package/dist/util/globMatch.js +50 -0
- package/dist/util/hash.js +37 -5
- package/dist/util/pushPlan.js +115 -5
- package/dist/util/security.js +91 -1
- package/dist/util/syncPath.js +76 -0
- package/package.json +1 -1
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
|
|
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,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
|
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
segments
|
|
38
|
-
diskSha256:
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
console.log(
|
|
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
|
+
}
|