@magicpixelart/cli 0.5.29 → 0.5.32

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,6 +5,30 @@ 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.32] - 2026-10-05
9
+
10
+ ### Fixed
11
+ - `sync --watch` status lines are clipped to the terminal width, so a long sprite path no longer wraps and leaves the previous status piled on screen.
12
+
13
+ ## [0.5.31] - 2026-10-01
14
+
15
+ ### Added
16
+ - `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.
17
+
18
+ ### Changed
19
+ - `sync <folder>` now errors and suggests `--only` instead of silently syncing the whole project.
20
+ - 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.
21
+
22
+ ### Fixed
23
+ - 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).
24
+
25
+ ## [0.5.30] - 2026-10-01
26
+
27
+ ### Changed
28
+ - `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.
29
+ - `start` asks "all sprites, or one folder?" on game projects; `--folder <path>` answers it up front.
30
+ - Rejected-key errors point to `login` first.
31
+
8
32
  ## [Unreleased]
9
33
 
10
34
  ## [0.5.29]
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` +
@@ -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,6 +37,17 @@ 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 = {}) {
@@ -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';
@@ -23,13 +23,14 @@ import { maxIsoTimestamp } from '../util/iso.js';
23
23
  import { formatBytes } from '../util/format.js';
24
24
  import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
25
25
  import { cmd } from '../util/invoke.js';
26
- import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
26
+ import { fitStatusLine, formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
27
27
  import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
28
28
  import { hasUnpushedLocalEdit } from '../util/localEdit.js';
29
29
  import { shouldReconcile } from '../util/reconcile.js';
30
30
  import { runPush } from './push.js';
31
31
  import { claimResyncRequest, reportResyncRequest } from '../api.js';
32
32
  import { runResync } from './resync.js';
33
+ import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
33
34
  /** How often the watcher asks for a library "Resync from game" request. */
34
35
  const RESYNC_POLL_MS = 15_000;
35
36
  let lastResyncPollAt = 0;
@@ -122,6 +123,15 @@ async function watchLoop(opts) {
122
123
  // manifest fetch fails we still want the loop to come up and retry.
123
124
  console.log(kleur.bold('👀 MagicPixel watching for changes…'));
124
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
+ }
125
135
  let countSpinner = null;
126
136
  if (!opts.quiet) {
127
137
  try {
@@ -217,7 +227,7 @@ async function watchLoop(opts) {
217
227
  const onStatus = (msg) => {
218
228
  if (opts.quiet)
219
229
  return;
220
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
230
+ writeStatusLine(msg);
221
231
  };
222
232
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
223
233
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -297,7 +307,7 @@ async function watchLoop(opts) {
297
307
  printChanges(r, /* indent */ ' ');
298
308
  }
299
309
  else {
300
- process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} Waiting for edits… (${r.unchanged} up to date)`)}`);
310
+ writeStatusLine(`Waiting for edits… (${r.unchanged} up to date)`);
301
311
  }
302
312
  }
303
313
  catch (e) {
@@ -535,6 +545,12 @@ async function runOnce(opts, runOpts = {}) {
535
545
  if (reconcileDue && verbose) {
536
546
  console.log(kleur.dim('Running a full reconcile (periodic) to clear anything deleted in MagicPixel.'));
537
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;
538
554
  const previousAssets = state.assets ?? {}; // id → key from prior sync
539
555
  const spinner = verbose
540
556
  ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
@@ -582,6 +598,12 @@ async function runOnce(opts, runOpts = {}) {
582
598
  // user presses Sync (the release bumps `updated_at`).
583
599
  observedUpdatedAt = maxIsoTimestamp(snapshot.entries.map((e) => e.updated_at));
584
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
+ }
585
607
  const projectSuffix = projectInfo && verbose
586
608
  ? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
587
609
  : '';
@@ -630,8 +652,39 @@ async function runOnce(opts, runOpts = {}) {
630
652
  : { files: [], capped: false });
631
653
  if (!runOpts.gameIndex)
632
654
  runOpts.onGameIndex?.(gameIndex);
633
- 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 ?? []);
634
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
+ }
635
688
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
636
689
  if (verbose && gameIndex.capped) {
637
690
  console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
@@ -995,6 +1048,8 @@ async function runOnce(opts, runOpts = {}) {
995
1048
  for (const p of orphanSet) {
996
1049
  if (conflictPaths.has(p))
997
1050
  continue;
1051
+ if (scope && !pathInScope(scope, p))
1052
+ continue; // outside --only: hands off
998
1053
  const key = keyByPath.get(p);
999
1054
  if (key &&
1000
1055
  (await hasUnpushedLocalEdit({ absPath: p, baselineDiskSha256: synced[key]?.diskSha256 }))) {
@@ -1041,7 +1096,8 @@ async function runOnce(opts, runOpts = {}) {
1041
1096
  if (top)
1042
1097
  knownFolderSlugs.add(top);
1043
1098
  }
1044
- 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
1045
1101
  if (verbose) {
1046
1102
  console.log();
1047
1103
  console.log(kleur.bold('Plan:'));
@@ -1365,6 +1421,7 @@ async function runOnce(opts, runOpts = {}) {
1365
1421
  assets: nextAssets,
1366
1422
  synced: nextSynced,
1367
1423
  manifestEtags: manifestEtagsSnapshot(),
1424
+ syncScope: scopeKey,
1368
1425
  // A completed full pass is the only run that can prove nothing is orphaned.
1369
1426
  ...(!since && result.failed === 0 ? { lastReconcile: startedAt } : {}),
1370
1427
  };
@@ -1437,7 +1494,7 @@ async function runOnce(opts, runOpts = {}) {
1437
1494
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
1438
1495
  }
1439
1496
  onStatus?.('Checking your game files…');
1440
- 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);
1441
1498
  if (verbose) {
1442
1499
  console.log();
1443
1500
  const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
@@ -1518,6 +1575,11 @@ function progressText(done, total, bytes) {
1518
1575
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1519
1576
  return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
1520
1577
  }
1578
+ /** Rewrite the watcher's single status row, clipped so it never wraps. */
1579
+ function writeStatusLine(msg) {
1580
+ const line = fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns);
1581
+ process.stdout.write(`\r\x1b[2K${kleur.dim(line)}`);
1582
+ }
1521
1583
  function timestamp() {
1522
1584
  const d = new Date();
1523
1585
  return `[${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}]`;
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
@@ -19,6 +19,7 @@ import { resyncCommand } from './commands/resync.js';
19
19
  import { searchCommand } from './commands/search.js';
20
20
  import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
21
21
  import { CLI_VERSION } from './version.js';
22
+ import { cmd } from './util/invoke.js';
22
23
  // Node version guard
23
24
  const major = Number(process.versions.node.split('.')[0]);
24
25
  if (major < 18) {
@@ -52,6 +53,7 @@ program
52
53
  .command('start')
53
54
  .description('One-command first-run setup: init + login + first sync')
54
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)')
55
57
  .action(wrap("start", async (opts) => startCommand(opts)));
56
58
  program
57
59
  .command('init')
@@ -91,8 +93,16 @@ program
91
93
  .option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
92
94
  .option('-q, --quiet', 'Minimal output (for CI)')
93
95
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
94
- .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')
95
- .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
+ }));
96
106
  program
97
107
  .command('push')
98
108
  .description('Upload local sprite edits back to MagicPixel (two-way sync)')
@@ -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
+ }
@@ -39,3 +39,19 @@ export function formatSlowTickLine(elapsedSec, lastStatus) {
39
39
  ? `${what} — still working (${elapsed})`
40
40
  : `Still working (${elapsed})`;
41
41
  }
42
+ /**
43
+ * Fit a carriage-return status line to one terminal row. `\r\x1b[2K` only
44
+ * clears the row the cursor is on, so a line that wraps leaves its first rows
45
+ * behind and the next status prints after them (long "Looking through your
46
+ * game sprites… <folder>" lines piled up this way). No width (not a TTY) →
47
+ * unchanged.
48
+ */
49
+ export function fitStatusLine(line, columns) {
50
+ if (!columns || columns < 2)
51
+ return line;
52
+ const max = columns - 1; // leave the last cell free so the cursor can't wrap
53
+ const chars = Array.from(line);
54
+ if (chars.length <= max)
55
+ return line;
56
+ return `${chars.slice(0, max - 1).join('')}…`;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.29",
3
+ "version": "0.5.32",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {