@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/CHANGELOG.md +54 -5
- package/README.md +26 -0
- package/dist/commands/add.js +2 -1
- package/dist/commands/cleanStrays.js +92 -0
- package/dist/commands/connect.js +7 -4
- package/dist/commands/consolidate.js +308 -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 +51 -17
- 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 +97 -16
- package/dist/commands/sync.js +149 -66
- package/dist/commands/whoami.js +4 -3
- package/dist/commands/why.js +91 -0
- package/dist/config.js +3 -0
- package/dist/index.js +47 -4
- package/dist/util/framework.js +20 -2
- package/dist/util/gameScan.js +94 -11
- package/dist/util/projectRoots.js +73 -0
- package/dist/util/prompt.js +15 -0
- package/dist/util/strayGuard.js +76 -0
- package/dist/util/ui.js +156 -0
- package/dist/util/watchCopy.js +0 -16
- package/dist/util/workspace.js +82 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ 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
|
+
|
|
49
|
+
## [0.5.38] - 2026-10-10
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- Full Unity projects also scan sprite dot-folders at the project root (e.g. `.SpineRaw`), not only `Assets/`. VCS/editor folders stay skipped.
|
|
53
|
+
- `sync` and `connect` report how many PNGs an `exclude` rule skipped, naming the rule.
|
|
54
|
+
|
|
55
|
+
## [0.5.37] - 2026-10-09
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- `sync` stops before writing when the sprites it would download are connected to another game folder on this machine (e.g. run from the wrong repo), naming the right folder. `--here` overrides; README documents it.
|
|
59
|
+
|
|
8
60
|
## [0.5.36] - 2026-10-07
|
|
9
61
|
|
|
10
62
|
### Changed
|
|
@@ -26,13 +78,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
26
78
|
### Fixed
|
|
27
79
|
- Local edits to game files that were never changed in MagicPixel no longer fail as "also changed in MagicPixel". Push now sends the cloud timestamp it last saw, and sync no longer records the local file as MagicPixel's copy when the server has no cached hash.
|
|
28
80
|
|
|
29
|
-
### Changed
|
|
30
|
-
- Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
|
|
31
|
-
|
|
32
81
|
## [0.5.32] - 2026-10-05
|
|
33
82
|
|
|
34
|
-
###
|
|
35
|
-
-
|
|
83
|
+
### Changed
|
|
84
|
+
- Watch header now reads "N game sprites connected" — it counts game PNGs matched by `connect`, not files waiting in MagicPixel.
|
|
36
85
|
|
|
37
86
|
## [0.5.31] - 2026-10-01
|
|
38
87
|
|
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,6 +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. |
|
|
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`. |
|
|
134
137
|
|
|
135
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.
|
|
136
139
|
|
|
@@ -162,8 +165,29 @@ To leave game files on disk but out of your library, add them to `exclude` in `m
|
|
|
162
165
|
{ "exclude": [".SpineRaw/rabbit_nft/**"] }
|
|
163
166
|
```
|
|
164
167
|
|
|
168
|
+
Sync prints how many PNGs your `exclude` rules skipped, so a forgotten rule is easy to spot. In full Unity projects sync scans `Assets/` plus sprite dot-folders at the project root (like `.SpineRaw`).
|
|
169
|
+
|
|
165
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.
|
|
166
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
|
+
|
|
167
191
|
## Config (`magicpixel.json`)
|
|
168
192
|
|
|
169
193
|
| Field | Type | Default | Meaning |
|
|
@@ -227,6 +251,8 @@ Every error message includes a `Fix:` block. Common ones:
|
|
|
227
251
|
| `401` / `403` | Regenerate the key at magicpixel.art/settings. |
|
|
228
252
|
| `whoami` shows 0 assets | The key is bound to an empty project. Mint a key for the project that has art. |
|
|
229
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. |
|
|
230
256
|
| Files keep re-downloading | Your build system is rewriting PNGs. Sync into a dir your bundler reads but doesn't mutate. |
|
|
231
257
|
|
|
232
258
|
## Telemetry
|
package/dist/commands/add.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
-
import
|
|
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';
|
|
6
|
-
import { indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
|
|
6
|
+
import { describeExcluded, indexGamePngs, matchConnectGlobs, countingSpritesText } from '../util/gameScan.js';
|
|
7
7
|
import { isAllSpritesGlob, nextConnectGlobs } from '../util/engineConnect.js';
|
|
8
8
|
import { cmd } from '../util/invoke.js';
|
|
9
9
|
import { runPush } from './push.js';
|
|
@@ -24,19 +24,22 @@ export async function connectCommand(glob) {
|
|
|
24
24
|
if (changed) {
|
|
25
25
|
config.connect = next;
|
|
26
26
|
await saveConfig(config);
|
|
27
|
-
console.log(
|
|
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 =
|
|
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
|
});
|
|
38
38
|
spinner.stop();
|
|
39
39
|
const matched = matchConnectGlobs(index, config.connect, config.exclude);
|
|
40
|
+
const skipped = describeExcluded(matched);
|
|
41
|
+
if (skipped)
|
|
42
|
+
console.log(kleur.dim(` ${skipped}`));
|
|
40
43
|
if (matched.entries.length === 0) {
|
|
41
44
|
console.log(kleur.yellow(` No PNGs matched. Try \`${cmd('search')} <name>\` to see indexed paths.`));
|
|
42
45
|
return;
|
|
@@ -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
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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: ${
|
|
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: ${
|
|
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)
|
package/dist/commands/init.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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/']);
|
package/dist/commands/login.js
CHANGED
|
@@ -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(
|
|
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.'));
|