@magicpixelart/cli 0.5.0 → 0.5.3

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,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.3] — 2026-06-02
11
+
12
+ ### Fixed
13
+
14
+ - **Continuous `.magicpixel/state.json.<pid>.<hex>.tmp` churn in watch mode.**
15
+ `saveState()` still used `atomicWrite`, and because watch mode persists
16
+ state every poll, editors like VS Code visibly filled `.magicpixel/` with
17
+ staged `state.json.*.tmp` files before the rename landed or when file
18
+ watchers raced the staging path. State saves now use a direct `writeFile`
19
+ with `0600` permissions instead. This is safe because `state.json` is a
20
+ recoverable cache: if a process dies mid-write, `loadState()` already
21
+ quarantines corrupt JSON and the next sync re-derives the snapshot from the
22
+ manifest and local files. `atomicWrite` remains for durable config,
23
+ credentials, `AGENTS.md`, package metadata, and generated index files.
24
+
25
+ ## [0.5.2] — 2026-06-02
26
+
27
+ ### Fixed
28
+
29
+ - **Instant HMR for synced assets (regression from 0.5.1).** 0.5.1 routed
30
+ the per-asset PNG writer through `atomicWrite`, which stages a
31
+ `<name>.<pid>.<hex>.tmp` file and `rename()`s over the target. Vite's
32
+ chokidar watcher only collapses the resulting unlink/add pair when the
33
+ tmp filename starts with `.` or ends with `~` — ours matches neither, so
34
+ the target was observed as an `add` rather than a `change` and Vite did
35
+ not re-push the new image URL to the open browser. Users had to hard
36
+ refresh to see freshly-synced assets (e.g. after renaming an artboard).
37
+ The asset writer is back to a direct `writeFile(diskPath, bytes)` —
38
+ same inode, single `change` event, immediate HMR. Atomicity is not
39
+ needed for asset bytes (a torn write is self-healing on the next watch
40
+ tick via sha mismatch); `atomicWrite` remains in use for `state.json`,
41
+ credentials, `AGENTS.md`, `package.json`, and `magicpixel.json`, where
42
+ torn writes WOULD corrupt cross-run state. A regression-guard test
43
+ (`tests/sync.assetWriterDirect.test.ts`) pins this contract.
44
+
45
+ ## [0.5.1] — 2026-06-02
46
+
47
+ ### Fixed
48
+
49
+ - **`.tmp` file leak in `atomicWrite` and the asset writer.** Every CLI write
50
+ that goes through `atomicWrite` (`state.json`, `package.json`,
51
+ `magicpixel.json`, credentials, `AGENTS.md`, the generated index) and the
52
+ inline tmp+rename in `sync` previously skipped cleanup when `rename()`
53
+ threw (FS hiccup, AV scan, racing writer, ENOSPC). Because `saveState`
54
+ fires every watch tick, even a low failure rate accumulated hundreds of
55
+ `<file>.<pid>.<hex>.tmp` files inside `.magicpixel/` and
56
+ `src/assets/magicpixel/<folder>/` — and they were invisible to the
57
+ `*.png`-only orphan walker. `atomicWrite` now wraps the write/chmod/rename
58
+ body in a `try { … } catch { unlink(tmp); throw }` block; the asset writer
59
+ in `sync` is reduced to a single `atomicWrite(diskPath, bytes)` call so
60
+ there's only one staging path left.
61
+ - **Renamed artboard left the old PNG on disk in incremental mode.** When
62
+ `state.json` was stale (e.g. a victim of the `.tmp` leak above) the
63
+ rename detector couldn't correlate the new manifest entry to the prior
64
+ key, and the incremental code path skips the full orphan sweep. Sync now
65
+ has a sha-based fallback: for each download-bound entry whose `sha256`
66
+ matches a stranded local PNG (one whose disk path is not in the current
67
+ manifest), we record a synthetic `RenameInfo` and prune the stale PNG.
68
+ Same-bytes + manifest-no-longer-references-that-path is unambiguous
69
+ evidence of a rename and never deletes anything the user still owns.
70
+
71
+ ### Added
72
+
73
+ - **Startup `.tmp` janitor.** `magicpixel sync` (one-shot and `--watch`)
74
+ sweeps stale `<file>.<pid>.<hex>.tmp` files left over from prior crashed
75
+ or killed runs across `outDir/**`, `.magicpixel/`, and the project root.
76
+ Pattern is anchored (`\.\d+\.[0-9a-f]{16}\.tmp$`) so user files with
77
+ `.tmp` in the name are never touched, and a 30-second age floor prevents
78
+ any race with a concurrent in-flight write.
79
+ - **`atomicWrite` now accepts `Uint8Array` in addition to `string`** so the
80
+ asset-bytes writer in `sync` can share the same cleanup-guaranteed
81
+ staging logic as every other writer.
82
+
10
83
  ## [0.5.0] — 2026-06-02
11
84
 
12
85
  ### Added
@@ -1,7 +1,7 @@
1
1
  import kleur from 'kleur';
2
2
  import ora from 'ora';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdir, writeFile, unlink, rename, readdir, rm } from 'node:fs/promises';
4
+ import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { dirname, relative, resolve } from 'node:path';
7
7
  import { loadConfig, loadState, saveState } from '../config.js';
@@ -10,11 +10,23 @@ import { fileSha256 } from '../util/hash.js';
10
10
  import { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
11
11
  import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
- import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
13
+ import { assertPathInsideRoot } from '../util/security.js';
14
+ import { runTmpJanitor } from '../util/tmpJanitor.js';
14
15
  import { friendlyFsError } from '../util/errors.js';
15
16
  import { maxIsoTimestamp } from '../util/iso.js';
16
17
  import { formatBytes } from '../util/format.js';
17
18
  export async function syncCommand(opts) {
19
+ // Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
20
+ // crashed/killed CLI run before they pile up. Runs once per CLI invocation
21
+ // (watch mode included) and only touches files older than 30s, so it can
22
+ // never race a concurrent in-flight write. See util/tmpJanitor.ts.
23
+ try {
24
+ const cfg = await loadConfig();
25
+ await runTmpJanitor(cfg.outDir);
26
+ }
27
+ catch {
28
+ /* janitor is best-effort — never let it block a sync */
29
+ }
18
30
  if (opts.watch) {
19
31
  await watchLoop(opts);
20
32
  return;
@@ -354,11 +366,58 @@ async function runOnce(opts, runOpts = {}) {
354
366
  // Orphan detection only when we have the full picture.
355
367
  // (Renames also produce a stale path on disk — collected separately below.)
356
368
  let orphans = [];
369
+ const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
370
+ // We walk local PNGs even in incremental mode so the sha-based rename
371
+ // fallback below has something to match against. The full-orphan path is
372
+ // still gated on `!since` to preserve incremental-mode safety (we never
373
+ // delete files outside what the current manifest delta touches).
374
+ const localPngs = await walkOutDirPngs(config.outDir);
357
375
  if (!since) {
358
- const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
359
- const localPngs = await walkOutDirPngs(config.outDir);
360
376
  orphans = localPngs.map((a) => a.abs).filter((p) => !remoteDiskPaths.has(p));
361
377
  }
378
+ // Heuristic-rename fallback for incremental mode.
379
+ //
380
+ // If `state.json` was lost or got out of sync (rare, but the 0.5.x leak of
381
+ // `state.json.<pid>.<hex>.tmp` files proved it happens in practice — see
382
+ // CHANGELOG 0.5.1), `previousAssets` won't have the mapping needed to mark
383
+ // a renamed artboard. The old `<oldSlug>.png` then lingers forever next to
384
+ // the new `<newSlug>.png` because incremental mode skips the full orphan
385
+ // sweep.
386
+ //
387
+ // Bridge that gap: for every download-bound entry, look for a stranded
388
+ // local PNG (path not in this manifest) whose sha256 matches the entry's.
389
+ // Same bytes + manifest no longer references that path = same artboard,
390
+ // renamed. We add the stranded path to `renameStalePaths` and record a
391
+ // RenameInfo so the CLI prints the migration hint.
392
+ if (since && toDownload.length > 0) {
393
+ const strandedBySha = new Map();
394
+ for (const local of localPngs) {
395
+ if (remoteDiskPaths.has(local.abs))
396
+ continue;
397
+ const sha = await fileSha256(local.abs);
398
+ if (sha)
399
+ strandedBySha.set(sha, local.abs);
400
+ }
401
+ if (strandedBySha.size > 0) {
402
+ const outRoot = resolve(process.cwd(), config.outDir);
403
+ const seenRenameIds = new Set(renamed.map((r) => r.id));
404
+ for (const entry of toDownload) {
405
+ if (!entry.sha256)
406
+ continue;
407
+ if (seenRenameIds.has(entry.id))
408
+ continue;
409
+ const strandedPath = strandedBySha.get(entry.sha256);
410
+ if (!strandedPath)
411
+ continue;
412
+ // Derive the disk key from the stranded path so the rename hint reads
413
+ // naturally (`cards/artboard-1 → cards/tree`).
414
+ const rel = relative(outRoot, strandedPath).replace(/\\/g, '/');
415
+ const oldKey = rel.endsWith('.png') ? rel.slice(0, -4) : rel;
416
+ renamed.push({ id: entry.id, oldKey, newKey: entry.key });
417
+ seenRenameIds.add(entry.id);
418
+ }
419
+ }
420
+ }
362
421
  // Stale paths from detected renames (always pruned, even in incremental mode —
363
422
  // otherwise the old PNG silently lingers next to the renamed copy).
364
423
  const renameStalePaths = renamed
@@ -486,14 +545,23 @@ async function runOnce(opts, runOpts = {}) {
486
545
  throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
487
546
  }
488
547
  }
489
- // Wrap any of mkdir/writeFile/rename so the user sees a friendly
548
+ // Wrap any of mkdir/writeFile so the user sees a friendly
490
549
  // multi-line diagnostic instead of a raw `EACCES: permission denied`.
550
+ //
551
+ // NOTE: Asset PNGs deliberately use a direct writeFile rather than
552
+ // atomicWrite. Vite's chokidar watcher only suppresses atomic-rename
553
+ // unlink/add pairs when the tmp filename starts with '.' or ends
554
+ // with '~'; our pattern <name>.<pid>.<hex>.tmp matches neither, so
555
+ // rename-over emits unlink+add instead of change, which breaks
556
+ // instant HMR for image imports in the browser (user has to hard
557
+ // refresh to see the new asset). A torn write here is self-healing
558
+ // on the next watch tick (re-download by sha mismatch), so the
559
+ // atomicity guarantee isn't worth the HMR regression. Do not
560
+ // "fix" this back to atomicWrite without also changing the
561
+ // tmp-name pattern AND verifying HMR end-to-end in a consumer app.
491
562
  try {
492
563
  await mkdir(dirname(diskPath), { recursive: true });
493
- const tmp = tmpPathFor(diskPath);
494
- assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
495
- await writeFile(tmp, bytes);
496
- await rename(tmp, diskPath);
564
+ await writeFile(diskPath, bytes);
497
565
  }
498
566
  catch (fsErr) {
499
567
  throw friendlyFsError(fsErr, {
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, {
@@ -1,25 +1,57 @@
1
- import { writeFile, rename, chmod } from 'node:fs/promises';
1
+ import { writeFile, rename, chmod, unlink } from 'node:fs/promises';
2
2
  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).
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.
10
+ *
11
+ * Accepts `string | Uint8Array` for callers that need binary-safe atomic
12
+ * writes, though asset bytes currently avoid this path for HMR compatibility.
13
+ *
14
+ * CRITICAL: On ANY failure between staging and rename, the tmp file is
15
+ * unlinked before the original error is rethrown. Without this, every rare
16
+ * `rename` failure (FS hiccup, AV scan, racing writer, ENOSPC) leaks a
17
+ * `<path>.<pid>.<hex>.tmp` file forever — `saveState` runs every watch tick,
18
+ * so even a low failure rate accumulates hundreds of stale tmps over a long
19
+ * dev session and they're invisible to the `.png`-only orphan walker.
8
20
  */
9
21
  export async function atomicWrite(path, contents, opts = {}) {
10
22
  const tmp = tmpPathFor(path);
11
- const writeOpts = opts.mode !== undefined ? { encoding: 'utf8', mode: opts.mode } : 'utf8';
12
- await writeFile(tmp, contents, writeOpts);
13
- if (opts.mode !== undefined) {
14
- // Belt-and-suspenders: writeFile's `mode` only applies on create. If the
15
- // tmp path was somehow pre-existing (collision is vanishingly unlikely
16
- // given the pid+random suffix) the chmod ensures the perms land.
23
+ try {
24
+ if (typeof contents === 'string') {
25
+ const writeOpts = opts.mode !== undefined ? { encoding: 'utf8', mode: opts.mode } : 'utf8';
26
+ await writeFile(tmp, contents, writeOpts);
27
+ }
28
+ else {
29
+ const writeOpts = opts.mode !== undefined ? { mode: opts.mode } : undefined;
30
+ await writeFile(tmp, contents, writeOpts);
31
+ }
32
+ if (opts.mode !== undefined) {
33
+ // Belt-and-suspenders: writeFile's `mode` only applies on create. If the
34
+ // tmp path was somehow pre-existing (collision is vanishingly unlikely
35
+ // given the pid+random suffix) the chmod ensures the perms land.
36
+ try {
37
+ await chmod(tmp, opts.mode);
38
+ }
39
+ catch {
40
+ // Windows / read-only FS — ignore.
41
+ }
42
+ }
43
+ await rename(tmp, path);
44
+ }
45
+ catch (err) {
46
+ // Best-effort cleanup. We don't want a cleanup failure (e.g. tmp was
47
+ // never created because writeFile threw on the very first syscall) to
48
+ // mask the real error the caller cares about.
17
49
  try {
18
- await chmod(tmp, opts.mode);
50
+ await unlink(tmp);
19
51
  }
20
52
  catch {
21
- // Windows / read-only FS — ignore.
53
+ /* ignore — tmp may not exist */
22
54
  }
55
+ throw err;
23
56
  }
24
- await rename(tmp, path);
25
57
  }
@@ -0,0 +1,80 @@
1
+ import { readdir, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Pattern produced by `tmpPathFor`: `<basename>.<pid>.<16-hex>.tmp`.
5
+ * Anchored at both ends to avoid matching user files that happen to contain
6
+ * the substring.
7
+ */
8
+ const TMP_SUFFIX_RE = /\.\d+\.[0-9a-f]{16}\.tmp$/;
9
+ /**
10
+ * Stale-tmp age threshold. Any tmp file modified within this window could
11
+ * still belong to a concurrent writer (another CLI process, or an in-flight
12
+ * write in this process whose `rename` is about to land). Skipping recent
13
+ * files is the safety net that makes the janitor side-effect-free for the
14
+ * happy path.
15
+ */
16
+ const MIN_STALE_AGE_MS = 30_000;
17
+ /**
18
+ * Walk `dirs` recursively (one level deep is enough for our layout — the
19
+ * outDir is `src/assets/magicpixel/<folder>/*.png` and state lives in
20
+ * `.magicpixel/`). Returns absolute paths of removed files. Errors during
21
+ * readdir/unlink are swallowed individually so one unreadable directory
22
+ * can't abort the whole sweep.
23
+ *
24
+ * Exported for tests; the entry point used by sync is `runTmpJanitor`.
25
+ */
26
+ export async function sweepTmpFiles(dir, now = Date.now(), minAgeMs = MIN_STALE_AGE_MS) {
27
+ const removed = [];
28
+ let entries;
29
+ try {
30
+ entries = await readdir(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return removed;
34
+ }
35
+ for (const ent of entries) {
36
+ const full = join(dir, ent.name);
37
+ if (ent.isDirectory()) {
38
+ // One level of recursion (e.g. outDir/<folder>/). Deeper trees are not
39
+ // produced by atomicWrite callers.
40
+ const nested = await sweepTmpFiles(full, now, minAgeMs);
41
+ for (const p of nested)
42
+ removed.push(p);
43
+ continue;
44
+ }
45
+ if (!ent.isFile())
46
+ continue;
47
+ if (!TMP_SUFFIX_RE.test(ent.name))
48
+ continue;
49
+ try {
50
+ const st = await stat(full);
51
+ if (now - st.mtimeMs < minAgeMs)
52
+ continue;
53
+ await unlink(full);
54
+ removed.push(full);
55
+ }
56
+ catch {
57
+ /* ignore — file may have just been renamed away by a concurrent writer */
58
+ }
59
+ }
60
+ return removed;
61
+ }
62
+ /**
63
+ * Run the janitor across the directories that atomicWrite targets land in:
64
+ * - `<outDir>/**` — asset PNGs (one level of folders).
65
+ * - `<cwd>/.magicpixel/` — state.json + credentials.
66
+ * - `<cwd>/` — package.json, magicpixel.json, AGENTS.md.
67
+ *
68
+ * Side-effect-free for the happy path: the `MIN_STALE_AGE_MS` floor prevents
69
+ * any race with a concurrent in-flight write.
70
+ */
71
+ export async function runTmpJanitor(outDir, cwd = process.cwd()) {
72
+ const dirs = [outDir, join(cwd, '.magicpixel'), cwd];
73
+ const all = [];
74
+ for (const d of dirs) {
75
+ const r = await sweepTmpFiles(d);
76
+ for (const p of r)
77
+ all.push(p);
78
+ }
79
+ return all;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {