@decocms/blocks-cli 7.21.0 → 7.22.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.
Files changed (37) hide show
  1. package/package.json +4 -3
  2. package/scripts/lib/upgrade-6-to-7.test.ts +145 -0
  3. package/scripts/lib/upgrade-6-to-7.ts +157 -0
  4. package/scripts/migrate/analyzers/tailwind-config.test.ts +149 -0
  5. package/scripts/migrate/analyzers/tailwind-config.ts +283 -0
  6. package/scripts/migrate/css-compile-check.test.ts +115 -0
  7. package/scripts/migrate/css-compile-check.ts +137 -0
  8. package/scripts/migrate/phase-analyze.ts +13 -0
  9. package/scripts/migrate/phase-cleanup.test.ts +1 -0
  10. package/scripts/migrate/phase-compile.test.ts +48 -0
  11. package/scripts/migrate/phase-compile.ts +38 -0
  12. package/scripts/migrate/phase-report.test.ts +103 -0
  13. package/scripts/migrate/phase-report.ts +64 -4
  14. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +134 -0
  15. package/scripts/migrate/phase-verify.test.ts +1 -0
  16. package/scripts/migrate/phase-verify.ts +24 -0
  17. package/scripts/migrate/post-cleanup/migration-artifacts.test.ts +262 -0
  18. package/scripts/migrate/post-cleanup/rules.ts +321 -0
  19. package/scripts/migrate/post-cleanup/runner.test.ts +2 -0
  20. package/scripts/migrate/templates/app-css.test.ts +150 -0
  21. package/scripts/migrate/templates/app-css.ts +142 -52
  22. package/scripts/migrate/templates/hooks.test.ts +1 -0
  23. package/scripts/migrate/templates/package-json.ts +5 -0
  24. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +51 -0
  25. package/scripts/migrate/templates/server-entry.ts +53 -1
  26. package/scripts/migrate/transforms/color-oklch.test.ts +83 -0
  27. package/scripts/migrate/transforms/color-oklch.ts +122 -0
  28. package/scripts/migrate/transforms/css.test.ts +109 -0
  29. package/scripts/migrate/transforms/css.ts +186 -0
  30. package/scripts/migrate/transforms/tailwind-renames.test.ts +37 -0
  31. package/scripts/migrate/transforms/tailwind-renames.ts +224 -0
  32. package/scripts/migrate/transforms/tailwind.test.ts +74 -0
  33. package/scripts/migrate/transforms/tailwind.ts +27 -123
  34. package/scripts/migrate/types.ts +18 -0
  35. package/scripts/tailwind-lint.ts +9 -54
  36. package/scripts/upgrade-6-to-7.test.ts +80 -0
  37. package/scripts/upgrade-6-to-7.ts +169 -0
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformTailwind } from "./tailwind";
3
+
4
+ describe("transformTailwind — v3 -> v4 class renames", () => {
5
+ it("renames the flex-grow/shrink family", () => {
6
+ const r = transformTailwind(`<div className="flex-grow-0 flex-shrink" />`);
7
+ expect(r.content).toContain('className="grow-0 shrink"');
8
+ });
9
+
10
+ it("removes transform/filter (v4 applies them automatically)", () => {
11
+ const r = transformTailwind(`<div className="transform filter scale-105" />`);
12
+ expect(r.content).toContain('className="scale-105"');
13
+ });
14
+
15
+ it("bumps the default ring width (v3 3px -> v4 ring-3)", () => {
16
+ const r = transformTailwind(`<div className="ring ring-blue-500" />`);
17
+ expect(r.content).toContain("ring-3 ring-blue-500");
18
+ });
19
+
20
+ it("shifts the shadow scale down one step without cascading (single-pass map lookup)", () => {
21
+ const r = transformTailwind(`<div className="shadow-sm" />`);
22
+ expect(r.content).toContain('className="shadow-xs"');
23
+ });
24
+
25
+ it("shifts shadow, blur, rounded, and drop-shadow scales independently", () => {
26
+ const r = transformTailwind(
27
+ `<div className="shadow blur rounded drop-shadow" />`,
28
+ );
29
+ expect(r.content).toContain("shadow-sm");
30
+ expect(r.content).toContain("blur-sm");
31
+ expect(r.content).toContain("rounded-sm");
32
+ expect(r.content).toContain("drop-shadow-sm");
33
+ });
34
+
35
+ it("renames outline-none to outline-hidden", () => {
36
+ const r = transformTailwind(`<div className="outline-none" />`);
37
+ expect(r.content).toContain('className="outline-hidden"');
38
+ });
39
+
40
+ it("renames bg-gradient-to-* to bg-linear-to-*", () => {
41
+ const r = transformTailwind(`<div className="bg-gradient-to-r from-red-500" />`);
42
+ expect(r.content).toContain("bg-linear-to-r");
43
+ });
44
+
45
+ it("renames known daisyUI v4 -> v5 classes", () => {
46
+ const r = transformTailwind(`<div className="badge-ghost card-compact" />`);
47
+ expect(r.content).toContain("badge-soft");
48
+ expect(r.content).toContain("card-sm");
49
+ });
50
+ });
51
+
52
+ describe("transformTailwind — gotcha detection (flag, don't auto-fix)", () => {
53
+ it("flags DaisyUI .collapse usage as a manual-review finding", () => {
54
+ const r = transformTailwind(
55
+ `<div className="collapse"><div className="collapse-title" /></div>`,
56
+ );
57
+ expect(r.notes.some((n) => n.startsWith("MANUAL:") && n.includes("collapse"))).toBe(true);
58
+ });
59
+
60
+ it("flags btn-group and form-control as removed-in-v5", () => {
61
+ const r = transformTailwind(`<div className="btn-group" /><div className="form-control" />`);
62
+ expect(r.notes.filter((n) => n.startsWith("MANUAL:")).length).toBeGreaterThanOrEqual(2);
63
+ });
64
+
65
+ it("flags a shorthand/longhand spacing conflict (px-* mixed with pl-*/pr-*)", () => {
66
+ const r = transformTailwind(`<div className="pl-4 sm:pl-0 md:px-6" />`);
67
+ expect(r.notes.some((n) => n.startsWith("MANUAL:") && n.includes("padding-inline"))).toBe(true);
68
+ });
69
+
70
+ it("does not flag px-* used alone (no mixed longhand)", () => {
71
+ const r = transformTailwind(`<div className="px-4 md:px-6" />`);
72
+ expect(r.notes.some((n) => n.includes("padding-inline"))).toBe(false);
73
+ });
74
+ });
@@ -1,4 +1,11 @@
1
1
  import type { TransformResult } from "../types";
2
+ import {
3
+ PX_TO_SPACING,
4
+ TEXT_SIZE_MAP,
5
+ detectDaisyUiV5StructuralIssues,
6
+ detectLogicalPropertyConflict,
7
+ renameToken,
8
+ } from "./tailwind-renames";
2
9
 
3
10
  /**
4
11
  * Tailwind v3 → v4 class migration transform.
@@ -9,6 +16,8 @@ import type { TransformResult } from "../types";
9
16
  * 3. Responsive class ordering (base → sm → md → lg → xl → 2xl)
10
17
  * 4. Arbitrary values → native equivalents (px-[16px] → px-4)
11
18
  * 5. Deprecated patterns
19
+ * 6. Flags (does not auto-fix) DaisyUI v5 structural breaks and v4
20
+ * logical-vs-physical spacing property conflicts
12
21
  */
13
22
 
14
23
  // ── Breakpoint order (mobile-first) ─────────────────────────────
@@ -18,111 +27,6 @@ BREAKPOINT_ORDER.forEach((bp, i) => {
18
27
  BP_INDEX[bp] = i + 1; // base = 0
19
28
  });
20
29
 
21
- // ── Tailwind v3 → v4 class renames ──────────────────────────────
22
- // These are direct 1:1 replacements
23
- const CLASS_RENAMES: Record<string, string> = {
24
- // Flexbox/Grid
25
- "flex-grow-0": "grow-0",
26
- "flex-grow": "grow",
27
- "flex-shrink-0": "shrink-0",
28
- "flex-shrink": "shrink",
29
-
30
- // Overflow
31
- "overflow-ellipsis": "text-ellipsis",
32
-
33
- // Decoration
34
- "decoration-clone": "box-decoration-clone",
35
- "decoration-slice": "box-decoration-slice",
36
-
37
- // Transforms (v4 applies transforms automatically)
38
- "transform": "", // remove — v4 applies automatically
39
- "transform-gpu": "",
40
- "transform-none": "transform-none", // this one stays
41
-
42
- // Blur/filter (v4 applies automatically)
43
- "filter": "", // remove
44
- "backdrop-filter": "", // remove
45
-
46
- // Ring width default
47
- "ring": "ring-3", // v4 changed default from 3px to 1px
48
- };
49
-
50
- // ── DaisyUI v4 → v5 class renames ──────────────────────────────
51
- const DAISYUI_RENAMES: Record<string, string> = {
52
- // Button changes
53
- "btn-ghost": "btn-ghost", // kept
54
- "btn-outline": "btn-outline", // kept
55
- "btn-active": "btn-active", // kept
56
-
57
- // Alert/Badge
58
- "badge-ghost": "badge-soft",
59
- "alert-info": "alert-info",
60
- "alert-success": "alert-success",
61
- "alert-warning": "alert-warning",
62
- "alert-error": "alert-error",
63
-
64
- // Card
65
- "card-compact": "card-sm",
66
-
67
- // Modal
68
- "modal-open": "modal-open",
69
-
70
- // Drawer
71
- "drawer-end": "drawer-end",
72
-
73
- // Menu
74
- "menu-horizontal": "menu-horizontal",
75
-
76
- // Toast position classes (daisy v5 uses different system)
77
- "toast-top": "toast-top",
78
- "toast-bottom": "toast-bottom",
79
- "toast-center": "toast-center",
80
- "toast-end": "toast-end",
81
- "toast-start": "toast-start",
82
- "toast-middle": "toast-middle",
83
-
84
- // Loading
85
- "loading-spinner": "loading-spinner",
86
- "loading-dots": "loading-dots",
87
- "loading-ring": "loading-ring",
88
- "loading-ball": "loading-ball",
89
- "loading-bars": "loading-bars",
90
- "loading-infinity": "loading-infinity",
91
-
92
- // Sizes (daisy v5 naming)
93
- "btn-xs": "btn-xs",
94
- "btn-sm": "btn-sm",
95
- "btn-md": "btn-md",
96
- "btn-lg": "btn-lg",
97
- };
98
-
99
- // ── Spacing scale: px → Tailwind unit ───────────────────────────
100
- const PX_TO_SPACING: Record<number, string> = {};
101
- for (let i = 0; i <= 96; i++) {
102
- PX_TO_SPACING[i * 4] = String(i);
103
- }
104
- PX_TO_SPACING[2] = "0.5";
105
- PX_TO_SPACING[6] = "1.5";
106
- PX_TO_SPACING[10] = "2.5";
107
- PX_TO_SPACING[14] = "3.5";
108
-
109
- // Text size: px → native class
110
- const TEXT_SIZE_MAP: Record<string, string> = {
111
- "12": "xs",
112
- "14": "sm",
113
- "16": "base",
114
- "18": "lg",
115
- "20": "xl",
116
- "24": "2xl",
117
- "30": "3xl",
118
- "36": "4xl",
119
- "48": "5xl",
120
- "60": "6xl",
121
- "72": "7xl",
122
- "96": "8xl",
123
- "128": "9xl",
124
- };
125
-
126
30
  // Properties that accept spacing values
127
31
  const SPACING_PROPS = new Set([
128
32
  "p", "px", "py", "pt", "pb", "pl", "pr",
@@ -200,24 +104,7 @@ function parseClass(cls: string): ParsedClass {
200
104
 
201
105
  // ── Fix class renames ───────────────────────────────────────────
202
106
  function fixRenames(cls: string): string {
203
- const parts = cls.split(":");
204
- const utility = parts.pop()!;
205
-
206
- // Check direct rename
207
- if (CLASS_RENAMES[utility] !== undefined) {
208
- const renamed = CLASS_RENAMES[utility];
209
- if (renamed === "") return ""; // Remove class entirely
210
- parts.push(renamed);
211
- return parts.join(":");
212
- }
213
-
214
- // Check DaisyUI rename
215
- if (DAISYUI_RENAMES[utility] && DAISYUI_RENAMES[utility] !== utility) {
216
- parts.push(DAISYUI_RENAMES[utility]);
217
- return parts.join(":");
218
- }
219
-
220
- return cls;
107
+ return renameToken(cls);
221
108
  }
222
109
 
223
110
  // ── Fix arbitrary values ────────────────────────────────────────
@@ -402,6 +289,11 @@ function fixClassNameString(classes: string): { fixed: string; changes: string[]
402
289
  }
403
290
  }
404
291
 
292
+ // 5. Flag (don't auto-fix) shorthand/longhand spacing conflicts — gotcha #42
293
+ for (const finding of detectLogicalPropertyConflict(classList)) {
294
+ changes.push(`MANUAL: ${finding.message}`);
295
+ }
296
+
405
297
  return { fixed: classList.join(" "), changes };
406
298
  }
407
299
 
@@ -626,6 +518,18 @@ export function transformTailwind(content: string): TransformResult {
626
518
  let changed = false;
627
519
  let result = content;
628
520
 
521
+ // ── Flag (don't auto-fix) DaisyUI v5 structural breaks — gotcha #37 ──
522
+ const daisyFindings = detectDaisyUiV5StructuralIssues(result);
523
+ if (daisyFindings.length > 0) {
524
+ // No content is rewritten here, but the caller (phase-transform) only
525
+ // surfaces `notes` when `changed` is true — mark it so these
526
+ // manual-review findings aren't silently dropped.
527
+ changed = true;
528
+ for (const finding of daisyFindings) {
529
+ notes.push(`MANUAL: ${finding.message}`);
530
+ }
531
+ }
532
+
629
533
  // ── Fix negative z-index on background images ──────────────────
630
534
  const zFix = fixNegativeZIndex(result);
631
535
  if (zFix.changed) {
@@ -157,6 +157,15 @@ export interface MigrationContext {
157
157
  /** Font family from CMS config */
158
158
  fontFamily: string | null;
159
159
 
160
+ /**
161
+ * Statically-extracted content of the source site's `tailwind.config.ts`
162
+ * (colors, fontFamily, screens, safelist, plugins) — read during analyze,
163
+ * before the file is deleted in cleanup, so its content can be ported into
164
+ * the scaffolded `src/styles/app.css` @theme block instead of silently
165
+ * dropped. See analyzers/tailwind-config.ts.
166
+ */
167
+ tailwindConfig: import("./analyzers/tailwind-config").TailwindConfigExtract;
168
+
160
169
  /** All categorized source files */
161
170
  files: FileRecord[];
162
171
 
@@ -217,6 +226,15 @@ export function createContext(
217
226
  discoveredNpmDeps: {},
218
227
  themeColors: {},
219
228
  fontFamily: null,
229
+ tailwindConfig: {
230
+ colors: {},
231
+ fontFamily: {},
232
+ screens: {},
233
+ safelist: [],
234
+ safelistPatterns: [],
235
+ plugins: [],
236
+ reviewItems: [],
237
+ },
220
238
  files: [],
221
239
  sectionMetas: [],
222
240
  islandClassifications: [],
@@ -18,6 +18,13 @@
18
18
 
19
19
  import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
20
20
  import { join, relative } from "node:path";
21
+ import {
22
+ CLASS_RENAMES,
23
+ DAISYUI_RENAMES,
24
+ PX_TO_SPACING,
25
+ TEXT_SIZE_MAP,
26
+ renameToken,
27
+ } from "./migrate/transforms/tailwind-renames";
21
28
 
22
29
  // ── Breakpoint order (mobile-first) ─────────────────────────────
23
30
  const BREAKPOINT_ORDER = ["sm", "md", "lg", "xl", "2xl"] as const;
@@ -26,44 +33,6 @@ BREAKPOINT_ORDER.forEach((bp, i) => {
26
33
  BP_INDEX[bp] = i + 1;
27
34
  });
28
35
 
29
- // ── Tailwind v3 → v4 class renames ──────────────────────────────
30
- const CLASS_RENAMES: Record<string, string> = {
31
- "flex-grow-0": "grow-0",
32
- "flex-grow": "grow",
33
- "flex-shrink-0": "shrink-0",
34
- "flex-shrink": "shrink",
35
- "overflow-ellipsis": "text-ellipsis",
36
- "decoration-clone": "box-decoration-clone",
37
- "decoration-slice": "box-decoration-slice",
38
- "transform": "",
39
- "transform-gpu": "",
40
- "filter": "",
41
- "backdrop-filter": "",
42
- "ring": "ring-3",
43
- };
44
-
45
- // ── DaisyUI v4 → v5 class renames ──────────────────────────────
46
- const DAISYUI_RENAMES: Record<string, string> = {
47
- "badge-ghost": "badge-soft",
48
- "card-compact": "card-sm",
49
- };
50
-
51
- // ── Spacing scale ───────────────────────────────────────────────
52
- const PX_TO_SPACING: Record<number, string> = {};
53
- for (let i = 0; i <= 96; i++) {
54
- PX_TO_SPACING[i * 4] = String(i);
55
- }
56
- PX_TO_SPACING[2] = "0.5";
57
- PX_TO_SPACING[6] = "1.5";
58
- PX_TO_SPACING[10] = "2.5";
59
- PX_TO_SPACING[14] = "3.5";
60
-
61
- const TEXT_SIZE_MAP: Record<string, string> = {
62
- "12": "xs", "14": "sm", "16": "base", "18": "lg", "20": "xl",
63
- "24": "2xl", "30": "3xl", "36": "4xl", "48": "5xl", "60": "6xl",
64
- "72": "7xl", "96": "8xl", "128": "9xl",
65
- };
66
-
67
36
  const SPACING_PROPS = new Set([
68
37
  "p", "px", "py", "pt", "pb", "pl", "pr",
69
38
  "m", "mx", "my", "mt", "mb", "ml", "mr",
@@ -247,7 +216,7 @@ function scanFile(filePath: string): Issue[] {
247
216
  for (const cls of classList) {
248
217
  const parts = cls.split(":");
249
218
  const utility = parts[parts.length - 1];
250
- if (CLASS_RENAMES[utility] !== undefined) {
219
+ if (CLASS_RENAMES[utility] !== undefined && CLASS_RENAMES[utility] !== utility) {
251
220
  const renamed = CLASS_RENAMES[utility];
252
221
  issues.push({
253
222
  file: filePath, line, type: "rename",
@@ -291,21 +260,7 @@ function fixClassOrder(classes: string): string {
291
260
  function fixClassName(classes: string): string {
292
261
  let classList = classes.split(/\s+/).filter(Boolean);
293
262
 
294
- classList = classList.map((cls) => {
295
- const parts = cls.split(":");
296
- const utility = parts.pop()!;
297
- if (CLASS_RENAMES[utility] !== undefined) {
298
- const renamed = CLASS_RENAMES[utility];
299
- if (renamed === "") return "";
300
- parts.push(renamed);
301
- return parts.join(":");
302
- }
303
- if (DAISYUI_RENAMES[utility] && DAISYUI_RENAMES[utility] !== utility) {
304
- parts.push(DAISYUI_RENAMES[utility]);
305
- return parts.join(":");
306
- }
307
- return cls;
308
- }).filter(Boolean);
263
+ classList = classList.map(renameToken).filter(Boolean);
309
264
 
310
265
  classList = classList.map((cls) => {
311
266
  if (!cls.includes("[")) return cls;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Smoke tests for the `deco-upgrade-6-to-7` CLI (#367) — drives the script
3
+ * as a child process against a tmp site fixture, verifying:
4
+ * - dry-run prints what would change without touching disk
5
+ * - --write applies the import-specifier rewrites and package.json swap
6
+ * - second --write run is a no-op (idempotency)
7
+ */
8
+ import * as cp from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+
14
+ const SCRIPT = path.resolve(__dirname, "upgrade-6-to-7.ts");
15
+
16
+ function run(cwd: string, args: string[]): { stdout: string; code: number } {
17
+ const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { cwd, encoding: "utf8" });
18
+ return { stdout: r.stdout || "", code: r.status ?? 0 };
19
+ }
20
+
21
+ describe("deco-upgrade-6-to-7 CLI (#367)", () => {
22
+ let tmp: string;
23
+
24
+ beforeEach(() => {
25
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "upgrade-6-to-7-"));
26
+ fs.mkdirSync(path.join(tmp, "src", "routes"), { recursive: true });
27
+ fs.writeFileSync(
28
+ path.join(tmp, "src", "routes", "$.tsx"),
29
+ `import { cmsRouteConfig } from "@decocms/start/routes";\n` +
30
+ `import { DecoPageRenderer } from "@decocms/start/hooks";\n`,
31
+ );
32
+ fs.writeFileSync(
33
+ path.join(tmp, "package.json"),
34
+ JSON.stringify(
35
+ { name: "fixture-site", dependencies: { "@decocms/start": "6.30.0", "@decocms/apps": "5.4.0" } },
36
+ null,
37
+ 2,
38
+ ),
39
+ );
40
+ });
41
+
42
+ afterEach(() => {
43
+ fs.rmSync(tmp, { recursive: true, force: true });
44
+ });
45
+
46
+ it("dry-run reports what would change without writing", () => {
47
+ const { stdout, code } = run(tmp, []);
48
+ expect(code).toBe(0);
49
+ expect(stdout).toContain("would upgrade");
50
+ expect(stdout).toContain("removing @decocms/start, @decocms/apps");
51
+
52
+ // Nothing actually changed on disk
53
+ const src = fs.readFileSync(path.join(tmp, "src", "routes", "$.tsx"), "utf-8");
54
+ expect(src).toContain("@decocms/start/routes");
55
+ const pkg = JSON.parse(fs.readFileSync(path.join(tmp, "package.json"), "utf-8"));
56
+ expect(pkg.dependencies).toHaveProperty("@decocms/start");
57
+ });
58
+
59
+ it("--write rewrites specifiers and package.json dependencies", () => {
60
+ const { code } = run(tmp, ["--write"]);
61
+ expect(code).toBe(0);
62
+
63
+ const src = fs.readFileSync(path.join(tmp, "src", "routes", "$.tsx"), "utf-8");
64
+ expect(src).toContain('import { cmsRouteConfig } from "@decocms/tanstack";');
65
+ expect(src).toContain('import { DecoPageRenderer } from "@decocms/tanstack";');
66
+ expect(src).not.toContain("@decocms/start");
67
+
68
+ const pkg = JSON.parse(fs.readFileSync(path.join(tmp, "package.json"), "utf-8"));
69
+ expect(pkg.dependencies).not.toHaveProperty("@decocms/start");
70
+ expect(pkg.dependencies).not.toHaveProperty("@decocms/apps");
71
+ expect(pkg.dependencies).toHaveProperty("@decocms/tanstack");
72
+ expect(pkg.dependencies).toHaveProperty("@decocms/blocks");
73
+ });
74
+
75
+ it("second --write run is idempotent (no-op)", () => {
76
+ run(tmp, ["--write"]);
77
+ const { stdout } = run(tmp, ["--write"]);
78
+ expect(stdout).toContain("Upgraded 0 file(s).");
79
+ });
80
+ });
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Upgrade a site off the frozen pre-split `@decocms/start@6.30.0` /
4
+ * `@decocms/apps@5.4.0` packages onto the current 7.x split
5
+ * (`@decocms/tanstack` + `@decocms/blocks` + `@decocms/blocks-admin` +
6
+ * `@decocms/apps-*`). See #367.
7
+ *
8
+ * This is NOT the Fresh→TanStack migrator (`deco-migrate`) — it assumes the
9
+ * site is already on TanStack Start and only needs its package dependency
10
+ * moved to the current split. Rewrites import specifiers (including the
11
+ * `@decocms/start/routes` / `@decocms/start/hooks` symbol fan-out and the
12
+ * `decoMetaRoute`/`RenderSection` renames — see scripts/lib/upgrade-6-to-7.ts
13
+ * for the full mapping) and updates package.json dependencies.
14
+ *
15
+ * Usage (from the site root):
16
+ * # dry-run (default): prints every file that would change, no writes
17
+ * npx -p @decocms/blocks-cli deco-upgrade-6-to-7
18
+ * # apply:
19
+ * npx -p @decocms/blocks-cli deco-upgrade-6-to-7 --write
20
+ *
21
+ * Options:
22
+ * --src-dir <dir> Source dir to scan (default: src)
23
+ * --write Apply changes (otherwise dry-run, exit 0)
24
+ * --help, -h Show this help
25
+ *
26
+ * Exit codes: 0 ok / dry-run; 2 error (bad dir)
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ import { upgradeFileToV7 } from "./lib/upgrade-6-to-7";
32
+
33
+ const FRAMEWORK_DEPS = [
34
+ "@decocms/blocks",
35
+ "@decocms/blocks-admin",
36
+ "@decocms/tanstack",
37
+ "@decocms/apps-commerce",
38
+ ];
39
+ const PLATFORM_APPS_PREFIX = "@decocms/apps-";
40
+
41
+ function parseArgs(argv: string[]) {
42
+ const has = (f: string) => argv.includes(f);
43
+ const val = (f: string, d: string) => {
44
+ const i = argv.indexOf(f);
45
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
46
+ };
47
+ return {
48
+ help: has("--help") || has("-h"),
49
+ write: has("--write"),
50
+ srcDir: val("--src-dir", "src"),
51
+ };
52
+ }
53
+
54
+ function walkTsFiles(dir: string, out: string[] = []): string[] {
55
+ if (!fs.existsSync(dir)) return out;
56
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
+ const full = path.join(dir, entry.name);
58
+ if (entry.isDirectory()) {
59
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
60
+ walkTsFiles(full, out);
61
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
62
+ out.push(full);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** Removes @decocms/start and @decocms/apps (bare, monolith) from a deps
69
+ * object, and ensures the current split packages are present. Site- and
70
+ * platform-specific version pins are left to `bun install`/`npm install`
71
+ * against the fallback "latest" this writes — sites should pin exact
72
+ * versions afterward the same way the scaffolder does. */
73
+ function upgradeDeps(deps: Record<string, string> | undefined): {
74
+ next: Record<string, string>;
75
+ removed: string[];
76
+ added: string[];
77
+ } {
78
+ const next: Record<string, string> = { ...(deps ?? {}) };
79
+ const removed: string[] = [];
80
+ for (const key of Object.keys(next)) {
81
+ if (key === "@decocms/start" || key === "@decocms/apps") {
82
+ delete next[key];
83
+ removed.push(key);
84
+ }
85
+ }
86
+ const added: string[] = [];
87
+ if (removed.length > 0) {
88
+ for (const dep of FRAMEWORK_DEPS) {
89
+ if (!next[dep]) {
90
+ next[dep] = "latest";
91
+ added.push(dep);
92
+ }
93
+ }
94
+ }
95
+ return { next, removed, added };
96
+ }
97
+
98
+ function upgradePackageJson(pkgPath: string, write: boolean): void {
99
+ if (!fs.existsSync(pkgPath)) return;
100
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
101
+
102
+ const depsResult = upgradeDeps(pkg.dependencies);
103
+ const devDepsResult = upgradeDeps(pkg.devDependencies);
104
+
105
+ const removed = [...depsResult.removed, ...devDepsResult.removed];
106
+ if (removed.length === 0) return;
107
+
108
+ console.log(` package.json: removing ${removed.join(", ")}`);
109
+ const added = [...new Set([...depsResult.added, ...devDepsResult.added])];
110
+ if (added.length > 0) {
111
+ console.log(` package.json: adding ${added.join(", ")} at "latest" — pin exact versions after install`);
112
+ }
113
+
114
+ if (!write) return;
115
+ pkg.dependencies = depsResult.next;
116
+ pkg.devDependencies = devDepsResult.next;
117
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
118
+ }
119
+
120
+ function main() {
121
+ const args = parseArgs(process.argv.slice(2));
122
+ if (args.help) {
123
+ console.log(
124
+ "Usage: deco-upgrade-6-to-7 [--src-dir <dir>] [--write]\n\n" +
125
+ "Upgrades a site off @decocms/start@6.30.0 / @decocms/apps@5.4.0 onto the\n" +
126
+ "current 7.x split. Dry-run by default; pass --write to apply.",
127
+ );
128
+ return;
129
+ }
130
+
131
+ const cwd = process.cwd();
132
+ const srcDir = path.resolve(cwd, args.srcDir);
133
+ if (!fs.existsSync(srcDir)) {
134
+ console.error(`Source dir not found: ${srcDir}`);
135
+ process.exitCode = 2;
136
+ return;
137
+ }
138
+
139
+ const files = walkTsFiles(srcDir);
140
+ let changedCount = 0;
141
+ const allNotes: Array<{ file: string; note: string }> = [];
142
+
143
+ for (const abs of files) {
144
+ const content = fs.readFileSync(abs, "utf-8");
145
+ const result = upgradeFileToV7(content);
146
+ if (!result.changed) continue;
147
+
148
+ changedCount++;
149
+ const rel = path.relative(cwd, abs);
150
+ console.log(`${args.write ? "upgraded" : "would upgrade"}: ${rel}`);
151
+ for (const note of result.notes) allNotes.push({ file: rel, note });
152
+
153
+ if (args.write) fs.writeFileSync(abs, result.content);
154
+ }
155
+
156
+ upgradePackageJson(path.join(cwd, "package.json"), args.write);
157
+
158
+ if (allNotes.length > 0) {
159
+ console.log("\nManual review needed:");
160
+ for (const { file, note } of allNotes) console.log(` [${file}] ${note}`);
161
+ }
162
+
163
+ console.log(
164
+ `\n${args.write ? "Upgraded" : "Would upgrade"} ${changedCount} file(s).` +
165
+ (args.write ? "" : " Re-run with --write to apply."),
166
+ );
167
+ }
168
+
169
+ main();