@magicpixelart/cli 0.5.38 → 0.6.1

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/dist/index.js CHANGED
@@ -17,6 +17,11 @@ import { startCommand } from './commands/start.js';
17
17
  import { connectCommand } from './commands/connect.js';
18
18
  import { resyncCommand } from './commands/resync.js';
19
19
  import { searchCommand } from './commands/search.js';
20
+ import { whyCommand } from './commands/why.js';
21
+ import { consolidateCommand } from './commands/consolidate.js';
22
+ import { findWorkspaceRoot, resolveInvokedPath } from './util/workspace.js';
23
+ import { errorCard, setPlain } from './util/ui.js';
24
+ import { cleanStraysCommand } from './commands/cleanStrays.js';
20
25
  import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
21
26
  import { CLI_VERSION } from './version.js';
22
27
  import { cmd } from './util/invoke.js';
@@ -26,6 +31,25 @@ if (major < 18) {
26
31
  console.error(kleur.red(`magicpixel requires Node.js >= 18 (you have ${process.versions.node}).`));
27
32
  process.exit(1);
28
33
  }
34
+ // A monorepo has one setup at its root. Run from any game folder inside it,
35
+ // every command uses that setup, so a second one is never created.
36
+ {
37
+ const wsRoot = findWorkspaceRoot(process.cwd());
38
+ if (wsRoot) {
39
+ process.stderr.write(kleur.dim(`Using the MagicPixel setup in ${wsRoot}\n`));
40
+ // Paths typed by the user stay relative to where they ran the command.
41
+ process.env.MAGICPIXEL_INVOKED_FROM = process.cwd();
42
+ process.chdir(wsRoot);
43
+ }
44
+ }
45
+ // `--plain` anywhere: no colors, emoji or redraws (also NO_COLOR, CI, pipes).
46
+ {
47
+ const i = process.argv.indexOf('--plain');
48
+ if (i > 1) {
49
+ process.argv.splice(i, 1);
50
+ setPlain(true);
51
+ }
52
+ }
29
53
  const program = new Command();
30
54
  program
31
55
  .name('magicpixel')
@@ -39,7 +63,7 @@ const wrap = (commandName, fn) => async (...args) => {
39
63
  const err = e;
40
64
  const msg = err.message ?? String(e);
41
65
  // Multi-line messages are already formatted with "Fix:" hints — print as-is.
42
- console.error(kleur.red(msg));
66
+ console.error(errorCard(msg));
43
67
  // Fire-and-forget telemetry → exit 1. `reportAndExit` decides whether
44
68
  // the error is worth surfacing on /admin/errors (5xx ApiErrors +
45
69
  // unexpected throws); user-fixable errors are filtered out inside
@@ -102,7 +126,8 @@ program
102
126
  throw new Error(`sync doesn't take a folder (got "${extra.join(' ')}").\n` +
103
127
  ` Fix: ${cmd(`sync --only ${extra[0].replace(/^\/+/, '')}`)}`);
104
128
  }
105
- return syncCommand(opts);
129
+ const o = opts;
130
+ return syncCommand(o.only ? { ...o, only: o.only.map((f) => resolveInvokedPath(f)) } : o);
106
131
  }));
107
132
  program
108
133
  .command('push')
@@ -119,12 +144,12 @@ program
119
144
  .option('-y, --yes', 'Skip the confirmation question')
120
145
  .option('--dry-run', 'Show what would change without sending anything')
121
146
  .addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
122
- .action(wrap("resync", async (folder, opts) => resyncCommand(folder, opts)));
147
+ .action(wrap("resync", async (folder, opts) => resyncCommand(resolveInvokedPath(folder), opts)));
123
148
  program
124
149
  .command('connect <glob>')
125
150
  .description('Limit which game folders sync (default is all sprites)')
126
151
  .addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
127
- .action(wrap("connect", async (glob) => connectCommand(glob)));
152
+ .action(wrap("connect", async (glob) => connectCommand(resolveInvokedPath(glob))));
128
153
  program
129
154
  .command('search <query>')
130
155
  .description('Search indexed game PNGs (no network)')
@@ -151,4 +176,21 @@ program
151
176
  .command('whoami')
152
177
  .description('Verify the API key and show what it can see')
153
178
  .action(wrap("whoami", async () => whoamiCommand()));
179
+ program
180
+ .command('why <path>')
181
+ .description('Explain what the next sync does with the game PNGs under a path')
182
+ .addHelpText('after', '\nExamples:\n $ magicpixel why .SpineRaw/rabbit\n $ magicpixel why Assets/Sprites/hero.png\n')
183
+ .action(wrap("why", async (path) => whyCommand(path)));
184
+ program
185
+ .command('consolidate')
186
+ .description('Merge the MagicPixel setups of several game folders into one at this (monorepo) folder')
187
+ .option('-y, --yes', 'Skip the confirmation question')
188
+ .option('--dry-run', 'Show what would change without changing anything')
189
+ .action(wrap("consolidate", async (opts) => { await consolidateCommand(opts); }));
190
+ program
191
+ .command('clean-strays')
192
+ .description('Delete copies sync wrote into outDir for sprites that belong to another game folder')
193
+ .option('-y, --yes', 'Skip the confirmation question')
194
+ .option('--dry-run', 'List the copies without deleting anything')
195
+ .action(wrap("clean-strays", async (opts) => cleanStraysCommand(opts)));
154
196
  program.parseAsync(process.argv);
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { relative, resolve } from 'node:path';
4
4
  const ENGINE_KINDS = new Set(['Unity', 'Godot', 'GameMaker']);
@@ -71,7 +71,23 @@ function hasMetaSidecar(dir) {
71
71
  return false;
72
72
  }
73
73
  }
74
- function detectEngineKind(cwd) {
74
+ /**
75
+ * Members of a monorepo workspace (`magicpixel.json` with `"workspace": true`),
76
+ * as root-relative folder names. Null when `cwd` is not a workspace root.
77
+ */
78
+ export function readWorkspaceMembers(cwd) {
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(resolve(cwd, 'magicpixel.json'), 'utf8'));
81
+ if (parsed?.workspace !== true || !Array.isArray(parsed.members))
82
+ return null;
83
+ const members = parsed.members.filter((m) => typeof m === 'string' && /^[^/\\.][^/\\]*$/.test(m) && m !== '..');
84
+ return members.length > 0 ? members : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ export function detectEngineKind(cwd) {
75
91
  if (existsSync(resolve(cwd, 'project.godot')))
76
92
  return 'Godot';
77
93
  const projectSettings = resolveChildDir(cwd, 'ProjectSettings');
@@ -102,6 +118,8 @@ function detectEngineKind(cwd) {
102
118
  }
103
119
  /** Infer project kind from package.json deps, then engine marker files. */
104
120
  export async function detectProjectKind(cwd = process.cwd()) {
121
+ if (readWorkspaceMembers(cwd))
122
+ return 'Unity';
105
123
  const js = await detectJsKind(cwd);
106
124
  if (js)
107
125
  return js;
@@ -6,7 +6,7 @@
6
6
  import { existsSync } from 'node:fs';
7
7
  import { opendir, readdir } from 'node:fs/promises';
8
8
  import { relative, resolve } from 'node:path';
9
- import { isEngineKind, resolveChildDir } from './framework.js';
9
+ import { detectEngineKind, isEngineKind, readWorkspaceMembers, resolveChildDir } from './framework.js';
10
10
  import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
11
11
  import { gameImportAdoptPath } from './pushPlan.js';
12
12
  import { matchGlob } from './globMatch.js';
@@ -73,6 +73,13 @@ function shouldSkipDir(name) {
73
73
  return true;
74
74
  return false;
75
75
  }
76
+ /** Does a connect/exclude glob select this file? (cwd-, asset-root-, member- or key-relative) */
77
+ export function entryMatchesGlob(e, g) {
78
+ return (matchGlob(e.sourceRel, g) ||
79
+ matchGlob(e.adoptRel, g) ||
80
+ matchGlob(e.key, g) ||
81
+ (!!e.member && matchGlob(`${e.member}/${e.adoptRel}`, g)));
82
+ }
76
83
  function importRel(abs, cwd, assetRoot) {
77
84
  if (assetRoot) {
78
85
  const rootNorm = assetRoot.replace(/\\/g, '/').toLowerCase();
@@ -173,12 +180,49 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
173
180
  if (!existsSync(scanRoot))
174
181
  return empty;
175
182
  const destNorm = outDir ? resolve(cwd, outDir).replace(/\\/g, '/').toLowerCase() : '';
183
+ const stats = { folders: 0, pngs: 0 };
184
+ const io = createLimit(SCAN_WALK_CONCURRENCY);
185
+ const report = bindScanProgress(opts.onProgress);
186
+ const members = readWorkspaceMembers(scanRoot);
187
+ if (members) {
188
+ // Each game folder is indexed exactly as if sync ran inside it (same keys),
189
+ // then its paths are re-rooted at the workspace.
190
+ const byKey = new Map();
191
+ const keyClashes = [];
192
+ for (const member of members) {
193
+ const dir = resolve(scanRoot, member);
194
+ if (!existsSync(dir))
195
+ continue;
196
+ const memberKind = detectEngineKind(dir) ?? kind;
197
+ for (const e of await indexOne(memberKind, dir, destNorm, io, report, stats)) {
198
+ const sourceRel = sanitizeSourceRel(`${member}/${e.sourceRel}`);
199
+ if (!sourceRel)
200
+ continue;
201
+ if (byKey.has(e.key)) {
202
+ keyClashes.push(sourceRel);
203
+ continue;
204
+ }
205
+ byKey.set(e.key, { ...e, sourceRel, member });
206
+ }
207
+ }
208
+ report.flush?.();
209
+ opts.onProgress?.({ pngs: stats.pngs, folders: stats.folders });
210
+ const files = [...byKey.values()].sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
211
+ return keyClashes.length ? { files, keyClashes } : { files };
212
+ }
213
+ const files = await indexOne(kind, scanRoot, destNorm, io, report, stats);
214
+ report.flush?.();
215
+ opts.onProgress?.({ pngs: stats.pngs, folders: stats.folders });
216
+ return { files };
217
+ }
218
+ /** Index one game folder (project or package). Paths are relative to `cwd`. */
219
+ async function indexOne(kind, cwd, destNorm, io, baseReport, stats) {
220
+ const scanRoot = resolve(cwd);
176
221
  const namedRoot = gameScanRoot(kind);
177
222
  const assetRoot = namedRoot ? resolveChildDir(cwd, namedRoot) : null;
178
223
  const found = [];
179
- const stats = { folders: 0 };
180
- const io = createLimit(SCAN_WALK_CONCURRENCY);
181
- const report = bindScanProgress(opts.onProgress);
224
+ const offset = stats.pngs;
225
+ const report = (pngs, folders, current) => baseReport(offset + pngs, folders, current);
182
226
  // Full Unity games: walk Assets/ only. Listing the project root waits on
183
227
  // Library/Packages/cloud placeholders and looks hung at "1 folder".
184
228
  const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
@@ -191,8 +235,7 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
191
235
  await walkPngs(dir, scanRoot, destNorm, found, Infinity, io, report, stats, null);
192
236
  }
193
237
  }
194
- report.flush?.();
195
- opts.onProgress?.({ pngs: found.length, folders: stats.folders });
238
+ stats.pngs += found.length;
196
239
  found.sort((a, b) => a.localeCompare(b));
197
240
  const files = [];
198
241
  for (const abs of found) {
@@ -200,7 +243,7 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
200
243
  if (entry)
201
244
  files.push(entry);
202
245
  }
203
- return { files };
246
+ return files;
204
247
  }
205
248
  /** One-line summary of exclude-dropped files, or null when none. */
206
249
  export function describeExcluded(r) {
@@ -225,7 +268,7 @@ export async function rootSpriteDotDirs(root) {
225
268
  export function matchConnectGlobs(index, globs, exclude = []) {
226
269
  if (globs.length === 0)
227
270
  return { entries: [] };
228
- const hit = (e, g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g) || matchGlob(e.key, g);
271
+ const hit = entryMatchesGlob;
229
272
  // `exclude` wins over `connect`: excluded game files are never uploaded.
230
273
  const matched = [];
231
274
  const byRule = new Map();
@@ -0,0 +1,15 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ /** Ask a y/N question. Non-interactive runs (CI) always answer no. */
4
+ export async function confirm(prompt) {
5
+ if (!stdin.isTTY)
6
+ return false;
7
+ const rl = createInterface({ input: stdin, output: stdout });
8
+ try {
9
+ const a = (await rl.question(prompt)).trim().toLowerCase();
10
+ return a === 'y' || a === 'yes';
11
+ }
12
+ finally {
13
+ rl.close();
14
+ }
15
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Game-owned sprites (manifest `game: true` — they live in a connected library
3
+ * folder) belong to a real file in some game folder. Sync may write one only
4
+ * to its game path here, or beside siblings this folder already owns (a new
5
+ * variant saved next to its family). Otherwise it is skipped instead of being
6
+ * dropped as a stray copy into outDir — this works on a machine that never
7
+ * synced the owning folder, unlike the project-root registry.
8
+ */
9
+ /** Library folder a manifest key sits in (`a/b/<doc>/<artboard>` → `a/b`). */
10
+ export function libraryFolderOf(key) {
11
+ const parts = key.split('/');
12
+ return parts.length > 2 ? parts.slice(0, -2).join('/') : '';
13
+ }
14
+ export function splitStrayDownloads(entries, localKeys) {
15
+ const local = new Set(localKeys);
16
+ const localFolders = new Set();
17
+ for (const k of local) {
18
+ const f = libraryFolderOf(k);
19
+ if (f)
20
+ localFolders.add(f);
21
+ }
22
+ const keep = [];
23
+ const foreign = [];
24
+ for (const e of entries) {
25
+ const homeless = e.game === true && !local.has(e.key) && !localFolders.has(libraryFolderOf(e.key));
26
+ (homeless ? foreign : keep).push(e);
27
+ }
28
+ return { keep, foreign };
29
+ }
30
+ export function straySkipMessage(count, ownerRoot, rerun) {
31
+ const where = ownerRoot ? ` (they live in ${ownerRoot})` : '';
32
+ return (`${count.toLocaleString('en-US')} sprite${count === 1 ? '' : 's'} belong to another game folder${where} — skipped, nothing written here.\n` +
33
+ ` Sync from that folder instead, or ${rerun} to download copies here anyway.`);
34
+ }
35
+ /**
36
+ * New game-folder art (e.g. a variant saved as a new file) goes next to its
37
+ * siblings in the game — same folder, same file-naming style — instead of
38
+ * into outDir. Adds `key → game path` to `sourceByKey`; returns how many.
39
+ */
40
+ export function placeBesideSiblings(sourceByKey, entries, liveRels) {
41
+ const byFolder = new Map();
42
+ for (const [k, rel] of sourceByKey) {
43
+ const f = libraryFolderOf(k);
44
+ if (!f)
45
+ continue;
46
+ const list = byFolder.get(f) ?? [];
47
+ list.push(rel);
48
+ byFolder.set(f, list);
49
+ }
50
+ const taken = new Set([...liveRels].map((r) => r.toLowerCase()));
51
+ for (const rel of sourceByKey.values())
52
+ taken.add(rel.toLowerCase());
53
+ let placed = 0;
54
+ for (const e of entries) {
55
+ if (e.game !== true || e.withheld || sourceByKey.has(e.key))
56
+ continue;
57
+ const sibs = byFolder.get(libraryFolderOf(e.key));
58
+ if (!sibs?.length)
59
+ continue;
60
+ const sib = [...sibs].sort()[0];
61
+ const slash = sib.lastIndexOf('/');
62
+ const dir = slash >= 0 ? sib.slice(0, slash) : '';
63
+ const base = sib.slice(slash + 1).replace(/\.png$/i, '');
64
+ const slug = e.key.split('/').pop() ?? '';
65
+ if (!slug)
66
+ continue;
67
+ const name = base.includes('_') && !base.includes('-') ? slug.replace(/-/g, '_') : slug;
68
+ const rel = dir ? `${dir}/${name}.png` : `${name}.png`;
69
+ if (taken.has(rel.toLowerCase()))
70
+ continue;
71
+ taken.add(rel.toLowerCase());
72
+ sourceByKey.set(e.key, rel);
73
+ placed++;
74
+ }
75
+ return placed;
76
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * One look for every CLI message: icons, colors, cards, progress bars.
3
+ * Plain mode (pipes, CI, NO_COLOR, TERM=dumb, --plain) drops colors, emoji and
4
+ * box drawing so logs stay greppable.
5
+ */
6
+ import kleur from 'kleur';
7
+ import ora from 'ora';
8
+ let forcedPlain = false;
9
+ /** True when output should be plain ASCII without colors. */
10
+ export function isPlain(stream = process.stdout) {
11
+ if (forcedPlain)
12
+ return true;
13
+ const env = process.env;
14
+ if (env.MAGICPIXEL_PLAIN === '1' || 'NO_COLOR' in env || env.TERM === 'dumb' || env.CI)
15
+ return true;
16
+ return !stream.isTTY;
17
+ }
18
+ /** In-place updates (spinners, `\r` status lines) only on a live, non-plain terminal. */
19
+ export function canRedraw(stream = process.stdout) {
20
+ return !isPlain(stream);
21
+ }
22
+ /** Spinner that stays silent in plain mode (no animation, no redraws). */
23
+ export function spinner(opts) {
24
+ const o = typeof opts === 'string' ? { text: opts } : opts;
25
+ return ora({ spinner: 'dots', ...o, isEnabled: canRedraw() && o.isEnabled !== false });
26
+ }
27
+ /** `--plain` flag: force plain output for this run. */
28
+ export function setPlain(on) {
29
+ forcedPlain = on;
30
+ if (on)
31
+ kleur.enabled = false;
32
+ }
33
+ const EMOJI = {
34
+ ok: '✅', warn: '⚠️ ', fail: '❌', tip: '💡', work: '⏳', watch: '👀', down: '⬇️ ', up: '⬆️ ',
35
+ removed: '🗑 ', sparkle: '✨', ghost: '👻', time: '⏱ ', paused: '⏸ ',
36
+ };
37
+ const ASCII = {
38
+ ok: '[ok]', warn: '[!]', fail: '[x]', tip: '[>]', work: '[..]', watch: '[watch]', down: '[v]', up: '[^]',
39
+ removed: '[-]', sparkle: '[*]', ghost: '[dry]', time: '[t]', paused: '[||]',
40
+ };
41
+ export function icon(name) {
42
+ return isPlain() ? ASCII[name] : EMOJI[name];
43
+ }
44
+ /** One status line: icon + colored text. */
45
+ export const ui = {
46
+ ok: (text) => `${icon('ok')} ${kleur.green(text)}`,
47
+ warn: (text) => `${icon('warn')} ${kleur.yellow(text)}`,
48
+ fail: (text) => `${icon('fail')} ${kleur.red(text)}`,
49
+ /** `💡 Fix: <command>` — the command is highlighted so it's easy to copy. */
50
+ fix: (command, indent = ' ') => `${indent}${icon('tip')} Fix: ${kleur.cyan().bold(command)}`,
51
+ /** Numbered section header, e.g. `1/4 · Checking your login`. */
52
+ step: (n, title, total) => `${kleur.magenta().bold(total ? `${n}/${total}` : `Step ${n}`)} ${kleur.dim('·')} ${kleur.bold(title)}`,
53
+ /** `💡 Fix: …` for a sentence that already contains its command in backticks. */
54
+ fixText: (text, indent = ' ') => `${indent}${icon('tip')} ${kleur.cyan('Fix:')} ${text.replace(/`([^`]+)`/g, (_, c) => kleur.cyan().bold(c))}`,
55
+ name: (s) => kleur.cyan(s),
56
+ count: (n) => kleur.bold(n.toLocaleString('en-US')),
57
+ dryRun: () => `${icon('ghost')} ${kleur.magenta('Preview only — nothing changed.')}`,
58
+ };
59
+ // Strip ANSI so widths count visible characters.
60
+ // eslint-disable-next-line no-control-regex
61
+ const ANSI = /\x1b\[[0-9;]*m/g;
62
+ export function visibleWidth(s) {
63
+ // Emoji are double width in most terminals; variation selectors take none.
64
+ let w = 0;
65
+ for (const ch of s.replace(ANSI, '')) {
66
+ const cp = ch.codePointAt(0);
67
+ if (cp === 0xfe0f || cp === 0x200d)
68
+ continue;
69
+ w += cp >= 0x1f000 || (cp >= 0x2600 && cp <= 0x27bf) || (cp >= 0x2b00 && cp <= 0x2bff) || cp === 0x23f1 || cp === 0x23f8 || cp === 0x23f3 ? 2 : 1;
70
+ }
71
+ return w;
72
+ }
73
+ /** Cut a plain line to `max` visible columns. */
74
+ export function fitLine(line, columns) {
75
+ const max = Math.max(20, (columns ?? 80) - 1);
76
+ if (visibleWidth(line) <= max)
77
+ return line;
78
+ const plain = line.replace(ANSI, '');
79
+ let out = '';
80
+ for (const ch of plain) {
81
+ if (visibleWidth(out + ch) > max - 1)
82
+ break;
83
+ out += ch;
84
+ }
85
+ return `${out}…`;
86
+ }
87
+ /** Boxed summary. Plain mode prints a title line plus indented rows. */
88
+ /** Break a line into pieces no wider than `max` visible columns (at spaces when possible). */
89
+ export function wrapLine(line, max) {
90
+ if (visibleWidth(line) <= max)
91
+ return [line];
92
+ const plain = line.replace(ANSI, '');
93
+ const out = [];
94
+ let cur = '';
95
+ for (const word of plain.split(/(\s+)/)) {
96
+ if (visibleWidth(cur + word) <= max) {
97
+ cur += word;
98
+ continue;
99
+ }
100
+ if (cur.trim())
101
+ out.push(cur.trimEnd());
102
+ cur = word.trimStart();
103
+ while (visibleWidth(cur) > max) {
104
+ out.push(cur.slice(0, max));
105
+ cur = cur.slice(max);
106
+ }
107
+ }
108
+ if (cur.trim())
109
+ out.push(cur);
110
+ return out;
111
+ }
112
+ export function card(title, rows, tone = 'info', columns = process.stdout.columns, overflow = 'cut') {
113
+ const paint = tone === 'ok' ? kleur.green : tone === 'warn' ? kleur.yellow : tone === 'fail' ? kleur.red : kleur.cyan;
114
+ if (isPlain())
115
+ return [title, ...rows.map((r) => ` ${r}`)].join('\n');
116
+ const limit = Math.max(30, Math.min(72, (columns ?? 80) - 2));
117
+ const body = rows.flatMap((r) => (visibleWidth(r) <= limit - 6 ? [r] : overflow === 'wrap' ? wrapLine(r, limit - 6) : [fitLine(r, limit - 5)]));
118
+ const inner = Math.min(limit - 2, Math.max(visibleWidth(title) + 4, ...body.map((r) => visibleWidth(r) + 4)));
119
+ const top = `╭─ ${title} ${'─'.repeat(Math.max(0, inner - visibleWidth(title) - 3))}╮`;
120
+ const mid = body.map((r) => `│ ${r}${' '.repeat(Math.max(0, inner - visibleWidth(r) - 2))}│`);
121
+ const bottom = `╰${'─'.repeat(inner)}╯`;
122
+ return [paint(top), ...mid.map((m) => paint('│') + m.slice(1, -1) + paint('│')), paint(bottom)].join('\n');
123
+ }
124
+ /** `[████░░░░] 1,204 / 3,114 · 39%` */
125
+ export function progressBar(done, total, width = 20) {
126
+ const pct = total === 0 ? 100 : Math.min(100, Math.round((done / total) * 100));
127
+ const filled = Math.round((pct / 100) * width);
128
+ const [on, off] = isPlain() ? ['#', '.'] : ['█', '░'];
129
+ return `[${kleur.green(on.repeat(filled))}${kleur.dim(off.repeat(width - filled))}] ${done.toLocaleString('en-US')}/${total.toLocaleString('en-US')} · ${pct}%`;
130
+ }
131
+ /** Format a multi-line error (first line = what happened, `Fix:` lines = how to fix). */
132
+ export function errorCard(message) {
133
+ const lines = message.split('\n').map((l) => l.trimEnd()).filter((l, i) => i === 0 || l.trim().length > 0);
134
+ const [head, ...rest] = lines;
135
+ const rows = rest.map((l) => {
136
+ const t = l.trim();
137
+ if (/^fix:/i.test(t))
138
+ return `${icon('tip')} ${kleur.cyan(t)}`;
139
+ if (/request id/i.test(t))
140
+ return kleur.dim(t);
141
+ return t;
142
+ });
143
+ if (isPlain(process.stderr))
144
+ return [`${icon('fail')} ${head}`, ...rows.map((r) => ` ${r}`)].join('\n');
145
+ const cols = process.stderr.columns ?? process.stdout.columns ?? 80;
146
+ const text = head ?? 'Something went wrong';
147
+ const fits = visibleWidth(text) <= Math.min(72, cols - 2) - 8;
148
+ const title = `${icon('fail')} ${fits ? kleur.bold(text) : 'Something went wrong'}`;
149
+ return card(title, fits ? (rows.length ? rows : [kleur.dim('Run the command again, or add --plain for a log-friendly copy.')]) : [kleur.bold(text), ...rows], 'fail', cols, 'wrap');
150
+ }
151
+ export function formatDuration(ms) {
152
+ if (ms < 1000)
153
+ return `${ms}ms`;
154
+ const s = Math.round(ms / 1000);
155
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
156
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Monorepo workspaces: one `magicpixel.json` at the root syncs several game
3
+ * folders (Unity projects / packages). Each folder keeps the sprite names it
4
+ * would have on its own, so moving to a workspace never re-uploads anything.
5
+ */
6
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
7
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
8
+ import { detectEngineKind, isEngineKind, readWorkspaceMembers } from './framework.js';
9
+ const NOT_MEMBERS = new Set(['node_modules', 'library', 'temp', 'build', 'builds', 'logs', 'obj']);
10
+ /** Child folders of `root` that are game folders on their own. Sorted. */
11
+ export function detectWorkspaceMembers(root) {
12
+ try {
13
+ return readdirSync(root, { withFileTypes: true })
14
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith('.') && !NOT_MEMBERS.has(e.name.toLowerCase()))
15
+ .filter((e) => isEngineKind(detectEngineKind(resolve(root, e.name))))
16
+ .map((e) => e.name)
17
+ .sort((a, b) => a.localeCompare(b));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ /** Nearest ancestor (excluding `cwd`) that is a workspace root, or null. */
24
+ export function findWorkspaceRoot(cwd) {
25
+ let dir = dirname(resolve(cwd));
26
+ for (let i = 0; i < 32; i++) {
27
+ if (existsSync(resolve(dir, 'magicpixel.json')) && readWorkspaceMembers(dir))
28
+ return dir;
29
+ const parent = dirname(dir);
30
+ if (parent === dir)
31
+ return null;
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+ /** Re-anchor a game-folder glob at the workspace root. */
37
+ export function anchorGlob(member, glob) {
38
+ const g = glob.trim().replace(/^\.\//, '');
39
+ return `${member}/${g}`;
40
+ }
41
+ /**
42
+ * A path typed by the user, made relative to the setup folder. Commands run
43
+ * inside a game folder of a monorepo work from the root, so `Runtime` typed in
44
+ * `kr-core` means `kr-core/Runtime`.
45
+ */
46
+ /** Resolve symlinks on the existing prefix so `/var` and `/private/var` compare equal. */
47
+ function canonical(p) {
48
+ const abs = resolve(p);
49
+ const missing = [];
50
+ let dir = abs;
51
+ while (!existsSync(dir)) {
52
+ const parent = dirname(dir);
53
+ if (parent === dir)
54
+ return abs;
55
+ missing.push(basename(dir));
56
+ dir = parent;
57
+ }
58
+ try {
59
+ const real = realpathSync(dir);
60
+ return missing.length === 0 ? real : resolve(real, ...missing.reverse());
61
+ }
62
+ catch {
63
+ return abs;
64
+ }
65
+ }
66
+ export function resolveInvokedPath(typed, cwd = process.cwd()) {
67
+ const from = process.env.MAGICPIXEL_INVOKED_FROM;
68
+ if (!from || isAbsolute(typed))
69
+ return typed;
70
+ return relative(canonical(cwd), canonical(resolve(from, typed))).replace(/\\/g, '/') || '.';
71
+ }
72
+ /**
73
+ * Library folders don't include the game-folder name, so drop a leading
74
+ * monorepo member (`kr-core/Runtime` → `Runtime`) before matching them.
75
+ */
76
+ export function stripMemberSegment(segments, cwd = process.cwd()) {
77
+ const members = readWorkspaceMembers(cwd);
78
+ if (!members || segments.length < 2)
79
+ return segments;
80
+ const first = segments[0];
81
+ return members.some((m) => m.toLowerCase() === first) ? segments.slice(1) : segments;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.38",
3
+ "version": "0.6.1",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {