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
@@ -0,0 +1,644 @@
1
+ import { promises as fsp } from "fs";
2
+ import path from "path";
3
+ import { copyDirRecursive, writeFileRecursive } from "@/utils/fs.js";
4
+ import {
5
+ CAPABILITIES,
6
+ hasCapability,
7
+ normalizeFramework,
8
+ } from "./capabilities.js";
9
+ import { getProvider } from "./providers/base-provider.js";
10
+
11
+ /**
12
+ * Capability-scoped fragment constants.
13
+ * Represents the 5 architectural layers of Lumen project composition.
14
+ */
15
+ export const FRAGMENT_SCOPES = {
16
+ BASE: "base",
17
+ FRAMEWORK: "framework",
18
+ ARCH: "arch",
19
+ STYLING: "styling",
20
+ TOOLING: "tooling",
21
+ };
22
+
23
+ /**
24
+ * Documented deterministic precedence order.
25
+ * Earlier layers form the foundation; later layers overwrite on path collision.
26
+ * base -> framework -> arch -> styling -> tooling
27
+ */
28
+ export const FRAGMENT_PRECEDENCE = [
29
+ FRAGMENT_SCOPES.BASE,
30
+ FRAGMENT_SCOPES.FRAMEWORK,
31
+ FRAGMENT_SCOPES.ARCH,
32
+ FRAGMENT_SCOPES.STYLING,
33
+ FRAGMENT_SCOPES.TOOLING,
34
+ ];
35
+
36
+ /**
37
+ * Deterministic execution order for tooling sub-categories.
38
+ */
39
+ export const TOOLING_ORDER = [
40
+ "state",
41
+ "router",
42
+ "icons",
43
+ "api",
44
+ "testing",
45
+ "linter",
46
+ "feature-script",
47
+ "formatter",
48
+ ];
49
+
50
+ /**
51
+ * Narrow a template tree copy to the selected language: .tsx/.ts for ts,
52
+ * .jsx/.js for js (keeps the opposite language's files out of the project).
53
+ * @param {string} language - "ts" | "js"
54
+ * @returns {(name: string) => boolean}
55
+ */
56
+ export function langFileFilter(language) {
57
+ return (name) => {
58
+ if (language === "ts") {
59
+ if (name.endsWith(".jsx")) return false;
60
+ if (name.endsWith(".js")) return false;
61
+ return true;
62
+ }
63
+ if (language === "js") {
64
+ if (name.endsWith(".tsx")) return false;
65
+ if (name.endsWith(".ts")) return false;
66
+ return true;
67
+ }
68
+ return true;
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Remove .gitkeep placeholders from src/ directories that now contain real
74
+ * files. A folder with nothing but its placeholder keeps it.
75
+ * @param {string} projectPath
76
+ */
77
+ export async function pruneRedundantGitkeeps(projectPath) {
78
+ const walk = async (dir) => {
79
+ let entries;
80
+ try {
81
+ entries = await fsp.readdir(dir, { withFileTypes: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const entry of entries) {
86
+ if (entry.isDirectory()) {
87
+ await walk(path.join(dir, entry.name));
88
+ }
89
+ }
90
+ if (entries.some((e) => e.name === ".gitkeep")) {
91
+ const hasRealFiles = entries.some((e) => e.isFile() && e.name !== ".gitkeep");
92
+ if (hasRealFiles) {
93
+ await fsp.rm(path.join(dir, ".gitkeep"), { force: true });
94
+ }
95
+ }
96
+ };
97
+ await walk(path.join(projectPath, "src"));
98
+ }
99
+
100
+ /**
101
+ * Resolve the complete, deterministic list of template fragments to apply
102
+ * for a given responses configuration.
103
+ *
104
+ * @param {object} responses - Generation options
105
+ * @param {string} templatesDir - Absolute path to templates root
106
+ * @returns {Array<object>} Ordered array of fragment descriptors
107
+ */
108
+ export function resolveFragments(responses = {}, templatesDir) {
109
+ const fw = normalizeFramework(
110
+ responses.framework || {
111
+ name: responses.frameworkName,
112
+ variant: responses.frameworkVariant,
113
+ }
114
+ );
115
+ const language = responses.language || "ts";
116
+ const ext = language === "ts" ? "tsx" : "jsx";
117
+ const extConfig = language === "ts" ? "ts" : "js";
118
+ const architecture = responses.architecture || "feature-based";
119
+ const cssFramework = responses.cssFramework || "none";
120
+
121
+ const fragments = [];
122
+
123
+ // =========================================================================
124
+ // Layer 1: Base (base provider file plan / templates/bases/<provider>/<variant>/<lang>)
125
+ // =========================================================================
126
+ const provider = getProvider(fw.name, fw.variant);
127
+ const baseRel = provider ? provider.getFilePlan({ lang: language }) : null;
128
+ const baseDir = baseRel ? path.join(templatesDir, "..", baseRel) : null;
129
+ const packageSet = provider ? provider.getPackageSet({ options: responses }) : null;
130
+
131
+ fragments.push({
132
+ scope: FRAGMENT_SCOPES.BASE,
133
+ id: `base:${fw.name}:${fw.variant}`,
134
+ sourceDir: baseDir,
135
+ targetDir: "",
136
+ dependencies: packageSet?.dependencies || {},
137
+ devDependencies: packageSet?.devDependencies || {},
138
+ });
139
+
140
+ // =========================================================================
141
+ // Layer 2: Framework (entry points & default boilerplate cleanup)
142
+ // =========================================================================
143
+ fragments.push({
144
+ scope: FRAGMENT_SCOPES.FRAMEWORK,
145
+ id: `framework:${fw.name}:${fw.variant}`,
146
+ action: async ({ projectPath }) => {
147
+ // Remove Vite default App and CSS files before architecture overlays land
148
+ try { await fsp.rm(path.join(projectPath, "src", "App.tsx"), { force: true }); } catch {}
149
+ try { await fsp.rm(path.join(projectPath, "src", "App.jsx"), { force: true }); } catch {}
150
+ try { await fsp.rm(path.join(projectPath, "src", "index.css"), { force: true }); } catch {}
151
+ try { await fsp.rm(path.join(projectPath, "src", "App.css"), { force: true }); } catch {}
152
+ },
153
+ });
154
+
155
+ // =========================================================================
156
+ // Layer 3: Architecture (feature-based / type-based / hybrid / none)
157
+ // =========================================================================
158
+ const archSrcDir = path.join(templatesDir, "architectures", architecture, "src");
159
+ const archMainSrc = path.join(templatesDir, "architectures", architecture, `main.${ext}`);
160
+
161
+ fragments.push({
162
+ scope: FRAGMENT_SCOPES.ARCH,
163
+ id: `arch:${architecture}`,
164
+ sourceDir: archSrcDir,
165
+ targetDir: "src",
166
+ action: async ({ projectPath }) => {
167
+ // Copy architecture main entry
168
+ try {
169
+ await fsp.access(archMainSrc);
170
+ await fsp.copyFile(archMainSrc, path.join(projectPath, "src", `main.${ext}`));
171
+ } catch {}
172
+ // Remove opposite language main entry if present
173
+ const oppositeExt = language === "ts" ? "jsx" : "tsx";
174
+ try {
175
+ await fsp.rm(path.join(projectPath, "src", `main.${oppositeExt}`), { force: true });
176
+ } catch {}
177
+ },
178
+ });
179
+
180
+ // =========================================================================
181
+ // Layer 4: Styling (component styles overlay & global CSS)
182
+ // =========================================================================
183
+ if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
184
+ const componentStylesDir = path.join(
185
+ templatesDir,
186
+ "css",
187
+ "component-styles",
188
+ cssFramework,
189
+ architecture,
190
+ "src"
191
+ );
192
+ fragments.push({
193
+ scope: FRAGMENT_SCOPES.STYLING,
194
+ id: `styling:${cssFramework}:components`,
195
+ sourceDir: componentStylesDir,
196
+ targetDir: "src",
197
+ });
198
+ }
199
+
200
+ // =========================================================================
201
+ // Layer 5: Tooling (State, Router, Icons, API, Testing, Linter, Script, Formatter)
202
+ // =========================================================================
203
+
204
+ // 5.1 State Management
205
+ if (responses.stateManagement === "redux") {
206
+ fragments.push({
207
+ scope: FRAGMENT_SCOPES.TOOLING,
208
+ category: "state",
209
+ id: "tooling:state:redux",
210
+ sourceDir: path.join(templatesDir, "conditional", "state", "redux", architecture, "src"),
211
+ targetDir: "src",
212
+ action: async ({ projectPath }) => {
213
+ await updateMainWithProvider(projectPath, "redux", architecture, ext);
214
+ },
215
+ });
216
+ } else if (responses.stateManagement === "zustand") {
217
+ fragments.push({
218
+ scope: FRAGMENT_SCOPES.TOOLING,
219
+ category: "state",
220
+ id: "tooling:state:zustand",
221
+ sourceDir: path.join(templatesDir, "conditional", "state", "zustand", architecture, "src"),
222
+ targetDir: "src",
223
+ });
224
+ }
225
+
226
+ // 5.2 Router (Gated by CLIENT_ROUTING capability; Next uses filesystem routing and skips)
227
+ if (responses.router && hasCapability(fw, CAPABILITIES.CLIENT_ROUTING)) {
228
+ const routerBaseDir = path.join(templatesDir, "conditional", "router", architecture, "src");
229
+ fragments.push({
230
+ scope: FRAGMENT_SCOPES.TOOLING,
231
+ category: "router",
232
+ id: "tooling:router:base",
233
+ sourceDir: routerBaseDir,
234
+ targetDir: "src",
235
+ });
236
+
237
+ if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
238
+ const routerVariantDir = path.join(
239
+ templatesDir,
240
+ "conditional",
241
+ "router",
242
+ architecture,
243
+ cssFramework,
244
+ "src"
245
+ );
246
+ fragments.push({
247
+ scope: FRAGMENT_SCOPES.TOOLING,
248
+ category: "router",
249
+ id: `tooling:router:${cssFramework}`,
250
+ sourceDir: routerVariantDir,
251
+ targetDir: "src",
252
+ });
253
+ }
254
+ }
255
+
256
+ // 5.3 Icons
257
+ if (responses.iconLibrary && responses.iconLibrary !== "none") {
258
+ const iconsBaseDir = path.join(
259
+ templatesDir,
260
+ "conditional",
261
+ "icons",
262
+ responses.iconLibrary,
263
+ architecture,
264
+ "src"
265
+ );
266
+ fragments.push({
267
+ scope: FRAGMENT_SCOPES.TOOLING,
268
+ category: "icons",
269
+ id: `tooling:icons:${responses.iconLibrary}:base`,
270
+ sourceDir: iconsBaseDir,
271
+ targetDir: "src",
272
+ });
273
+
274
+ if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
275
+ const iconsVariantDir = path.join(
276
+ templatesDir,
277
+ "conditional",
278
+ "icons",
279
+ responses.iconLibrary,
280
+ architecture,
281
+ cssFramework,
282
+ "src"
283
+ );
284
+ fragments.push({
285
+ scope: FRAGMENT_SCOPES.TOOLING,
286
+ category: "icons",
287
+ id: `tooling:icons:${responses.iconLibrary}:${cssFramework}`,
288
+ sourceDir: iconsVariantDir,
289
+ targetDir: "src",
290
+ });
291
+ }
292
+ }
293
+
294
+ // 5.4 API Client
295
+ if (responses.apiClient === "axios" || responses.apiClient === "fetch") {
296
+ const apiDir = path.join(
297
+ templatesDir,
298
+ "conditional",
299
+ responses.apiClient,
300
+ architecture,
301
+ "src"
302
+ );
303
+ fragments.push({
304
+ scope: FRAGMENT_SCOPES.TOOLING,
305
+ category: "api",
306
+ id: `tooling:api:${responses.apiClient}`,
307
+ sourceDir: apiDir,
308
+ targetDir: "src",
309
+ });
310
+ }
311
+
312
+ // 5.5 Testing
313
+ if (responses.testing === "vitest" || responses.testing === "jest") {
314
+ const testingFramework = responses.testing;
315
+ const testingDir = path.join(templatesDir, "conditional", "testing", testingFramework);
316
+ const testScripts =
317
+ testingFramework === "vitest"
318
+ ? { test: "vitest", "test:run": "vitest run" }
319
+ : { test: "jest", "test:watch": "jest --watch" };
320
+
321
+ fragments.push({
322
+ scope: FRAGMENT_SCOPES.TOOLING,
323
+ category: "testing",
324
+ id: `tooling:testing:${testingFramework}`,
325
+ scripts: testScripts,
326
+ action: async ({ projectPath }) => {
327
+ // Config file to project root
328
+ const configSrc = path.join(testingDir, `${testingFramework}.config.${extConfig}`);
329
+ try {
330
+ await fsp.access(configSrc);
331
+ await fsp.copyFile(configSrc, path.join(projectPath, `${testingFramework}.config.${extConfig}`));
332
+ } catch {}
333
+
334
+ // Setup file to src/test/setup.*
335
+ const setupSrc = path.join(testingDir, `src/test/setup.${extConfig}`);
336
+ try {
337
+ await fsp.access(setupSrc);
338
+ const setupDest = path.join(projectPath, "src", "test", `setup.${extConfig}`);
339
+ await writeFileRecursive(setupDest, await fsp.readFile(setupSrc, "utf8"));
340
+ } catch {}
341
+
342
+ if (testingFramework === "jest") {
343
+ const jestSetupSrc = path.join(testingDir, `jest.setup.${extConfig}`);
344
+ try {
345
+ await fsp.access(jestSetupSrc);
346
+ await fsp.copyFile(jestSetupSrc, path.join(projectPath, `jest.setup.${extConfig}`));
347
+ } catch {}
348
+ const babelSrc = path.join(testingDir, "babel.config.cjs");
349
+ try {
350
+ await fsp.access(babelSrc);
351
+ await fsp.copyFile(babelSrc, path.join(projectPath, "babel.config.cjs"));
352
+ } catch {}
353
+ }
354
+
355
+ // Architecture-scoped test file
356
+ const testSrc = path.join(testingDir, architecture, `src/__tests__/App.test.${ext}`);
357
+ try {
358
+ await fsp.access(testSrc);
359
+ const testDest = path.join(projectPath, "src", "__tests__", `App.test.${ext}`);
360
+ await writeFileRecursive(testDest, await fsp.readFile(testSrc, "utf8"));
361
+ } catch {}
362
+ },
363
+ });
364
+ }
365
+
366
+ // 5.6 Linter
367
+ if (responses.linter === "eslint") {
368
+ fragments.push({
369
+ scope: FRAGMENT_SCOPES.TOOLING,
370
+ category: "linter",
371
+ id: "tooling:linter:eslint",
372
+ scripts: {
373
+ lint: "eslint .",
374
+ "lint:fix": "eslint . --fix",
375
+ },
376
+ action: async ({ projectPath }) => {
377
+ const lintingDir = path.join(templatesDir, "conditional", "linting", "eslint");
378
+ if (fw.name === "next") {
379
+ const configSrc = path.join(lintingDir, "next", "eslint.config.mjs");
380
+ try {
381
+ await fsp.access(configSrc);
382
+ await fsp.copyFile(configSrc, path.join(projectPath, "eslint.config.mjs"));
383
+ } catch {}
384
+ } else {
385
+ const configSrc = path.join(lintingDir, `eslint.config.${extConfig}`);
386
+ try {
387
+ await fsp.access(configSrc);
388
+ await fsp.copyFile(configSrc, path.join(projectPath, `eslint.config.${extConfig}`));
389
+ } catch {}
390
+ }
391
+ },
392
+ });
393
+ } else if (responses.linter === "oxlint") {
394
+ fragments.push({
395
+ scope: FRAGMENT_SCOPES.TOOLING,
396
+ category: "linter",
397
+ id: "tooling:linter:oxlint",
398
+ scripts: {
399
+ lint: "oxlint .",
400
+ },
401
+ action: async ({ projectPath }) => {
402
+ const configSrc = path.join(templatesDir, "conditional", "linting", "oxlint", "oxlintrc.json");
403
+ try {
404
+ await fsp.access(configSrc);
405
+ await fsp.copyFile(configSrc, path.join(projectPath, "oxlintrc.json"));
406
+ } catch {}
407
+ },
408
+ });
409
+ }
410
+
411
+ // 5.7 Feature Script (Feature-based only)
412
+ if (architecture === "feature-based") {
413
+ fragments.push({
414
+ scope: FRAGMENT_SCOPES.TOOLING,
415
+ category: "feature-script",
416
+ id: "tooling:feature-script",
417
+ scripts: {
418
+ "create:feature": "node scripts/create-feature.mjs",
419
+ },
420
+ action: async ({ projectPath }) => {
421
+ const scriptSrc = path.join(templatesDir, "architectures", "feature-based", "scripts", "create-feature.mjs");
422
+ const scriptDest = path.join(projectPath, "scripts", "create-feature.mjs");
423
+ try {
424
+ await fsp.access(scriptSrc);
425
+ let content = await fsp.readFile(scriptSrc, "utf8");
426
+ if (language === "js") {
427
+ content = content
428
+ .replace(/services\/index\.ts/g, "services/index.js")
429
+ .replace(/store\/index\.ts/g, "store/index.js")
430
+ .replace(/hooks\/index\.ts/g, "hooks/index.js")
431
+ .replace(/pages\/index\.ts/g, "pages/index.js")
432
+ .replace(/types\/index\.ts/g, "types/index.js");
433
+ }
434
+ await writeFileRecursive(scriptDest, content);
435
+ } catch {}
436
+ },
437
+ });
438
+ }
439
+
440
+ // 5.8 Formatter
441
+ if (responses.formatter && responses.formatter !== "none") {
442
+ const isPrettier = responses.formatter === "prettier";
443
+ const configFile = isPrettier ? ".prettierrc" : ".oxfmtrc.json";
444
+ const formatScripts = isPrettier
445
+ ? { format: "prettier --write .", "format:check": "prettier --check ." }
446
+ : { format: "oxfmt .", "format:check": "oxfmt --check ." };
447
+
448
+ fragments.push({
449
+ scope: FRAGMENT_SCOPES.TOOLING,
450
+ category: "formatter",
451
+ id: `tooling:formatter:${responses.formatter}`,
452
+ scripts: formatScripts,
453
+ action: async ({ projectPath }) => {
454
+ const configSrc = path.join(
455
+ templatesDir,
456
+ "conditional",
457
+ "formatter",
458
+ responses.formatter,
459
+ configFile
460
+ );
461
+ try {
462
+ await fsp.access(configSrc);
463
+ await fsp.copyFile(configSrc, path.join(projectPath, configFile));
464
+ } catch {}
465
+
466
+ if (responses.linter === "eslint" && responses.formatter === "prettier") {
467
+ await wireEslintPrettier(projectPath, language, fw.name);
468
+ }
469
+ },
470
+ });
471
+ }
472
+
473
+ return sortFragments(fragments);
474
+ }
475
+
476
+ /**
477
+ * Deterministically sort fragments by FRAGMENT_PRECEDENCE and TOOLING_ORDER.
478
+ * @param {Array<object>} fragments
479
+ * @returns {Array<object>}
480
+ */
481
+ export function sortFragments(fragments) {
482
+ return [...fragments].sort((a, b) => {
483
+ const aScopeIdx = FRAGMENT_PRECEDENCE.indexOf(a.scope);
484
+ const bScopeIdx = FRAGMENT_PRECEDENCE.indexOf(b.scope);
485
+ if (aScopeIdx !== bScopeIdx) {
486
+ return aScopeIdx - bScopeIdx;
487
+ }
488
+ // Both are tooling: sort by TOOLING_ORDER
489
+ if (a.scope === FRAGMENT_SCOPES.TOOLING && b.scope === FRAGMENT_SCOPES.TOOLING) {
490
+ const aCatIdx = TOOLING_ORDER.indexOf(a.category || "");
491
+ const bCatIdx = TOOLING_ORDER.indexOf(b.category || "");
492
+ if (aCatIdx !== bCatIdx) {
493
+ return aCatIdx - bCatIdx;
494
+ }
495
+ }
496
+ return (a.id || "").localeCompare(b.id || "");
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Apply a list of fragments to a project in deterministic precedence order.
502
+ *
503
+ * @param {string} projectPath
504
+ * @param {Array<object>} fragments
505
+ * @param {object} options
506
+ */
507
+ export async function applyFragments(projectPath, fragments, options = {}) {
508
+ const language = options.language || "ts";
509
+ const filter = langFileFilter(language);
510
+
511
+ // Collect scripts to merge into package.json in one deterministic pass
512
+ const pendingScripts = {};
513
+
514
+ for (const fragment of fragments) {
515
+ // 1. Copy directory contents if sourceDir is provided and exists
516
+ if (fragment.sourceDir) {
517
+ try {
518
+ await fsp.access(fragment.sourceDir);
519
+ const target = fragment.targetDir
520
+ ? path.join(projectPath, fragment.targetDir)
521
+ : projectPath;
522
+ await copyDirRecursive(fragment.sourceDir, target, filter);
523
+ } catch {
524
+ // Source dir does not exist, continue
525
+ }
526
+ }
527
+
528
+ // 2. Execute fragment action if provided
529
+ if (typeof fragment.action === "function") {
530
+ await fragment.action({
531
+ projectPath,
532
+ templatesDir: options.templatesDir,
533
+ responses: options.responses,
534
+ ext: language === "ts" ? "tsx" : "jsx",
535
+ language,
536
+ });
537
+ }
538
+
539
+ // 3. Queue scripts
540
+ if (fragment.scripts) {
541
+ Object.assign(pendingScripts, fragment.scripts);
542
+ }
543
+ }
544
+
545
+ // 4. Merge scripts into package.json
546
+ if (Object.keys(pendingScripts).length > 0) {
547
+ const pkgPath = path.join(projectPath, "package.json");
548
+ try {
549
+ const content = await fsp.readFile(pkgPath, "utf8");
550
+ const pkg = JSON.parse(content);
551
+ if (!pkg.scripts) pkg.scripts = {};
552
+ for (const [key, val] of Object.entries(pendingScripts)) {
553
+ pkg.scripts[key] = val;
554
+ }
555
+ await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
556
+ } catch {}
557
+ }
558
+
559
+ // 5. Prune redundant .gitkeep placeholders
560
+ await pruneRedundantGitkeeps(projectPath);
561
+ }
562
+
563
+ /**
564
+ * Helper to update main entry with Redux StoreProvider.
565
+ */
566
+ async function updateMainWithProvider(projectPath, stateType, architecture, ext) {
567
+ const mainPath = path.join(projectPath, "src", `main.${ext}`);
568
+ try {
569
+ let content = await fsp.readFile(mainPath, "utf8");
570
+
571
+ if (architecture === "feature-based") {
572
+ if (!content.includes("StoreProvider")) {
573
+ content = content.replace(
574
+ /import App from ['"]@\/app\/App['"]\n?/,
575
+ "import App from '@/app/App'\nimport { StoreProvider } from '@/app/providers/StoreProvider'\n"
576
+ );
577
+ content = content.replace(
578
+ /<App \/>/,
579
+ "<StoreProvider>\n <App />\n </StoreProvider>"
580
+ );
581
+ }
582
+ } else {
583
+ if (!content.includes("StoreProvider")) {
584
+ content = content.replace(
585
+ /import App from ['"]\.\/App['"]\n?/,
586
+ "import App from './App'\nimport { StoreProvider } from './providers/StoreProvider'\n"
587
+ );
588
+ content = content.replace(
589
+ /<App \/>/,
590
+ "<StoreProvider>\n <App />\n </StoreProvider>"
591
+ );
592
+ }
593
+ }
594
+
595
+ await fsp.writeFile(mainPath, content, "utf8");
596
+ } catch {}
597
+ }
598
+
599
+ /**
600
+ * Helper to wire eslint-config-prettier into eslint configuration.
601
+ */
602
+ async function wireEslintPrettier(projectPath, language, framework) {
603
+ const ext = framework === "next" ? "mjs" : language === "ts" ? "ts" : "js";
604
+ const eslintConfigPath = path.join(projectPath, `eslint.config.${ext}`);
605
+ try {
606
+ let content = await fsp.readFile(eslintConfigPath, "utf8");
607
+
608
+ if (content.includes("eslint-config-prettier")) return;
609
+
610
+ const lines = content.split("\n");
611
+ let lastImportIdx = -1;
612
+ for (let i = 0; i < lines.length; i++) {
613
+ if (lines[i].startsWith("import ")) lastImportIdx = i;
614
+ }
615
+ lines.splice(
616
+ lastImportIdx + 1,
617
+ 0,
618
+ 'import prettier from "eslint-config-prettier";'
619
+ );
620
+
621
+ let result = lines.join("\n");
622
+ const pushLast = (_, head, tail) =>
623
+ head.replace(/,\s*$/, "") + ",\n prettier," + tail;
624
+
625
+ if (result.includes("tseslint.config(")) {
626
+ result = result.replace(
627
+ /(export default tseslint\.config\([\s\S]*?)(\n\s*\);)/,
628
+ pushLast
629
+ );
630
+ } else if (result.includes("const eslintConfig = [")) {
631
+ result = result.replace(
632
+ /(const eslintConfig = \[[\s\S]*?)(\n\s*\];)/,
633
+ pushLast
634
+ );
635
+ } else {
636
+ result = result.replace(
637
+ /(export default \[[\s\S]*?)(\n\s*\];)/,
638
+ pushLast
639
+ );
640
+ }
641
+
642
+ await fsp.writeFile(eslintConfigPath, result, "utf8");
643
+ } catch {}
644
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./capabilities.js";
2
2
  export * from "./providers/base-provider.js";
3
+ export * from "./composition.js";