@magicpixelart/cli 0.5.35 → 0.5.38

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,22 @@ 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.38] - 2026-10-10
9
+
10
+ ### Fixed
11
+ - Full Unity projects also scan sprite dot-folders at the project root (e.g. `.SpineRaw`), not only `Assets/`. VCS/editor folders stay skipped.
12
+ - `sync` and `connect` report how many PNGs an `exclude` rule skipped, naming the rule.
13
+
14
+ ## [0.5.37] - 2026-10-09
15
+
16
+ ### Fixed
17
+ - `sync` stops before writing when the sprites it would download are connected to another game folder on this machine (e.g. run from the wrong repo), naming the right folder. `--here` overrides; README documents it.
18
+
19
+ ## [0.5.36] - 2026-10-07
20
+
21
+ ### Changed
22
+ - `push --force` no longer replaces files whose newer copy was saved in the MagicPixel editor; they are listed as "edited in MagicPixel — skipped". Add `--overwrite-editor-changes` to replace them too.
23
+
8
24
  ## [0.5.35] - 2026-10-07
9
25
 
10
26
  ### Changed
@@ -21,13 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
21
37
  ### Fixed
22
38
  - Local edits to game files that were never changed in MagicPixel no longer fail as "also changed in MagicPixel". Push now sends the cloud timestamp it last saw, and sync no longer records the local file as MagicPixel's copy when the server has no cached hash.
23
39
 
24
- ### Changed
25
- - Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
26
-
27
40
  ## [0.5.32] - 2026-10-05
28
41
 
29
- ### Fixed
30
- - `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.
42
+ ### Changed
43
+ - Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
31
44
 
32
45
  ## [0.5.31] - 2026-10-01
33
46
 
package/README.md CHANGED
@@ -131,6 +131,7 @@ No bundler config, no runtime, no extra package.
131
131
  | `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
132
132
  | `-q, --quiet` | Minimal output (for CI). |
133
133
  | `--only <folder...>` | Only push, pull and prune inside these game folders (repeatable). Everything else is left untouched. |
134
+ | `--here` | Download even if these sprites are connected to another game folder on this computer (sync otherwise stops and names that folder). Run sync from your game project folder. |
134
135
 
135
136
  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.
136
137
 
@@ -150,6 +151,10 @@ npx @magicpixelart/cli sync --watch --only Runtime/Sprites/Entities/decorations
150
151
 
151
152
  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
153
 
154
+ ## Forced pushes and editor edits
155
+
156
+ `push --force` keeps your local copy when a sprite changed on both sides, except for files last saved in the MagicPixel editor: those are skipped and listed, so a forced push never silently undoes editor work. Add `--overwrite-editor-changes` to replace them as well.
157
+
153
158
  ## Keep a folder out of MagicPixel
154
159
 
155
160
  To leave game files on disk but out of your library, add them to `exclude` in `magicpixel.json`:
@@ -158,6 +163,8 @@ To leave game files on disk but out of your library, add them to `exclude` in `m
158
163
  { "exclude": [".SpineRaw/rabbit_nft/**"] }
159
164
  ```
160
165
 
166
+ Sync prints how many PNGs your `exclude` rules skipped, so a forgotten rule is easy to spot. In full Unity projects sync scans `Assets/` plus sprite dot-folders at the project root (like `.SpineRaw`).
167
+
161
168
  Excluded files are never uploaded, even when `connect` is `**`, and the CLI never deletes them from your game. Then delete the folder in the MagicPixel library (it goes to Trash) and it stays gone.
162
169
 
163
170
  ## Config (`magicpixel.json`)
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import { loadConfig, saveConfig } from '../config.js';
4
4
  import { assertSafeGlob } from '../util/security.js';
5
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
6
- import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
6
+ import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
7
7
  import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
8
8
  import { cmd } from '../util/invoke.js';
9
9
  import { runPush } from './push.js';
@@ -37,6 +37,9 @@ export async function connectCommand(glob) {
37
37
  });
38
38
  spinner.stop();
39
39
  const matched = matchConnectGlobs(index, config.connect, config.exclude);
40
+ const skipped = describeExcluded(matched);
41
+ if (skipped)
42
+ console.log(kleur.dim(` ${skipped}`));
40
43
  if (matched.entries.length === 0) {
41
44
  console.log(kleur.yellow(` No PNGs matched. Try \`${cmd('search')} <name>\` to see indexed paths.`));
42
45
  return;
@@ -255,7 +255,7 @@ export async function runPushWith(config, state, opts = {}) {
255
255
  // without a live sha, and the retry as an update is what makes the server
256
256
  // composite the artboard and report one.
257
257
  for (let round = 0; round < 2; round++) {
258
- const retry = forcedRetrySprites(results, spriteByKey, opts.flatten === true);
258
+ const retry = forcedRetrySprites(results, spriteByKey, opts.flatten === true, opts.overwriteEditorChanges === true);
259
259
  if (retry.length === 0)
260
260
  break;
261
261
  const retryResults = await pushSpritesAdaptive(retry);
@@ -300,7 +300,7 @@ export async function runPushWith(config, state, opts = {}) {
300
300
  for (const r of results) {
301
301
  if (r.status === 'conflict') {
302
302
  if (printedIssues++ < 10)
303
- console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
303
+ console.log(` ${kleur.yellow('!')} ${r.key}: ${opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges ? `edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.` : conflictHint(r.reason)}`);
304
304
  }
305
305
  else if (r.status === 'error') {
306
306
  if (printedIssues++ < 10)
@@ -344,11 +344,14 @@ export function collectKeptAssetIds(candidates, synced, results) {
344
344
  * reported. Not a bypass — the server still compares, so a save that lands in
345
345
  * between refuses again.
346
346
  */
347
- export function forcedRetrySprites(results, spriteByKey, flatten) {
347
+ export function forcedRetrySprites(results, spriteByKey, flatten, overwriteEditorChanges = false) {
348
348
  const out = [];
349
349
  for (const r of results) {
350
350
  if (r.status !== 'conflict' || r.reason !== 'cloud-changed' || !r.assetId)
351
351
  continue;
352
+ // A newer save made in the MagicPixel editor is never silently undone.
353
+ if (r.lastWriteSource === 'editor' && !overwriteEditorChanges)
354
+ continue;
352
355
  const original = spriteByKey.get(r.key);
353
356
  if (!original)
354
357
  continue;
@@ -12,7 +12,7 @@ import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
13
  import { assertPathInsideRoot, assertSafeIoPath } from '../util/security.js';
14
14
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
15
- import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
15
+ import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
16
16
  import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
17
17
  import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
18
18
  import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, workingSetPullKeys } from '../util/unityFilter.js';
@@ -23,7 +23,7 @@ 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 { fitStatusLine, formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
26
+ import { 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';
@@ -31,6 +31,7 @@ import { runPush } from './push.js';
31
31
  import { claimResyncRequest, reportResyncRequest } from '../api.js';
32
32
  import { runResync } from './resync.js';
33
33
  import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
34
+ import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
34
35
  /** How often the watcher asks for a library "Resync from game" request. */
35
36
  const RESYNC_POLL_MS = 15_000;
36
37
  let lastResyncPollAt = 0;
@@ -227,7 +228,7 @@ async function watchLoop(opts) {
227
228
  const onStatus = (msg) => {
228
229
  if (opts.quiet)
229
230
  return;
230
- writeStatusLine(msg);
231
+ process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
231
232
  };
232
233
  // Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
233
234
  // link) must not look like the silent hang a timeout-less fetch used to
@@ -307,7 +308,7 @@ async function watchLoop(opts) {
307
308
  printChanges(r, /* indent */ ' ');
308
309
  }
309
310
  else {
310
- writeStatusLine(`Waiting for edits… (${r.unchanged} up to date)`);
311
+ process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} Waiting for edits… (${r.unchanged} up to date)`)}`);
311
312
  }
312
313
  }
313
314
  catch (e) {
@@ -688,6 +689,9 @@ async function runOnce(opts, runOpts = {}) {
688
689
  aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
689
690
  if (verbose && isEngineKind(projectKind)) {
690
691
  console.log(kleur.dim(` game index → ${gameIndex.files.length} PNG${gameIndex.files.length === 1 ? '' : 's'} · working set ${connected.entries.length}`));
692
+ const skipped = describeExcluded(connected);
693
+ if (skipped)
694
+ console.log(kleur.dim(` ${skipped}`));
691
695
  }
692
696
  const pathForKey = (key) => syncDiskPathFromKey(config.outDir, key, sourceByKey);
693
697
  // Every folder on disk that belongs to a withheld document. Anything already
@@ -879,6 +883,20 @@ async function runOnce(opts, runOpts = {}) {
879
883
  const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
880
884
  const remoteKeySet = new Set(manifest.map((e) => e.key));
881
885
  const previousKeySet = new Set(Object.values(previousAssets));
886
+ // Wrong-folder guard: sprites connected to another game folder on this
887
+ // machine would land here as stray copies under outDir. Stop before writing.
888
+ const projectIdForRoots = getLastProjectInfo()?.id;
889
+ if (projectIdForRoots && !opts.here) {
890
+ const owner = await findForeignOwner(projectIdForRoots, process.cwd(), toDownload.filter((e) => !sourceByKey.has(e.key)).map((e) => e.key));
891
+ if (owner) {
892
+ const msg = foreignOwnerMessage(owner, process.cwd(), `add ${kleur.cyan('--here')}`);
893
+ if (runOpts.watchMode) {
894
+ console.error(kleur.red(msg));
895
+ process.exit(1);
896
+ }
897
+ throw new Error(msg);
898
+ }
899
+ }
882
900
  const localPngs = needsLocalPngWalk(since, toDownload.length)
883
901
  ? await walkOutDirPngs(config.outDir)
884
902
  : [];
@@ -1485,6 +1503,11 @@ async function runOnce(opts, runOpts = {}) {
1485
1503
  // Never let an AGENTS.md write failure break sync.
1486
1504
  }
1487
1505
  await saveState(nextState);
1506
+ // This folder owns real game paths → remember it so a sync elsewhere can
1507
+ // point back here instead of writing stray copies.
1508
+ if (projectIdForRoots && sourceByKey.size > 0) {
1509
+ await recordProjectRoot(projectIdForRoots, process.cwd());
1510
+ }
1488
1511
  if (result.failed > 0 && verbose) {
1489
1512
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
1490
1513
  }
@@ -1570,11 +1593,6 @@ function progressText(done, total, bytes) {
1570
1593
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1571
1594
  return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
1572
1595
  }
1573
- /** Rewrite the watcher's single status row, clipped so it never wraps. */
1574
- function writeStatusLine(msg) {
1575
- const line = fitStatusLine(`${timestamp()} ${msg}`, process.stdout.columns);
1576
- process.stdout.write(`\r\x1b[2K${kleur.dim(line)}`);
1577
- }
1578
1596
  function timestamp() {
1579
1597
  const d = new Date();
1580
1598
  return `[${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}]`;
package/dist/index.js CHANGED
@@ -94,6 +94,7 @@ program
94
94
  .option('-q, --quiet', 'Minimal output (for CI)')
95
95
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
96
96
  .option('--only <folder...>', 'Only sync these game folders (repeatable), e.g. Runtime/Sprites/Entities/decorations')
97
+ .option('--here', 'Download here even if these sprites are connected to another game folder')
97
98
  .argument('[extra...]')
98
99
  .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
100
  .action(wrap("sync", async (extra, opts) => {
@@ -108,7 +109,8 @@ program
108
109
  .description('Upload local sprite edits back to MagicPixel (two-way sync)')
109
110
  .option('--dry-run', 'Print what would be pushed without sending anything')
110
111
  .option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
111
- .option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
112
+ .option('--force', 'Keep your local copy when a sprite also changed in MagicPixel (skips files saved in the MagicPixel editor)')
113
+ .option('--overwrite-editor-changes', 'With --force: also replace files saved in the MagicPixel editor')
112
114
  .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')
113
115
  .action(wrap("push", async (opts) => pushCommand(opts)));
114
116
  program
@@ -4,7 +4,7 @@
4
4
  * globs select a working set; sync writes back to those original paths.
5
5
  */
6
6
  import { existsSync } from 'node:fs';
7
- import { opendir } from 'node:fs/promises';
7
+ import { opendir, readdir } from 'node:fs/promises';
8
8
  import { relative, resolve } from 'node:path';
9
9
  import { isEngineKind, resolveChildDir } from './framework.js';
10
10
  import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
@@ -184,6 +184,13 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
184
184
  const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
185
185
  const preferFirst = startAt === scanRoot ? assetRoot : null;
186
186
  await walkPngs(startAt, scanRoot, destNorm, found, Infinity, io, report, stats, preferFirst);
187
+ // Full Unity games also keep sprite sources in root dot-folders next to
188
+ // Assets/ (e.g. `.SpineRaw`). Walk those too; Unity's own folders stay out.
189
+ if (startAt !== scanRoot) {
190
+ for (const dir of await rootSpriteDotDirs(scanRoot)) {
191
+ await walkPngs(dir, scanRoot, destNorm, found, Infinity, io, report, stats, null);
192
+ }
193
+ }
187
194
  report.flush?.();
188
195
  opts.onProgress?.({ pngs: found.length, folders: stats.folders });
189
196
  found.sort((a, b) => a.localeCompare(b));
@@ -195,15 +202,48 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
195
202
  }
196
203
  return { files };
197
204
  }
205
+ /** One-line summary of exclude-dropped files, or null when none. */
206
+ export function describeExcluded(r) {
207
+ if (!r.excluded)
208
+ return null;
209
+ return `${r.excluded.toLocaleString('en-US')} PNG${r.excluded === 1 ? '' : 's'} skipped by exclude rules (${r.topExclude}) — edit "exclude" in magicpixel.json to include them`;
210
+ }
211
+ /** Root-level dot-folders that may hold sprites (not VCS/editor/tool dirs). */
212
+ export async function rootSpriteDotDirs(root) {
213
+ try {
214
+ const ents = await readdir(root, { withFileTypes: true });
215
+ return ents
216
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && e.name.startsWith('.') && !shouldSkipDir(e.name))
217
+ .map((e) => resolve(root, e.name))
218
+ .sort();
219
+ }
220
+ catch {
221
+ return [];
222
+ }
223
+ }
198
224
  /** Filter the index by connect globs minus exclude globs (cwd-, asset-root-relative or manifest key). */
199
225
  export function matchConnectGlobs(index, globs, exclude = []) {
200
226
  if (globs.length === 0)
201
227
  return { entries: [] };
202
228
  const hit = (e, g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g) || matchGlob(e.key, g);
203
229
  // `exclude` wins over `connect`: excluded game files are never uploaded.
204
- const matched = index.files.filter((e) => globs.some((g) => hit(e, g)) && !exclude.some((g) => hit(e, g)));
230
+ const matched = [];
231
+ const byRule = new Map();
232
+ let excluded = 0;
233
+ for (const e of index.files) {
234
+ if (!globs.some((g) => hit(e, g)))
235
+ continue;
236
+ const rule = exclude.find((g) => hit(e, g));
237
+ if (rule === undefined)
238
+ matched.push(e);
239
+ else {
240
+ excluded++;
241
+ byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
242
+ }
243
+ }
205
244
  matched.sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
206
- return { entries: matched };
245
+ const topExclude = [...byRule.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
246
+ return { entries: matched, ...(excluded ? { excluded, topExclude } : {}) };
207
247
  }
208
248
  export function searchGameIndex(index, query) {
209
249
  const q = query.trim().toLowerCase();
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { atomicWrite } from './atomicWrite.js';
6
+ export function registryPath(home = homedir()) {
7
+ return resolve(home, '.magicpixel', 'project-roots.json');
8
+ }
9
+ async function readRegistry(home) {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(registryPath(home), 'utf8'));
12
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ /** Remember `root` as a home for this project's connected sprites. Best-effort. */
19
+ const recorded = new Set();
20
+ export async function recordProjectRoot(projectId, root, home) {
21
+ const memo = `${home ?? ''}\0${projectId}\0${resolve(root)}`;
22
+ if (recorded.has(memo))
23
+ return; // watch mode: once per run
24
+ recorded.add(memo);
25
+ try {
26
+ const reg = await readRegistry(home);
27
+ const abs = resolve(root);
28
+ const list = reg[projectId] ?? [];
29
+ if (list.includes(abs))
30
+ return;
31
+ reg[projectId] = [...list, abs].slice(-20);
32
+ const path = registryPath(home);
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await atomicWrite(path, JSON.stringify(reg, null, 2));
35
+ }
36
+ catch {
37
+ /* registry is advisory */
38
+ }
39
+ }
40
+ /** Keys another folder already syncs to a real game path. */
41
+ function connectedKeysAt(root) {
42
+ try {
43
+ const state = JSON.parse(readFileSync(resolve(root, '.magicpixel', 'state.json'), 'utf8'));
44
+ return new Set(Object.entries(state.synced ?? {}).filter(([, v]) => !!v?.sourceRel).map(([k]) => k));
45
+ }
46
+ catch {
47
+ return new Set();
48
+ }
49
+ }
50
+ /**
51
+ * Of the keys this run would download WITHOUT a local game path, how many
52
+ * belong to another registered folder. Returns the folder owning the most, or
53
+ * null when the download is safe.
54
+ */
55
+ export async function findForeignOwner(projectId, cwd, downloadKeysWithoutLocalPath, home) {
56
+ if (downloadKeysWithoutLocalPath.length === 0)
57
+ return null;
58
+ const here = resolve(cwd);
59
+ const roots = ((await readRegistry(home))[projectId] ?? []).filter((r) => r !== here && existsSync(r));
60
+ let best = null;
61
+ for (const root of roots) {
62
+ const owned = connectedKeysAt(root);
63
+ const count = downloadKeysWithoutLocalPath.filter((k) => owned.has(k)).length;
64
+ if (count > 0 && (!best || count > best.count))
65
+ best = { root, count };
66
+ }
67
+ return best;
68
+ }
69
+ export function foreignOwnerMessage(owner, cwd, rerun) {
70
+ return (`This folder isn't the game these sprites are connected to.\n` +
71
+ ` ${owner.count} sprite${owner.count === 1 ? '' : 's'} live in ${owner.root}, not ${resolve(cwd)}.\n` +
72
+ ` Fix: cd "${owner.root}" and run sync there — or ${rerun} to download copies here anyway.`);
73
+ }
@@ -39,19 +39,3 @@ 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.35",
3
+ "version": "0.5.38",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {