@magicpixelart/cli 0.5.38 → 0.6.2
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 +53 -0
- package/README.md +26 -1
- package/dist/commands/add.js +2 -1
- package/dist/commands/cleanStrays.js +92 -0
- package/dist/commands/connect.js +9 -6
- package/dist/commands/consolidate.js +309 -0
- package/dist/commands/doctor.js +3 -2
- package/dist/commands/init.js +6 -5
- package/dist/commands/login.js +2 -1
- package/dist/commands/logout.js +2 -1
- package/dist/commands/push.js +64 -19
- package/dist/commands/remove.js +2 -1
- package/dist/commands/repair.js +13 -23
- package/dist/commands/resync.js +11 -20
- package/dist/commands/search.js +2 -2
- package/dist/commands/start.js +100 -16
- package/dist/commands/sync.js +128 -60
- package/dist/commands/whoami.js +4 -3
- package/dist/commands/why.js +105 -0
- package/dist/config.js +3 -0
- package/dist/index.js +49 -5
- package/dist/util/commandTips.js +33 -0
- package/dist/util/engineConnect.js +39 -0
- package/dist/util/framework.js +20 -2
- package/dist/util/gameScan.js +51 -8
- package/dist/util/prompt.js +15 -0
- package/dist/util/strayGuard.js +76 -0
- package/dist/util/ui.js +175 -0
- package/dist/util/workspace.js +82 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -17,20 +17,46 @@ 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';
|
|
28
|
+
import { formatHelpTips } from './util/commandTips.js';
|
|
23
29
|
// Node version guard
|
|
24
30
|
const major = Number(process.versions.node.split('.')[0]);
|
|
25
31
|
if (major < 18) {
|
|
26
32
|
console.error(kleur.red(`magicpixel requires Node.js >= 18 (you have ${process.versions.node}).`));
|
|
27
33
|
process.exit(1);
|
|
28
34
|
}
|
|
35
|
+
// A monorepo has one setup at its root. Run from any game folder inside it,
|
|
36
|
+
// every command uses that setup, so a second one is never created.
|
|
37
|
+
{
|
|
38
|
+
const wsRoot = findWorkspaceRoot(process.cwd());
|
|
39
|
+
if (wsRoot) {
|
|
40
|
+
process.stderr.write(kleur.dim(`Using the MagicPixel setup in ${wsRoot}\n`));
|
|
41
|
+
// Paths typed by the user stay relative to where they ran the command.
|
|
42
|
+
process.env.MAGICPIXEL_INVOKED_FROM = process.cwd();
|
|
43
|
+
process.chdir(wsRoot);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// `--plain` anywhere: no colors, emoji or redraws (also NO_COLOR, CI, pipes).
|
|
47
|
+
{
|
|
48
|
+
const i = process.argv.indexOf('--plain');
|
|
49
|
+
if (i > 1) {
|
|
50
|
+
process.argv.splice(i, 1);
|
|
51
|
+
setPlain(true);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
29
54
|
const program = new Command();
|
|
30
55
|
program
|
|
31
56
|
.name('magicpixel')
|
|
32
57
|
.description('Sync MagicPixel pixel-art assets to your local project')
|
|
33
|
-
.version(CLI_VERSION)
|
|
58
|
+
.version(CLI_VERSION)
|
|
59
|
+
.addHelpText('after', formatHelpTips());
|
|
34
60
|
const wrap = (commandName, fn) => async (...args) => {
|
|
35
61
|
try {
|
|
36
62
|
await fn(...args);
|
|
@@ -39,7 +65,7 @@ const wrap = (commandName, fn) => async (...args) => {
|
|
|
39
65
|
const err = e;
|
|
40
66
|
const msg = err.message ?? String(e);
|
|
41
67
|
// Multi-line messages are already formatted with "Fix:" hints — print as-is.
|
|
42
|
-
console.error(
|
|
68
|
+
console.error(errorCard(msg));
|
|
43
69
|
// Fire-and-forget telemetry → exit 1. `reportAndExit` decides whether
|
|
44
70
|
// the error is worth surfacing on /admin/errors (5xx ApiErrors +
|
|
45
71
|
// unexpected throws); user-fixable errors are filtered out inside
|
|
@@ -102,7 +128,8 @@ program
|
|
|
102
128
|
throw new Error(`sync doesn't take a folder (got "${extra.join(' ')}").\n` +
|
|
103
129
|
` Fix: ${cmd(`sync --only ${extra[0].replace(/^\/+/, '')}`)}`);
|
|
104
130
|
}
|
|
105
|
-
|
|
131
|
+
const o = opts;
|
|
132
|
+
return syncCommand(o.only ? { ...o, only: o.only.map((f) => resolveInvokedPath(f)) } : o);
|
|
106
133
|
}));
|
|
107
134
|
program
|
|
108
135
|
.command('push')
|
|
@@ -119,12 +146,12 @@ program
|
|
|
119
146
|
.option('-y, --yes', 'Skip the confirmation question')
|
|
120
147
|
.option('--dry-run', 'Show what would change without sending anything')
|
|
121
148
|
.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)));
|
|
149
|
+
.action(wrap("resync", async (folder, opts) => resyncCommand(resolveInvokedPath(folder), opts)));
|
|
123
150
|
program
|
|
124
151
|
.command('connect <glob>')
|
|
125
152
|
.description('Limit which game folders sync (default is all sprites)')
|
|
126
153
|
.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)));
|
|
154
|
+
.action(wrap("connect", async (glob) => connectCommand(resolveInvokedPath(glob))));
|
|
128
155
|
program
|
|
129
156
|
.command('search <query>')
|
|
130
157
|
.description('Search indexed game PNGs (no network)')
|
|
@@ -151,4 +178,21 @@ program
|
|
|
151
178
|
.command('whoami')
|
|
152
179
|
.description('Verify the API key and show what it can see')
|
|
153
180
|
.action(wrap("whoami", async () => whoamiCommand()));
|
|
181
|
+
program
|
|
182
|
+
.command('why <path>')
|
|
183
|
+
.description('Explain what the next sync does with the game PNGs under a path')
|
|
184
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel why .SpineRaw/rabbit\n $ magicpixel why Assets/Sprites/hero.png\n')
|
|
185
|
+
.action(wrap("why", async (path) => whyCommand(path)));
|
|
186
|
+
program
|
|
187
|
+
.command('consolidate')
|
|
188
|
+
.description('Merge the MagicPixel setups of several game folders into one at this (monorepo) folder')
|
|
189
|
+
.option('-y, --yes', 'Skip the confirmation question')
|
|
190
|
+
.option('--dry-run', 'Show what would change without changing anything')
|
|
191
|
+
.action(wrap("consolidate", async (opts) => { await consolidateCommand(opts); }));
|
|
192
|
+
program
|
|
193
|
+
.command('clean-strays')
|
|
194
|
+
.description('Delete copies sync wrote into outDir for sprites that belong to another game folder')
|
|
195
|
+
.option('-y, --yes', 'Skip the confirmation question')
|
|
196
|
+
.option('--dry-run', 'List the copies without deleting anything')
|
|
197
|
+
.action(wrap("clean-strays", async (opts) => cleanStraysCommand(opts)));
|
|
154
198
|
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common commands shown after `start`, in `--help`, and hinted after `sync`.
|
|
3
|
+
* One list so the three places never drift apart.
|
|
4
|
+
*/
|
|
5
|
+
import kleur from 'kleur';
|
|
6
|
+
import { cmd } from './invoke.js';
|
|
7
|
+
export const COMMAND_TIPS = [
|
|
8
|
+
{ run: 'sync --watch', what: 'keep sprites fresh while you work' },
|
|
9
|
+
{ run: "connect '<folder>/**'", what: 'add a folder to sync (hidden ones like .SpineRaw/... too)' },
|
|
10
|
+
{ run: 'sync --only <folder>', what: 'sync just one folder' },
|
|
11
|
+
{ run: 'why <folder>', what: "see why files aren't uploading" },
|
|
12
|
+
{ run: 'resync <folder>', what: 'local files win for one folder' },
|
|
13
|
+
{ run: 'clean-strays', what: 'remove copies in the wrong place' },
|
|
14
|
+
{ run: 'status', what: 'check your setup and last sync' },
|
|
15
|
+
{ run: 'doctor', what: 'diagnose problems' },
|
|
16
|
+
];
|
|
17
|
+
const width = Math.max(...COMMAND_TIPS.map((t) => t.run.length));
|
|
18
|
+
/** "Useful commands" block for the end of `start`. */
|
|
19
|
+
export function formatTipsBlock(tips = COMMAND_TIPS) {
|
|
20
|
+
const prefix = cmd('').trimEnd();
|
|
21
|
+
return [
|
|
22
|
+
kleur.bold(' Useful commands'),
|
|
23
|
+
...tips.map((t) => ` ${kleur.cyan(`${prefix} ${t.run.padEnd(width)}`)} ${kleur.dim(t.what)}`),
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
/** "Common tasks" section for `magicpixel --help` (no colors: commander prints raw). */
|
|
27
|
+
export function formatHelpTips() {
|
|
28
|
+
return ['', 'Common tasks:', ...COMMAND_TIPS.map((t) => ` $ magicpixel ${t.run.padEnd(width)} # ${t.what}`), ''].join('\n');
|
|
29
|
+
}
|
|
30
|
+
/** One-line hint after a one-shot sync. */
|
|
31
|
+
export function syncFooterHint() {
|
|
32
|
+
return ` Not seeing a folder? Run \`${cmd('why')} <folder>\`. All commands: \`${cmd('--help')}\`.`;
|
|
33
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { saveConfig } from '../config.js';
|
|
2
2
|
import { detectProjectKind, isEngineKind } from './framework.js';
|
|
3
|
+
import { entryMatchesGlob } from './gameScan.js';
|
|
3
4
|
export const DEFAULT_ENGINE_CONNECT = ['**'];
|
|
4
5
|
const ALL_SPRITES_GLOBS = new Set(['**', '**/*', '**/**', '**/*.png', '**/**/*.png']);
|
|
5
6
|
export function isAllSpritesGlob(glob) {
|
|
@@ -19,6 +20,44 @@ export function nextConnectGlobs(current, pattern) {
|
|
|
19
20
|
return withoutAll;
|
|
20
21
|
return [...withoutAll, pattern];
|
|
21
22
|
}
|
|
23
|
+
/** Clean one folder pattern: `\` → `/`, collapse `//`, drop `./`, a trailing `/` or stray `~`. */
|
|
24
|
+
export function normalizeConnectGlob(glob) {
|
|
25
|
+
return glob
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/\\/g, '/')
|
|
28
|
+
.replace(/\/{2,}/g, '/')
|
|
29
|
+
.replace(/^(\.\/)+/, '')
|
|
30
|
+
.replace(/~+$/, '')
|
|
31
|
+
.replace(/\/+$/, '');
|
|
32
|
+
}
|
|
33
|
+
/** Normalize, dedupe, and drop patterns already covered by a broader `<folder>/**`. */
|
|
34
|
+
export function tidyConnectGlobs(globs) {
|
|
35
|
+
const clean = [...new Set(globs.map(normalizeConnectGlob).filter(Boolean))];
|
|
36
|
+
const roots = clean.filter((g) => g.endsWith('/**')).map((g) => g.slice(0, -3).toLowerCase());
|
|
37
|
+
return clean.filter((g) => {
|
|
38
|
+
const base = (g.endsWith('/**') ? g.slice(0, -3) : g).toLowerCase();
|
|
39
|
+
return !roots.some((r) => r !== base && (base === r || base.startsWith(`${r}/`)))
|
|
40
|
+
&& !(roots.includes(base) && !g.endsWith('/**'));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hidden art folders (`<member>/.SpineRaw/<name>`) with PNGs that no pattern
|
|
45
|
+
* watches. Returns `[folder, count]`, largest first.
|
|
46
|
+
*/
|
|
47
|
+
export function unwatchedHiddenFolders(files, watchedKeys, exclude = [], min = 10) {
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
if (watchedKeys.has(f.key) || exclude.some((g) => entryMatchesGlob(f, g)))
|
|
51
|
+
continue;
|
|
52
|
+
const parts = f.sourceRel.split('/');
|
|
53
|
+
const i = parts.findIndex((p) => p.startsWith('.') && p.length > 1);
|
|
54
|
+
if (i < 0 || i + 2 >= parts.length)
|
|
55
|
+
continue;
|
|
56
|
+
const folder = parts.slice(0, i + 2).join('/');
|
|
57
|
+
counts.set(folder, (counts.get(folder) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
return [...counts].filter(([, n]) => n >= min).sort((a, b) => b[1] - a[1]);
|
|
60
|
+
}
|
|
22
61
|
export async function ensureEngineConnect(config, cwd = process.cwd(), persist = true) {
|
|
23
62
|
if (config.connect.length > 0)
|
|
24
63
|
return config;
|
package/dist/util/framework.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/util/gameScan.js
CHANGED
|
@@ -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
|
|
180
|
-
const
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/dist/util/ui.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
/**
|
|
75
|
+
* Path that fits in `max` columns, keeping the end (the file and its parent
|
|
76
|
+
* folders) and dropping leading folders: `…/rabbit/agni/ear_left_front.png`.
|
|
77
|
+
*/
|
|
78
|
+
export function shortenPath(path, max) {
|
|
79
|
+
const p = path.replace(/\.png$/i, '');
|
|
80
|
+
const limit = Math.max(12, max);
|
|
81
|
+
if (p.length <= limit)
|
|
82
|
+
return p;
|
|
83
|
+
const parts = p.split('/');
|
|
84
|
+
let out = parts[parts.length - 1];
|
|
85
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
86
|
+
const next = `${parts[i]}/${out}`;
|
|
87
|
+
if (next.length + 2 > limit)
|
|
88
|
+
break;
|
|
89
|
+
out = next;
|
|
90
|
+
}
|
|
91
|
+
return out.length + 2 <= limit ? `…/${out}` : `…${out.slice(-(limit - 1))}`;
|
|
92
|
+
}
|
|
93
|
+
export function fitLine(line, columns) {
|
|
94
|
+
const max = Math.max(20, (columns ?? 80) - 1);
|
|
95
|
+
if (visibleWidth(line) <= max)
|
|
96
|
+
return line;
|
|
97
|
+
const plain = line.replace(ANSI, '');
|
|
98
|
+
let out = '';
|
|
99
|
+
for (const ch of plain) {
|
|
100
|
+
if (visibleWidth(out + ch) > max - 1)
|
|
101
|
+
break;
|
|
102
|
+
out += ch;
|
|
103
|
+
}
|
|
104
|
+
return `${out}…`;
|
|
105
|
+
}
|
|
106
|
+
/** Boxed summary. Plain mode prints a title line plus indented rows. */
|
|
107
|
+
/** Break a line into pieces no wider than `max` visible columns (at spaces when possible). */
|
|
108
|
+
export function wrapLine(line, max) {
|
|
109
|
+
if (visibleWidth(line) <= max)
|
|
110
|
+
return [line];
|
|
111
|
+
const plain = line.replace(ANSI, '');
|
|
112
|
+
const out = [];
|
|
113
|
+
let cur = '';
|
|
114
|
+
for (const word of plain.split(/(\s+)/)) {
|
|
115
|
+
if (visibleWidth(cur + word) <= max) {
|
|
116
|
+
cur += word;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (cur.trim())
|
|
120
|
+
out.push(cur.trimEnd());
|
|
121
|
+
cur = word.trimStart();
|
|
122
|
+
while (visibleWidth(cur) > max) {
|
|
123
|
+
out.push(cur.slice(0, max));
|
|
124
|
+
cur = cur.slice(max);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (cur.trim())
|
|
128
|
+
out.push(cur);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
export function card(title, rows, tone = 'info', columns = process.stdout.columns, overflow = 'cut') {
|
|
132
|
+
const paint = tone === 'ok' ? kleur.green : tone === 'warn' ? kleur.yellow : tone === 'fail' ? kleur.red : kleur.cyan;
|
|
133
|
+
if (isPlain())
|
|
134
|
+
return [title, ...rows.map((r) => ` ${r}`)].join('\n');
|
|
135
|
+
const limit = Math.max(30, Math.min(72, (columns ?? 80) - 2));
|
|
136
|
+
const body = rows.flatMap((r) => (visibleWidth(r) <= limit - 6 ? [r] : overflow === 'wrap' ? wrapLine(r, limit - 6) : [fitLine(r, limit - 5)]));
|
|
137
|
+
const inner = Math.min(limit - 2, Math.max(visibleWidth(title) + 4, ...body.map((r) => visibleWidth(r) + 4)));
|
|
138
|
+
const top = `╭─ ${title} ${'─'.repeat(Math.max(0, inner - visibleWidth(title) - 3))}╮`;
|
|
139
|
+
const mid = body.map((r) => `│ ${r}${' '.repeat(Math.max(0, inner - visibleWidth(r) - 2))}│`);
|
|
140
|
+
const bottom = `╰${'─'.repeat(inner)}╯`;
|
|
141
|
+
return [paint(top), ...mid.map((m) => paint('│') + m.slice(1, -1) + paint('│')), paint(bottom)].join('\n');
|
|
142
|
+
}
|
|
143
|
+
/** `[████░░░░] 1,204 / 3,114 · 39%` */
|
|
144
|
+
export function progressBar(done, total, width = 20) {
|
|
145
|
+
const pct = total === 0 ? 100 : Math.min(100, Math.round((done / total) * 100));
|
|
146
|
+
const filled = Math.round((pct / 100) * width);
|
|
147
|
+
const [on, off] = isPlain() ? ['#', '.'] : ['█', '░'];
|
|
148
|
+
return `[${kleur.green(on.repeat(filled))}${kleur.dim(off.repeat(width - filled))}] ${done.toLocaleString('en-US')}/${total.toLocaleString('en-US')} · ${pct}%`;
|
|
149
|
+
}
|
|
150
|
+
/** Format a multi-line error (first line = what happened, `Fix:` lines = how to fix). */
|
|
151
|
+
export function errorCard(message) {
|
|
152
|
+
const lines = message.split('\n').map((l) => l.trimEnd()).filter((l, i) => i === 0 || l.trim().length > 0);
|
|
153
|
+
const [head, ...rest] = lines;
|
|
154
|
+
const rows = rest.map((l) => {
|
|
155
|
+
const t = l.trim();
|
|
156
|
+
if (/^fix:/i.test(t))
|
|
157
|
+
return `${icon('tip')} ${kleur.cyan(t)}`;
|
|
158
|
+
if (/request id/i.test(t))
|
|
159
|
+
return kleur.dim(t);
|
|
160
|
+
return t;
|
|
161
|
+
});
|
|
162
|
+
if (isPlain(process.stderr))
|
|
163
|
+
return [`${icon('fail')} ${head}`, ...rows.map((r) => ` ${r}`)].join('\n');
|
|
164
|
+
const cols = process.stderr.columns ?? process.stdout.columns ?? 80;
|
|
165
|
+
const text = head ?? 'Something went wrong';
|
|
166
|
+
const fits = visibleWidth(text) <= Math.min(72, cols - 2) - 8;
|
|
167
|
+
const title = `${icon('fail')} ${fits ? kleur.bold(text) : 'Something went wrong'}`;
|
|
168
|
+
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');
|
|
169
|
+
}
|
|
170
|
+
export function formatDuration(ms) {
|
|
171
|
+
if (ms < 1000)
|
|
172
|
+
return `${ms}ms`;
|
|
173
|
+
const s = Math.round(ms / 1000);
|
|
174
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
175
|
+
}
|