@magicpixelart/cli 0.4.0 → 0.5.0

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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-06-02
11
+
12
+ ### Added
13
+ - **Legacy slug-folder sweep.** When a server-side slug rule change renames
14
+ a doc (e.g. `cards-2` → `cards`) and the prior id→key snapshot is missing
15
+ (fresh clone, CI runner, snapshot wipe), `sync` now detects top-level
16
+ `outDir` folders matching `<currentSlug>-<n>` for any slug known from the
17
+ manifest OR the prior persisted snapshot, and prunes them whole. Always
18
+ prints a `Legacy slug folders removed — update your imports:` block with
19
+ find/replace hints so users fix any source code references. Runs in both
20
+ full and incremental mode; respects `--no-prune`. Sibling slugs that are
21
+ themselves valid (e.g. a user-named `tiles-2/` next to `tiles/`) are
22
+ never deleted because the "known slugs" set unions the live manifest
23
+ with the persisted id→key map.
24
+
10
25
  ### Fixed
26
+
11
27
  - **`sync --watch 1` silently coerced to 2.** The commander validator
12
28
  accepted `1–3600` but the watch loop enforced a 2s floor via
13
29
  `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, writeFile, unlink, rename, readdir, rm } 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';
@@ -369,6 +369,38 @@ async function runOnce(opts, runOpts = {}) {
369
369
  for (const p of renameStalePaths)
370
370
  orphanSet.add(p);
371
371
  orphans = [...orphanSet];
372
+ // Legacy-suffix folder sweep — always runs, even in incremental mode.
373
+ //
374
+ // Background: server-side slug uniqueness rules have changed over time
375
+ // (e.g. per-user → per-project). A doc that used to live under
376
+ // `outDir/cards-2/` may now report slug `cards` in the manifest. If the
377
+ // CLI's prior snapshot doesn't have the old id→key mapping (fresh clone,
378
+ // CI runner, snapshot wipe), rename detection finds nothing and the stale
379
+ // folder lingers next to the current one, breaking the user's imports of
380
+ // `@/assets/.../cards-2/...`.
381
+ //
382
+ // We detect this by looking for top-level disk folders whose name matches
383
+ // `<currentSlug>-<n>` for any slug currently in the manifest. Those are
384
+ // unambiguously legacy suffix collisions. We prune the whole folder
385
+ // (when --prune is on) and surface a clear "update your imports" notice
386
+ // so the user knows their source code references must be migrated.
387
+ // Build the "known top-level slugs" set from BOTH the current manifest AND
388
+ // the prior id→key snapshot. In incremental (`--watch`) mode the manifest
389
+ // only contains rows changed since `lastSync`, so a user with two
390
+ // legitimate sibling slugs (`tiles/` + `tiles-2/`) would lose `tiles-2/`
391
+ // the moment only `tiles` appeared in a delta. Pulling from
392
+ // `previousAssets` (the persisted full snapshot) closes that hole.
393
+ const knownFolderSlugs = new Set();
394
+ for (const e of manifest) {
395
+ if (e.folder)
396
+ knownFolderSlugs.add(e.folder.split('/')[0]);
397
+ }
398
+ for (const key of Object.values(previousAssets)) {
399
+ const top = key.split('/')[0];
400
+ if (top)
401
+ knownFolderSlugs.add(top);
402
+ }
403
+ const legacyFolders = await findLegacySuffixFolders(config.outDir, knownFolderSlugs);
372
404
  if (verbose) {
373
405
  console.log();
374
406
  console.log(kleur.bold('Plan:'));
@@ -376,6 +408,10 @@ async function runOnce(opts, runOpts = {}) {
376
408
  console.log(` ${kleur.dim('=')} unchanged ${unchanged}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
377
409
  if (renamed.length)
378
410
  console.log(` ${kleur.cyan('↪')} renamed ${renamed.length}`);
411
+ if (legacyFolders.length) {
412
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep');
413
+ console.log(` ${verb} ${legacyFolders.length} legacy slug folder${legacyFolders.length === 1 ? '' : 's'}`);
414
+ }
379
415
  if (since && !renamed.length) {
380
416
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
381
417
  }
@@ -392,6 +428,8 @@ async function runOnce(opts, runOpts = {}) {
392
428
  printRenames(renamed, { withHints: false });
393
429
  if (orphans.length > 0)
394
430
  printOrphans(orphans);
431
+ if (legacyFolders.length > 0)
432
+ printLegacyFolders(legacyFolders);
395
433
  }
396
434
  return {
397
435
  added: [],
@@ -523,6 +561,36 @@ async function runOnce(opts, runOpts = {}) {
523
561
  else if (verbose && orphans.length > 0) {
524
562
  printOrphans(orphans);
525
563
  }
564
+ // Legacy-suffix folder sweep. Runs in BOTH full and incremental mode so a
565
+ // user who is hot off a server-side slug rule change recovers without
566
+ // having to remember `--full`. Folders are deleted whole (children
567
+ // included) because every PNG inside is, by definition, stale; their
568
+ // canonical copies live under the de-suffixed folder we just downloaded.
569
+ if (shouldPrune && legacyFolders.length > 0) {
570
+ const outRoot = resolve(process.cwd(), config.outDir);
571
+ for (const lf of legacyFolders) {
572
+ assertPathInsideRoot(lf.abs, outRoot, 'outDir');
573
+ try {
574
+ await rm(lf.abs, { recursive: true, force: true });
575
+ // Push a synthetic key so the watch loop's changedCount picks this up
576
+ // (otherwise a tick that ONLY swept legacy folders reports "no changes").
577
+ result.removed.push(`${lf.legacyName}/ (legacy)`);
578
+ if (verbose) {
579
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), lf.abs)} ${kleur.dim(`(legacy slug — now ${lf.currentSlug}/)`)}`);
580
+ }
581
+ }
582
+ catch (e) {
583
+ if (verbose)
584
+ console.log(` ${kleur.yellow('!')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
585
+ }
586
+ }
587
+ // Always surface the import-update notice — even in --quiet/watch mode
588
+ // — because user source code now references paths that no longer exist.
589
+ printLegacyFolders(legacyFolders);
590
+ }
591
+ else if (verbose && legacyFolders.length > 0) {
592
+ printLegacyFolders(legacyFolders);
593
+ }
526
594
  // Always persist the id → key snapshot so rename detection survives even
527
595
  // when `emitIndex` is toggled off-then-on. `lastSync` only advances on a
528
596
  // clean run, so partial-failure syncs are retried from the prior cursor.
@@ -687,5 +755,62 @@ function printOrphans(orphans) {
687
755
  }
688
756
  console.log(kleur.dim(' You passed --no-prune, so these were kept. Remove the flag to delete them.'));
689
757
  }
758
+ /**
759
+ * Find top-level disk folders whose name matches `<currentSlug>-<n>` for some
760
+ * slug present in the manifest. These are unambiguously legacy artifacts
761
+ * from a server-side slug rule change (e.g. per-user → per-project
762
+ * uniqueness) — the canonical copy now lives under `<currentSlug>/`, and the
763
+ * suffixed folder's PNGs are stale.
764
+ *
765
+ * Only scans the FIRST level under outDir; nested folders are user content.
766
+ * Returns `[]` when outDir doesn't exist or when no remote slugs are known
767
+ * (defensive — never wipe folders when we have no manifest to compare to).
768
+ */
769
+ export async function findLegacySuffixFolders(outDir, knownFolderSlugs, cwd = process.cwd()) {
770
+ if (knownFolderSlugs.size === 0)
771
+ return [];
772
+ const root = resolve(cwd, outDir);
773
+ if (!existsSync(root))
774
+ return [];
775
+ let entries;
776
+ try {
777
+ entries = await readdir(root, { withFileTypes: true });
778
+ }
779
+ catch {
780
+ return [];
781
+ }
782
+ const out = [];
783
+ for (const ent of entries) {
784
+ if (!ent.isDirectory())
785
+ continue;
786
+ if (ent.name.startsWith('.'))
787
+ continue;
788
+ // Skip folders that ARE known slugs — only suffixed siblings are suspect.
789
+ if (knownFolderSlugs.has(ent.name))
790
+ continue;
791
+ const m = /^(.+)-(\d+)$/.exec(ent.name);
792
+ if (!m)
793
+ continue;
794
+ const base = m[1];
795
+ if (!knownFolderSlugs.has(base))
796
+ continue;
797
+ out.push({
798
+ abs: resolve(root, ent.name),
799
+ legacyName: ent.name,
800
+ currentSlug: base,
801
+ });
802
+ }
803
+ return out;
804
+ }
805
+ function printLegacyFolders(legacy) {
806
+ if (legacy.length === 0)
807
+ return;
808
+ console.log();
809
+ console.log(kleur.bold('Legacy slug folders removed — update your imports:'));
810
+ for (const lf of legacy) {
811
+ console.log(` ${kleur.red('-')} ${lf.legacyName}/ ${kleur.dim(`→ now ${lf.currentSlug}/`)}`);
812
+ console.log(` ${kleur.dim(`Find/replace in your project: ${lf.legacyName}/ → ${lf.currentSlug}/`)}`);
813
+ }
814
+ }
690
815
  // Note: orphan scan delegates to `walkOutDirPngs` in util/paths.ts (shared
691
816
  // with the typed-index emitter). `maxIsoTimestamp` lives in util/iso.ts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {