@magicpixelart/cli 0.6.1 → 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 CHANGED
@@ -5,6 +5,18 @@ 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.6.2] - 2026-10-10
9
+
10
+ ### Fixed
11
+ - Merging setups cleans folder patterns (`//`, stray `~`, duplicates, ones a broader folder covers).
12
+ - `connect` cleans typed patterns and rejects empty ones.
13
+ - Sync warns when a hidden art folder (e.g. `.SpineRaw/rabbit`) has PNGs nothing watches, with the exact `connect` command.
14
+ - `why` lists watched patterns once and prints a ready-to-run `connect` command.
15
+
16
+ ### Added
17
+ - Upload progress shows the file's folder path (e.g. `…/rabbit/agni/ear_left_front`), not just its name.
18
+ - "Useful commands" after `start`, "Common tasks" in `--help`, and a short hint after `sync`, all from one shared list.
19
+
8
20
  ## [0.6.1] - 2026-10-10
9
21
 
10
22
  ### Changed
package/README.md CHANGED
@@ -102,6 +102,8 @@ No bundler config, no runtime, no extra package.
102
102
 
103
103
  ## Commands
104
104
 
105
+ Run `magicpixel --help` for a short "Common tasks" list. A folder isn't syncing? `magicpixel why <folder>` prints the `connect` command that fixes it.
106
+
105
107
  | Command | What it does |
106
108
  | --- | --- |
107
109
  | `start [--force]` | One-command bootstrap. Init + login + first sync. The only command to tell new users to run. |
@@ -4,7 +4,7 @@ import { loadConfig, saveConfig } from '../config.js';
4
4
  import { assertSafeGlob } from '../util/security.js';
5
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
6
6
  import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
7
- import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
7
+ import { isAllSpritesGlob, nextConnectGlobs, normalizeConnectGlob, tidyConnectGlobs } from '../util/engineConnect.js';
8
8
  import { cmd } from '../util/invoke.js';
9
9
  import { runPush } from './push.js';
10
10
  /** Plain-language label for a connect glob (`**` is not meaningful to players). */
@@ -17,14 +17,17 @@ export function describeWorkingSet(glob) {
17
17
  * the original files — not a copy under outDir.
18
18
  */
19
19
  export async function connectCommand(glob) {
20
- const pattern = assertSafeGlob(glob);
20
+ const pattern = normalizeConnectGlob(assertSafeGlob(glob));
21
+ if (!pattern)
22
+ throw new Error(`"${glob}" isn't a folder pattern. Example: ${cmd('connect')} 'Assets/Sprites/**'`);
21
23
  const config = await loadConfig();
22
- const next = nextConnectGlobs(config.connect, pattern);
24
+ const next = tidyConnectGlobs(nextConnectGlobs(config.connect.map(normalizeConnectGlob), pattern));
23
25
  const changed = next.length !== config.connect.length || next.some((g, i) => g !== config.connect[i]);
24
26
  if (changed) {
27
+ const added = !config.connect.map(normalizeConnectGlob).includes(pattern);
25
28
  config.connect = next;
26
29
  await saveConfig(config);
27
- console.log(ui.ok(`now syncing ${describeWorkingSet(pattern)}`));
30
+ console.log(ui.ok(added ? `now syncing ${describeWorkingSet(pattern)}` : 'tidied your folder patterns'));
28
31
  }
29
32
  const kind = await detectProjectKind();
30
33
  if (!isEngineKind(kind)) {
@@ -10,6 +10,7 @@ import { readCredentialsSync, writeCredentials } from '../util/credentials.js';
10
10
  import { indexGamePngs } from '../util/gameScan.js';
11
11
  import { walkOutDirPngs } from '../util/paths.js';
12
12
  import { anchorGlob, detectWorkspaceMembers } from '../util/workspace.js';
13
+ import { normalizeConnectGlob, tidyConnectGlobs } from '../util/engineConnect.js';
13
14
  import { detectEngineKind, readWorkspaceMembers } from '../util/framework.js';
14
15
  import { cmd } from '../util/invoke.js';
15
16
  import { ensureGitignore } from './init.js';
@@ -30,9 +31,9 @@ export function mergeSetups(members, children) {
30
31
  const configured = children.filter((c) => c.config);
31
32
  const first = configured[0]?.config ?? {};
32
33
  const uniq = (xs) => [...new Set(xs)];
33
- const connect = uniq(configured.flatMap((c) => {
34
+ const connect = tidyConnectGlobs(configured.flatMap((c) => {
34
35
  const globs = c.config.connect?.length ? c.config.connect : ['**'];
35
- return globs.map((g) => anchorGlob(c.member, g));
36
+ return globs.map((g) => anchorGlob(c.member, normalizeConnectGlob(g)));
36
37
  }));
37
38
  const exclude = uniq(configured.flatMap((c) => (c.config.exclude ?? []).map((g) => anchorGlob(c.member, g))));
38
39
  const include = uniq(configured.flatMap((c) => c.config.include ?? defaultConfig.include));
@@ -169,7 +170,7 @@ export async function consolidateCommand(opts = {}) {
169
170
  if (current) {
170
171
  merged.config = {
171
172
  ...current,
172
- connect: [...new Set([...(current.connect ?? []), ...merged.config.connect])],
173
+ connect: tidyConnectGlobs([...(current.connect ?? []), ...merged.config.connect]),
173
174
  exclude: [...new Set([...(current.exclude ?? []), ...merged.config.exclude])],
174
175
  };
175
176
  }
@@ -7,11 +7,11 @@
7
7
  * `POST /api/public/integration/ingest`.
8
8
  */
9
9
  import kleur from 'kleur';
10
- import { icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
10
+ import { icon, progressBar, shortenPath, spinner as makeSpinner, ui, visibleWidth } from '../util/ui.js';
11
11
  import { mkdir, readFile, rename } from 'node:fs/promises';
12
12
  import { dirname, relative, resolve } from 'node:path';
13
13
  import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
14
- import { ensureEngineConnect } from '../util/engineConnect.js';
14
+ import { ensureEngineConnect, unwatchedHiddenFolders } from '../util/engineConnect.js';
15
15
  import { PUSH_BATCH_SIZE, pushSpritesAdaptive, fetchManifestPage, getLastProjectInfo } from '../api.js';
16
16
  import { hashFile } from '../util/hash.js';
17
17
  import { createLimit } from '../util/limit.js';
@@ -80,6 +80,13 @@ export async function runPushWith(config, state, opts = {}) {
80
80
  : { files: [] });
81
81
  const matched = matchConnectGlobs(index, config.connect ?? [], config.exclude);
82
82
  const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
83
+ if (!quiet) {
84
+ const watched = new Set(matched.entries.map((e) => e.key));
85
+ for (const [folder, n] of unwatchedHiddenFolders(index.files, watched, config.exclude ?? []).slice(0, 5)) {
86
+ console.log(ui.warn(`${n.toLocaleString('en-US')} PNGs in ${folder} aren't watched.`));
87
+ console.log(ui.fix(`${cmd('connect')} '${folder}/**'`));
88
+ }
89
+ }
83
90
  const disk = await walkOutDirPngs(config.outDir);
84
91
  const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
85
92
  const entryByKey = new Map(matched.entries.map((e) => [e.key, e]));
@@ -256,10 +263,14 @@ export async function runPushWith(config, state, opts = {}) {
256
263
  });
257
264
  await saveState({ ...state, synced });
258
265
  const doneN = Math.min(i + batch.length, sprites.length);
259
- if (progress)
260
- progress.text = `Uploading ${progressBar(doneN, sprites.length)} ${kleur.dim(batch[batch.length - 1]?.name ?? '')}`;
266
+ const last = batch[batch.length - 1];
267
+ const where = last ? fpByKey.get(last.key)?.sourceRel ?? last.key : '';
268
+ if (progress) {
269
+ const head = `Uploading ${progressBar(doneN, sprites.length)} `;
270
+ progress.text = head + kleur.dim(shortenPath(where, (process.stdout.columns ?? 80) - visibleWidth(head) - 4));
271
+ }
261
272
  else
262
- opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${batch[batch.length - 1]?.name ?? ''}…`);
273
+ opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${shortenPath(where, 60)}…`);
263
274
  }
264
275
  }
265
276
  catch (e) {
@@ -25,6 +25,7 @@ import { assertKeyValid } from '../util/auth.js';
25
25
  import { ApiError } from '../api.js';
26
26
  import { connectCommand } from './connect.js';
27
27
  import { isAllSpritesGlob } from '../util/engineConnect.js';
28
+ import { COMMAND_TIPS, formatTipsBlock } from '../util/commandTips.js';
28
29
  /** Validate a key; only auth rejections map to 'rejected' — other failures throw. */
29
30
  export async function checkKey(key, config) {
30
31
  try {
@@ -215,6 +216,8 @@ export async function startCommand(opts = {}) {
215
216
  console.log();
216
217
  console.log(kleur.dim(' Game sprites write back to their original path. New MagicPixel art lands in outDir.'));
217
218
  console.log();
219
+ console.log(formatTipsBlock(COMMAND_TIPS.slice(1)));
220
+ console.log();
218
221
  if (await hasDevScript()) {
219
222
  const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
220
223
  console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
@@ -1,5 +1,5 @@
1
1
  import kleur from 'kleur';
2
- import { canRedraw, card, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
2
+ import { canRedraw, card, isPlain, fitLine, formatDuration, icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
3
3
  import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { dirname, relative, resolve } from 'node:path';
@@ -33,6 +33,7 @@ import { runResync } from './resync.js';
33
33
  import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
34
34
  import { findForeignOwner, foreignOwnerMessage, recordProjectRoot } from '../util/projectRoots.js';
35
35
  import { placeBesideSiblings, splitStrayDownloads, straySkipMessage } from '../util/strayGuard.js';
36
+ import { syncFooterHint } from '../util/commandTips.js';
36
37
  /** How often the watcher asks for a library "Resync from game" request. */
37
38
  const RESYNC_POLL_MS = 15_000;
38
39
  let lastResyncPollAt = 0;
@@ -1593,6 +1594,8 @@ async function runOnce(opts, runOpts = {}) {
1593
1594
  printChanges(result, ' ', { includeRenames: renamed.length === 0 });
1594
1595
  if (renamed.length > 0)
1595
1596
  printRenames(renamed, { withHints: true });
1597
+ if (!runOpts.watchMode && !isPlain())
1598
+ console.log(kleur.dim(syncFooterHint()));
1596
1599
  }
1597
1600
  if (result.failed)
1598
1601
  process.exitCode = 1;
@@ -1,5 +1,5 @@
1
1
  import kleur from 'kleur';
2
- import { spinner as makeSpinner } from '../util/ui.js';
2
+ import { spinner as makeSpinner, ui } from '../util/ui.js';
3
3
  import { loadConfig, loadState } from '../config.js';
4
4
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
5
  import { countingSpritesText, entryMatchesGlob, indexGamePngs, rootSpriteDotDirs } from '../util/gameScan.js';
@@ -27,10 +27,10 @@ export function explainSprite(entry, config, synced, currentSha) {
27
27
  return { kind: 'unchanged' };
28
28
  return { kind: 'changed', ...(typeof known.layers === 'number' ? { layers: known.layers } : {}) };
29
29
  }
30
- export function describeVerdict(v, config) {
30
+ export function describeVerdict(v) {
31
31
  switch (v.kind) {
32
32
  case 'not-watched':
33
- return `not watched — no "connect" pattern matches it (${(config.connect ?? []).join(', ') || 'none'}). Fix: ${cmd('connect')} '<folder>/**'`;
33
+ return 'not watched — no folder pattern covers it';
34
34
  case 'excluded':
35
35
  return `skipped by exclude rule "${v.rule}" — remove it from "exclude" in magicpixel.json to sync this file`;
36
36
  case 'new':
@@ -73,9 +73,13 @@ export async function whyCommand(pathArg) {
73
73
  const synced = state.synced ?? {};
74
74
  const counts = new Map();
75
75
  const lines = [];
76
+ let unwatched = 0;
76
77
  for (const e of files) {
77
78
  const sha = (await hashFile(e.abs))?.sha256 ?? null;
78
- const text = describeVerdict(explainSprite(e, config, synced, sha), config);
79
+ const verdict = explainSprite(e, config, synced, sha);
80
+ if (verdict.kind === 'not-watched')
81
+ unwatched++;
82
+ const text = describeVerdict(verdict);
79
83
  counts.set(text, (counts.get(text) ?? 0) + 1);
80
84
  if (lines.length < 20)
81
85
  lines.push(` ${e.sourceRel}\n ${kleur.dim('→')} ${text}`);
@@ -88,4 +92,14 @@ export async function whyCommand(pathArg) {
88
92
  console.log(lines.join('\n'));
89
93
  if (files.length > lines.length)
90
94
  console.log(kleur.dim(` …and ${files.length - lines.length} more`));
95
+ if (unwatched > 0) {
96
+ console.log();
97
+ console.log(kleur.dim(` Watching: ${(config.connect ?? []).join(', ') || 'nothing'}`));
98
+ console.log(ui.fix(whyConnectCommand(files[0].sourceRel, target), ' '));
99
+ }
100
+ }
101
+ /** Ready-to-run connect command for the folder the user asked about. */
102
+ export function whyConnectCommand(firstRel, target) {
103
+ const folder = firstRel.toLowerCase() === target ? firstRel.split('/').slice(0, -1).join('/') : firstRel.slice(0, target.length);
104
+ return `${cmd('connect')} '${folder ? `${folder}/**` : '**'}'`;
91
105
  }
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { cleanStraysCommand } from './commands/cleanStrays.js';
25
25
  import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
26
26
  import { CLI_VERSION } from './version.js';
27
27
  import { cmd } from './util/invoke.js';
28
+ import { formatHelpTips } from './util/commandTips.js';
28
29
  // Node version guard
29
30
  const major = Number(process.versions.node.split('.')[0]);
30
31
  if (major < 18) {
@@ -54,7 +55,8 @@ const program = new Command();
54
55
  program
55
56
  .name('magicpixel')
56
57
  .description('Sync MagicPixel pixel-art assets to your local project')
57
- .version(CLI_VERSION);
58
+ .version(CLI_VERSION)
59
+ .addHelpText('after', formatHelpTips());
58
60
  const wrap = (commandName, fn) => async (...args) => {
59
61
  try {
60
62
  await fn(...args);
@@ -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/ui.js CHANGED
@@ -71,6 +71,25 @@ export function visibleWidth(s) {
71
71
  return w;
72
72
  }
73
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
+ }
74
93
  export function fitLine(line, columns) {
75
94
  const max = Math.max(20, (columns ?? 80) - 1);
76
95
  if (visibleWidth(line) <= max)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {