@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 +73 -0
- package/dist/commands/sync.js +77 -9
- package/dist/config.js +14 -6
- package/dist/util/atomicWrite.js +44 -12
- package/dist/util/tmpJanitor.js +80 -0
- package/package.json +1 -1
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
|
package/dist/commands/sync.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
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, {
|
package/dist/util/atomicWrite.js
CHANGED
|
@@ -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 (
|
|
7
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
50
|
+
await unlink(tmp);
|
|
19
51
|
}
|
|
20
52
|
catch {
|
|
21
|
-
|
|
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
|
+
}
|