@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 +74 -0
- package/dist/commands/sync.js +202 -9
- package/dist/util/atomicWrite.js +43 -11
- package/dist/util/tmpJanitor.js +80 -0
- package/package.json +1 -1
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.
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
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
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
|
-
|
|
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
|
+
}
|