@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/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ 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.1] - 2026-10-10
9
+
10
+ ### Changed
11
+ - Friendlier terminal: one set of icons and colors everywhere (✅ done, ⚠️ needs attention, ❌ failed, 💡 fix), numbered setup steps, a summary card after each sync, and progress bars with counts and percent.
12
+ - `sync --watch` opens with a header card, shows ⬇️ pulled / ⬆️ sent / 🗑 removed lines, and a live "all in sync" status.
13
+ - Errors show what happened with the fix command highlighted underneath.
14
+ - Dry runs show a clear "Preview only — nothing changed" banner.
15
+ - `start` numbers its steps (`1/3 · Your MagicPixel account`).
16
+ - Checking and uploading show progress bars with the current file; the watch status line follows along (`⬆️ Uploading 4/12 body_front…`).
17
+ - A paused watcher (offline, key or daily allowance) shows a yellow "Paused · retrying in 30s" status.
18
+ - Errors print as a red card with the fix underneath; long lines wrap instead of being cut.
19
+
20
+ ### Added
21
+ - `--plain` (also `NO_COLOR`, `MAGICPIXEL_PLAIN=1`, `CI`, `TERM=dumb`, or piped output): no colors, emoji, boxes, spinners or redraws. Errors check the error stream, so `2>errors.log` stays plain.
22
+
23
+ ## [0.6.0] - 2026-10-10
24
+
25
+ ### Added
26
+ - Monorepo support: one `magicpixel.json` at the root (`"workspace": true`, `"members": [...]`) syncs every game folder inside it. Sprite names stay exactly as they were per folder, so nothing re-uploads.
27
+ - `magicpixel consolidate [--dry-run] [-y]` (also run by `start` at the root) merges each game folder's old setup into one: settings and sync history are combined, `exclude`/`connect` rules re-anchored to their folder, the login key moved, old files backed up to `.magicpixel/backup/`, and stray copies of game files deleted.
28
+ - Any command run inside a game folder of a monorepo uses the root setup, so a second one is never created. `start` inside one game folder offers to set up at the root instead.
29
+ - New art in a game folder's library folder (e.g. a variant saved as a new file) is written next to its sibling files, in their naming style, instead of into `outDir`.
30
+ - `start` offers to replace multi-layer MagicPixel artboards with your local files instead of skipping them.
31
+ - Folder names typed inside a game folder (`sync --only`, `connect`, `resync`, `why`) mean that game folder, e.g. `Runtime` typed in `kr-core` is `kr-core/Runtime`.
32
+
33
+ ### Fixed
34
+ - Re-running `consolidate` on a merged setup changes nothing.
35
+ - Merge cleanup only removes empty folders and orphan `.meta` files; your own files in old output folders stay.
36
+
37
+ ## [0.5.39] - 2026-10-10
38
+
39
+ ### Fixed
40
+ - `sync` never writes stray copies of game sprites into `outDir`. Sprites in Connected folders are written only to their real game file or beside sibling files this folder owns; the rest are skipped with one message. Works even if the owning folder never synced on this machine. `--here` still downloads copies.
41
+ - `sync --watch` status lines fit the terminal width (no more wrapped, stacked lines), and only redraw on a live terminal.
42
+ - `sync --watch` rescans the game tree every 5 minutes instead of every 30 seconds; edits to known files are still picked up every tick.
43
+ - `sync --watch` now prints uploads ("Sent N edited sprites") and every sprite it could not send, with the reason (exclude rule, multi-layer artboard, legacy file, conflict). The header names any `exclude` rule that skips files.
44
+
45
+ ### Added
46
+ - `magicpixel why <path>` — explains what the next sync does with each PNG under a path.
47
+ - `magicpixel clean-strays` — removes copies a past sync wrote for sprites that belong to another game folder.
48
+
8
49
  ## [0.5.38] - 2026-10-10
9
50
 
10
51
  ### Fixed
package/README.md CHANGED
@@ -119,6 +119,8 @@ No bundler config, no runtime, no extra package.
119
119
  | `list` | Print matching manifest as a table. |
120
120
  | `status` | Config, last sync, diff vs remote. |
121
121
  | `whoami` | Verify API key, report visible assets. |
122
+ | `why <path>` | Explain what the next sync does with each game PNG under a path (watched? excluded by which rule? new, changed, unchanged?). No network. |
123
+ | `clean-strays [--dry-run] [-y]` | Delete copies sync wrote into `outDir` for sprites that belong to another game folder (plus their `.meta`). Never touches your own game files. |
122
124
 
123
125
  ### `sync` flags
124
126
 
@@ -131,7 +133,7 @@ No bundler config, no runtime, no extra package.
131
133
  | `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
132
134
  | `-q, --quiet` | Minimal output (for CI). |
133
135
  | `--only <folder...>` | Only push, pull and prune inside these game folders (repeatable). Everything else is left untouched. |
134
- | `--here` | Download even if these sprites are connected to another game folder on this computer (sync otherwise stops and names that folder). Run sync from your game project folder. |
136
+ | `--here` | Download copies even if these sprites belong to another game folder. Without it, sprites from Connected folders are only written to their real game file, or beside sibling files this folder owns — never as copies under `outDir`. |
135
137
 
136
138
  Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
137
139
 
@@ -167,6 +169,25 @@ Sync prints how many PNGs your `exclude` rules skipped, so a forgotten rule is e
167
169
 
168
170
  Excluded files are never uploaded, even when `connect` is `**`, and the CLI never deletes them from your game. Then delete the folder in the MagicPixel library (it goes to Trash) and it stays gone.
169
171
 
172
+ ## Monorepos (several Unity projects)
173
+
174
+ Keep **one** MagicPixel setup at the root of a repo that holds several game folders (e.g. `kr-core/`, `kr-remote-bundles/`):
175
+
176
+ ```sh
177
+ cd my-monorepo
178
+ npx @magicpixelart/cli@latest start # or: magicpixel consolidate
179
+ ```
180
+
181
+ - Every game folder is scanned, including hidden folders such as `.SpineRaw` (never `.git`, `Library`, `Temp`).
182
+ - Sprite names stay the same as when each folder synced on its own — nothing re-uploads.
183
+ - Old per-folder setups are merged, backed up to `.magicpixel/backup/`, and removed; stray copies of game files are deleted.
184
+ - Run any command from a game folder and it uses the root setup. Folder names you type are read from where you are: `sync --only Runtime` inside `kr-core` means `kr-core/Runtime`.
185
+ - Add a folder later with `magicpixel connect "kr-puzzle/**"`.
186
+
187
+ ## Plain output
188
+
189
+ Add `--plain` to any command (or set `NO_COLOR=1` or `MAGICPIXEL_PLAIN=1`) for output without colors, emoji or boxes. Piped output and CI logs are plain automatically.
190
+
170
191
  ## Config (`magicpixel.json`)
171
192
 
172
193
  | Field | Type | Default | Meaning |
@@ -230,6 +251,8 @@ Every error message includes a `Fix:` block. Common ones:
230
251
  | `401` / `403` | Regenerate the key at magicpixel.art/settings. |
231
252
  | `whoami` shows 0 assets | The key is bound to an empty project. Mint a key for the project that has art. |
232
253
  | `index.ts` doesn't update | Run `sync --full` once; renames may take a full pass to propagate. |
254
+ | A file in your game isn't uploading | Run `magicpixel why <folder>` — it names the exclude rule, multi-layer artboard, or other reason. |
255
+ | Sprites from another game appeared under `outDir` | Run `magicpixel clean-strays` to remove them, then sync from the game folder that owns them. |
233
256
  | Files keep re-downloading | Your build system is rewriting PNGs. Sync into a dir your bundler reads but doesn't mutate. |
234
257
 
235
258
  ## Telemetry
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { loadConfig, saveConfig } from '../config.js';
3
4
  import { assertSafeGlob } from '../util/security.js';
4
5
  export async function addCommand(glob) {
@@ -10,5 +11,5 @@ export async function addCommand(glob) {
10
11
  }
11
12
  config.include.push(pattern);
12
13
  await saveConfig(config);
13
- console.log(kleur.green(`✓ added include pattern: ${pattern}`));
14
+ console.log(ui.ok(`added include pattern: ${pattern}`));
14
15
  }
@@ -0,0 +1,92 @@
1
+ import kleur from 'kleur';
2
+ import { spinner as makeSpinner, ui } from '../util/ui.js';
3
+ import { confirm } from '../util/prompt.js';
4
+ import { existsSync } from 'node:fs';
5
+ import { readdir, rm, rmdir } from 'node:fs/promises';
6
+ import { dirname, relative, resolve } from 'node:path';
7
+ import { fetchAllManifest } from '../api.js';
8
+ import { loadConfig, loadState, saveState } from '../config.js';
9
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
10
+ import { countingSpritesText, indexGamePngs, matchConnectGlobs } from '../util/gameScan.js';
11
+ import { walkOutDirPngs } from '../util/paths.js';
12
+ import { splitStrayDownloads } from '../util/strayGuard.js';
13
+ import { collectSourceRelMap } from '../util/syncPath.js';
14
+ /**
15
+ * Copies sync wrote under outDir for game-owned sprites that have no home in
16
+ * this folder. Only outDir files are candidates — never a working-set original.
17
+ */
18
+ export function findStrayCopies(disk, manifest, localKeys) {
19
+ const byKey = new Map(manifest.map((e) => [e.key, e]));
20
+ const candidates = disk.flatMap((d) => {
21
+ const m = byKey.get(d.key);
22
+ return m ? [{ key: d.key, game: m.game, disk: d }] : [];
23
+ });
24
+ return splitStrayDownloads(candidates, localKeys).foreign.map((c) => c.disk);
25
+ }
26
+ export async function removeEmptyDirsUpTo(dir, stopAt) {
27
+ let cur = dir;
28
+ while (cur.startsWith(stopAt) && cur !== stopAt) {
29
+ try {
30
+ const left = (await readdir(cur)).filter((n) => n !== '.DS_Store');
31
+ if (left.length > 0)
32
+ return;
33
+ await rm(resolve(cur, '.DS_Store'), { force: true });
34
+ await rmdir(cur);
35
+ await rm(`${cur}.meta`, { force: true }); // Unity folder meta
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ cur = dirname(cur);
41
+ }
42
+ }
43
+ export async function cleanStraysCommand(opts = {}) {
44
+ const config = await loadConfig();
45
+ const state = await loadState();
46
+ const kind = await detectProjectKind();
47
+ const spinner = makeSpinner({ text: 'Fetching your sprites from MagicPixel…', spinner: 'dots' }).start();
48
+ const manifest = await fetchAllManifest(config);
49
+ spinner.text = countingSpritesText(0);
50
+ const index = isEngineKind(kind)
51
+ ? await indexGamePngs(kind, process.cwd(), config.outDir, {
52
+ onProgress: (p) => { spinner.text = countingSpritesText(p); },
53
+ })
54
+ : { files: [] };
55
+ const connected = matchConnectGlobs(index, config.connect ?? [], config.exclude).entries;
56
+ const localKeys = [...collectSourceRelMap(connected, state.synced).keys(), ...connected.map((e) => e.key)];
57
+ const strays = findStrayCopies(await walkOutDirPngs(config.outDir), manifest, localKeys);
58
+ spinner.stop();
59
+ if (strays.length === 0) {
60
+ console.log(ui.ok(`No stray copies in ${config.outDir}.`));
61
+ return;
62
+ }
63
+ console.log(kleur.bold(`${strays.length} stray cop${strays.length === 1 ? 'y' : 'ies'} of sprites from another game folder:`));
64
+ for (const s of strays.slice(0, 30))
65
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), s.abs)}`);
66
+ if (strays.length > 30)
67
+ console.log(kleur.dim(` …and ${strays.length - 30} more`));
68
+ console.log(kleur.dim(' Their .meta files go too. Your own game files are never touched.'));
69
+ if (opts.dryRun) {
70
+ console.log(kleur.dim('--dry-run: nothing deleted.'));
71
+ return;
72
+ }
73
+ if (!opts.yes && !(await confirm('Delete them? (y/N) '))) {
74
+ console.log(kleur.dim('Nothing deleted. Re-run with --yes to skip this question.'));
75
+ return;
76
+ }
77
+ const outRoot = resolve(process.cwd(), config.outDir);
78
+ const removedKeys = new Set();
79
+ for (const s of strays) {
80
+ await rm(s.abs, { force: true });
81
+ if (existsSync(`${s.abs}.meta`))
82
+ await rm(`${s.abs}.meta`, { force: true });
83
+ removedKeys.add(s.key);
84
+ await removeEmptyDirsUpTo(dirname(s.abs), outRoot);
85
+ }
86
+ const synced = { ...(state.synced ?? {}) };
87
+ for (const k of removedKeys)
88
+ delete synced[k];
89
+ const assets = Object.fromEntries(Object.entries(state.assets ?? {}).filter(([, k]) => !removedKeys.has(k)));
90
+ await saveState({ ...state, synced, assets });
91
+ console.log(ui.ok(`Removed ${removedKeys.size} stray cop${removedKeys.size === 1 ? 'y' : 'ies'}.`));
92
+ }
@@ -1,5 +1,5 @@
1
1
  import kleur from 'kleur';
2
- import ora from 'ora';
2
+ import { spinner as makeSpinner, ui } from '../util/ui.js';
3
3
  import { loadConfig, saveConfig } from '../config.js';
4
4
  import { assertSafeGlob } from '../util/security.js';
5
5
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
@@ -24,14 +24,14 @@ export async function connectCommand(glob) {
24
24
  if (changed) {
25
25
  config.connect = next;
26
26
  await saveConfig(config);
27
- console.log(kleur.green(`✓ now syncing ${describeWorkingSet(pattern)}`));
27
+ console.log(ui.ok(`now syncing ${describeWorkingSet(pattern)}`));
28
28
  }
29
29
  const kind = await detectProjectKind();
30
30
  if (!isEngineKind(kind)) {
31
31
  console.log(kleur.yellow(' This folder is not a Unity/Godot/GameMaker project — nothing to ingest from disk.'));
32
32
  return;
33
33
  }
34
- const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
34
+ const spinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
35
35
  const index = await indexGamePngs(kind, process.cwd(), config.outDir, {
36
36
  onProgress: (p) => { spinner.text = countingSpritesText(p); },
37
37
  });
@@ -0,0 +1,308 @@
1
+ import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
3
+ import { confirm } from '../util/prompt.js';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
6
+ import { dirname, relative, resolve } from 'node:path';
7
+ import { stdin } from 'node:process';
8
+ import { configPath, defaultConfig, loadState, saveConfig, saveState, } from '../config.js';
9
+ import { readCredentialsSync, writeCredentials } from '../util/credentials.js';
10
+ import { indexGamePngs } from '../util/gameScan.js';
11
+ import { walkOutDirPngs } from '../util/paths.js';
12
+ import { anchorGlob, detectWorkspaceMembers } from '../util/workspace.js';
13
+ import { detectEngineKind, readWorkspaceMembers } from '../util/framework.js';
14
+ import { cmd } from '../util/invoke.js';
15
+ import { ensureGitignore } from './init.js';
16
+ function earliest(values) {
17
+ return values.filter((v) => !!v).sort()[0];
18
+ }
19
+ /**
20
+ * Fold per-folder setups into one workspace setup. Globs and game paths are
21
+ * re-anchored at the root; sprite keys are untouched, so nothing re-uploads.
22
+ */
23
+ export function mergeSetups(members, children) {
24
+ const projects = new Set(children.map((c) => c.state.projectId).filter((p) => !!p));
25
+ if (projects.size > 1) {
26
+ throw new Error(`These folders are linked to different MagicPixel projects:\n` +
27
+ children.filter((c) => c.state.projectId).map((c) => ` ${c.member}: ${c.state.projectId}`).join('\n') +
28
+ `\n Fix: run \`${cmd('logout')}\` in the folder you don't want to keep, then run this again.`);
29
+ }
30
+ const configured = children.filter((c) => c.config);
31
+ const first = configured[0]?.config ?? {};
32
+ const uniq = (xs) => [...new Set(xs)];
33
+ const connect = uniq(configured.flatMap((c) => {
34
+ const globs = c.config.connect?.length ? c.config.connect : ['**'];
35
+ return globs.map((g) => anchorGlob(c.member, g));
36
+ }));
37
+ const exclude = uniq(configured.flatMap((c) => (c.config.exclude ?? []).map((g) => anchorGlob(c.member, g))));
38
+ const include = uniq(configured.flatMap((c) => c.config.include ?? defaultConfig.include));
39
+ const firstWithOut = configured.find((c) => typeof c.config.outDir === 'string');
40
+ const outDir = firstWithOut ? `${firstWithOut.member}/${firstWithOut.config.outDir}` : `${members[0]}/Assets/MagicPixel`;
41
+ const config = {
42
+ outDir,
43
+ include: include.length ? include : defaultConfig.include,
44
+ exclude,
45
+ connect,
46
+ ...(first.endpoint ? { endpoint: first.endpoint } : {}),
47
+ ...(first.unityPpu ? { unityPpu: first.unityPpu } : {}),
48
+ ...(first.push === false ? { push: false } : {}),
49
+ workspace: true,
50
+ members,
51
+ };
52
+ const synced = {};
53
+ const assets = {};
54
+ for (const c of children) {
55
+ for (const [k, v] of Object.entries(c.state.synced ?? {})) {
56
+ const next = v.sourceRel ? { ...v, sourceRel: `${c.member}/${v.sourceRel}` } : v;
57
+ if (!synced[k] || (!synced[k].sourceRel && next.sourceRel))
58
+ synced[k] = next;
59
+ }
60
+ Object.assign(assets, c.state.assets ?? {});
61
+ }
62
+ const state = {
63
+ synced,
64
+ assets,
65
+ ...(projects.size === 1 ? { projectId: [...projects][0] } : {}),
66
+ ...(earliest(children.map((c) => c.state.lastSync)) ? { lastSync: earliest(children.map((c) => c.state.lastSync)) } : {}),
67
+ ...(earliest(children.map((c) => c.state.lastReconcile)) ? { lastReconcile: earliest(children.map((c) => c.state.lastReconcile)) } : {}),
68
+ };
69
+ return { config, state };
70
+ }
71
+ /** CLI-written AGENTS.md: delete when it's only our section, else strip it. */
72
+ export function stripAgentsSection(text) {
73
+ const start = text.indexOf('<!-- magicpixel:start -->');
74
+ const endTag = '<!-- magicpixel:end -->';
75
+ const end = text.indexOf(endTag);
76
+ if (start < 0 || end < start)
77
+ return text;
78
+ const rest = (text.slice(0, start) + text.slice(end + endTag.length)).replace(/\n{3,}/g, '\n\n').trim();
79
+ return rest === '' || rest === '# AGENTS' ? null : `${rest}\n`;
80
+ }
81
+ /** Remove `dir` and its sub-folders when they hold nothing but `.meta`/`.DS_Store` leftovers. */
82
+ async function pruneEmptyTree(dir) {
83
+ let entries;
84
+ try {
85
+ entries = await readdir(dir, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ let empty = true;
91
+ for (const e of entries) {
92
+ const p = resolve(dir, e.name);
93
+ if (e.name === '.DS_Store')
94
+ continue;
95
+ if (e.isDirectory() && !e.isSymbolicLink()) {
96
+ if (!(await pruneEmptyTree(p)))
97
+ empty = false;
98
+ }
99
+ else if (e.isFile() && e.name.endsWith('.meta') && !existsSync(p.slice(0, -5))) {
100
+ await rm(p, { force: true }); // sidecar whose file or folder is gone
101
+ }
102
+ else if (!(e.isFile() && e.name.endsWith('.meta'))) {
103
+ empty = false; // any real file keeps the folder
104
+ }
105
+ }
106
+ if (!empty)
107
+ return false;
108
+ try {
109
+ const left = (await readdir(dir)).filter((n) => n !== '.DS_Store');
110
+ if (left.length > 0)
111
+ return false;
112
+ await rm(resolve(dir, '.DS_Store'), { force: true });
113
+ await rmdir(dir);
114
+ await rm(`${dir}.meta`, { force: true });
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ async function readJson(path) {
122
+ try {
123
+ return JSON.parse(await readFile(path, 'utf8'));
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /** True when this folder holds old per-game-folder setups worth merging. */
130
+ export function needsConsolidation(root = process.cwd()) {
131
+ const members = readWorkspaceMembers(root);
132
+ if (members) {
133
+ return members.some((m) => existsSync(resolve(root, m, 'magicpixel.json')));
134
+ }
135
+ return detectWorkspaceMembers(root).length >= 2;
136
+ }
137
+ /**
138
+ * Merge every game folder's old setup into one at `root`, back up and remove
139
+ * the leftovers, and delete copies that duplicate a real game file.
140
+ * Returns false when the user declined or nothing was done.
141
+ */
142
+ export async function consolidateCommand(opts = {}) {
143
+ const root = resolve(process.cwd());
144
+ const existing = readWorkspaceMembers(root);
145
+ if (!existing && existsSync(configPath(root))) {
146
+ throw new Error(`${root} already has its own magicpixel.json (not a monorepo setup).\n` +
147
+ ` Fix: move it aside and run \`${cmd('consolidate')}\` again to merge every game folder here.`);
148
+ }
149
+ const members = existing ?? detectWorkspaceMembers(root);
150
+ if (members.length === 0) {
151
+ console.log(kleur.yellow('No game folders found here. Run this in the folder that contains your Unity projects.'));
152
+ return false;
153
+ }
154
+ const children = [];
155
+ for (const member of members) {
156
+ const dir = resolve(root, member);
157
+ const config = await readJson(resolve(dir, 'magicpixel.json'));
158
+ const state = existsSync(resolve(dir, '.magicpixel')) ? await loadState(dir) : {};
159
+ children.push({ member, config, state });
160
+ }
161
+ const withSetup = children.filter((c) => c.config || Object.keys(c.state).length);
162
+ const merged = mergeSetups(members, children);
163
+ if (existing) {
164
+ // Re-run on an existing workspace: root records already use root-relative paths.
165
+ const rootState = await loadState(root);
166
+ merged.state.synced = { ...merged.state.synced, ...(rootState.synced ?? {}) };
167
+ merged.state.assets = { ...merged.state.assets, ...(rootState.assets ?? {}) };
168
+ const current = await readJson(configPath(root));
169
+ if (current) {
170
+ merged.config = {
171
+ ...current,
172
+ connect: [...new Set([...(current.connect ?? []), ...merged.config.connect])],
173
+ exclude: [...new Set([...(current.exclude ?? []), ...merged.config.exclude])],
174
+ };
175
+ }
176
+ }
177
+ // Plan the cleanup.
178
+ const moves = [];
179
+ for (const c of withSetup) {
180
+ const dir = resolve(root, c.member);
181
+ for (const name of ['magicpixel.json', 'magicpixel.json.meta', '.magicpixel']) {
182
+ if (existsSync(resolve(dir, name)))
183
+ moves.push({ from: resolve(dir, name), reason: 'old setup' });
184
+ }
185
+ }
186
+ const agentsEdits = [];
187
+ for (const c of withSetup) {
188
+ const path = resolve(root, c.member, 'AGENTS.md');
189
+ if (!existsSync(path))
190
+ continue;
191
+ const text = await readFile(path, 'utf8');
192
+ const next = stripAgentsSection(text);
193
+ if (next !== text)
194
+ agentsEdits.push({ path, next });
195
+ }
196
+ // Copies under a folder's old outDir that duplicate a real game file
197
+ // (each folder indexed on its own — same keys the workspace will use).
198
+ const liveKeys = new Set();
199
+ for (const m of members) {
200
+ const kind = detectEngineKind(resolve(root, m));
201
+ if (!kind)
202
+ continue;
203
+ (await indexGamePngs(kind, resolve(root, m), '', {})).files.forEach((f) => liveKeys.add(f.key));
204
+ }
205
+ const rootOut = resolve(root, merged.config.outDir);
206
+ const relocate = [];
207
+ for (const c of withSetup) {
208
+ if (typeof c.config?.outDir !== 'string')
209
+ continue;
210
+ const childOut = resolve(root, c.member, c.config.outDir);
211
+ if (!existsSync(childOut))
212
+ continue;
213
+ for (const d of await walkOutDirPngs(c.config.outDir, resolve(root, c.member))) {
214
+ if (liveKeys.has(d.key))
215
+ moves.push({ from: d.abs, reason: 'copy of a game file' });
216
+ else if (resolve(childOut) !== rootOut)
217
+ relocate.push({ from: d.abs, to: resolve(rootOut, relative(childOut, d.abs)) });
218
+ }
219
+ }
220
+ const strayCount = moves.filter((m) => m.reason === 'copy of a game file').length;
221
+ if (existing && moves.length === 0 && agentsEdits.length === 0 && relocate.length === 0) {
222
+ console.log(ui.ok('Already one MagicPixel setup here — nothing to merge.'));
223
+ return false;
224
+ }
225
+ // Show the plan.
226
+ const rel = (p) => relative(root, p) || '.';
227
+ console.log(kleur.bold(`One MagicPixel setup for ${rel(root) === '.' ? root : rel(root)}`));
228
+ console.log(` Game folders: ${members.map((m) => (withSetup.some((c) => c.member === m) ? kleur.cyan(m) : kleur.dim(`${m} (not connected)`))).join(', ')}`);
229
+ console.log(` Watching: ${merged.config.connect.join(', ') || kleur.dim('nothing yet')}`);
230
+ if (merged.config.exclude.length)
231
+ console.log(` Skipping: ${merged.config.exclude.join(', ')}`);
232
+ console.log(` New art goes: next to its sibling files, otherwise ${merged.config.outDir}`);
233
+ const oldSetups = moves.filter((m) => m.reason === 'old setup');
234
+ if (oldSetups.length)
235
+ console.log(` Removing old setup files: ${oldSetups.map((m) => rel(m.from)).join(', ')}`);
236
+ if (agentsEdits.length)
237
+ console.log(` Removing generated notes from: ${agentsEdits.map((a) => rel(a.path)).join(', ')}`);
238
+ if (strayCount)
239
+ console.log(` Deleting ${strayCount} stray cop${strayCount === 1 ? 'y' : 'ies'} of game files (and their .meta)`);
240
+ if (relocate.length)
241
+ console.log(` Moving ${relocate.length} MagicPixel-only file${relocate.length === 1 ? '' : 's'} into ${merged.config.outDir}`);
242
+ const unconnected = members.filter((m) => !withSetup.some((c) => c.member === m));
243
+ if (unconnected.length) {
244
+ console.log(kleur.dim(` ${unconnected.join(', ')} stay${unconnected.length === 1 ? 's' : ''} out until you run \`${cmd('connect')} "<folder>/**"\`.`));
245
+ }
246
+ console.log(kleur.dim(` Everything removed is backed up to .magicpixel/backup/. Your game PNGs are never touched.`));
247
+ if (opts.dryRun) {
248
+ console.log(kleur.dim('--dry-run: nothing changed.'));
249
+ return false;
250
+ }
251
+ if (!opts.yes && !(await confirm('Merge into one setup? (y/N) '))) {
252
+ console.log(kleur.dim(stdin.isTTY ? 'Nothing changed.' : 'Nothing changed. Re-run with --yes to apply.'));
253
+ return false;
254
+ }
255
+ // Apply. The root .magicpixel/ holds the login key and backups — keep it out of git.
256
+ await ensureGitignore(root);
257
+ // Credentials first (they live inside the .magicpixel folders we move).
258
+ if (!readCredentialsSync(root)) {
259
+ const key = withSetup.map((c) => readCredentialsSync(resolve(root, c.member))).find(Boolean)?.apiKey;
260
+ if (key)
261
+ await writeCredentials(key, root);
262
+ }
263
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
264
+ const backup = resolve(root, '.magicpixel', 'backup', stamp);
265
+ const backupMove = async (from) => {
266
+ const to = resolve(backup, relative(root, from));
267
+ await mkdir(dirname(to), { recursive: true });
268
+ await rename(from, to);
269
+ };
270
+ for (const m of moves) {
271
+ if (!existsSync(m.from))
272
+ continue;
273
+ await backupMove(m.from);
274
+ if (m.reason === 'copy of a game file' && existsSync(`${m.from}.meta`))
275
+ await backupMove(`${m.from}.meta`);
276
+ }
277
+ for (const r of relocate) {
278
+ if (existsSync(r.to)) {
279
+ await backupMove(r.from);
280
+ continue;
281
+ }
282
+ await mkdir(dirname(r.to), { recursive: true });
283
+ await rename(r.from, r.to);
284
+ if (existsSync(`${r.from}.meta`))
285
+ await rename(`${r.from}.meta`, `${r.to}.meta`);
286
+ }
287
+ for (const c of withSetup) {
288
+ if (typeof c.config?.outDir !== 'string')
289
+ continue;
290
+ const childOut = resolve(root, c.member, c.config.outDir);
291
+ if (resolve(childOut) !== rootOut)
292
+ await pruneEmptyTree(childOut);
293
+ }
294
+ for (const a of agentsEdits) {
295
+ if (a.next === null) {
296
+ await backupMove(a.path);
297
+ if (existsSync(`${a.path}.meta`))
298
+ await backupMove(`${a.path}.meta`);
299
+ }
300
+ else {
301
+ await writeFile(a.path, a.next);
302
+ }
303
+ }
304
+ await saveConfig(merged.config, root);
305
+ await saveState(merged.state, root);
306
+ console.log(ui.ok(`One setup at ${root}. Run sync from here (or any game folder — it finds this one).`));
307
+ return true;
308
+ }
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { icon } from '../util/ui.js';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { readFile } from 'node:fs/promises';
4
5
  import { resolve, relative } from 'node:path';
@@ -224,13 +225,13 @@ export function renderDoctorReport(r) {
224
225
  push(`Network probe: ${kleur.dim(reason)}`);
225
226
  }
226
227
  else if (r.network.ok) {
227
- push(`Network probe: ${kleur.green('✓')} ${r.network.status} in ${r.network.roundtripMs}ms`);
228
+ push(`Network probe: ${icon('ok')} ${r.network.status} in ${r.network.roundtripMs}ms`);
228
229
  if (r.network.requestId)
229
230
  push(` request id: ${kleur.dim(r.network.requestId)}`);
230
231
  }
231
232
  else {
232
233
  const statusLabel = r.network.status !== null ? `HTTP ${r.network.status}` : 'no response';
233
- push(`Network probe: ${kleur.red('✗')} ${statusLabel} (${r.network.roundtripMs}ms)`);
234
+ push(`Network probe: ${icon('fail')} ${statusLabel} (${r.network.roundtripMs}ms)`);
234
235
  if (r.network.requestId)
235
236
  push(` request id: ${kleur.dim(r.network.requestId)}`);
236
237
  if (r.network.error)
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { readFile, appendFile, writeFile } from 'node:fs/promises';
4
5
  import { resolve } from 'node:path';
@@ -79,21 +80,21 @@ export async function initCommand(opts) {
79
80
  }
80
81
  await saveConfig(config);
81
82
  console.log();
82
- console.log(kleur.green('✓ wrote magicpixel.json'));
83
+ console.log(ui.ok('wrote magicpixel.json'));
83
84
  if (kind === 'GameMaker') {
84
85
  console.log(kleur.dim(" Note: GameMaker doesn't auto-import — refresh Included Files after each sync."));
85
86
  }
86
87
  if (addGitignore) {
87
88
  const added = await ensureGitignore();
88
89
  if (added)
89
- console.log(kleur.green('✓ added .magicpixel/ to .gitignore'));
90
+ console.log(ui.ok('added .magicpixel/ to .gitignore'));
90
91
  }
91
92
  let watchScriptAdded = false;
92
93
  if (addWatchScript && pkgExists) {
93
94
  const r = await ensureWatchScript(pkgPath);
94
95
  watchScriptAdded = r.added;
95
96
  if (r.added)
96
- console.log(kleur.green(`✓ added "${WATCH_SCRIPT_NAME}" script to package.json`));
97
+ console.log(ui.ok(`added "${WATCH_SCRIPT_NAME}" script to package.json`));
97
98
  else if (r.alreadyPresent)
98
99
  console.log(kleur.dim(` "${WATCH_SCRIPT_NAME}" script already present in package.json`));
99
100
  else if (r.error)
@@ -146,8 +147,8 @@ async function ensureWatchScript(pkgPath) {
146
147
  }
147
148
  // `isImportableOutDir` lives in util/framework.ts as `isStaticOutDir`
148
149
  // (negated) — shared with `emitIndex.ts`'s AGENTS.md snippet resolver.
149
- async function ensureGitignore() {
150
- const path = resolve(process.cwd(), '.gitignore');
150
+ export async function ensureGitignore(cwd = process.cwd()) {
151
+ const path = resolve(cwd, '.gitignore');
151
152
  const marker = '.magicpixel/';
152
153
  // Accept any of these as "already ignored" so we don't append a duplicate.
153
154
  const equivalents = new Set(['.magicpixel', '.magicpixel/', '/.magicpixel', '/.magicpixel/']);
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { createInterface } from 'node:readline/promises';
3
4
  import { stdin, stdout } from 'node:process';
4
5
  import { loadConfig, defaultConfig } from '../config.js';
@@ -32,7 +33,7 @@ export async function loginCommand(opts = {}) {
32
33
  }
33
34
  const path = await writeCredentials(key);
34
35
  console.log();
35
- console.log(kleur.green('✓ logged in'));
36
+ console.log(ui.ok('logged in'));
36
37
  console.log(kleur.dim(` Key stored at ${relative(process.cwd(), path)} (mode 0600).`));
37
38
  if (process.env.MAGICPIXEL_API_KEY) {
38
39
  console.log(kleur.yellow(' Note: MAGICPIXEL_API_KEY is still set in your environment and takes precedence.'));
@@ -1,11 +1,12 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { deleteCredentials, credentialsPath } from '../util/credentials.js';
3
4
  import { relative } from 'node:path';
4
5
  export async function logoutCommand() {
5
6
  const path = credentialsPath();
6
7
  const removed = await deleteCredentials();
7
8
  if (removed) {
8
- console.log(kleur.green(`✓ logged out`));
9
+ console.log(ui.ok(`logged out`));
9
10
  console.log(kleur.dim(` Removed ${relative(process.cwd(), path)}.`));
10
11
  }
11
12
  else {