@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 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: regenerate at https://magicpixel.art/settings and re-run \`${cmd('login')}\`.`);
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
+ }
@@ -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
- const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
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 { ...empty, imported: matched.entries.length, unchanged: skipped };
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 { ...empty, imported: matched.entries.length };
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({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
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 { ...counts, imported: matched.entries.length };
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
+ }
@@ -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. Env var or stored credentials are fine.
59
- const haveEnv = !!process.env.MAGICPIXEL_API_KEY;
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 (!haveEnv && !stored) {
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 (haveEnv) {
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.
@@ -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 connected = matchConnectGlobs(gameIndex, config.connect ?? []);
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, gameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
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
- .addHelpText('after', '\nExamples:\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')
94
- .action(wrap("sync", async (opts) => syncCommand(opts)));
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)')
@@ -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
- if (lastDisk === c.diskSha256) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.28",
3
+ "version": "0.5.31",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {