@magicpixelart/cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] — 2026-06-02
11
+
12
+ ### Fixed
13
+
14
+ - **Instant HMR for synced assets (regression from 0.5.1).** 0.5.1 routed
15
+ the per-asset PNG writer through `atomicWrite`, which stages a
16
+ `<name>.<pid>.<hex>.tmp` file and `rename()`s over the target. Vite's
17
+ chokidar watcher only collapses the resulting unlink/add pair when the
18
+ tmp filename starts with `.` or ends with `~` — ours matches neither, so
19
+ the target was observed as an `add` rather than a `change` and Vite did
20
+ not re-push the new image URL to the open browser. Users had to hard
21
+ refresh to see freshly-synced assets (e.g. after renaming an artboard).
22
+ The asset writer is back to a direct `writeFile(diskPath, bytes)` —
23
+ same inode, single `change` event, immediate HMR. Atomicity is not
24
+ needed for asset bytes (a torn write is self-healing on the next watch
25
+ tick via sha mismatch); `atomicWrite` remains in use for `state.json`,
26
+ credentials, `AGENTS.md`, `package.json`, and `magicpixel.json`, where
27
+ torn writes WOULD corrupt cross-run state. A regression-guard test
28
+ (`tests/sync.assetWriterDirect.test.ts`) pins this contract.
29
+
30
+ ## [0.5.1] — 2026-06-02
31
+
32
+ ### Fixed
33
+
34
+ - **`.tmp` file leak in `atomicWrite` and the asset writer.** Every CLI write
35
+ that goes through `atomicWrite` (`state.json`, `package.json`,
36
+ `magicpixel.json`, credentials, `AGENTS.md`, the generated index) and the
37
+ inline tmp+rename in `sync` previously skipped cleanup when `rename()`
38
+ threw (FS hiccup, AV scan, racing writer, ENOSPC). Because `saveState`
39
+ fires every watch tick, even a low failure rate accumulated hundreds of
40
+ `<file>.<pid>.<hex>.tmp` files inside `.magicpixel/` and
41
+ `src/assets/magicpixel/<folder>/` — and they were invisible to the
42
+ `*.png`-only orphan walker. `atomicWrite` now wraps the write/chmod/rename
43
+ body in a `try { … } catch { unlink(tmp); throw }` block; the asset writer
44
+ in `sync` is reduced to a single `atomicWrite(diskPath, bytes)` call so
45
+ there's only one staging path left.
46
+ - **Renamed artboard left the old PNG on disk in incremental mode.** When
47
+ `state.json` was stale (e.g. a victim of the `.tmp` leak above) the
48
+ rename detector couldn't correlate the new manifest entry to the prior
49
+ key, and the incremental code path skips the full orphan sweep. Sync now
50
+ has a sha-based fallback: for each download-bound entry whose `sha256`
51
+ matches a stranded local PNG (one whose disk path is not in the current
52
+ manifest), we record a synthetic `RenameInfo` and prune the stale PNG.
53
+ Same-bytes + manifest-no-longer-references-that-path is unambiguous
54
+ evidence of a rename and never deletes anything the user still owns.
55
+
56
+ ### Added
57
+
58
+ - **Startup `.tmp` janitor.** `magicpixel sync` (one-shot and `--watch`)
59
+ sweeps stale `<file>.<pid>.<hex>.tmp` files left over from prior crashed
60
+ or killed runs across `outDir/**`, `.magicpixel/`, and the project root.
61
+ Pattern is anchored (`\.\d+\.[0-9a-f]{16}\.tmp$`) so user files with
62
+ `.tmp` in the name are never touched, and a 30-second age floor prevents
63
+ any race with a concurrent in-flight write.
64
+ - **`atomicWrite` now accepts `Uint8Array` in addition to `string`** so the
65
+ asset-bytes writer in `sync` can share the same cleanup-guaranteed
66
+ staging logic as every other writer.
67
+
10
68
  ## [0.5.0] — 2026-06-02
11
69
 
12
70
  ### 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, {
@@ -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
6
  * everywhere a corrupted target would break the project (state.json,
7
- * package.json, magicpixel.json, credentials, AGENTS.md, generated index files).
7
+ * package.json, magicpixel.json, credentials, AGENTS.md, generated index files,
8
+ * asset PNGs from `sync`).
9
+ *
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).
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.2",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {