create-lumen 2.0.0-alpha.0 → 2.0.0-alpha.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.
Files changed (74) hide show
  1. package/README.md +35 -12
  2. package/bin/cli.js +1 -0
  3. package/package.json +3 -2
  4. package/schema/fixtures/v2/invalid/architecture-preset-type-mismatch.json +35 -0
  5. package/schema/fixtures/v2/invalid/harness-empty-commands.json +37 -0
  6. package/schema/fixtures/v2/invalid/harness-missing-command.json +41 -0
  7. package/schema/fixtures/v2/invalid/invalid-validation-mode.json +34 -0
  8. package/schema/fixtures/v2/invalid/shadcn-without-tailwind.json +1 -1
  9. package/schema/fixtures/v2/valid/harness-full.json +44 -0
  10. package/schema/fixtures/v2/valid/react-vite-type-based.json +1 -1
  11. package/schema/fixtures/v2/valid/validation-none.json +41 -0
  12. package/schema/fixtures/v2/valid/validation-relaxed.json +41 -0
  13. package/schema/lumen.config.v2.json +54 -2
  14. package/skills/create-lumen/SKILL.md +110 -0
  15. package/skills/create-lumen/references/manifest-v2.md +9 -0
  16. package/src/agent-context.js +114 -0
  17. package/src/cli-args.js +65 -3
  18. package/src/configure.js +4 -0
  19. package/src/css.js +14 -30
  20. package/src/dependencies.js +28 -18
  21. package/src/engine/capabilities.js +174 -4
  22. package/src/engine/composition.js +644 -0
  23. package/src/engine/index.js +1 -0
  24. package/src/injector.js +294 -446
  25. package/src/main.js +20 -7
  26. package/src/manifest/emit.js +49 -3
  27. package/src/manifest/schema.js +65 -7
  28. package/src/prompts.js +459 -247
  29. package/src/readme.js +23 -2
  30. package/src/scaffold.js +17 -0
  31. package/templates/architectures/hybrid/main.jsx +10 -0
  32. package/templates/architectures/hybrid/main.tsx +10 -0
  33. package/templates/architectures/hybrid/src/App.jsx +20 -0
  34. package/templates/architectures/hybrid/src/App.tsx +20 -0
  35. package/templates/architectures/hybrid/src/shared/components/ui/Button.jsx +31 -0
  36. package/templates/architectures/hybrid/src/shared/components/ui/Button.tsx +36 -0
  37. package/templates/architectures/hybrid/src/shared/components/ui/index.js +1 -0
  38. package/templates/architectures/hybrid/src/shared/components/ui/index.ts +1 -0
  39. package/templates/architectures/hybrid/src/shared/hooks/useLocalStorage.jsx +28 -0
  40. package/templates/architectures/hybrid/src/shared/hooks/useLocalStorage.ts +28 -0
  41. package/templates/architectures/hybrid/src/shared/layouts/.gitkeep +0 -0
  42. package/templates/architectures/hybrid/src/shared/stores/.gitkeep +0 -0
  43. package/templates/architectures/hybrid/src/shared/styles/.gitkeep +0 -0
  44. package/templates/architectures/hybrid/src/shared/types/index.js +20 -0
  45. package/templates/architectures/hybrid/src/shared/types/index.ts +16 -0
  46. package/templates/architectures/hybrid/src/shared/utils/cn.jsx +3 -0
  47. package/templates/architectures/hybrid/src/shared/utils/cn.ts +5 -0
  48. package/templates/architectures/none/main.jsx +10 -0
  49. package/templates/architectures/none/main.tsx +10 -0
  50. package/templates/architectures/none/src/App.jsx +15 -0
  51. package/templates/architectures/none/src/App.tsx +15 -0
  52. package/templates/architectures/none/src/styles/.gitkeep +1 -0
  53. package/templates/architectures/none/src/types/index.js +20 -0
  54. package/templates/architectures/none/src/types/index.ts +16 -0
  55. package/templates/conditional/testing/jest/hybrid/src/__tests__/App.test.jsx +11 -0
  56. package/templates/conditional/testing/jest/hybrid/src/__tests__/App.test.tsx +11 -0
  57. package/templates/conditional/testing/jest/none/src/__tests__/App.test.jsx +11 -0
  58. package/templates/conditional/testing/jest/none/src/__tests__/App.test.tsx +11 -0
  59. package/templates/conditional/testing/vitest/hybrid/src/__tests__/App.test.jsx +10 -0
  60. package/templates/conditional/testing/vitest/hybrid/src/__tests__/App.test.tsx +10 -0
  61. package/templates/conditional/testing/vitest/none/src/__tests__/App.test.jsx +10 -0
  62. package/templates/conditional/testing/vitest/none/src/__tests__/App.test.tsx +10 -0
  63. package/templates/css/tailwind/src/globals.css +10 -0
  64. package/templates/css/tailwind/src/themes.css +86 -4
  65. package/templates/ui/shadcn/lib/utils.js +6 -0
  66. package/templates/ui/shadcn/lib/utils.ts +6 -0
  67. package/templates/ui/shadcn/primitives/Button.jsx +48 -0
  68. package/templates/ui/shadcn/primitives/Button.tsx +59 -0
  69. package/templates/ui/shadcn/primitives/Card.jsx +55 -0
  70. package/templates/ui/shadcn/primitives/Card.tsx +73 -0
  71. package/templates/ui/shadcn/primitives/index.js +9 -0
  72. package/templates/ui/shadcn/primitives/index.ts +9 -0
  73. package/templates/css/bootstrap/src/themes.css +0 -14
  74. /package/templates/{css/bootstrap/src/globals.css → architectures/hybrid/src/features/.gitkeep} +0 -0
package/src/injector.js CHANGED
@@ -1,492 +1,340 @@
1
1
  import { promises as fsp } from "fs";
2
2
  import path from "path";
3
- import { copyDirRecursive, writeFileRecursive } from "@/utils/fs.js";
4
-
5
- // Narrow a template tree copy to the selected language: .tsx/.ts for ts,
6
- // .jsx/.js for js (keeps the opposite language's files out of the project).
7
- function langFileFilter(language) {
8
- return (name) => {
9
- if (language === "ts") {
10
- if (name.endsWith(".jsx")) return false;
11
- if (name.endsWith(".js")) return false;
12
- return true;
13
- }
14
- if (language === "js") {
15
- if (name.endsWith(".tsx")) return false;
16
- if (name.endsWith(".ts")) return false;
17
- return true;
18
- }
19
- return true;
20
- };
21
- }
22
-
23
- export async function injectArchitecture(projectPath, templatesDir, architecture, language, cssFramework) {
24
- const ext = language === "ts" ? "tsx" : "jsx";
25
- const srcDir = path.join(templatesDir, "architectures", architecture, "src");
26
-
27
- // Remove Vite's default App files BEFORE copying architecture
28
- const defaultAppTsx = path.join(projectPath, "src", "App.tsx");
29
- const defaultAppJsx = path.join(projectPath, "src", "App.jsx");
30
- try { await fsp.rm(defaultAppTsx, { force: true }); } catch {}
31
- try { await fsp.rm(defaultAppJsx, { force: true }); } catch {}
32
- // Remove Vite's default index.css (framework CSS lives in src/styles/,
33
- // copied below — never at the src/ root).
34
- try { await fsp.rm(path.join(projectPath, "src", "index.css"), { force: true }); } catch {}
35
- try { await fsp.rm(path.join(projectPath, "src", "App.css"), { force: true }); } catch {}
36
-
37
- // Copy the architecture tree into the project's src/
38
- await copyDirRecursive(srcDir, path.join(projectPath, "src"), langFileFilter(language));
39
-
40
- // Copy the architecture's main.tsx/main.jsx to src/main.{ext}
41
- const mainSrc = path.join(
42
- templatesDir,
43
- "architectures",
44
- architecture,
45
- `main.${ext}`
3
+ import {
4
+ FRAGMENT_SCOPES,
5
+ resolveFragments,
6
+ applyFragments,
7
+ langFileFilter,
8
+ pruneRedundantGitkeeps,
9
+ } from "./engine/composition.js";
10
+
11
+ export {
12
+ FRAGMENT_SCOPES,
13
+ resolveFragments,
14
+ applyFragments,
15
+ langFileFilter,
16
+ pruneRedundantGitkeeps,
17
+ };
18
+
19
+ /**
20
+ * Inject architecture templates into projectPath from composed fragments.
21
+ * Composes framework entry points, architecture layout, and styling component overlays.
22
+ *
23
+ * @param {string} projectPath
24
+ * @param {string} templatesDir
25
+ * @param {string} architecture
26
+ * @param {string} language
27
+ * @param {string} cssFramework
28
+ */
29
+ export async function injectArchitecture(
30
+ projectPath,
31
+ templatesDir,
32
+ architecture,
33
+ language,
34
+ cssFramework
35
+ ) {
36
+ const responses = { architecture, language, cssFramework };
37
+ const allFragments = resolveFragments(responses, templatesDir);
38
+ const archFragments = allFragments.filter(
39
+ (f) =>
40
+ f.scope === FRAGMENT_SCOPES.FRAMEWORK ||
41
+ f.scope === FRAGMENT_SCOPES.ARCH ||
42
+ f.scope === FRAGMENT_SCOPES.STYLING
46
43
  );
47
- try {
48
- await fsp.access(mainSrc);
49
- await fsp.copyFile(mainSrc, path.join(projectPath, "src", `main.${ext}`));
50
- } catch {
51
- // main file doesn't exist in template, skip
52
- }
53
-
54
- // Remove the opposite language's main file (Vite creates main.jsx by default)
55
- const oppositeExt = language === "ts" ? "jsx" : "tsx";
56
- const oppositeMain = path.join(projectPath, "src", `main.${oppositeExt}`);
57
- try { await fsp.rm(oppositeMain, { force: true }); } catch {}
58
-
59
- // Apply the chosen CSS framework's component styling on top of the base
60
- // (only where the base ships framework-agnostic inline markup). Runs here,
61
- // before conditional overlays, so icons/router can still override the files
62
- // they own.
63
- if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
64
- const variantSrc = path.join(
65
- templatesDir,
66
- "css",
67
- "component-styles",
68
- cssFramework,
69
- architecture,
70
- "src"
71
- );
72
- try {
73
- await fsp.access(variantSrc);
74
- await copyDirRecursive(
75
- variantSrc,
76
- path.join(projectPath, "src"),
77
- langFileFilter(language)
78
- );
79
- } catch {
80
- // no component-styles for this arch/framework
81
- }
82
- }
44
+ await applyFragments(projectPath, archFragments, {
45
+ language,
46
+ templatesDir,
47
+ responses,
48
+ });
83
49
  }
84
50
 
51
+ /**
52
+ * Inject code formatter configuration and wiring from composed fragments.
53
+ *
54
+ * @param {string} projectPath
55
+ * @param {string} templatesDir
56
+ * @param {object} responses
57
+ */
85
58
  export async function injectFormatter(projectPath, templatesDir, responses) {
86
59
  if (!responses.formatter || responses.formatter === "none") return;
87
-
88
- // Copy the formatter config file to the project root
89
- const configFile =
90
- responses.formatter === "prettier" ? ".prettierrc" : ".oxfmtrc.json";
91
- const configSrc = path.join(
92
- templatesDir,
93
- "conditional",
94
- "formatter",
95
- responses.formatter,
96
- configFile
60
+ const allFragments = resolveFragments(responses, templatesDir);
61
+ const formatterFragments = allFragments.filter(
62
+ (f) => f.scope === FRAGMENT_SCOPES.TOOLING && f.category === "formatter"
97
63
  );
98
- try {
99
- await fsp.access(configSrc);
100
- await fsp.copyFile(configSrc, path.join(projectPath, configFile));
101
- } catch {}
102
-
103
- // Append format script(s) to package.json
104
- const pkgPath = path.join(projectPath, "package.json");
105
- try {
106
- const content = await fsp.readFile(pkgPath, "utf8");
107
- const pkg = JSON.parse(content);
108
- if (!pkg.scripts) pkg.scripts = {};
109
- if (responses.formatter === "prettier") {
110
- pkg.scripts.format = "prettier --write .";
111
- pkg.scripts["format:check"] = "prettier --check .";
112
- } else if (responses.formatter === "oxfmt") {
113
- pkg.scripts.format = "oxfmt .";
114
- pkg.scripts["format:check"] = "oxfmt --check .";
115
- }
116
- await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
117
- } catch {}
118
-
119
- // Wire eslint-config-prettier only for the eslint + prettier combination
120
- if (responses.linter === "eslint" && responses.formatter === "prettier") {
121
- await wireEslintPrettier(projectPath, responses.language, responses.framework);
122
- }
64
+ await applyFragments(projectPath, formatterFragments, {
65
+ language: responses.language,
66
+ templatesDir,
67
+ responses,
68
+ });
123
69
  }
124
70
 
125
- async function wireEslintPrettier(projectPath, language, framework) {
126
- const ext = framework === "next" ? "mjs" : language === "ts" ? "ts" : "js";
127
- const eslintConfigPath = path.join(projectPath, `eslint.config.${ext}`);
128
- try {
129
- let content = await fsp.readFile(eslintConfigPath, "utf8");
130
-
131
- // Avoid double-injection on re-runs
132
- if (content.includes("eslint-config-prettier")) return;
133
-
134
- // Add the import after the last import line
135
- const lines = content.split("\n");
136
- let lastImportIdx = -1;
137
- for (let i = 0; i < lines.length; i++) {
138
- if (lines[i].startsWith("import ")) lastImportIdx = i;
139
- }
140
- lines.splice(
141
- lastImportIdx + 1,
142
- 0,
143
- 'import prettier from "eslint-config-prettier";'
144
- );
145
-
146
- let result = lines.join("\n");
71
+ /**
72
+ * Inject conditional tooling features (state, router, icons, api client, testing, linter, scripts)
73
+ * using the composed fragment pipeline.
74
+ *
75
+ * @param {string} projectPath
76
+ * @param {string} templatesDir
77
+ * @param {object} responses
78
+ * @param {string} architecture
79
+ * @param {string} language
80
+ */
81
+ export async function injectConditionals(
82
+ projectPath,
83
+ templatesDir,
84
+ responses,
85
+ architecture,
86
+ language
87
+ ) {
88
+ const mergedResponses = { ...responses, architecture, language };
89
+ const allFragments = resolveFragments(mergedResponses, templatesDir);
90
+ const conditionalFragments = allFragments.filter(
91
+ (f) => f.scope === FRAGMENT_SCOPES.TOOLING && f.category !== "formatter"
92
+ );
93
+ await applyFragments(projectPath, conditionalFragments, {
94
+ language,
95
+ templatesDir,
96
+ responses: mergedResponses,
97
+ });
147
98
 
148
- // Append prettier as the LAST element of the eslint config.
149
- // eslint-config-prettier exports a flat-config object (not an iterable),
150
- // so it must be passed by reference, not spread — passing `...prettier`
151
- // breaks under ESLint's jiti-based TS config loading.
152
- // The JS template exports an array (`export default [...]`); the TS
153
- // template uses a function call (`export default tseslint.config(...)`).
154
- const pushLast = (_, head, tail) =>
155
- head.replace(/,\s*$/, "") + ",\n prettier," + tail;
156
- if (result.includes("tseslint.config(")) {
157
- result = result.replace(
158
- /(export default tseslint\.config\([\s\S]*?)(\n\s*\);)/,
159
- pushLast
160
- );
161
- } else if (result.includes("const eslintConfig = [")) {
162
- result = result.replace(
163
- /(const eslintConfig = \[[\s\S]*?)(\n\s*\];)/,
164
- pushLast
165
- );
166
- } else {
167
- result = result.replace(
168
- /(export default \[[\s\S]*?)(\n\s*\];)/,
169
- pushLast
170
- );
171
- }
99
+ // 8. UI Kit (shadcn gated to tailwind) — applied after fragments so its
100
+ // components.json, cn helper and primitives land on the composed tree.
101
+ await injectUiKit(projectPath, templatesDir, mergedResponses, architecture, language);
102
+ }
172
103
 
173
- await fsp.writeFile(eslintConfigPath, result, "utf8");
174
- } catch {}
104
+ /**
105
+ * Inject linter configuration and package scripts using the composed fragment pipeline.
106
+ *
107
+ * @param {string} projectPath
108
+ * @param {string} templatesDir
109
+ * @param {string} linter
110
+ * @param {string} language
111
+ * @param {string|object} framework
112
+ */
113
+ export async function injectLinter(
114
+ projectPath,
115
+ templatesDir,
116
+ linter,
117
+ language,
118
+ framework
119
+ ) {
120
+ const responses = { linter, language, framework };
121
+ const allFragments = resolveFragments(responses, templatesDir);
122
+ const linterFragments = allFragments.filter(
123
+ (f) => f.scope === FRAGMENT_SCOPES.TOOLING && f.category === "linter"
124
+ );
125
+ await applyFragments(projectPath, linterFragments, {
126
+ language,
127
+ templatesDir,
128
+ responses,
129
+ });
175
130
  }
176
131
 
177
- export async function injectConditionals(projectPath, templatesDir, responses, architecture, language) {
178
- const ext = language === "ts" ? "tsx" : "jsx";
132
+ /**
133
+ * High-level composition API: inject all capability fragments in documented precedence order.
134
+ *
135
+ * @param {string} projectPath
136
+ * @param {string} templatesDir
137
+ * @param {object} responses
138
+ */
139
+ export async function injectComposedFragments(projectPath, templatesDir, responses) {
140
+ const fragments = resolveFragments(responses, templatesDir);
141
+ await applyFragments(projectPath, fragments, {
142
+ language: responses.language,
143
+ templatesDir,
144
+ responses,
145
+ });
146
+ }
179
147
 
180
- // 1. State management
181
- if (responses.stateManagement === "redux") {
182
- await injectOverlay(
183
- projectPath,
184
- templatesDir,
185
- `conditional/state/redux/${architecture}`,
186
- language
187
- );
188
- await updateMainWithProvider(projectPath, "redux", architecture, ext);
189
- } else if (responses.stateManagement === "zustand") {
190
- await injectOverlay(
191
- projectPath,
192
- templatesDir,
193
- `conditional/state/zustand/${architecture}`,
194
- language
195
- );
148
+ export function getComponentsJson({
149
+ framework = "vite",
150
+ architecture = "feature-based",
151
+ language = "ts",
152
+ }) {
153
+ const isNext = framework === "next";
154
+ const isTs = language === "ts";
155
+
156
+ if (isNext) {
157
+ // Next App Router contract stub
158
+ return {
159
+ $schema: "https://ui.shadcn.com/schema.json",
160
+ style: "new-york",
161
+ rsc: true,
162
+ tsx: isTs,
163
+ tailwind: {
164
+ config: "",
165
+ css: "app/globals.css",
166
+ baseColor: "neutral",
167
+ cssVariables: true,
168
+ prefix: "",
169
+ },
170
+ aliases: {
171
+ components: "@/components",
172
+ utils: "@/lib/utils",
173
+ ui: "@/components/ui",
174
+ lib: "@/lib",
175
+ hooks: "@/hooks",
176
+ },
177
+ iconLibrary: "lucide",
178
+ };
196
179
  }
197
180
 
198
- // 2. Router (overwrites App.tsx with routed version)
199
- if (responses.router) {
200
- await injectOverlayVariant(
201
- projectPath,
202
- templatesDir,
203
- `router/${architecture}`,
204
- responses.cssFramework,
205
- language
206
- );
207
- }
181
+ const isFeatureBased = architecture === "feature-based";
182
+ return {
183
+ $schema: "https://ui.shadcn.com/schema.json",
184
+ style: "new-york",
185
+ rsc: false,
186
+ tsx: isTs,
187
+ tailwind: {
188
+ config: "",
189
+ css: isFeatureBased
190
+ ? "src/shared/styles/globals.css"
191
+ : "src/styles/globals.css",
192
+ baseColor: "neutral",
193
+ cssVariables: true,
194
+ prefix: "",
195
+ },
196
+ aliases: isFeatureBased
197
+ ? {
198
+ components: "@/shared/components",
199
+ utils: "@/lib/utils",
200
+ ui: "@/shared/components/ui",
201
+ lib: "@/lib",
202
+ hooks: "@/shared/hooks",
203
+ }
204
+ : {
205
+ components: "@/components",
206
+ utils: "@/lib/utils",
207
+ ui: "@/ui",
208
+ lib: "@/lib",
209
+ hooks: "@/hooks",
210
+ },
211
+ iconLibrary: "lucide",
212
+ };
213
+ }
208
214
 
209
- // 3. Icons (overwrites home page)
210
- if (responses.iconLibrary && responses.iconLibrary !== "none") {
211
- await injectOverlayVariant(
212
- projectPath,
213
- templatesDir,
214
- `icons/${responses.iconLibrary}/${architecture}`,
215
- responses.cssFramework,
216
- language
215
+ export async function injectUiKit(
216
+ projectPath,
217
+ templatesDir,
218
+ responses,
219
+ architecture,
220
+ language
221
+ ) {
222
+ const uiKit = responses.uiKit || responses.ui?.kit || responses.ui || "none";
223
+ if (uiKit !== "shadcn") return;
224
+
225
+ if (responses.cssFramework !== "tailwind") {
226
+ throw new Error(
227
+ `ui.kit "shadcn" requires styling.engine "tailwind" (got "${responses.cssFramework}")`
217
228
  );
218
229
  }
219
230
 
220
- // 4. API Client
221
- if (responses.apiClient === "axios") {
222
- await injectOverlay(
223
- projectPath,
224
- templatesDir,
225
- `conditional/axios/${architecture}`,
226
- language
227
- );
228
- } else if (responses.apiClient === "fetch") {
229
- await injectOverlay(
230
- projectPath,
231
- templatesDir,
232
- `conditional/fetch/${architecture}`,
233
- language
234
- );
235
- }
231
+ const isTs = language === "ts";
232
+ const codeExt = isTs ? "tsx" : "jsx";
233
+ const utilExt = isTs ? "ts" : "js";
234
+ const framework = responses.framework || responses.frameworkName || "vite";
236
235
 
237
- // 5. Testing
238
- if (responses.testing === "vitest") {
239
- await injectTesting(projectPath, templatesDir, "vitest", language, architecture);
240
- } else if (responses.testing === "jest") {
241
- await injectTesting(projectPath, templatesDir, "jest", language, architecture);
242
- }
236
+ // 1. Write components.json
237
+ const componentsJson = getComponentsJson({
238
+ framework,
239
+ architecture,
240
+ language,
241
+ });
242
+ await fsp.writeFile(
243
+ path.join(projectPath, "components.json"),
244
+ JSON.stringify(componentsJson, null, 2) + "\n",
245
+ "utf8"
246
+ );
243
247
 
244
- // 6. Linter
245
- if (responses.linter === "eslint") {
246
- await injectLinter(projectPath, templatesDir, "eslint", language, responses.framework);
247
- } else if (responses.linter === "oxlint") {
248
- await injectLinter(projectPath, templatesDir, "oxlint", language, responses.framework);
248
+ // 2. Write cn helper to src/lib/utils.{ts,js}
249
+ const libDir = path.join(projectPath, "src", "lib");
250
+ await fsp.mkdir(libDir, { recursive: true });
251
+ const utilsSrc = path.join(
252
+ templatesDir,
253
+ "ui",
254
+ "shadcn",
255
+ "lib",
256
+ `utils.${utilExt}`
257
+ );
258
+ const utilsDest = path.join(libDir, `utils.${utilExt}`);
259
+ try {
260
+ await fsp.access(utilsSrc);
261
+ await fsp.copyFile(utilsSrc, utilsDest);
262
+ } catch {
263
+ const code = isTs
264
+ ? `import { clsx, type ClassValue } from "clsx";\nimport { twMerge } from "tailwind-merge";\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n`
265
+ : `import { clsx } from "clsx";\nimport { twMerge } from "tailwind-merge";\n\nexport function cn(...inputs) {\n return twMerge(clsx(inputs));\n}\n`;
266
+ await fsp.writeFile(utilsDest, code, "utf8");
249
267
  }
250
268
 
251
- // 7. Create feature script (feature-based only)
269
+ // Also sync architecture's cn helper if present
252
270
  if (architecture === "feature-based") {
253
- await injectCreateFeatureScript(projectPath, templatesDir, language);
271
+ const sharedUtilsDir = path.join(projectPath, "src", "shared", "utils");
272
+ await fsp.mkdir(sharedUtilsDir, { recursive: true });
273
+ await fsp.copyFile(utilsDest, path.join(sharedUtilsDir, `cn.${codeExt === "tsx" ? "ts" : "jsx"}`));
274
+ } else {
275
+ const typeUtilsDir = path.join(projectPath, "src", "utils");
276
+ await fsp.mkdir(typeUtilsDir, { recursive: true });
277
+ await fsp.copyFile(utilsDest, path.join(typeUtilsDir, `cn.${codeExt === "tsx" ? "ts" : "jsx"}`));
254
278
  }
255
279
 
256
- // A .gitkeep only keeps a folder alive when that folder holds nothing else;
257
- // once an overlay writes real files into it the placeholder is redundant.
258
- await pruneRedundantGitkeeps(projectPath);
259
- }
280
+ // 3. Inject Button and Card primitives
281
+ const isFeatureBased = architecture === "feature-based";
282
+ const targetUiDir = isFeatureBased
283
+ ? path.join(projectPath, "src", "shared", "components", "ui")
284
+ : path.join(projectPath, "src", "ui");
260
285
 
261
- // Remove .gitkeep placeholders from src/ directories that now contain real
262
- // files (e.g. type-based `services/.gitkeep` after the axios/fetch client
263
- // overlay lands). A folder with nothing but its placeholder keeps it.
264
- async function pruneRedundantGitkeeps(projectPath) {
265
- const walk = async (dir) => {
266
- let entries;
267
- try {
268
- entries = await fsp.readdir(dir, { withFileTypes: true });
269
- } catch {
270
- return;
271
- }
272
- for (const entry of entries) {
273
- if (entry.isDirectory()) {
274
- await walk(path.join(dir, entry.name));
275
- }
276
- }
277
- if (entries.some((e) => e.name === ".gitkeep")) {
278
- const hasRealFiles = entries.some((e) => e.isFile() && e.name !== ".gitkeep");
279
- if (hasRealFiles) {
280
- await fsp.rm(path.join(dir, ".gitkeep"), { force: true });
281
- }
282
- }
283
- };
284
- await walk(path.join(projectPath, "src"));
285
- }
286
+ await fsp.mkdir(targetUiDir, { recursive: true });
286
287
 
287
- // Overlays that ship presentational components (router, icons) carry optional
288
- // tailwind/bootstrap subfolders. Copy the root version first — it holds the
289
- // framework-agnostic skeleton (routed App, route tables) plus the inline
290
- // markup defaults used for cssFramework "none" — then overlay the framework's
291
- // subfolder so its markup-specific files (transitive layouts, home pages)
292
- // win; `copyDirRecursive` overwrites on name collision.
293
- async function injectOverlayVariant(projectPath, templatesDir, rel, cssFramework, language) {
294
- const base = `conditional/${rel}`;
295
- await injectOverlay(projectPath, templatesDir, base, language);
296
- const variant =
297
- cssFramework === "tailwind" || cssFramework === "bootstrap"
298
- ? cssFramework
299
- : "";
300
- if (variant) {
301
- await injectOverlay(
302
- projectPath,
303
- templatesDir,
304
- `${base}/${variant}`,
305
- language
306
- );
307
- }
308
- }
288
+ const buttonDest = path.join(targetUiDir, `Button.${codeExt}`);
289
+ const cardDest = path.join(targetUiDir, `Card.${codeExt}`);
290
+ const indexDest = path.join(targetUiDir, `index.${utilExt}`);
309
291
 
310
- async function injectOverlay(projectPath, templatesDir, overlayPath, language) {
311
- const srcDir = path.join(templatesDir, overlayPath, "src");
312
- try {
313
- await fsp.access(srcDir);
314
- await copyDirRecursive(
315
- srcDir,
316
- path.join(projectPath, "src"),
317
- langFileFilter(language)
318
- );
319
- } catch {
320
- // overlay src dir doesn't exist
321
- }
322
- }
323
-
324
- async function injectTesting(projectPath, templatesDir, framework, language, architecture) {
325
- const testingDir = path.join(templatesDir, "conditional", "testing", framework);
292
+ const buttonTemplate = path.join(
293
+ templatesDir,
294
+ "ui",
295
+ "shadcn",
296
+ "primitives",
297
+ `Button.${codeExt}`
298
+ );
299
+ const cardTemplate = path.join(
300
+ templatesDir,
301
+ "ui",
302
+ "shadcn",
303
+ "primitives",
304
+ `Card.${codeExt}`
305
+ );
306
+ const indexTemplate = path.join(
307
+ templatesDir,
308
+ "ui",
309
+ "shadcn",
310
+ "primitives",
311
+ `index.${utilExt}`
312
+ );
326
313
 
327
- // Copy config files to project root
328
- const configExt = language === "ts" ? "ts" : "js";
329
- const configSrc = path.join(testingDir, `${framework}.config.${configExt}`);
330
314
  try {
331
- await fsp.access(configSrc);
332
- await fsp.copyFile(configSrc, path.join(projectPath, `${framework}.config.${configExt}`));
315
+ await fsp.access(buttonTemplate);
316
+ await fsp.copyFile(buttonTemplate, buttonDest);
333
317
  } catch {}
334
318
 
335
- // Copy setup file
336
- const setupSrc = path.join(testingDir, `src/test/setup.${configExt}`);
337
319
  try {
338
- await fsp.access(setupSrc);
339
- const setupDest = path.join(projectPath, "src", "test", `setup.${configExt}`);
340
- await writeFileRecursive(setupDest, await fsp.readFile(setupSrc, "utf8"));
320
+ await fsp.access(cardTemplate);
321
+ await fsp.copyFile(cardTemplate, cardDest);
341
322
  } catch {}
342
323
 
343
- // Jest keeps its setup at the template root (jest.setup.ts/js), referenced by
344
- // jest.config as <rootDir>/jest.setup.ts — copy it to the project root.
345
- if (framework === "jest") {
346
- const jestSetupSrc = path.join(testingDir, `jest.setup.${configExt}`);
347
- try {
348
- await fsp.access(jestSetupSrc);
349
- await fsp.copyFile(jestSetupSrc, path.join(projectPath, `jest.setup.${configExt}`));
350
- } catch {}
351
-
352
- // Unified Babel transform config for Jest (env + react + typescript presets).
353
- const babelSrc = path.join(testingDir, "babel.config.cjs");
354
- try {
355
- await fsp.access(babelSrc);
356
- await fsp.copyFile(babelSrc, path.join(projectPath, "babel.config.cjs"));
357
- } catch {}
358
- }
359
-
360
- // Copy test file (architecture-scoped so the App import path resolves)
361
- const testExt = language === "ts" ? "tsx" : "jsx";
362
- const testSrc = path.join(testingDir, architecture, `src/__tests__/App.test.${testExt}`);
363
324
  try {
364
- await fsp.access(testSrc);
365
- const testDest = path.join(projectPath, "src", "__tests__", `App.test.${testExt}`);
366
- await writeFileRecursive(testDest, await fsp.readFile(testSrc, "utf8"));
325
+ await fsp.access(indexTemplate);
326
+ await fsp.copyFile(indexTemplate, indexDest);
367
327
  } catch {}
368
328
 
369
- // Add test script to package.json
370
- const pkgPath = path.join(projectPath, "package.json");
371
- try {
372
- const content = await fsp.readFile(pkgPath, "utf8");
373
- const pkg = JSON.parse(content);
374
- if (!pkg.scripts) pkg.scripts = {};
375
- if (framework === "vitest") {
376
- pkg.scripts.test = "vitest";
377
- pkg.scripts["test:run"] = "vitest run";
378
- } else {
379
- pkg.scripts.test = "jest";
380
- pkg.scripts["test:watch"] = "jest --watch";
381
- }
382
- await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
383
- } catch {}
384
- }
385
-
386
- export async function injectLinter(projectPath, templatesDir, linter, language, framework) {
387
- const lintingDir = path.join(templatesDir, "conditional", "linting", linter);
388
-
389
- if (linter === "eslint") {
390
- if (framework === "next") {
391
- const configSrc = path.join(lintingDir, "next", "eslint.config.mjs");
392
- try {
393
- await fsp.access(configSrc);
394
- await fsp.copyFile(configSrc, path.join(projectPath, "eslint.config.mjs"));
395
- } catch {}
396
- } else {
397
- const configExt = language === "ts" ? "ts" : "js";
398
- const configSrc = path.join(lintingDir, `eslint.config.${configExt}`);
399
- try {
400
- await fsp.access(configSrc);
401
- await fsp.copyFile(configSrc, path.join(projectPath, `eslint.config.${configExt}`));
402
- } catch {}
403
- }
404
- } else if (linter === "oxlint") {
405
- const configSrc = path.join(lintingDir, "oxlintrc.json");
329
+ // If type-based, also populate src/components/ui for maximum tooling compatibility
330
+ if (!isFeatureBased) {
331
+ const fallbackUiDir = path.join(projectPath, "src", "components", "ui");
332
+ await fsp.mkdir(fallbackUiDir, { recursive: true });
406
333
  try {
407
- await fsp.access(configSrc);
408
- await fsp.copyFile(configSrc, path.join(projectPath, "oxlintrc.json"));
334
+ await fsp.copyFile(buttonDest, path.join(fallbackUiDir, `Button.${codeExt}`));
335
+ await fsp.copyFile(cardDest, path.join(fallbackUiDir, `Card.${codeExt}`));
336
+ await fsp.copyFile(indexDest, path.join(fallbackUiDir, `index.${utilExt}`));
409
337
  } catch {}
410
338
  }
411
-
412
- // Add lint script to package.json
413
- const pkgPath = path.join(projectPath, "package.json");
414
- try {
415
- const content = await fsp.readFile(pkgPath, "utf8");
416
- const pkg = JSON.parse(content);
417
- if (!pkg.scripts) pkg.scripts = {};
418
- if (linter === "eslint") {
419
- pkg.scripts.lint = "eslint .";
420
- pkg.scripts["lint:fix"] = "eslint . --fix";
421
- } else {
422
- pkg.scripts.lint = "oxlint .";
423
- }
424
- await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
425
- } catch {}
426
- }
427
-
428
- async function injectCreateFeatureScript(projectPath, templatesDir, language) {
429
- const scriptSrc = path.join(templatesDir, "architectures", "feature-based", "scripts", "create-feature.mjs");
430
- const scriptDest = path.join(projectPath, "scripts", "create-feature.mjs");
431
-
432
- try {
433
- await fsp.access(scriptSrc);
434
- let content = await fsp.readFile(scriptSrc, "utf8");
435
-
436
- // Adapt to JS if needed
437
- if (language === "js") {
438
- // Replace .ts extensions with .js in the initialFiles
439
- content = content
440
- .replace(/services\/index\.ts/g, "services/index.js")
441
- .replace(/store\/index\.ts/g, "store/index.js")
442
- .replace(/hooks\/index\.ts/g, "hooks/index.js")
443
- .replace(/pages\/index\.ts/g, "pages/index.js")
444
- .replace(/types\/index\.ts/g, "types/index.js");
445
- }
446
-
447
- await writeFileRecursive(scriptDest, content);
448
- } catch {}
449
-
450
- // Add script to package.json
451
- const pkgPath = path.join(projectPath, "package.json");
452
- try {
453
- const content = await fsp.readFile(pkgPath, "utf8");
454
- const pkg = JSON.parse(content);
455
- if (!pkg.scripts) pkg.scripts = {};
456
- pkg.scripts["create:feature"] = "node scripts/create-feature.mjs";
457
- await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
458
- } catch {}
459
339
  }
460
340
 
461
- async function updateMainWithProvider(projectPath, stateType, architecture, ext) {
462
- const mainPath = path.join(projectPath, "src", `main.${ext}`);
463
- try {
464
- let content = await fsp.readFile(mainPath, "utf8");
465
-
466
- if (architecture === "feature-based") {
467
- if (!content.includes("StoreProvider")) {
468
- content = content.replace(
469
- /import App from ['"]@\/app\/App['"]\n?/,
470
- "import App from '@/app/App'\nimport { StoreProvider } from '@/app/providers/StoreProvider'\n"
471
- );
472
- content = content.replace(
473
- /<App \/>/,
474
- "<StoreProvider>\n <App />\n </StoreProvider>"
475
- );
476
- }
477
- } else {
478
- if (!content.includes("StoreProvider")) {
479
- content = content.replace(
480
- /import App from ['"]\.\/App['"]\n?/,
481
- "import App from './App'\nimport { StoreProvider } from './providers/StoreProvider'\n"
482
- );
483
- content = content.replace(
484
- /<App \/>/,
485
- "<StoreProvider>\n <App />\n </StoreProvider>"
486
- );
487
- }
488
- }
489
-
490
- await fsp.writeFile(mainPath, content, "utf8");
491
- } catch {}
492
- }