@magicpixelart/cli 0.4.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,7 +7,81 @@ 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
+
68
+ ## [0.5.0] — 2026-06-02
69
+
70
+ ### Added
71
+ - **Legacy slug-folder sweep.** When a server-side slug rule change renames
72
+ a doc (e.g. `cards-2` → `cards`) and the prior id→key snapshot is missing
73
+ (fresh clone, CI runner, snapshot wipe), `sync` now detects top-level
74
+ `outDir` folders matching `<currentSlug>-<n>` for any slug known from the
75
+ manifest OR the prior persisted snapshot, and prunes them whole. Always
76
+ prints a `Legacy slug folders removed — update your imports:` block with
77
+ find/replace hints so users fix any source code references. Runs in both
78
+ full and incremental mode; respects `--no-prune`. Sibling slugs that are
79
+ themselves valid (e.g. a user-named `tiles-2/` next to `tiles/`) are
80
+ never deleted because the "known slugs" set unions the live manifest
81
+ with the persisted id→key map.
82
+
10
83
  ### Fixed
84
+
11
85
  - **`sync --watch 1` silently coerced to 2.** The commander validator
12
86
  accepted `1–3600` but the watch loop enforced a 2s floor via
13
87
  `Math.max(2, …)`, so `--watch 1` quietly ran at 2s with no warning.
@@ -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 } 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
@@ -369,6 +428,38 @@ async function runOnce(opts, runOpts = {}) {
369
428
  for (const p of renameStalePaths)
370
429
  orphanSet.add(p);
371
430
  orphans = [...orphanSet];
431
+ // Legacy-suffix folder sweep — always runs, even in incremental mode.
432
+ //
433
+ // Background: server-side slug uniqueness rules have changed over time
434
+ // (e.g. per-user → per-project). A doc that used to live under
435
+ // `outDir/cards-2/` may now report slug `cards` in the manifest. If the
436
+ // CLI's prior snapshot doesn't have the old id→key mapping (fresh clone,
437
+ // CI runner, snapshot wipe), rename detection finds nothing and the stale
438
+ // folder lingers next to the current one, breaking the user's imports of
439
+ // `@/assets/.../cards-2/...`.
440
+ //
441
+ // We detect this by looking for top-level disk folders whose name matches
442
+ // `<currentSlug>-<n>` for any slug currently in the manifest. Those are
443
+ // unambiguously legacy suffix collisions. We prune the whole folder
444
+ // (when --prune is on) and surface a clear "update your imports" notice
445
+ // so the user knows their source code references must be migrated.
446
+ // Build the "known top-level slugs" set from BOTH the current manifest AND
447
+ // the prior id→key snapshot. In incremental (`--watch`) mode the manifest
448
+ // only contains rows changed since `lastSync`, so a user with two
449
+ // legitimate sibling slugs (`tiles/` + `tiles-2/`) would lose `tiles-2/`
450
+ // the moment only `tiles` appeared in a delta. Pulling from
451
+ // `previousAssets` (the persisted full snapshot) closes that hole.
452
+ const knownFolderSlugs = new Set();
453
+ for (const e of manifest) {
454
+ if (e.folder)
455
+ knownFolderSlugs.add(e.folder.split('/')[0]);
456
+ }
457
+ for (const key of Object.values(previousAssets)) {
458
+ const top = key.split('/')[0];
459
+ if (top)
460
+ knownFolderSlugs.add(top);
461
+ }
462
+ const legacyFolders = await findLegacySuffixFolders(config.outDir, knownFolderSlugs);
372
463
  if (verbose) {
373
464
  console.log();
374
465
  console.log(kleur.bold('Plan:'));
@@ -376,6 +467,10 @@ async function runOnce(opts, runOpts = {}) {
376
467
  console.log(` ${kleur.dim('=')} unchanged ${unchanged}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
377
468
  if (renamed.length)
378
469
  console.log(` ${kleur.cyan('↪')} renamed ${renamed.length}`);
470
+ if (legacyFolders.length) {
471
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep');
472
+ console.log(` ${verb} ${legacyFolders.length} legacy slug folder${legacyFolders.length === 1 ? '' : 's'}`);
473
+ }
379
474
  if (since && !renamed.length) {
380
475
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
381
476
  }
@@ -392,6 +487,8 @@ async function runOnce(opts, runOpts = {}) {
392
487
  printRenames(renamed, { withHints: false });
393
488
  if (orphans.length > 0)
394
489
  printOrphans(orphans);
490
+ if (legacyFolders.length > 0)
491
+ printLegacyFolders(legacyFolders);
395
492
  }
396
493
  return {
397
494
  added: [],
@@ -448,14 +545,23 @@ async function runOnce(opts, runOpts = {}) {
448
545
  throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
449
546
  }
450
547
  }
451
- // Wrap any of mkdir/writeFile/rename so the user sees a friendly
548
+ // Wrap any of mkdir/writeFile so the user sees a friendly
452
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.
453
562
  try {
454
563
  await mkdir(dirname(diskPath), { recursive: true });
455
- const tmp = tmpPathFor(diskPath);
456
- assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
457
- await writeFile(tmp, bytes);
458
- await rename(tmp, diskPath);
564
+ await writeFile(diskPath, bytes);
459
565
  }
460
566
  catch (fsErr) {
461
567
  throw friendlyFsError(fsErr, {
@@ -523,6 +629,36 @@ async function runOnce(opts, runOpts = {}) {
523
629
  else if (verbose && orphans.length > 0) {
524
630
  printOrphans(orphans);
525
631
  }
632
+ // Legacy-suffix folder sweep. Runs in BOTH full and incremental mode so a
633
+ // user who is hot off a server-side slug rule change recovers without
634
+ // having to remember `--full`. Folders are deleted whole (children
635
+ // included) because every PNG inside is, by definition, stale; their
636
+ // canonical copies live under the de-suffixed folder we just downloaded.
637
+ if (shouldPrune && legacyFolders.length > 0) {
638
+ const outRoot = resolve(process.cwd(), config.outDir);
639
+ for (const lf of legacyFolders) {
640
+ assertPathInsideRoot(lf.abs, outRoot, 'outDir');
641
+ try {
642
+ await rm(lf.abs, { recursive: true, force: true });
643
+ // Push a synthetic key so the watch loop's changedCount picks this up
644
+ // (otherwise a tick that ONLY swept legacy folders reports "no changes").
645
+ result.removed.push(`${lf.legacyName}/ (legacy)`);
646
+ if (verbose) {
647
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), lf.abs)} ${kleur.dim(`(legacy slug — now ${lf.currentSlug}/)`)}`);
648
+ }
649
+ }
650
+ catch (e) {
651
+ if (verbose)
652
+ console.log(` ${kleur.yellow('!')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
653
+ }
654
+ }
655
+ // Always surface the import-update notice — even in --quiet/watch mode
656
+ // — because user source code now references paths that no longer exist.
657
+ printLegacyFolders(legacyFolders);
658
+ }
659
+ else if (verbose && legacyFolders.length > 0) {
660
+ printLegacyFolders(legacyFolders);
661
+ }
526
662
  // Always persist the id → key snapshot so rename detection survives even
527
663
  // when `emitIndex` is toggled off-then-on. `lastSync` only advances on a
528
664
  // clean run, so partial-failure syncs are retried from the prior cursor.
@@ -687,5 +823,62 @@ function printOrphans(orphans) {
687
823
  }
688
824
  console.log(kleur.dim(' You passed --no-prune, so these were kept. Remove the flag to delete them.'));
689
825
  }
826
+ /**
827
+ * Find top-level disk folders whose name matches `<currentSlug>-<n>` for some
828
+ * slug present in the manifest. These are unambiguously legacy artifacts
829
+ * from a server-side slug rule change (e.g. per-user → per-project
830
+ * uniqueness) — the canonical copy now lives under `<currentSlug>/`, and the
831
+ * suffixed folder's PNGs are stale.
832
+ *
833
+ * Only scans the FIRST level under outDir; nested folders are user content.
834
+ * Returns `[]` when outDir doesn't exist or when no remote slugs are known
835
+ * (defensive — never wipe folders when we have no manifest to compare to).
836
+ */
837
+ export async function findLegacySuffixFolders(outDir, knownFolderSlugs, cwd = process.cwd()) {
838
+ if (knownFolderSlugs.size === 0)
839
+ return [];
840
+ const root = resolve(cwd, outDir);
841
+ if (!existsSync(root))
842
+ return [];
843
+ let entries;
844
+ try {
845
+ entries = await readdir(root, { withFileTypes: true });
846
+ }
847
+ catch {
848
+ return [];
849
+ }
850
+ const out = [];
851
+ for (const ent of entries) {
852
+ if (!ent.isDirectory())
853
+ continue;
854
+ if (ent.name.startsWith('.'))
855
+ continue;
856
+ // Skip folders that ARE known slugs — only suffixed siblings are suspect.
857
+ if (knownFolderSlugs.has(ent.name))
858
+ continue;
859
+ const m = /^(.+)-(\d+)$/.exec(ent.name);
860
+ if (!m)
861
+ continue;
862
+ const base = m[1];
863
+ if (!knownFolderSlugs.has(base))
864
+ continue;
865
+ out.push({
866
+ abs: resolve(root, ent.name),
867
+ legacyName: ent.name,
868
+ currentSlug: base,
869
+ });
870
+ }
871
+ return out;
872
+ }
873
+ function printLegacyFolders(legacy) {
874
+ if (legacy.length === 0)
875
+ return;
876
+ console.log();
877
+ console.log(kleur.bold('Legacy slug folders removed — update your imports:'));
878
+ for (const lf of legacy) {
879
+ console.log(` ${kleur.red('-')} ${lf.legacyName}/ ${kleur.dim(`→ now ${lf.currentSlug}/`)}`);
880
+ console.log(` ${kleur.dim(`Find/replace in your project: ${lf.legacyName}/ → ${lf.currentSlug}/`)}`);
881
+ }
882
+ }
690
883
  // Note: orphan scan delegates to `walkOutDirPngs` in util/paths.ts (shared
691
884
  // with the typed-index emitter). `maxIsoTimestamp` lives in util/iso.ts.
@@ -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.4.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": {