@magicpixelart/cli 0.5.2 → 0.5.4

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
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.4] — 2026-06-22
11
+
12
+ ### Added
13
+
14
+ - **Game engine project detection.** `init` and `doctor` now recognize Unity
15
+ (`ProjectSettings/ProjectVersion.txt`), Godot (`project.godot`), and
16
+ GameMaker (root `*.yyp`) and suggest engine-conventional `outDir` defaults:
17
+ `Assets/MagicPixel`, `assets/magicpixel`, and `datafiles/magicpixel`.
18
+ JS framework detection still runs first (monorepo-safe). Engine projects
19
+ skip typed `index.ts` and npm watch-script prompts; GameMaker prints a
20
+ one-liner to refresh Included Files after sync. `magicpixel start` remains
21
+ JS-only and redirects engine users to `init → login → sync --watch`.
22
+
23
+ ## [0.5.3] — 2026-06-02
24
+
25
+ ### Fixed
26
+
27
+ - **Continuous `.magicpixel/state.json.<pid>.<hex>.tmp` churn in watch mode.**
28
+ `saveState()` still used `atomicWrite`, and because watch mode persists
29
+ state every poll, editors like VS Code visibly filled `.magicpixel/` with
30
+ staged `state.json.*.tmp` files before the rename landed or when file
31
+ watchers raced the staging path. State saves now use a direct `writeFile`
32
+ with `0600` permissions instead. This is safe because `state.json` is a
33
+ recoverable cache: if a process dies mid-write, `loadState()` already
34
+ quarantines corrupt JSON and the next sync re-derives the snapshot from the
35
+ manifest and local files. `atomicWrite` remains for durable config,
36
+ credentials, `AGENTS.md`, package metadata, and generated index files.
37
+
10
38
  ## [0.5.2] — 2026-06-02
11
39
 
12
40
  ### Fixed
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { resolve, relative } from 'node:path';
5
5
  import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
6
6
  import { describeKeySource, readKeyForDisplay } from '../util/credentials.js';
7
- import { detectFramework, hasPackageJson } from '../util/framework.js';
7
+ import { detectProjectKind, hasPackageJson } from '../util/framework.js';
8
8
  import { safeFetch } from '../util/security.js';
9
9
  import { authHeaders } from '../util/authHeaders.js';
10
10
  import { CLI_VERSION } from '../version.js';
@@ -24,7 +24,7 @@ export async function doctorCommand(opts = {}) {
24
24
  * state on disk.
25
25
  */
26
26
  export async function collectDoctorReport(opts = {}) {
27
- const framework = await detectFramework();
27
+ const framework = await detectProjectKind();
28
28
  const pkgFound = hasPackageJson();
29
29
  let config = null;
30
30
  let configErr = null;
@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin, stdout } from 'node:process';
7
7
  import { configPath, defaultConfig, saveConfig } from '../config.js';
8
- import { detectFramework, suggestOutDir, hasPackageJson, isStaticOutDir } from '../util/framework.js';
8
+ import { detectProjectKind, suggestOutDir, hasPackageJson, isStaticOutDir, supportsTypedIndex, isEngineKind, } from '../util/framework.js';
9
9
  import { assertSafeOutDir } from '../util/security.js';
10
10
  import { atomicWrite } from '../util/atomicWrite.js';
11
11
  const WATCH_SCRIPT_NAME = 'magicpixel:watch';
@@ -19,18 +19,18 @@ export async function initCommand(opts) {
19
19
  const interactive = !opts.yes && stdin.isTTY;
20
20
  const config = { ...defaultConfig };
21
21
  let addGitignore = true;
22
- const framework = await detectFramework();
23
- const suggestedOutDir = suggestOutDir(framework);
22
+ const kind = await detectProjectKind();
23
+ const suggestedOutDir = suggestOutDir(kind);
24
24
  config.outDir = suggestedOutDir;
25
25
  const pkgExists = hasPackageJson();
26
26
  const pkgPath = resolve(process.cwd(), 'package.json');
27
- let addWatchScript = pkgExists;
27
+ let addWatchScript = pkgExists && !isEngineKind(kind);
28
28
  if (interactive) {
29
29
  const rl = createInterface({ input: stdin, output: stdout });
30
30
  try {
31
31
  console.log(kleur.bold('Set up @magicpixelart/cli'));
32
- if (framework)
33
- console.log(kleur.dim(` Detected: ${framework}`));
32
+ if (kind)
33
+ console.log(kleur.dim(` Detected: ${kind}`));
34
34
  console.log();
35
35
  // Re-prompt loop: catch unsafe outDir values (null bytes, `..` segments)
36
36
  // at input time rather than letting `loadConfig` reject them on the next
@@ -47,9 +47,11 @@ export async function initCommand(opts) {
47
47
  console.log(kleur.yellow(` ${e.message} Try again.`));
48
48
  }
49
49
  }
50
- // Typed index only makes sense when the outDir is importable by a bundler.
51
- // public/ and static/ are served as-is and cannot be imported.
52
- if (!isStaticOutDir(config.outDir)) {
50
+ // Typed index only makes sense for JS projects with a bundler-importable outDir.
51
+ if (!supportsTypedIndex(kind)) {
52
+ config.emitIndex = false;
53
+ }
54
+ else if (!isStaticOutDir(config.outDir)) {
53
55
  const emitAns = (await rl.question(`${kleur.cyan('?')} Emit a typed index.ts for autocomplete? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
54
56
  config.emitIndex = emitAns !== 'n' && emitAns !== 'no';
55
57
  }
@@ -59,7 +61,7 @@ export async function initCommand(opts) {
59
61
  }
60
62
  const giAns = (await rl.question(`${kleur.cyan('?')} Add .magicpixel/ to .gitignore? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
61
63
  addGitignore = giAns !== 'n' && giAns !== 'no';
62
- if (pkgExists) {
64
+ if (pkgExists && !isEngineKind(kind)) {
63
65
  const wAns = (await rl.question(`${kleur.cyan('?')} Add a "${WATCH_SCRIPT_NAME}" script to package.json? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
64
66
  addWatchScript = wAns !== 'n' && wAns !== 'no';
65
67
  }
@@ -69,11 +71,14 @@ export async function initCommand(opts) {
69
71
  }
70
72
  }
71
73
  else {
72
- config.emitIndex = !isStaticOutDir(config.outDir);
74
+ config.emitIndex = supportsTypedIndex(kind) && !isStaticOutDir(config.outDir);
73
75
  }
74
76
  await saveConfig(config);
75
77
  console.log();
76
78
  console.log(kleur.green('✓ wrote magicpixel.json'));
79
+ if (kind === 'GameMaker') {
80
+ console.log(kleur.dim(" Note: GameMaker doesn't auto-import — refresh Included Files after each sync."));
81
+ }
77
82
  if (addGitignore) {
78
83
  const added = await ensureGitignore();
79
84
  if (added)
@@ -5,7 +5,7 @@ import { createInterface } from 'node:readline/promises';
5
5
  import { stdin, stdout } from 'node:process';
6
6
  import { resolve } from 'node:path';
7
7
  import { configPath, loadConfig } from '../config.js';
8
- import { hasPackageJson } from '../util/framework.js';
8
+ import { detectProjectKind, hasPackageJson, isEngineKind } from '../util/framework.js';
9
9
  import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
10
10
  import { initCommand } from './init.js';
11
11
  import { loginCommand } from './login.js';
@@ -20,8 +20,14 @@ export async function startCommand(opts = {}) {
20
20
  console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
21
21
  console.log(kleur.dim(' This walks you through getting your sprites onto disk.'));
22
22
  console.log();
23
- // 1. Need a package.json — refuse to scaffold into a non-project directory.
23
+ // 1. `start` is JS-only — engine projects use init + login + sync --watch.
24
24
  if (!hasPackageJson()) {
25
+ const kind = await detectProjectKind();
26
+ if (isEngineKind(kind)) {
27
+ console.log(kleur.yellow(` Detected ${kind}. \`magicpixel start\` is JS-only; run ` +
28
+ '`npx magicpixel init && npx magicpixel login && npx magicpixel sync --watch` instead.'));
29
+ return;
30
+ }
25
31
  console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
26
32
  ' Run this inside your project folder — the one with package.json.'));
27
33
  return;
@@ -15,6 +15,7 @@ import { runTmpJanitor } from '../util/tmpJanitor.js';
15
15
  import { friendlyFsError } from '../util/errors.js';
16
16
  import { maxIsoTimestamp } from '../util/iso.js';
17
17
  import { formatBytes } from '../util/format.js';
18
+ import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
18
19
  export async function syncCommand(opts) {
19
20
  // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
20
21
  // crashed/killed CLI run before they pile up. Runs once per CLI invocation
@@ -427,6 +428,25 @@ async function runOnce(opts, runOpts = {}) {
427
428
  const orphanSet = new Set(orphans);
428
429
  for (const p of renameStalePaths)
429
430
  orphanSet.add(p);
431
+ // Server-side rename history. Each manifest entry carries the composite
432
+ // keys this row was previously emitted under (populated by the editor when
433
+ // a doc/artboard is renamed). For each prior key whose file is still on
434
+ // disk and is NOT shadowed by a live manifest entry, we add a rename hint
435
+ // + prune target — so cruft like `untitled-19/untitled.png` evaporates on
436
+ // the next sync without the user having to clean it up manually.
437
+ const prevKeyResult = computePreviousKeyOrphans({
438
+ manifest,
439
+ remoteDiskPaths,
440
+ resolveDiskPath: (key) => assetDiskPathFromKey(config.outDir, key),
441
+ fileExists: existsSync,
442
+ });
443
+ for (const p of prevKeyResult.orphanPaths)
444
+ orphanSet.add(p);
445
+ for (const r of prevKeyResult.renames) {
446
+ if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
447
+ renamed.push(r);
448
+ }
449
+ }
430
450
  orphans = [...orphanSet];
431
451
  // Legacy-suffix folder sweep — always runs, even in incremental mode.
432
452
  //
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, mkdir, rename } from 'node:fs/promises';
1
+ import { readFile, mkdir, rename, writeFile, chmod } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { assertSafeOutDir, MAX_GLOB_LEN, validateEndpointUrl } from './util/security.js';
@@ -151,11 +151,19 @@ export async function saveState(state, cwd = process.cwd()) {
151
151
  const path = statePath(cwd);
152
152
  try {
153
153
  await mkdir(dirname(path), { recursive: true });
154
- // Atomic write: stage to a sibling tmp file then rename. Prevents a half-
155
- // written state.json on crash/power-loss, which would then poison the next
156
- // run (and require the corrupt-state recovery path above). Mode 0600
157
- // because state.json sits next to the API-key-bearing credentials file.
158
- await atomicWrite(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
154
+ // state.json is saved on every watch tick, so it must not use a visible
155
+ // stage-and-rename tmp path (`state.json.<pid>.<hex>.tmp`) that churns in
156
+ // VS Code / Vite file watchers. A torn state write is recoverable: loadState
157
+ // quarantines corrupt JSON and the next sync re-derives state from disk +
158
+ // manifest, so direct writeFile is the safer UX trade-off here. Keep
159
+ // atomicWrite for durable config/credentials/index writers.
160
+ await writeFile(path, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
161
+ try {
162
+ await chmod(path, 0o600);
163
+ }
164
+ catch {
165
+ // Windows / read-only FS — ignore. The contents are non-secret sync state.
166
+ }
159
167
  }
160
168
  catch (e) {
161
169
  throw friendlyFsError(e, {
@@ -3,13 +3,13 @@ import { tmpPathFor } from './security.js';
3
3
  /**
4
4
  * Stage-and-rename file write. A crash mid-write can never leave a truncated
5
5
  * file on disk — the rename is atomic on every POSIX FS and on NTFS. Used
6
- * everywhere a corrupted target would break the project (state.json,
7
- * package.json, magicpixel.json, credentials, AGENTS.md, generated index files,
8
- * asset PNGs from `sync`).
6
+ * everywhere a corrupted target would break the project (package.json,
7
+ * magicpixel.json, credentials, AGENTS.md, generated index files). High-churn
8
+ * `state.json` saves and asset PNG writes intentionally use direct writeFile;
9
+ * see CHANGELOG 0.5.2/0.5.3 for the watcher/HMR trade-offs.
9
10
  *
10
- * Accepts `string | Uint8Array` so the asset-bytes writer in `sync` can reuse
11
- * this instead of duplicating the staging logic (one of the two prior leak
12
- * sites — see CHANGELOG 0.5.1).
11
+ * Accepts `string | Uint8Array` for callers that need binary-safe atomic
12
+ * writes, though asset bytes currently avoid this path for HMR compatibility.
13
13
  *
14
14
  * CRITICAL: On ANY failure between staging and rename, the tmp file is
15
15
  * unlinked before the original error is rethrown. Without this, every rare
@@ -1,8 +1,15 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
- /** Read `package.json` and infer the framework from its dependencies. */
5
- export async function detectFramework(cwd = process.cwd()) {
4
+ const ENGINE_KINDS = new Set(['Unity', 'Godot', 'GameMaker']);
5
+ export function isEngineKind(kind) {
6
+ return kind !== null && ENGINE_KINDS.has(kind);
7
+ }
8
+ /** Typed `index.ts` is JS-only — game engines load PNGs directly. */
9
+ export function supportsTypedIndex(kind) {
10
+ return !isEngineKind(kind);
11
+ }
12
+ async function detectJsKind(cwd) {
6
13
  try {
7
14
  const pkgPath = resolve(cwd, 'package.json');
8
15
  if (!existsSync(pkgPath))
@@ -31,9 +38,35 @@ export async function detectFramework(cwd = process.cwd()) {
31
38
  return null;
32
39
  }
33
40
  }
34
- /** Default `outDir` for a detected framework. */
35
- export function suggestOutDir(framework, cwd = process.cwd()) {
36
- switch (framework) {
41
+ function detectEngineKind(cwd) {
42
+ if (existsSync(resolve(cwd, 'project.godot')))
43
+ return 'Godot';
44
+ if (existsSync(resolve(cwd, 'ProjectSettings', 'ProjectVersion.txt')))
45
+ return 'Unity';
46
+ try {
47
+ const yyp = readdirSync(cwd).find((name) => name.endsWith('.yyp'));
48
+ if (yyp)
49
+ return 'GameMaker';
50
+ }
51
+ catch {
52
+ // unreadable cwd — treat as no engine marker
53
+ }
54
+ return null;
55
+ }
56
+ /** Infer project kind from package.json deps, then engine marker files. */
57
+ export async function detectProjectKind(cwd = process.cwd()) {
58
+ const js = await detectJsKind(cwd);
59
+ if (js)
60
+ return js;
61
+ return detectEngineKind(cwd);
62
+ }
63
+ /** @deprecated use `detectProjectKind` */
64
+ export async function detectFramework(cwd = process.cwd()) {
65
+ return detectProjectKind(cwd);
66
+ }
67
+ /** Default `outDir` for a detected project kind. */
68
+ export function suggestOutDir(kind, cwd = process.cwd()) {
69
+ switch (kind) {
37
70
  case 'Next.js':
38
71
  case 'Astro':
39
72
  case 'Nuxt':
@@ -45,8 +78,14 @@ export function suggestOutDir(framework, cwd = process.cwd()) {
45
78
  case 'Remix':
46
79
  case 'TanStack Start':
47
80
  return 'src/assets/magicpixel';
81
+ case 'Unity':
82
+ return 'Assets/MagicPixel';
83
+ case 'Godot':
84
+ return 'assets/magicpixel';
85
+ case 'GameMaker':
86
+ return 'datafiles/magicpixel';
48
87
  default:
49
- // No framework detected — prefer src/ if present so the typed index is
88
+ // No kind detected — prefer src/ if present so the typed index is
50
89
  // importable; otherwise fall back to a top-level assets/ dir.
51
90
  return existsSync(resolve(cwd, 'src')) ? 'src/assets/magicpixel' : 'assets/magicpixel';
52
91
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Walk each manifest entry's `previous_keys`, emit orphan paths + rename
3
+ * hints for prior composite keys whose PNGs still exist on disk and are
4
+ * NOT shadowed by a live manifest entry.
5
+ *
6
+ * Pure / side-effect-free — file existence + path resolution are injected
7
+ * so this is unit-testable without touching the real filesystem.
8
+ *
9
+ * The same `previous_keys` array is repeated on every entry from a given
10
+ * row; the `(id|prevKey)` signature set keeps the work idempotent.
11
+ */
12
+ export function computePreviousKeyOrphans(input) {
13
+ const { manifest, remoteDiskPaths, resolveDiskPath, fileExists } = input;
14
+ const orphans = new Set();
15
+ const renames = [];
16
+ const seen = new Set();
17
+ for (const entry of manifest) {
18
+ if (!entry.previous_keys || entry.previous_keys.length === 0)
19
+ continue;
20
+ for (const prevKey of entry.previous_keys) {
21
+ if (prevKey === entry.key)
22
+ continue;
23
+ const sig = `${entry.id}|${prevKey}`;
24
+ if (seen.has(sig))
25
+ continue;
26
+ seen.add(sig);
27
+ const prevPath = resolveDiskPath(prevKey);
28
+ // Skip when another live entry owns the old path (e.g. the "old" name
29
+ // is still actively used by a different row in the same project).
30
+ if (remoteDiskPaths.has(prevPath))
31
+ continue;
32
+ if (!fileExists(prevPath))
33
+ continue;
34
+ orphans.add(prevPath);
35
+ if (!renames.some((r) => r.id === entry.id && r.oldKey === prevKey)) {
36
+ renames.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
37
+ }
38
+ }
39
+ }
40
+ return { orphanPaths: [...orphans], renames };
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {