@magicpixelart/cli 0.5.36 → 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/config.js CHANGED
@@ -113,6 +113,9 @@ export async function loadConfig(cwd = process.cwd()) {
113
113
  push: parsed.push === false ? false : undefined,
114
114
  connect,
115
115
  ...(parsed.syncOnly !== undefined ? { syncOnly: normalizeGlobList(parsed.syncOnly, 'syncOnly') } : {}),
116
+ ...(parsed.workspace === true && Array.isArray(parsed.members)
117
+ ? { workspace: true, members: normalizeGlobList(parsed.members, 'members') }
118
+ : {}),
116
119
  };
117
120
  }
118
121
  const MAX_GLOBS = 64;
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
@@ -94,6 +118,7 @@ program
94
118
  .option('-q, --quiet', 'Minimal output (for CI)')
95
119
  .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
96
120
  .option('--only <folder...>', 'Only sync these game folders (repeatable), e.g. Runtime/Sprites/Entities/decorations')
121
+ .option('--here', 'Download here even if these sprites are connected to another game folder')
97
122
  .argument('[extra...]')
98
123
  .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
124
  .action(wrap("sync", async (extra, opts) => {
@@ -101,7 +126,8 @@ program
101
126
  throw new Error(`sync doesn't take a folder (got "${extra.join(' ')}").\n` +
102
127
  ` Fix: ${cmd(`sync --only ${extra[0].replace(/^\/+/, '')}`)}`);
103
128
  }
104
- return syncCommand(opts);
129
+ const o = opts;
130
+ return syncCommand(o.only ? { ...o, only: o.only.map((f) => resolveInvokedPath(f)) } : o);
105
131
  }));
106
132
  program
107
133
  .command('push')
@@ -118,12 +144,12 @@ program
118
144
  .option('-y, --yes', 'Skip the confirmation question')
119
145
  .option('--dry-run', 'Show what would change without sending anything')
120
146
  .addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
121
- .action(wrap("resync", async (folder, opts) => resyncCommand(folder, opts)));
147
+ .action(wrap("resync", async (folder, opts) => resyncCommand(resolveInvokedPath(folder), opts)));
122
148
  program
123
149
  .command('connect <glob>')
124
150
  .description('Limit which game folders sync (default is all sprites)')
125
151
  .addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
126
- .action(wrap("connect", async (glob) => connectCommand(glob)));
152
+ .action(wrap("connect", async (glob) => connectCommand(resolveInvokedPath(glob))));
127
153
  program
128
154
  .command('search <query>')
129
155
  .description('Search indexed game PNGs (no network)')
@@ -150,4 +176,21 @@ program
150
176
  .command('whoami')
151
177
  .description('Verify the API key and show what it can see')
152
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)));
153
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;
@@ -4,9 +4,9 @@
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
- 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,19 +180,62 @@ 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;
185
229
  const preferFirst = startAt === scanRoot ? assetRoot : null;
186
230
  await walkPngs(startAt, scanRoot, destNorm, found, Infinity, io, report, stats, preferFirst);
187
- report.flush?.();
188
- opts.onProgress?.({ pngs: found.length, folders: stats.folders });
231
+ // Full Unity games also keep sprite sources in root dot-folders next to
232
+ // Assets/ (e.g. `.SpineRaw`). Walk those too; Unity's own folders stay out.
233
+ if (startAt !== scanRoot) {
234
+ for (const dir of await rootSpriteDotDirs(scanRoot)) {
235
+ await walkPngs(dir, scanRoot, destNorm, found, Infinity, io, report, stats, null);
236
+ }
237
+ }
238
+ stats.pngs += found.length;
189
239
  found.sort((a, b) => a.localeCompare(b));
190
240
  const files = [];
191
241
  for (const abs of found) {
@@ -193,17 +243,50 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
193
243
  if (entry)
194
244
  files.push(entry);
195
245
  }
196
- return { files };
246
+ return files;
247
+ }
248
+ /** One-line summary of exclude-dropped files, or null when none. */
249
+ export function describeExcluded(r) {
250
+ if (!r.excluded)
251
+ return null;
252
+ 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`;
253
+ }
254
+ /** Root-level dot-folders that may hold sprites (not VCS/editor/tool dirs). */
255
+ export async function rootSpriteDotDirs(root) {
256
+ try {
257
+ const ents = await readdir(root, { withFileTypes: true });
258
+ return ents
259
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && e.name.startsWith('.') && !shouldSkipDir(e.name))
260
+ .map((e) => resolve(root, e.name))
261
+ .sort();
262
+ }
263
+ catch {
264
+ return [];
265
+ }
197
266
  }
198
267
  /** Filter the index by connect globs minus exclude globs (cwd-, asset-root-relative or manifest key). */
199
268
  export function matchConnectGlobs(index, globs, exclude = []) {
200
269
  if (globs.length === 0)
201
270
  return { entries: [] };
202
- const hit = (e, g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g) || matchGlob(e.key, g);
271
+ const hit = entryMatchesGlob;
203
272
  // `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)));
273
+ const matched = [];
274
+ const byRule = new Map();
275
+ let excluded = 0;
276
+ for (const e of index.files) {
277
+ if (!globs.some((g) => hit(e, g)))
278
+ continue;
279
+ const rule = exclude.find((g) => hit(e, g));
280
+ if (rule === undefined)
281
+ matched.push(e);
282
+ else {
283
+ excluded++;
284
+ byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
285
+ }
286
+ }
205
287
  matched.sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
206
- return { entries: matched };
288
+ const topExclude = [...byRule.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
289
+ return { entries: matched, ...(excluded ? { excluded, topExclude } : {}) };
207
290
  }
208
291
  export function searchGameIndex(index, query) {
209
292
  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
+ }
@@ -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
+ }
@@ -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
- }