@magicpixelart/cli 0.5.28 → 0.5.31
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 +30 -0
- package/README.md +12 -1
- package/dist/api.js +27 -1
- package/dist/commands/push.js +54 -9
- package/dist/commands/resync.js +123 -0
- package/dist/commands/start.js +67 -4
- package/dist/commands/sync.js +102 -4
- package/dist/config.js +24 -0
- package/dist/index.js +20 -2
- package/dist/util/pushPlan.js +4 -2
- package/dist/util/syncScope.js +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,38 @@ All notable changes to `@magicpixelart/cli` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.5.31] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `sync --only <folder>` (repeatable; or `"syncOnly": [...]` in magicpixel.json) limits push, pull and pruning to those game folders — e.g. `sync --watch --only Runtime/Sprites/Entities/decorations`. Everything else is left untouched. Changing the scope triggers one full pass.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `sync <folder>` now errors and suggests `--only` instead of silently syncing the whole project.
|
|
15
|
+
- With `--only`, the legacy-folder sweep never touches folders outside the scope, and `--watch` on a folder that matches nothing exits once with a clear message instead of erroring every tick.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Syncing an already-synced game folder to a new account/key/project no longer skips every sprite as "unchanged": the upload record now remembers its project and resets when the project changes (game files untouched).
|
|
19
|
+
|
|
20
|
+
## [0.5.30] - 2026-10-01
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `start` now checks the saved or environment key before syncing; a rejected saved key re-opens the key prompt instead of failing every request with 401.
|
|
24
|
+
- `start` asks "all sprites, or one folder?" on game projects; `--folder <path>` answers it up front.
|
|
25
|
+
- Rejected-key errors point to `login` first.
|
|
26
|
+
|
|
8
27
|
## [Unreleased]
|
|
9
28
|
|
|
29
|
+
## [0.5.29]
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Ship `magicpixel resync <folder>`: the npm 0.5.28 build was published before the command landed.
|
|
33
|
+
|
|
34
|
+
## [0.5.28]
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `magicpixel resync <folder>` (`--dry-run`, `--yes`): make one MagicPixel folder match the local game folder. Local files overwrite their MagicPixel copies (size follows the file); files missing locally move to Trash. Nothing is trashed if any upload fails or the game scan was capped.
|
|
38
|
+
- `sync --watch` runs "Resync from game…" requests queued from the library folder menu.
|
|
39
|
+
|
|
10
40
|
## [0.5.27] — 2026-09-21
|
|
11
41
|
|
|
12
42
|
- Fixed Sync presses that changed only an artboard selection being skipped as
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Then leave this running while you work:
|
|
|
32
32
|
npx @magicpixelart/cli sync --watch
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`.
|
|
35
|
+
After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`. To watch just one folder of a huge project: `npx @magicpixelart/cli sync --watch --only Assets/Sprites/Enemies`.
|
|
36
36
|
|
|
37
37
|
When it finishes:
|
|
38
38
|
|
|
@@ -112,6 +112,7 @@ No bundler config, no runtime, no extra package.
|
|
|
112
112
|
| `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
|
|
113
113
|
| `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
|
|
114
114
|
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
|
|
115
|
+
| `resync <folder> [--dry-run] [-y]` | Local files win for one folder: overwrite MagicPixel copies, move cloud-only files to Trash. |
|
|
115
116
|
| `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
|
|
116
117
|
| `search <query>` | Search indexed game PNGs (no network). |
|
|
117
118
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
@@ -129,6 +130,7 @@ No bundler config, no runtime, no extra package.
|
|
|
129
130
|
| `--full` | Ignore `lastSync`; re-fetch the full manifest. |
|
|
130
131
|
| `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
|
|
131
132
|
| `-q, --quiet` | Minimal output (for CI). |
|
|
133
|
+
| `--only <folder...>` | Only push, pull and prune inside these game folders (repeatable). Everything else is left untouched. |
|
|
132
134
|
|
|
133
135
|
Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
|
|
134
136
|
|
|
@@ -140,6 +142,14 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
140
142
|
- Atomic writes (`*.tmp` → `rename`) survive crashes mid-write.
|
|
141
143
|
- Retry with backoff on 429/5xx; `lastSync` only advances on a clean run.
|
|
142
144
|
|
|
145
|
+
### Sync one folder of a big project
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx @magicpixelart/cli sync --watch --only Runtime/Sprites/Entities/decorations
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Only that folder is pushed, pulled, or pruned. Every other file stays exactly as it is. Repeat `--only` for more folders, or set `"syncOnly"` in `magicpixel.json`. Changing the folder list triggers one full check. `sync <folder>` without `--only` stops with an error instead of syncing everything.
|
|
152
|
+
|
|
143
153
|
## Config (`magicpixel.json`)
|
|
144
154
|
|
|
145
155
|
| Field | Type | Default | Meaning |
|
|
@@ -151,6 +161,7 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
151
161
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
152
162
|
| `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
|
|
153
163
|
| `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
|
|
164
|
+
| `syncOnly` | `string[]?` | — | Default for `sync --only`: folders a plain `sync` is limited to. |
|
|
154
165
|
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
155
166
|
|
|
156
167
|
State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
|
package/dist/api.js
CHANGED
|
@@ -92,7 +92,7 @@ export function errorCodeFromResponse(res) {
|
|
|
92
92
|
export function friendlyApiError(status, body, context, code) {
|
|
93
93
|
if (status === 401 || status === 403) {
|
|
94
94
|
return (`${context}: ${status} — API key rejected.\n` +
|
|
95
|
-
` Fix:
|
|
95
|
+
` Fix: run \`${cmd('login')}\` and paste a fresh key from https://magicpixel.art/settings.`);
|
|
96
96
|
}
|
|
97
97
|
if (status === 404) {
|
|
98
98
|
return (`${context}: 404 — endpoint or asset not found.\n` +
|
|
@@ -449,3 +449,29 @@ export async function pushSpritesAdaptive(sprites, retryOpts) {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
|
+
// ---------- folder resync (local folder is the source of truth) -------------
|
|
453
|
+
export function resolveResyncEndpoint() {
|
|
454
|
+
return resolveIngestEndpoint().replace(/\/ingest$/, '/resync');
|
|
455
|
+
}
|
|
456
|
+
async function postResync(body) {
|
|
457
|
+
const url = resolveResyncEndpoint();
|
|
458
|
+
return retryTransient('resync', async () => {
|
|
459
|
+
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
460
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
461
|
+
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
462
|
+
if (res.ok)
|
|
463
|
+
return (await res.json());
|
|
464
|
+
const text = await res.text();
|
|
465
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, text, 'resync', errorCodeFromResponse(res)), serverRequestId), serverRequestId);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
export function pruneResyncFolder(folderPath, keepAssetIds, dryRun) {
|
|
469
|
+
return postResync({ action: 'prune', folderPath, keepAssetIds, dryRun });
|
|
470
|
+
}
|
|
471
|
+
export async function claimResyncRequest() {
|
|
472
|
+
const r = await postResync({ action: 'claim' });
|
|
473
|
+
return r.request ?? null;
|
|
474
|
+
}
|
|
475
|
+
export async function reportResyncRequest(id, status, message, counts) {
|
|
476
|
+
await postResync({ action: 'report', id, status, message, counts });
|
|
477
|
+
}
|
package/dist/commands/push.js
CHANGED
|
@@ -10,9 +10,9 @@ import kleur from 'kleur';
|
|
|
10
10
|
import ora from 'ora';
|
|
11
11
|
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
12
12
|
import { dirname, relative, resolve } from 'node:path';
|
|
13
|
-
import { loadConfig, loadState, saveState } from '../config.js';
|
|
13
|
+
import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
|
|
14
14
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
15
|
-
import { PUSH_BATCH_SIZE, pushSpritesAdaptive } from '../api.js';
|
|
15
|
+
import { PUSH_BATCH_SIZE, pushSpritesAdaptive, fetchManifestPage, getLastProjectInfo } from '../api.js';
|
|
16
16
|
import { hashFile } from '../util/hash.js';
|
|
17
17
|
import { createLimit } from '../util/limit.js';
|
|
18
18
|
import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
|
|
@@ -37,11 +37,26 @@ export async function pushCommand(opts = {}) {
|
|
|
37
37
|
export async function runPush(opts = {}) {
|
|
38
38
|
const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
|
|
39
39
|
const state = await loadState();
|
|
40
|
+
// Standalone push has no manifest yet — one tiny page tells us which project
|
|
41
|
+
// this key writes to, so a record from another account can't skip everything.
|
|
42
|
+
if (!opts.nestedSync && Object.keys(state.synced ?? {}).length > 0) {
|
|
43
|
+
const page = await fetchManifestPage({ config, limit: 1 });
|
|
44
|
+
if (resetStateIfProjectChanged(state, getLastProjectInfo()?.id, page.items.length === 0)) {
|
|
45
|
+
if (!opts.dryRun)
|
|
46
|
+
await saveState(state);
|
|
47
|
+
if (!opts.quiet)
|
|
48
|
+
console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
40
51
|
return runPushWith(config, state, opts);
|
|
41
52
|
}
|
|
42
53
|
export async function runPushWith(config, state, opts = {}) {
|
|
43
54
|
const quiet = opts.quiet === true;
|
|
44
|
-
|
|
55
|
+
if (opts.replace)
|
|
56
|
+
opts = { ...opts, flatten: true };
|
|
57
|
+
const empty = {
|
|
58
|
+
created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0, keptAssetIds: [], scanned: 0,
|
|
59
|
+
};
|
|
45
60
|
const spinner = quiet ? null : ora(countingSpritesText(0)).start();
|
|
46
61
|
const kind = await detectProjectKind();
|
|
47
62
|
const index = opts.gameIndex
|
|
@@ -110,7 +125,7 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
110
125
|
const segments = entry
|
|
111
126
|
? entry.adoptRel.replace(/\.png$/i, '').split('/')
|
|
112
127
|
: key.split('/');
|
|
113
|
-
if (opts.excludeKeys?.has(key)) {
|
|
128
|
+
if (opts.excludeKeys?.has(key) || (opts.keyFilter && !opts.keyFilter(key))) {
|
|
114
129
|
reportHash();
|
|
115
130
|
return;
|
|
116
131
|
}
|
|
@@ -129,7 +144,7 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
129
144
|
if (sourceByKey.has(k))
|
|
130
145
|
folderTreeKeys.add(k);
|
|
131
146
|
}
|
|
132
|
-
const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys });
|
|
147
|
+
const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys, replace: opts.replace });
|
|
133
148
|
const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
|
|
134
149
|
const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
|
|
135
150
|
const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
|
|
@@ -162,7 +177,10 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
162
177
|
await persistDiskFingerprints(state, candidates);
|
|
163
178
|
if (!quiet)
|
|
164
179
|
console.log(kleur.green('✓ nothing to push.'));
|
|
165
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
...empty, imported: matched.entries.length, unchanged: skipped,
|
|
182
|
+
keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
|
|
183
|
+
};
|
|
166
184
|
}
|
|
167
185
|
if (opts.dryRun) {
|
|
168
186
|
if (!quiet) {
|
|
@@ -173,7 +191,10 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
173
191
|
}
|
|
174
192
|
console.log(kleur.dim('--dry-run: nothing sent.'));
|
|
175
193
|
}
|
|
176
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
...empty, imported: matched.entries.length,
|
|
196
|
+
keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
|
|
197
|
+
};
|
|
177
198
|
}
|
|
178
199
|
const outRoot = resolve(process.cwd(), config.outDir);
|
|
179
200
|
const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
|
|
@@ -195,10 +216,14 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
195
216
|
layerIdx: a.layerIdx,
|
|
196
217
|
baseSha256: a.baseSha256,
|
|
197
218
|
flatten: opts.flatten === true,
|
|
219
|
+
...(opts.replace ? { replace: true } : {}),
|
|
198
220
|
});
|
|
199
221
|
}
|
|
200
222
|
else if (a.kind === 'adopt') {
|
|
201
|
-
sprites.push({
|
|
223
|
+
sprites.push({
|
|
224
|
+
key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name,
|
|
225
|
+
...(opts.replace ? { replace: true } : {}),
|
|
226
|
+
});
|
|
202
227
|
}
|
|
203
228
|
}
|
|
204
229
|
const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
|
|
@@ -296,7 +321,27 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
296
321
|
}
|
|
297
322
|
if (failed)
|
|
298
323
|
process.exitCode = 1;
|
|
299
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
...counts, imported: matched.entries.length,
|
|
326
|
+
keptAssetIds: collectKeptAssetIds(candidates, synced, results), scanned: candidates.length,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Library documents that now mirror an in-scope local file: every successful
|
|
331
|
+
* push result, plus unchanged candidates already recorded in sync state.
|
|
332
|
+
*/
|
|
333
|
+
export function collectKeptAssetIds(candidates, synced, results) {
|
|
334
|
+
const out = new Set();
|
|
335
|
+
for (const r of results) {
|
|
336
|
+
if (r.assetId && (r.status === 'created' || r.status === 'updated' || r.status === 'unchanged'))
|
|
337
|
+
out.add(r.assetId);
|
|
338
|
+
}
|
|
339
|
+
for (const c of candidates) {
|
|
340
|
+
const id = synced[c.key]?.assetId;
|
|
341
|
+
if (id)
|
|
342
|
+
out.add(id);
|
|
343
|
+
}
|
|
344
|
+
return [...out];
|
|
300
345
|
}
|
|
301
346
|
/**
|
|
302
347
|
* Sprites to re-send under `--force`: every sprite the cloud refused because it
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magicpixel resync <folder>` — make one MagicPixel library folder match the
|
|
3
|
+
* local game folder exactly:
|
|
4
|
+
* - every local sprite in the folder overwrites its MagicPixel copy (size
|
|
5
|
+
* follows the file, MagicPixel-only edits are replaced);
|
|
6
|
+
* - new local sprites are imported;
|
|
7
|
+
* - MagicPixel files in that folder with no local file go to Trash.
|
|
8
|
+
*/
|
|
9
|
+
import kleur from 'kleur';
|
|
10
|
+
import { createInterface } from 'node:readline/promises';
|
|
11
|
+
import { stdin, stdout } from 'node:process';
|
|
12
|
+
import { loadConfig, loadState } from '../config.js';
|
|
13
|
+
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
14
|
+
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
15
|
+
import { indexGamePngs, countingSpritesText } from '../util/gameScan.js';
|
|
16
|
+
import { pruneResyncFolder } from '../api.js';
|
|
17
|
+
import { runPushWith } from './push.js';
|
|
18
|
+
/** Same slug rules as library folders / manifest keys. */
|
|
19
|
+
export function slugifySegment(input) {
|
|
20
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
21
|
+
}
|
|
22
|
+
export function parseFolderArg(folder) {
|
|
23
|
+
return folder
|
|
24
|
+
.replace(/\\/g, '/')
|
|
25
|
+
.split('/')
|
|
26
|
+
.map((s) => slugifySegment(s.trim()))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* True when `key` (`<folders…>/<doc>/<artboard>`) lives inside `segments`.
|
|
31
|
+
* Only the folder part of the key counts, so resyncing `enemies` never touches
|
|
32
|
+
* a root document that happens to be named "enemies".
|
|
33
|
+
*/
|
|
34
|
+
export function keyInFolder(key, segments) {
|
|
35
|
+
const parts = key.split('/');
|
|
36
|
+
const folderParts = parts.slice(0, Math.max(0, parts.length - 2));
|
|
37
|
+
if (folderParts.length < segments.length)
|
|
38
|
+
return false;
|
|
39
|
+
return segments.every((s, i) => folderParts[i] === s);
|
|
40
|
+
}
|
|
41
|
+
async function confirm(prompt) {
|
|
42
|
+
if (!stdin.isTTY)
|
|
43
|
+
return false;
|
|
44
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
try {
|
|
46
|
+
const a = (await rl.question(prompt)).trim().toLowerCase();
|
|
47
|
+
return a === 'y' || a === 'yes';
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function resyncCommand(folder, opts = {}) {
|
|
54
|
+
await runResync(parseFolderArg(folder), opts);
|
|
55
|
+
}
|
|
56
|
+
export async function runResync(segments, opts = {}) {
|
|
57
|
+
if (segments.length === 0) {
|
|
58
|
+
throw new Error('Pass a folder, e.g. `magicpixel resync Sprites/Enemies`. Resyncing the whole project is `push --force`.');
|
|
59
|
+
}
|
|
60
|
+
const log = (m) => { if (!opts.quiet)
|
|
61
|
+
console.log(m); };
|
|
62
|
+
const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
|
|
63
|
+
const kind = await detectProjectKind();
|
|
64
|
+
if (!isEngineKind(kind))
|
|
65
|
+
throw new Error('This folder is not a Unity/Godot/GameMaker project.');
|
|
66
|
+
const gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, {
|
|
67
|
+
onProgress: opts.quiet ? undefined : (p) => { stdout.write(`\r${countingSpritesText(p)}`); },
|
|
68
|
+
});
|
|
69
|
+
if (!opts.quiet)
|
|
70
|
+
stdout.write('\n');
|
|
71
|
+
const keyFilter = (key) => keyInFolder(key, segments);
|
|
72
|
+
const label = segments.join('/');
|
|
73
|
+
// Plan first: what would change, and what would go to Trash.
|
|
74
|
+
const plan = await runPushWith(config, await loadState(), {
|
|
75
|
+
dryRun: true, replace: true, keyFilter, gameIndex, quiet: true,
|
|
76
|
+
});
|
|
77
|
+
if (plan.scanned === 0) {
|
|
78
|
+
throw new Error(`No local sprites found in "${label}". Nothing was changed. Check the folder name with \`magicpixel search\`.`);
|
|
79
|
+
}
|
|
80
|
+
const pruneAllowed = !gameIndex.capped;
|
|
81
|
+
const preview = pruneAllowed && plan.keptAssetIds.length > 0
|
|
82
|
+
? await pruneResyncFolder(segments, plan.keptAssetIds, true)
|
|
83
|
+
: null;
|
|
84
|
+
log(kleur.bold(`Resync ${label} from your game`));
|
|
85
|
+
log(` local sprites: ${plan.scanned}`);
|
|
86
|
+
if (preview) {
|
|
87
|
+
log(` to trash in MagicPixel (no local file): ${preview.wouldTrash.length}`);
|
|
88
|
+
for (const n of preview.wouldTrash.slice(0, 10))
|
|
89
|
+
log(kleur.dim(` - ${n}`));
|
|
90
|
+
if (preview.wouldTrash.length > 10)
|
|
91
|
+
log(kleur.dim(` …and ${preview.wouldTrash.length - 10} more`));
|
|
92
|
+
}
|
|
93
|
+
if (!pruneAllowed)
|
|
94
|
+
log(kleur.yellow(' ! game scan hit its file cap — nothing will be trashed.'));
|
|
95
|
+
log(kleur.dim(' Local files win: MagicPixel copies are overwritten and resized to match.'));
|
|
96
|
+
if (opts.dryRun) {
|
|
97
|
+
log(kleur.dim('--dry-run: nothing sent.'));
|
|
98
|
+
return { push: plan, trashed: 0, pruneSkipped: 'dry-run' };
|
|
99
|
+
}
|
|
100
|
+
if (!opts.yes && !(await confirm('Continue? (y/N) '))) {
|
|
101
|
+
throw new Error('Cancelled. Re-run with --yes to skip this question.');
|
|
102
|
+
}
|
|
103
|
+
const push = await runPushWith(config, await loadState(), {
|
|
104
|
+
replace: true, keyFilter, gameIndex, quiet: opts.quiet,
|
|
105
|
+
});
|
|
106
|
+
let pruneSkipped = null;
|
|
107
|
+
let trashed = 0;
|
|
108
|
+
if (!pruneAllowed)
|
|
109
|
+
pruneSkipped = 'game scan capped';
|
|
110
|
+
else if (push.error > 0 || push.conflict > 0)
|
|
111
|
+
pruneSkipped = 'some sprites failed to upload';
|
|
112
|
+
else if (push.keptAssetIds.length === 0)
|
|
113
|
+
pruneSkipped = 'no files confirmed';
|
|
114
|
+
else {
|
|
115
|
+
const res = await pruneResyncFolder(segments, push.keptAssetIds, false);
|
|
116
|
+
trashed = res.trashed;
|
|
117
|
+
}
|
|
118
|
+
if (pruneSkipped)
|
|
119
|
+
log(kleur.yellow(`! Nothing moved to Trash (${pruneSkipped}). Re-run once fixed.`));
|
|
120
|
+
else
|
|
121
|
+
log(kleur.green(`✓ ${label} resynced. ${trashed} file${trashed === 1 ? '' : 's'} moved to Trash.`));
|
|
122
|
+
return { push, trashed, pruneSkipped };
|
|
123
|
+
}
|
package/dist/commands/start.js
CHANGED
|
@@ -11,6 +11,31 @@ import { initCommand } from './init.js';
|
|
|
11
11
|
import { loginCommand } from './login.js';
|
|
12
12
|
import { syncCommand } from './sync.js';
|
|
13
13
|
import { cmd } from '../util/invoke.js';
|
|
14
|
+
import { assertKeyValid } from '../util/auth.js';
|
|
15
|
+
import { ApiError } from '../api.js';
|
|
16
|
+
import { connectCommand } from './connect.js';
|
|
17
|
+
import { isAllSpritesGlob } from '../util/engineConnect.js';
|
|
18
|
+
/** Validate a key; only auth rejections map to 'rejected' — other failures throw. */
|
|
19
|
+
export async function checkKey(key, config) {
|
|
20
|
+
try {
|
|
21
|
+
await assertKeyValid(key, config);
|
|
22
|
+
return 'ok';
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof ApiError && (e.status === 401 || e.status === 403))
|
|
26
|
+
return 'rejected';
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** `Sprites/Enemies` → `Sprites/Enemies/**`; already-globbed input is kept. */
|
|
31
|
+
export function folderToGlob(folder) {
|
|
32
|
+
const f = folder.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
33
|
+
return /[*?]/.test(f) ? f : `${f}/**`;
|
|
34
|
+
}
|
|
35
|
+
/** Only ask on an interactive engine project still set to "everything". */
|
|
36
|
+
export function shouldAskFolder(connect, isTTY, engine) {
|
|
37
|
+
return isTTY && engine && (connect.length === 0 || connect.every(isAllSpritesGlob));
|
|
38
|
+
}
|
|
14
39
|
/**
|
|
15
40
|
* One-command bootstrap for new users. Runs init → key prompt → first sync →
|
|
16
41
|
* prints copy-pasteable watch instructions. Designed so a non-technical user
|
|
@@ -55,20 +80,58 @@ export async function startCommand(opts = {}) {
|
|
|
55
80
|
}
|
|
56
81
|
// 3. Offer to migrate any key sitting in .env / .env.local.
|
|
57
82
|
await maybeMigrateDotenvKey();
|
|
58
|
-
// 4. Make sure we have a usable key
|
|
59
|
-
|
|
83
|
+
// 4. Make sure we have a usable key — and that it actually works. A stale
|
|
84
|
+
// stored key used to sail through and fail every request with 401.
|
|
85
|
+
const config = await loadConfig();
|
|
86
|
+
const envKey = process.env.MAGICPIXEL_API_KEY?.trim();
|
|
60
87
|
const stored = readCredentialsSync();
|
|
61
|
-
if (!
|
|
88
|
+
if (!envKey && !stored) {
|
|
62
89
|
console.log();
|
|
63
90
|
console.log(kleur.bold('Step: connect your account'));
|
|
64
91
|
await loginCommand();
|
|
65
92
|
}
|
|
66
|
-
else if (
|
|
93
|
+
else if (envKey) {
|
|
94
|
+
if ((await checkKey(envKey, config)) === 'rejected') {
|
|
95
|
+
console.log(kleur.red('✖ The MAGICPIXEL_API_KEY in your environment was rejected.'));
|
|
96
|
+
console.log(kleur.dim(' Fix: set it to a fresh key from https://magicpixel.art/settings (or unset it and re-run).'));
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
67
100
|
console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
|
|
68
101
|
}
|
|
102
|
+
else if (stored && (await checkKey(stored.apiKey, config)) === 'rejected') {
|
|
103
|
+
console.log(kleur.yellow('! Your saved key was rejected (revoked, or from another account).'));
|
|
104
|
+
if (!stdin.isTTY) {
|
|
105
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')} --key mp_live_…\` with a fresh key from https://magicpixel.art/settings.`));
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log(kleur.bold('Step: connect your account'));
|
|
110
|
+
await loginCommand();
|
|
111
|
+
}
|
|
69
112
|
else {
|
|
70
113
|
console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
|
|
71
114
|
}
|
|
115
|
+
// 4b. Don't push a whole game by surprise — offer one folder first.
|
|
116
|
+
let folder = opts.folder?.trim();
|
|
117
|
+
if (!folder && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
|
|
118
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
119
|
+
try {
|
|
120
|
+
folder = (await rl.question(`${kleur.cyan('?')} Sync all sprites, or one folder? ${kleur.dim('(type a folder like Sprites/Enemies, or press Enter for all)')} `)).trim();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
rl.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (folder) {
|
|
127
|
+
console.log();
|
|
128
|
+
console.log(kleur.bold('Step: sync one folder'));
|
|
129
|
+
await connectCommand(folderToGlob(folder));
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(kleur.dim(` Add another folder later with \`${cmd('connect')} "<folder>/**"\`.`));
|
|
132
|
+
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
72
135
|
// 5. First sync. syncCommand sets `process.exitCode = 1` on download
|
|
73
136
|
// failures without throwing — snapshot around the call so we don't
|
|
74
137
|
// print a misleading green "you're set up" over a half-failed run.
|
package/dist/commands/sync.js
CHANGED
|
@@ -3,7 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
|
-
import { loadConfig, loadState, saveState } from '../config.js';
|
|
6
|
+
import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
|
|
7
7
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
8
8
|
import { fetchManifestSnapshot, fetchAssetBytes, ApiError, DAILY_QUOTA_ERROR_CODE, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
@@ -28,6 +28,47 @@ import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../uti
|
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.js';
|
|
31
|
+
import { claimResyncRequest, reportResyncRequest } from '../api.js';
|
|
32
|
+
import { runResync } from './resync.js';
|
|
33
|
+
import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
|
|
34
|
+
/** How often the watcher asks for a library "Resync from game" request. */
|
|
35
|
+
const RESYNC_POLL_MS = 15_000;
|
|
36
|
+
let lastResyncPollAt = 0;
|
|
37
|
+
/**
|
|
38
|
+
* Run one queued library "Resync from game…" request, if any. Never throws:
|
|
39
|
+
* a failed resync is reported back to MagicPixel and the watcher keeps going.
|
|
40
|
+
*/
|
|
41
|
+
async function maybeRunQueuedResync(quiet) {
|
|
42
|
+
if (Date.now() - lastResyncPollAt < RESYNC_POLL_MS)
|
|
43
|
+
return;
|
|
44
|
+
lastResyncPollAt = Date.now();
|
|
45
|
+
let req = null;
|
|
46
|
+
try {
|
|
47
|
+
req = await claimResyncRequest();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!req)
|
|
53
|
+
return;
|
|
54
|
+
if (!quiet) {
|
|
55
|
+
process.stdout.write('\x1b[2K\r');
|
|
56
|
+
console.log(kleur.bold(`↻ Resyncing "${req.folderName}" from your game (requested in MagicPixel)…`));
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const out = await runResync(req.folderPath, { yes: true, quiet });
|
|
60
|
+
const p = out.push;
|
|
61
|
+
await reportResyncRequest(req.id, 'done', out.pruneSkipped ? `Nothing trashed: ${out.pruneSkipped}` : null, {
|
|
62
|
+
created: p.created, updated: p.updated, unchanged: p.unchanged,
|
|
63
|
+
conflict: p.conflict, error: p.error, trashed: out.trashed,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
|
|
68
|
+
if (!quiet)
|
|
69
|
+
console.log(kleur.red(`! Resync failed: ${e.message}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
31
72
|
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
32
73
|
export function pushWasActive(pushed) {
|
|
33
74
|
if (!pushed)
|
|
@@ -82,6 +123,15 @@ async function watchLoop(opts) {
|
|
|
82
123
|
// manifest fetch fails we still want the loop to come up and retry.
|
|
83
124
|
console.log(kleur.bold('👀 MagicPixel watching for changes…'));
|
|
84
125
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
126
|
+
try {
|
|
127
|
+
const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
|
|
128
|
+
if (scope) {
|
|
129
|
+
console.log(` Only: ${scope.folders.length} folder${scope.folders.length === 1 ? '' : 's'}: ${kleur.cyan(scope.folders.join(', '))}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* header is cosmetic */
|
|
134
|
+
}
|
|
85
135
|
let countSpinner = null;
|
|
86
136
|
if (!opts.quiet) {
|
|
87
137
|
try {
|
|
@@ -222,6 +272,7 @@ async function watchLoop(opts) {
|
|
|
222
272
|
pausedForNetwork = false;
|
|
223
273
|
pausedForQuota = false;
|
|
224
274
|
consecutiveAuthFailures = 0;
|
|
275
|
+
await maybeRunQueuedResync(opts.quiet === true);
|
|
225
276
|
if (wasPausedForAuth && !opts.quiet) {
|
|
226
277
|
process.stdout.write('\x1b[2K\r');
|
|
227
278
|
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
|
|
@@ -494,6 +545,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
494
545
|
if (reconcileDue && verbose) {
|
|
495
546
|
console.log(kleur.dim('Running a full reconcile (periodic) to clear anything deleted in MagicPixel.'));
|
|
496
547
|
}
|
|
548
|
+
// Folder scope: a changed scope needs one full pass so newly-included
|
|
549
|
+
// folders aren't skipped by a cursor that advanced while they were held.
|
|
550
|
+
const scope = resolveSyncScope(opts.only ?? config.syncOnly);
|
|
551
|
+
const scopeKey = scopeFingerprint(scope);
|
|
552
|
+
if (scopeKey !== state.syncScope)
|
|
553
|
+
since = undefined;
|
|
497
554
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
498
555
|
const spinner = verbose
|
|
499
556
|
? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
@@ -541,6 +598,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
541
598
|
// user presses Sync (the release bumps `updated_at`).
|
|
542
599
|
observedUpdatedAt = maxIsoTimestamp(snapshot.entries.map((e) => e.updated_at));
|
|
543
600
|
const projectInfo = getLastProjectInfo();
|
|
601
|
+
// Full read + empty project is the only safe "cloud is empty" signal.
|
|
602
|
+
if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
|
|
603
|
+
await saveState(state);
|
|
604
|
+
if (verbose)
|
|
605
|
+
console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
|
|
606
|
+
}
|
|
544
607
|
const projectSuffix = projectInfo && verbose
|
|
545
608
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
546
609
|
: '';
|
|
@@ -589,8 +652,39 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
589
652
|
: { files: [], capped: false });
|
|
590
653
|
if (!runOpts.gameIndex)
|
|
591
654
|
runOpts.onGameIndex?.(gameIndex);
|
|
592
|
-
const
|
|
655
|
+
const fullGameIndex = gameIndex;
|
|
656
|
+
const scopedGameIndex = scope
|
|
657
|
+
? { ...fullGameIndex, files: fullGameIndex.files.filter((f) => pathInScope(scope, f.sourceRel)) }
|
|
658
|
+
: fullGameIndex;
|
|
659
|
+
const connected = matchConnectGlobs(scopedGameIndex, config.connect ?? []);
|
|
593
660
|
const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
|
|
661
|
+
if (scope) {
|
|
662
|
+
// Out-of-scope rows become hold-only: never pulled, and their folders are
|
|
663
|
+
// protected from every prune path exactly like unreleased documents.
|
|
664
|
+
const inScope = [];
|
|
665
|
+
for (const e of manifest) {
|
|
666
|
+
const rel = sourceByKey.get(e.key) ?? state.synced?.[e.key]?.sourceRel;
|
|
667
|
+
if (keyInScope(scope, e.key, rel))
|
|
668
|
+
inScope.push(e);
|
|
669
|
+
else
|
|
670
|
+
withheldEntries.push(e);
|
|
671
|
+
}
|
|
672
|
+
manifest = inScope;
|
|
673
|
+
removedRemoteKeys = removedRemoteKeys.filter((k) => keyInScope(scope, k, sourceByKey.get(k) ?? state.synced?.[k]?.sourceRel));
|
|
674
|
+
if (scopedGameIndex.files.length === 0 && manifest.length === 0) {
|
|
675
|
+
const msg = `Nothing found in ${scope.folders.join(', ')}.\n` +
|
|
676
|
+
` Fix: check the folder path (relative to ${process.cwd()}, e.g. Runtime/Sprites/Entities/decorations).`;
|
|
677
|
+
// Watch would hit this on every tick — say it once and stop.
|
|
678
|
+
if (runOpts.watchMode) {
|
|
679
|
+
console.error(kleur.red(msg));
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
throw new Error(msg);
|
|
683
|
+
}
|
|
684
|
+
if (verbose) {
|
|
685
|
+
console.log(kleur.dim(` only ${scope.folders.join(', ')} → ${connected.entries.length} game file${connected.entries.length === 1 ? '' : 's'} · ${manifest.length} in MagicPixel`));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
594
688
|
aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
|
|
595
689
|
if (verbose && gameIndex.capped) {
|
|
596
690
|
console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
|
|
@@ -954,6 +1048,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
954
1048
|
for (const p of orphanSet) {
|
|
955
1049
|
if (conflictPaths.has(p))
|
|
956
1050
|
continue;
|
|
1051
|
+
if (scope && !pathInScope(scope, p))
|
|
1052
|
+
continue; // outside --only: hands off
|
|
957
1053
|
const key = keyByPath.get(p);
|
|
958
1054
|
if (key &&
|
|
959
1055
|
(await hasUnpushedLocalEdit({ absPath: p, baselineDiskSha256: synced[key]?.diskSha256 }))) {
|
|
@@ -1000,7 +1096,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1000
1096
|
if (top)
|
|
1001
1097
|
knownFolderSlugs.add(top);
|
|
1002
1098
|
}
|
|
1003
|
-
const legacyFolders = await findLegacySuffixFolders(config.outDir, knownFolderSlugs)
|
|
1099
|
+
const legacyFolders = (await findLegacySuffixFolders(config.outDir, knownFolderSlugs))
|
|
1100
|
+
.filter((lf) => !scope || pathInScope(scope, lf.abs)); // --only: hands off elsewhere
|
|
1004
1101
|
if (verbose) {
|
|
1005
1102
|
console.log();
|
|
1006
1103
|
console.log(kleur.bold('Plan:'));
|
|
@@ -1324,6 +1421,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1324
1421
|
assets: nextAssets,
|
|
1325
1422
|
synced: nextSynced,
|
|
1326
1423
|
manifestEtags: manifestEtagsSnapshot(),
|
|
1424
|
+
syncScope: scopeKey,
|
|
1327
1425
|
// A completed full pass is the only run that can prove nothing is orphaned.
|
|
1328
1426
|
...(!since && result.failed === 0 ? { lastReconcile: startedAt } : {}),
|
|
1329
1427
|
};
|
|
@@ -1396,7 +1494,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1396
1494
|
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
1397
1495
|
}
|
|
1398
1496
|
onStatus?.('Checking your game files…');
|
|
1399
|
-
const pushed = await maybePushLocalSprites(config.push, verbose,
|
|
1497
|
+
const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1400
1498
|
if (verbose) {
|
|
1401
1499
|
console.log();
|
|
1402
1500
|
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,29 @@ const CONFIG_FILENAME = 'magicpixel.json';
|
|
|
10
10
|
const STATE_DIR = '.magicpixel';
|
|
11
11
|
const STATE_FILENAME = 'state.json';
|
|
12
12
|
const DEFAULT_ENDPOINT = 'https://sddsilidjhvtvejzvolx.supabase.co/functions/v1/integration-assets';
|
|
13
|
+
/**
|
|
14
|
+
* The upload record is only valid for the project it was built against. A new
|
|
15
|
+
* key / account / project must not inherit "already uploaded" entries, or every
|
|
16
|
+
* sprite is skipped as unchanged and nothing reaches the new project.
|
|
17
|
+
* Legacy records (no projectId) are treated as stale when the server reports
|
|
18
|
+
* an empty project. Mutates `state`; returns true when it was reset. Disk is
|
|
19
|
+
* never touched.
|
|
20
|
+
*/
|
|
21
|
+
export function resetStateIfProjectChanged(state, projectId, cloudIsEmpty) {
|
|
22
|
+
if (!projectId)
|
|
23
|
+
return false;
|
|
24
|
+
const hasRecord = Object.keys(state.synced ?? {}).length > 0 || Object.keys(state.assets ?? {}).length > 0;
|
|
25
|
+
const stale = state.projectId ? state.projectId !== projectId : hasRecord && cloudIsEmpty;
|
|
26
|
+
if (stale) {
|
|
27
|
+
delete state.synced;
|
|
28
|
+
delete state.assets;
|
|
29
|
+
delete state.manifestEtags;
|
|
30
|
+
delete state.lastReconcile;
|
|
31
|
+
}
|
|
32
|
+
state.projectId = projectId;
|
|
33
|
+
return stale;
|
|
34
|
+
}
|
|
35
|
+
export const PROJECT_CHANGED_MESSAGE = 'This key belongs to a different MagicPixel project — re-uploading your connected folders.';
|
|
13
36
|
export const defaultConfig = {
|
|
14
37
|
outDir: 'src/assets/magicpixel',
|
|
15
38
|
include: ['**/*'],
|
|
@@ -89,6 +112,7 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
89
112
|
unityPpu,
|
|
90
113
|
push: parsed.push === false ? false : undefined,
|
|
91
114
|
connect,
|
|
115
|
+
...(parsed.syncOnly !== undefined ? { syncOnly: normalizeGlobList(parsed.syncOnly, 'syncOnly') } : {}),
|
|
92
116
|
};
|
|
93
117
|
}
|
|
94
118
|
const MAX_GLOBS = 64;
|
package/dist/index.js
CHANGED
|
@@ -15,9 +15,11 @@ import { doctorCommand } from './commands/doctor.js';
|
|
|
15
15
|
import { repairCommand } from './commands/repair.js';
|
|
16
16
|
import { startCommand } from './commands/start.js';
|
|
17
17
|
import { connectCommand } from './commands/connect.js';
|
|
18
|
+
import { resyncCommand } from './commands/resync.js';
|
|
18
19
|
import { searchCommand } from './commands/search.js';
|
|
19
20
|
import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
|
|
20
21
|
import { CLI_VERSION } from './version.js';
|
|
22
|
+
import { cmd } from './util/invoke.js';
|
|
21
23
|
// Node version guard
|
|
22
24
|
const major = Number(process.versions.node.split('.')[0]);
|
|
23
25
|
if (major < 18) {
|
|
@@ -51,6 +53,7 @@ program
|
|
|
51
53
|
.command('start')
|
|
52
54
|
.description('One-command first-run setup: init + login + first sync')
|
|
53
55
|
.option('--force', 'Re-run init even if magicpixel.json exists')
|
|
56
|
+
.option('--folder <path>', 'Sync only this game folder first (skips the "all or one folder" question)')
|
|
54
57
|
.action(wrap("start", async (opts) => startCommand(opts)));
|
|
55
58
|
program
|
|
56
59
|
.command('init')
|
|
@@ -90,8 +93,16 @@ program
|
|
|
90
93
|
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
|
|
91
94
|
.option('-q, --quiet', 'Minimal output (for CI)')
|
|
92
95
|
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
|
|
93
|
-
.
|
|
94
|
-
.
|
|
96
|
+
.option('--only <folder...>', 'Only sync these game folders (repeatable), e.g. Runtime/Sprites/Entities/decorations')
|
|
97
|
+
.argument('[extra...]')
|
|
98
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel sync --watch --only Runtime/Sprites/Enemies # one folder\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
|
|
99
|
+
.action(wrap("sync", async (extra, opts) => {
|
|
100
|
+
if (extra.length > 0) {
|
|
101
|
+
throw new Error(`sync doesn't take a folder (got "${extra.join(' ')}").\n` +
|
|
102
|
+
` Fix: ${cmd(`sync --only ${extra[0].replace(/^\/+/, '')}`)}`);
|
|
103
|
+
}
|
|
104
|
+
return syncCommand(opts);
|
|
105
|
+
}));
|
|
95
106
|
program
|
|
96
107
|
.command('push')
|
|
97
108
|
.description('Upload local sprite edits back to MagicPixel (two-way sync)')
|
|
@@ -100,6 +111,13 @@ program
|
|
|
100
111
|
.option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
|
|
101
112
|
.addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n $ magicpixel push --force # your local copy wins on both-sides-changed\n')
|
|
102
113
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
114
|
+
program
|
|
115
|
+
.command('resync <folder>')
|
|
116
|
+
.description('Make one MagicPixel folder match your local game folder (local wins, missing files go to Trash)')
|
|
117
|
+
.option('-y, --yes', 'Skip the confirmation question')
|
|
118
|
+
.option('--dry-run', 'Show what would change without sending anything')
|
|
119
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
|
|
120
|
+
.action(wrap("resync", async (folder, opts) => resyncCommand(folder, opts)));
|
|
103
121
|
program
|
|
104
122
|
.command('connect <glob>')
|
|
105
123
|
.description('Limit which game folders sync (default is all sprites)')
|
package/dist/util/pushPlan.js
CHANGED
|
@@ -120,11 +120,13 @@ export function planPush(candidates, synced, opts = {}) {
|
|
|
120
120
|
if (known) {
|
|
121
121
|
const connected = Boolean(c.sourceRel || opts.folderTreeKeys?.has(c.key) || (stateKey != null && opts.folderTreeKeys?.has(stateKey)));
|
|
122
122
|
const lastDisk = lastPushedDiskSha(known, { connected });
|
|
123
|
-
|
|
123
|
+
// Resync (replace) sends every in-scope sprite: local wins even when
|
|
124
|
+
// only the MagicPixel copy changed. The server reports true no-ops.
|
|
125
|
+
if (lastDisk === c.diskSha256 && !opts.replace) {
|
|
124
126
|
out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
|
|
125
127
|
continue;
|
|
126
128
|
}
|
|
127
|
-
if (lastDisk === undefined) {
|
|
129
|
+
if (lastDisk === undefined && !opts.replace) {
|
|
128
130
|
// Connected original with no disk baseline yet. Ingest's composite
|
|
129
131
|
// sha is not the game file — seeding happens in `applyDiskFingerprints`.
|
|
130
132
|
out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync --only <folder>`: limit both halves of a sync (push + pull + prune) to
|
|
3
|
+
* a few game folders. Everything outside the scope is treated as hold-only —
|
|
4
|
+
* never pulled, pushed, or deleted — so a huge project can be watched one
|
|
5
|
+
* folder at a time.
|
|
6
|
+
*/
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { keyInFolder, parseFolderArg } from '../commands/resync.js';
|
|
9
|
+
/** Accepts `/Runtime/Foo/`, `Runtime\\Foo`, `Runtime/Foo/**`. */
|
|
10
|
+
export function normalizeScopeFolder(input) {
|
|
11
|
+
return input
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/\\/g, '/')
|
|
14
|
+
.replace(/\/\*\*?$/g, '')
|
|
15
|
+
.replace(/^\.?\/+/, '')
|
|
16
|
+
.replace(/\/+$/, '')
|
|
17
|
+
.replace(/\/{2,}/g, '/');
|
|
18
|
+
}
|
|
19
|
+
export function resolveSyncScope(inputs) {
|
|
20
|
+
const folders = Array.from(new Set((inputs ?? []).map(normalizeScopeFolder).filter(Boolean)));
|
|
21
|
+
if (folders.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
folders: folders.map((f) => f.toLowerCase()),
|
|
25
|
+
keySegments: folders.map(parseFolderArg).filter((s) => s.length > 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Is a cwd-relative (or absolute) path inside the scope? */
|
|
29
|
+
export function pathInScope(scope, path) {
|
|
30
|
+
const rel = (path.startsWith('/') || /^[a-z]:/i.test(path) ? relative(process.cwd(), path) : path)
|
|
31
|
+
.replace(/\\/g, '/')
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
return scope.folders.some((f) => rel === f || rel.startsWith(`${f}/`));
|
|
34
|
+
}
|
|
35
|
+
/** A manifest key is in scope when its game file or its library folder is. */
|
|
36
|
+
export function keyInScope(scope, key, sourceRel) {
|
|
37
|
+
if (sourceRel)
|
|
38
|
+
return pathInScope(scope, sourceRel);
|
|
39
|
+
return scope.keySegments.some((segs) => keyInFolder(key, segs));
|
|
40
|
+
}
|
|
41
|
+
/** Stable fingerprint persisted in state — a change forces one full pass. */
|
|
42
|
+
export function scopeFingerprint(scope) {
|
|
43
|
+
return scope ? [...scope.folders].sort().join('|') : undefined;
|
|
44
|
+
}
|