create-win-project 1.3.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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +187 -0
  3. package/ci/expo.yml +28 -0
  4. package/ci/laravel.yml +49 -0
  5. package/ci/nextjs.yml +29 -0
  6. package/ci/springboot.yml +33 -0
  7. package/ci/vite.yml +36 -0
  8. package/compatibility/profiles.json +237 -0
  9. package/docs/ARCHITECTURE.md +118 -0
  10. package/docs/CONTENT_MODEL.md +36 -0
  11. package/docs/DEPENDENCY_MAINTENANCE.md +31 -0
  12. package/index.js +431 -0
  13. package/lib/application-shapes.js +95 -0
  14. package/lib/banner.js +45 -0
  15. package/lib/catalog.js +343 -0
  16. package/lib/compatibility.js +116 -0
  17. package/lib/constants.js +3 -0
  18. package/lib/doctor.js +45 -0
  19. package/lib/files.js +158 -0
  20. package/lib/generator.js +366 -0
  21. package/lib/interview.js +77 -0
  22. package/lib/laravel-scaffold.js +3 -0
  23. package/lib/playbooks.js +172 -0
  24. package/lib/project-location.js +18 -0
  25. package/lib/scaffold.js +467 -0
  26. package/lib/stacks/context.js +27 -0
  27. package/lib/stacks/contract.js +142 -0
  28. package/lib/stacks/index.js +8 -0
  29. package/lib/stacks/laravel/architecture.js +27 -0
  30. package/lib/stacks/laravel/auth/oidc.js +13 -0
  31. package/lib/stacks/laravel/auth/public.js +28 -0
  32. package/lib/stacks/laravel/auth/sanctum.js +14 -0
  33. package/lib/stacks/laravel/auth/session.js +17 -0
  34. package/lib/stacks/laravel/composer.js +50 -0
  35. package/lib/stacks/laravel/generate.js +362 -0
  36. package/lib/stacks/laravel/index.js +18 -0
  37. package/lib/stacks/laravel/ui/blade.js +15 -0
  38. package/lib/stacks/laravel/ui/index.js +29 -0
  39. package/lib/stacks/laravel/ui/inertia-react.js +35 -0
  40. package/lib/stacks/laravel/ui/livewire.js +39 -0
  41. package/lib/stacks/laravel/ui/shared.js +14 -0
  42. package/lib/stacks/registry.js +42 -0
  43. package/lib/stacks/shared/contributions.js +25 -0
  44. package/lib/template.js +62 -0
  45. package/package.json +49 -0
  46. package/playbooks/INDEX.md +58 -0
  47. package/playbooks/capabilities/auth/oidc-resource-server.md +15 -0
  48. package/playbooks/capabilities/auth/spring-session.md +15 -0
  49. package/playbooks/capabilities/ci/github-actions.manifest.json +10 -0
  50. package/playbooks/capabilities/ci/github-actions.md +61 -0
  51. package/playbooks/capabilities/docker/docker.manifest.json +11 -0
  52. package/playbooks/capabilities/docker/overview.md +55 -0
  53. package/playbooks/capabilities/flyway/environments.md +5 -0
  54. package/playbooks/capabilities/flyway/flyway.manifest.json +12 -0
  55. package/playbooks/capabilities/flyway/migrations.md +7 -0
  56. package/playbooks/capabilities/flyway/testing.md +5 -0
  57. package/playbooks/capabilities/laravel/database.md +8 -0
  58. package/playbooks/capabilities/laravel/migrations.md +15 -0
  59. package/playbooks/capabilities/laravel/observability.md +8 -0
  60. package/playbooks/capabilities/laravel/oidc-resource-server.md +10 -0
  61. package/playbooks/capabilities/laravel/queues.md +8 -0
  62. package/playbooks/capabilities/laravel/sanctum-spa.md +8 -0
  63. package/playbooks/capabilities/laravel/scheduler.md +7 -0
  64. package/playbooks/capabilities/laravel/session-auth.md +8 -0
  65. package/playbooks/capabilities/laravel/storage-uploads.md +7 -0
  66. package/playbooks/capabilities/postgresql/architecture.md +5 -0
  67. package/playbooks/capabilities/postgresql/migrations.md +5 -0
  68. package/playbooks/capabilities/postgresql/postgresql.manifest.json +15 -0
  69. package/playbooks/capabilities/postgresql/schema-design.md +7 -0
  70. package/playbooks/capabilities/postgresql/security.md +5 -0
  71. package/playbooks/capabilities/postgresql/testing.md +5 -0
  72. package/playbooks/capabilities/prisma/architecture.md +5 -0
  73. package/playbooks/capabilities/prisma/migrations.md +11 -0
  74. package/playbooks/capabilities/prisma/prisma.manifest.json +14 -0
  75. package/playbooks/capabilities/prisma/runtime.md +5 -0
  76. package/playbooks/capabilities/prisma/schema.md +5 -0
  77. package/playbooks/capabilities/prisma/testing.md +5 -0
  78. package/playbooks/capabilities/supabase/architecture.md +5 -0
  79. package/playbooks/capabilities/supabase/authentication.md +5 -0
  80. package/playbooks/capabilities/supabase/expo.md +16 -0
  81. package/playbooks/capabilities/supabase/migrations.md +7 -0
  82. package/playbooks/capabilities/supabase/nextjs.md +24 -0
  83. package/playbooks/capabilities/supabase/rls.md +7 -0
  84. package/playbooks/capabilities/supabase/supabase.manifest.json +32 -0
  85. package/playbooks/capabilities/supabase/testing.md +5 -0
  86. package/playbooks/capabilities/supabase/vite.md +14 -0
  87. package/playbooks/concerns/axios.md +123 -0
  88. package/playbooks/concerns/next-safe-action.md +66 -0
  89. package/playbooks/concerns/next-themes.md +87 -0
  90. package/playbooks/concerns/nuqs.md +59 -0
  91. package/playbooks/concerns/t3-env.md +48 -0
  92. package/playbooks/concerns/tanstack-query.md +145 -0
  93. package/playbooks/concerns/zod.md +174 -0
  94. package/playbooks/concerns/zustand.md +93 -0
  95. package/playbooks/devops/makefile.manifest.json +10 -0
  96. package/playbooks/devops/makefile.md +556 -0
  97. package/playbooks/devops/pr-template.manifest.json +10 -0
  98. package/playbooks/devops/pr-template.md +106 -0
  99. package/playbooks/platform/laravel-ui/blade/architecture.md +5 -0
  100. package/playbooks/platform/laravel-ui/blade/runtime.md +5 -0
  101. package/playbooks/platform/laravel-ui/blade/security.md +5 -0
  102. package/playbooks/platform/laravel-ui/blade/structure.md +5 -0
  103. package/playbooks/platform/laravel-ui/blade/testing.md +5 -0
  104. package/playbooks/platform/laravel-ui/inertia-react/architecture.md +5 -0
  105. package/playbooks/platform/laravel-ui/inertia-react/runtime.md +5 -0
  106. package/playbooks/platform/laravel-ui/inertia-react/security.md +5 -0
  107. package/playbooks/platform/laravel-ui/inertia-react/structure.md +5 -0
  108. package/playbooks/platform/laravel-ui/inertia-react/testing.md +5 -0
  109. package/playbooks/platform/laravel-ui/livewire/architecture.md +5 -0
  110. package/playbooks/platform/laravel-ui/livewire/runtime.md +5 -0
  111. package/playbooks/platform/laravel-ui/livewire/security.md +5 -0
  112. package/playbooks/platform/laravel-ui/livewire/structure.md +5 -0
  113. package/playbooks/platform/laravel-ui/livewire/testing.md +5 -0
  114. package/playbooks/platform/mobile.manifest.json +17 -0
  115. package/playbooks/platform/mobile.md +15 -0
  116. package/playbooks/platform/web.manifest.json +17 -0
  117. package/playbooks/platform/web.md +17 -0
  118. package/playbooks/stack/expo/architecture.md +27 -0
  119. package/playbooks/stack/expo/runtime.md +14 -0
  120. package/playbooks/stack/expo/security.md +18 -0
  121. package/playbooks/stack/expo/structure.md +26 -0
  122. package/playbooks/stack/expo/testing.md +13 -0
  123. package/playbooks/stack/laravel/architecture.md +34 -0
  124. package/playbooks/stack/laravel/runtime.md +27 -0
  125. package/playbooks/stack/laravel/security.md +23 -0
  126. package/playbooks/stack/laravel/structure.md +29 -0
  127. package/playbooks/stack/laravel/testing.md +22 -0
  128. package/playbooks/stack/laravel-ui.manifest.json +42 -0
  129. package/playbooks/stack/laravel.manifest.json +35 -0
  130. package/playbooks/stack/nextjs/architecture.md +62 -0
  131. package/playbooks/stack/nextjs/runtime.md +31 -0
  132. package/playbooks/stack/nextjs/security.md +32 -0
  133. package/playbooks/stack/nextjs/structure.md +50 -0
  134. package/playbooks/stack/nextjs/testing.md +20 -0
  135. package/playbooks/stack/nextjs.manifest.json +49 -0
  136. package/playbooks/stack/no-frontend.manifest.json +25 -0
  137. package/playbooks/stack/none.manifest.json +21 -0
  138. package/playbooks/stack/react-native.manifest.json +49 -0
  139. package/playbooks/stack/react-vite/architecture.md +33 -0
  140. package/playbooks/stack/react-vite/runtime.md +13 -0
  141. package/playbooks/stack/react-vite/security.md +16 -0
  142. package/playbooks/stack/react-vite/structure.md +23 -0
  143. package/playbooks/stack/react-vite/testing.md +12 -0
  144. package/playbooks/stack/react-vite.manifest.json +46 -0
  145. package/playbooks/stack/springboot/architecture.md +40 -0
  146. package/playbooks/stack/springboot/runtime.md +28 -0
  147. package/playbooks/stack/springboot/security.md +28 -0
  148. package/playbooks/stack/springboot/structure.md +30 -0
  149. package/playbooks/stack/springboot/testing.md +22 -0
  150. package/playbooks/stack/springboot.manifest.json +38 -0
  151. package/playbooks/styling/css-modules-extensions.md +267 -0
  152. package/playbooks/styling/css-modules.manifest.json +11 -0
  153. package/playbooks/styling/native-styles.manifest.json +9 -0
  154. package/playbooks/styling/native-styles.md +29 -0
  155. package/playbooks/styling/tailwind-extensions.md +182 -0
  156. package/playbooks/styling/tailwind.manifest.json +11 -0
  157. package/playbooks/universal/accessibility.manifest.json +11 -0
  158. package/playbooks/universal/accessibility.md +45 -0
  159. package/playbooks/universal/coding-rules.manifest.json +12 -0
  160. package/playbooks/universal/coding-rules.md +281 -0
  161. package/playbooks/universal/error-handling.manifest.json +10 -0
  162. package/playbooks/universal/error-handling.md +21 -0
  163. package/playbooks/universal/git-conventions.manifest.json +10 -0
  164. package/playbooks/universal/git-conventions.md +186 -0
  165. package/playbooks/universal/observability.manifest.json +10 -0
  166. package/playbooks/universal/observability.md +29 -0
  167. package/playbooks/universal/security.manifest.json +11 -0
  168. package/playbooks/universal/security.md +30 -0
  169. package/playbooks/universal/typescript.manifest.json +11 -0
  170. package/playbooks/universal/typescript.md +272 -0
  171. package/scripts/compatibility-matrix.mjs +48 -0
  172. package/scripts/validate-content.mjs +76 -0
  173. package/scripts/verify-generated.mjs +153 -0
  174. package/templates/agents/nextjs.md +47 -0
  175. package/templates/agents/react-native.md +48 -0
  176. package/templates/agents/react-vite.md +48 -0
  177. package/templates/docker/compose/postgres.yml +29 -0
  178. package/templates/docker/compose/springboot.yml +69 -0
  179. package/templates/docker/compose/supabase.yml +18 -0
  180. package/templates/docker/compose-prod/springboot.yml +64 -0
  181. package/templates/docker/dockerfile/laravel-inertia.dev.dockerfile +21 -0
  182. package/templates/docker/dockerfile/laravel.dev.dockerfile +12 -0
  183. package/templates/docker/dockerfile/laravel.prod.dockerfile +13 -0
  184. package/templates/docker/dockerfile/nextjs.dev.dockerfile +6 -0
  185. package/templates/docker/dockerfile/nextjs.prod.dockerfile +18 -0
  186. package/templates/docker/dockerfile/springboot.dev.dockerfile +5 -0
  187. package/templates/docker/dockerfile/springboot.prod.dockerfile +10 -0
  188. package/templates/docker/dockerfile/vite.dev.dockerfile +6 -0
  189. package/templates/docker/dockerfile/vite.prod.dockerfile +12 -0
  190. package/templates/gitignore/base.gitignore +32 -0
  191. package/templates/gitignore/nextjs.gitignore +40 -0
  192. package/templates/gitignore/react-native.gitignore +43 -0
  193. package/templates/gitignore/react-vite.gitignore +39 -0
  194. package/templates/makefile/frontend.mk +31 -0
  195. package/templates/makefile/laravel.mk +47 -0
  196. package/templates/makefile/postgres.mk +40 -0
  197. package/templates/makefile/springboot.mk +125 -0
  198. package/templates/makefile/supabase.mk +42 -0
@@ -0,0 +1,95 @@
1
+ export const APPLICATION_SHAPES = Object.freeze({
2
+ fullstack: {
3
+ label: 'Full-stack web application',
4
+ description: 'One project owns the website and server logic. Example: Next.js or Laravel.',
5
+ },
6
+ separate: {
7
+ label: 'Separate frontend and backend API',
8
+ description: 'Two applications connected through an API. Example: React/Vite + Spring Boot or Laravel.',
9
+ },
10
+ api: {
11
+ label: 'Backend API only',
12
+ description: 'An API without a generated website. Example: Spring Boot or Laravel.',
13
+ },
14
+ mobile: {
15
+ label: 'Mobile application',
16
+ description: 'An Expo app using Supabase or a separate Spring Boot or Laravel API.',
17
+ },
18
+ frontend: {
19
+ label: 'Frontend-only website',
20
+ description: 'A browser application without a generated server. Example: React with Vite.',
21
+ },
22
+ })
23
+
24
+ const VALID_COMBINATIONS = Object.freeze({
25
+ fullstack: [
26
+ ['nextjs', 'none'],
27
+ ['nextjs', 'postgres'],
28
+ ['nextjs', 'supabase'],
29
+ ['laravel-ui', 'laravel'],
30
+ ],
31
+ separate: [
32
+ ['nextjs', 'springboot'], ['nextjs', 'laravel'],
33
+ ['react', 'springboot'], ['react', 'laravel'], ['react', 'supabase'],
34
+ ],
35
+ api: [['no-frontend', 'springboot'], ['no-frontend', 'laravel']],
36
+ mobile: [
37
+ ['react-native', 'none'], ['react-native', 'supabase'],
38
+ ['react-native', 'springboot'], ['react-native', 'laravel'],
39
+ ],
40
+ frontend: [['react', 'none']],
41
+ })
42
+
43
+ export function applicationShapeChoices() {
44
+ return Object.entries(APPLICATION_SHAPES).map(([value, shape], index) => ({
45
+ name: `${shape.label}${index === 0 ? ' (Recommended)' : ''}\n ${shape.description}`,
46
+ short: shape.label,
47
+ value,
48
+ }))
49
+ }
50
+
51
+ export function combinationsForShape(shape) {
52
+ if (!APPLICATION_SHAPES[shape]) throw new Error(`Unknown application shape: ${shape}`)
53
+ return VALID_COMBINATIONS[shape].map(([frontend, backend]) => ({ frontend, backend }))
54
+ }
55
+
56
+ export function inferApplicationShape({ frontend, backend }) {
57
+ const matches = Object.entries(VALID_COMBINATIONS).filter(([, combinations]) =>
58
+ combinations.some(([candidateFrontend, candidateBackend]) =>
59
+ candidateFrontend === frontend && candidateBackend === backend))
60
+ if (matches.length === 1) return matches[0][0]
61
+ if (matches.length === 0) {
62
+ throw new Error(`No application shape supports ${frontend} + ${backend}`)
63
+ }
64
+ throw new Error(`Specify --shape because ${frontend} + ${backend} is ambiguous`)
65
+ }
66
+
67
+ export function validateApplicationShape(shape, frontend, backend) {
68
+ const combinations = combinationsForShape(shape)
69
+ if (!combinations.some((entry) => entry.frontend === frontend && entry.backend === backend)) {
70
+ const allowed = combinations.map((entry) => `${entry.frontend} + ${entry.backend}`).join(', ')
71
+ throw new Error(`${frontend} + ${backend} is not valid for ${shape}; choose one of: ${allowed}`)
72
+ }
73
+ return shape
74
+ }
75
+
76
+ export function availableCombinationsForShape(shape, catalog) {
77
+ return combinationsForShape(shape).filter(({ frontend, backend }) =>
78
+ catalog.byId[frontend] && catalog.byId[backend])
79
+ }
80
+
81
+ export function frontendChoicesForShape(shape, catalog) {
82
+ const seen = new Set()
83
+ return availableCombinationsForShape(shape, catalog).flatMap(({ frontend }) => {
84
+ if (seen.has(frontend)) return []
85
+ seen.add(frontend)
86
+ const manifest = catalog.byId[frontend]
87
+ return [{ name: manifest.label, value: frontend }]
88
+ })
89
+ }
90
+
91
+ export function backendChoicesForShape(shape, frontend, catalog) {
92
+ return availableCombinationsForShape(shape, catalog)
93
+ .filter((entry) => entry.frontend === frontend)
94
+ .map(({ backend }) => ({ name: catalog.byId[backend].label, value: backend }))
95
+ }
package/lib/banner.js ADDED
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk'
2
+
3
+ const W1N = [
4
+ ' ██╗ ██╗ ██╗ ███╗ ██╗',
5
+ ' ██║ ██║ ███║ ████╗ ██║',
6
+ ' ██║ █╗ ██║ ╚██║ ██╔██╗██║',
7
+ ' ██║███╗██║ ██║ ██║╚████║',
8
+ ' ╚███╔███╔╝ ██║ ██║ ╚███║',
9
+ ' ╚══╝╚══╝ ╚═╝ ╚═╝ ╚══╝',
10
+ ]
11
+
12
+ const PROJECT = [
13
+ ' █▀█ █▀█ █▀█ █ █▀▀ █▀▀ ▀█▀',
14
+ ' █▀▀ █▀█ █ █ █ █▀ █ █ ',
15
+ ' █ █ █ █▄█ █▄█ █▄▄ █▄▄ █ ',
16
+ ]
17
+
18
+ function plain() {
19
+ return [...W1N, '', ...PROJECT].join('\n')
20
+ }
21
+
22
+ function colored() {
23
+ const w1n = W1N.map((l) => chalk.bold.cyan(l)).join('\n')
24
+ const proj = PROJECT.map((l) => chalk.bold.white(l)).join('\n')
25
+ return `${w1n}\n${proj}`
26
+ }
27
+
28
+ export function w1nBanner() {
29
+ const noColor = process.env.NO_COLOR !== undefined || process.env.FORCE_COLOR === '0'
30
+ const isTTY = process.stdout.isTTY
31
+ const width = process.stdout.columns || 80
32
+
33
+ // Fallback for narrow terminals or non-TTY (CI/logs): plain single-line wordmark
34
+ if (!isTTY || width < 80 || noColor) {
35
+ // Keep stacked plain for README consistency, but without colors
36
+ if (width < 60) return 'W1N PROJECT'
37
+ return plain()
38
+ }
39
+
40
+ return colored()
41
+ }
42
+
43
+ export const bannerRaw = plain()
44
+ export const W1N_ART = W1N.join('\n')
45
+ export const PROJECT_ART = PROJECT.join('\n')
package/lib/catalog.js ADDED
@@ -0,0 +1,343 @@
1
+ import fs from 'fs-extra'
2
+ import path from 'path'
3
+ import { loadCompatibility, resolvePackages } from './compatibility.js'
4
+ import { inferApplicationShape, validateApplicationShape } from './application-shapes.js'
5
+
6
+ // ─── Loader ───────────────────────────────────────────────────────────────────
7
+
8
+ export async function loadCatalog(playbooksDir, selectedProfile) {
9
+ const manifests = []
10
+ const walk = async (dir) => {
11
+ const entries = await fs.readdir(dir, { withFileTypes: true })
12
+ for (const e of entries) {
13
+ const full = path.join(dir, e.name)
14
+ if (e.isDirectory()) { await walk(full); continue }
15
+ if (e.name.endsWith('.manifest.json')) {
16
+ const m = await fs.readJson(full)
17
+ m._file = full
18
+ const defaultPlaybook = path.relative(playbooksDir, full).replace(/\.manifest\.json$/, '.md')
19
+ m._playbooks = m.playbooks || [m.file || defaultPlaybook]
20
+ const firstPlaybook = m._playbooks[0]
21
+ m._playbook = typeof firstPlaybook === 'string' ? firstPlaybook : firstPlaybook?.file
22
+ manifests.push(m)
23
+ }
24
+ }
25
+ }
26
+ await walk(playbooksDir)
27
+ validateManifests(manifests)
28
+ const profile = selectedProfile || (await loadCompatibility(
29
+ path.join(path.dirname(playbooksDir), 'compatibility/profiles.json'),
30
+ )).profile
31
+ return { ...buildIndex(manifests), profile }
32
+ }
33
+
34
+ export function validateManifests(manifests) {
35
+ const ids = new Set()
36
+ const validKinds = new Set(['frontend', 'backend', 'database', 'migration', 'styling', 'devops', 'platform', 'universal'])
37
+ for (const manifest of manifests) {
38
+ if (!manifest.id || typeof manifest.id !== 'string') throw new Error('Every manifest needs a string id')
39
+ if (ids.has(manifest.id)) throw new Error(`Duplicate manifest id: ${manifest.id}`)
40
+ ids.add(manifest.id)
41
+ if (!validKinds.has(manifest.kind)) throw new Error(`Invalid manifest kind for ${manifest.id}: ${manifest.kind}`)
42
+ if (!manifest.label || typeof manifest.label !== 'string') throw new Error(`Manifest ${manifest.id} needs a label`)
43
+ if (['frontend', 'backend'].includes(manifest.kind)) {
44
+ const profiles = manifest.architectureProfiles
45
+ if (!Array.isArray(profiles) || profiles.length !== 3 ||
46
+ !['small', 'medium', 'large'].every((profile) => profiles.includes(profile))) {
47
+ throw new Error(`Manifest ${manifest.id} must support small, medium, and large architecture profiles`)
48
+ }
49
+ }
50
+ if (manifest.playbooks && (!Array.isArray(manifest.playbooks) || manifest.playbooks.some((entry) =>
51
+ typeof entry !== 'string' && !(entry && typeof entry.file === 'string')))) {
52
+ throw new Error(`Manifest ${manifest.id}.playbooks must contain paths or conditional file entries`)
53
+ }
54
+ for (const field of ['deps', 'devDeps']) {
55
+ if (manifest[field] && (!Array.isArray(manifest[field]) || manifest[field].some((name) => typeof name !== 'string'))) {
56
+ throw new Error(`Manifest ${manifest.id}.${field} must be an array of package names`)
57
+ }
58
+ }
59
+ const env = new Set(manifest.env || [])
60
+ for (const name of manifest.clientEnv || []) {
61
+ if (!env.has(name)) throw new Error(`Manifest ${manifest.id} client env ${name} is missing from env`)
62
+ }
63
+ for (const concern of manifest.concerns || []) {
64
+ if (!concern.id || typeof concern.required !== 'boolean') {
65
+ throw new Error(`Manifest ${manifest.id} has an invalid concern`)
66
+ }
67
+ if (!concern.required && !concern.when) {
68
+ throw new Error(`Optional concern ${manifest.id}/${concern.id} needs a when condition`)
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ function buildIndex(manifests) {
75
+ const byId = {}
76
+ const byKind = {}
77
+ for (const m of manifests) {
78
+ byId[m.id] = m
79
+ ;(byKind[m.kind] ||= []).push(m)
80
+ }
81
+ return {
82
+ manifests, byId, byKind,
83
+ universal: byKind['universal'] || [],
84
+ frontends: byKind['frontend'] || [],
85
+ backends: byKind['backend'] || [],
86
+ }
87
+ }
88
+
89
+ // ─── Interview helpers ────────────────────────────────────────────────────────
90
+
91
+ function displayLabel(label, language) {
92
+ if (!language) return label
93
+ if (label.includes('(') && label.endsWith(')')) {
94
+ return label.replace(/\)$/, `, ${language})`)
95
+ }
96
+ return `${label} (${language})`
97
+ }
98
+
99
+ export function frontendChoices(catalog) {
100
+ return catalog.frontends.map((f) => ({
101
+ name: displayLabel(f.label, f.language),
102
+ value: f.id,
103
+ }))
104
+ }
105
+
106
+ export function backendChoicesFor(catalog, feId) {
107
+ const fe = catalog.byId[feId]
108
+ const allowed = fe?.appliesTo?.backend || []
109
+ return allowed.map((b) => {
110
+ const m = catalog.byId[b]
111
+ const label = m ? m.label : b
112
+ return { name: displayLabel(label, m?.language), value: b }
113
+ })
114
+ }
115
+
116
+ /** Returns styling choices for a frontend, or [] if only one option (auto-select). */
117
+ export function stylingChoicesFor(catalog, feId) {
118
+ const fe = catalog.byId[feId]
119
+ const opts = fe?.stylingOptions || []
120
+ if (opts.length <= 1) return []
121
+ return opts.map((id) => {
122
+ const m = catalog.byId[id]
123
+ return { name: m ? m.label : id, value: id }
124
+ })
125
+ }
126
+
127
+ /** Returns true if the selected frontend supports architecture depth choice. */
128
+ export function architectureChoicesFor(catalog, feId, beId) {
129
+ const frontend = catalog.byId[feId]
130
+ const backend = catalog.byId[beId]
131
+ if (!frontend || !backend) return []
132
+ const supported = new Set(frontend.architectureProfiles || [])
133
+ return (backend.architectureProfiles || []).filter((profile) => supported.has(profile))
134
+ }
135
+
136
+ function matchesWhen(when, context) {
137
+ if (!when) return true
138
+ return Object.entries(when).every(([key, expected]) => {
139
+ const values = Array.isArray(expected) ? expected : [expected]
140
+ return values.includes(context[key])
141
+ })
142
+ }
143
+
144
+ // ─── Stack resolver ───────────────────────────────────────────────────────────
145
+
146
+ export function resolveStack(answers, catalog) {
147
+ const fe = catalog.byId[answers.frontend]
148
+ const be = catalog.byId[answers.backend]
149
+ if (!fe) throw new Error(`Unknown frontend: ${answers.frontend}`)
150
+ if (!be) throw new Error(`Unknown backend: ${answers.backend}`)
151
+
152
+ const allowed = fe.appliesTo?.backend || []
153
+ if (!allowed.includes(be.id)) {
154
+ throw new Error(`${fe.label} cannot pair with ${be.label}`)
155
+ }
156
+ const applicationShape = answers.applicationShape || answers.shape || inferApplicationShape({
157
+ frontend: fe.id,
158
+ backend: be.id,
159
+ })
160
+ validateApplicationShape(applicationShape, fe.id, be.id)
161
+
162
+ // ── Auto-resolve styling ─────────────────────────────────────────────────
163
+ // If only one option, auto-select it. Otherwise use the user's answer.
164
+ const stylingOpts = fe.stylingOptions || []
165
+ const styleId = stylingOpts.length === 0
166
+ ? null
167
+ : stylingOpts.length === 1
168
+ ? stylingOpts[0]
169
+ : (answers.styling || stylingOpts[0] || 'tailwind')
170
+
171
+ // ── Architecture ─────────────────────────────────────────────────────────
172
+ const supportedArchitectures = architectureChoicesFor(catalog, fe.id, be.id)
173
+ const architecture = answers.architecture || 'medium'
174
+ if (!supportedArchitectures.includes(architecture)) {
175
+ throw new Error(`${architecture} architecture is not supported by ${fe.label} + ${be.label}`)
176
+ }
177
+
178
+ const authenticationIntent = answers.authentication || 'not-yet'
179
+ if (!['yes', 'not-yet', 'none'].includes(authenticationIntent)) {
180
+ throw new Error(`Unknown authentication choice: ${authenticationIntent}`)
181
+ }
182
+ const authAudience = answers.authAudience || (fe.platform === 'mobile' ? 'multi-client' : 'website')
183
+ if (!['website', 'multi-client'].includes(authAudience)) {
184
+ throw new Error(`Unknown authentication audience: ${authAudience}`)
185
+ }
186
+ let authentication = authenticationIntent === 'none' ? 'public' : 'undecided'
187
+ if (authenticationIntent === 'yes') {
188
+ if (be.id === 'supabase') authentication = 'supabase'
189
+ else if (be.id === 'springboot') authentication = authAudience === 'multi-client' ? 'oidc' : 'session'
190
+ else if (be.id === 'laravel') {
191
+ if (fe.id === 'laravel-ui') authentication = 'laravel-session'
192
+ else authentication = authAudience === 'website' && fe.platform === 'web' ? 'sanctum-spa' : 'laravel-oidc'
193
+ }
194
+ else throw new Error(`Authentication generation requires Supabase or Spring Boot for ${fe.label}`)
195
+ }
196
+
197
+ // ── Collect selected manifests ───────────────────────────────────────────
198
+ const selected = []
199
+ const include = (m) => { if (m && !selected.find((s) => s.id === m.id)) selected.push(m) }
200
+
201
+ for (const m of catalog.universal) include(m)
202
+ for (const m of (catalog.byKind['platform'] || [])) {
203
+ if ((m.appliesTo?.platform || []).includes(fe.platform || 'web')) include(m)
204
+ }
205
+ include(fe)
206
+ include(be)
207
+
208
+ // database + migration manifests that apply to the chosen backend
209
+ for (const m of (catalog.byKind['database'] || [])) {
210
+ if ((m.appliesTo?.backend || []).includes(be.id)) include(m)
211
+ }
212
+ for (const m of (catalog.byKind['migration'] || [])) {
213
+ if ((m.appliesTo?.backend || []).includes(be.id)) include(m)
214
+ }
215
+
216
+ // Styling manifests contribute their own folders, dependencies, and concerns.
217
+ const styleManifest = catalog.byId[styleId]
218
+ if (styleManifest) include(styleManifest)
219
+
220
+ // devops extras
221
+ if (answers.docker) include(catalog.byId['docker'])
222
+ if (answers.makefile) include(catalog.byId['makefile'])
223
+ if (answers.githubActions) {
224
+ include(catalog.byId['github-actions'])
225
+ include(catalog.byId['pr-template'])
226
+ }
227
+
228
+ // ── Merge manifest fields ────────────────────────────────────────────────
229
+ const selectionContext = {
230
+ frontend: fe.id,
231
+ backend: be.id,
232
+ platform: fe.platform || 'web',
233
+ architecture,
234
+ applicationShape,
235
+ authentication,
236
+ authenticationIntent,
237
+ testing: answers.testing || 'basic',
238
+ laravelUi: answers.laravelUi,
239
+ }
240
+ const playbooks = [...new Set(selected.flatMap((manifest) =>
241
+ (manifest._playbooks || []).filter((entry) =>
242
+ typeof entry === 'string' || matchesWhen(entry.appliesWhen, selectionContext)
243
+ ).map((entry) => typeof entry === 'string' ? entry : entry.file)
244
+ ).filter(Boolean))]
245
+ let folders = []
246
+ const deps = {}
247
+ const devDeps = {}
248
+ const dependencyKinds = new Map()
249
+ const rawEnv = [] // env var names without prefix
250
+ const clientEnv = new Set() // which vars get the fe prefix
251
+ const constraints = []
252
+ const concerns = []
253
+
254
+ for (const m of selected) {
255
+ folders = folders.concat(m.folders || [])
256
+ folders = folders.concat(m.foldersByArchitecture?.[architecture] || [])
257
+ for (const [field, target, kind] of [['deps', deps, 'dependency'], ['devDeps', devDeps, 'devDependency']]) {
258
+ const resolved = resolvePackages(m[field] || [], catalog.profile, `${m.id}.${field}`)
259
+ for (const [name, version] of Object.entries(resolved)) {
260
+ const existingKind = dependencyKinds.get(name)
261
+ if (existingKind && existingKind !== kind) {
262
+ throw new Error(`${name} is declared as both a dependency and devDependency`)
263
+ }
264
+ dependencyKinds.set(name, kind)
265
+ target[name] = version
266
+ }
267
+ }
268
+ if (m.env) rawEnv.push(...m.env)
269
+ if (m.clientEnv) m.clientEnv.forEach((v) => clientEnv.add(v))
270
+ if (m.constraints) constraints.push(...m.constraints)
271
+ if (m.concerns) {
272
+ for (const c of m.concerns) {
273
+ if (matchesWhen(c.appliesWhen, selectionContext)) concerns.push({ ...c, _playbook: m._playbook })
274
+ }
275
+ }
276
+ }
277
+
278
+ // Apply env prefix: clientEnv vars get fe.envPrefix, others stay as-is
279
+ const envPrefix = fe.envPrefix || ''
280
+ if (authentication === 'session') rawEnv.push('SPRING_SECURITY_USER_NAME', 'SPRING_SECURITY_USER_PASSWORD')
281
+ if (authentication === 'oidc') rawEnv.push('OIDC_ISSUER_URI', 'OIDC_AUDIENCE')
282
+ if (authentication === 'laravel-session') rawEnv.push('SESSION_DOMAIN')
283
+ if (authentication === 'sanctum-spa') rawEnv.push('SESSION_DOMAIN', 'SANCTUM_STATEFUL_DOMAINS', 'CORS_ALLOWED_ORIGINS')
284
+ if (authentication === 'laravel-oidc') rawEnv.push('AUTH0_DOMAIN', 'AUTH0_AUDIENCE')
285
+ const env = [...new Set(rawEnv)].map((v) =>
286
+ clientEnv.has(v) ? `${envPrefix}${v}` : v
287
+ )
288
+
289
+ // Resolve folder template tokens
290
+ const packagePath = (answers.packageName || 'com.app').replace(/\./g, '/')
291
+ const resolvedFolders = [...new Set(folders)].map((f) =>
292
+ f.replace(/\{\{PACKAGE_PATH\}\}/g, packagePath)
293
+ )
294
+
295
+ // ── Capability flags (read from manifest, not hardcoded) ─────────────────
296
+ return {
297
+ // Identity
298
+ key: `${fe.id}-${be.id}`,
299
+ frontendKey: fe.id,
300
+ backendKey: be.id,
301
+ label: `${fe.label} + ${be.label}`,
302
+ frontendLabel: fe.label,
303
+ backendLabel: be.label,
304
+
305
+ // Capabilities from manifest — no hardcoded isSomething checks
306
+ platform: fe.platform || 'web', // 'web' | 'mobile' | 'api'
307
+ ciTemplate: fe.ciTemplate || 'generic',
308
+ scripts: fe.scripts || {},
309
+ frontendDir: fe.frontendDir ?? '',
310
+ frontendPort: String(fe.port ?? ''),
311
+ backendPort: be.port ? String(be.port) : '',
312
+ envPrefix: fe.envPrefix || '',
313
+ needsDocker: be.needsDocker ?? fe.needsDocker ?? false,
314
+ profile: catalog.profile,
315
+ architecture,
316
+ applicationShape,
317
+ supportedArchitectures,
318
+ authenticationIntent,
319
+ authentication,
320
+ authAudience,
321
+ laravelUi: answers.laravelUi,
322
+ styleId,
323
+
324
+ // Template fields (per fix-template-identity spec)
325
+ makefileTemplate: fe.platform === 'mobile' ? null : (be.makefileTemplate || fe.makefileTemplate || null),
326
+ dockerTemplate: fe.dockerTemplate || null,
327
+ agentsTemplate: fe.agentsTemplate || null,
328
+ gitignoreTemplate: fe.gitignoreTemplate || null,
329
+
330
+ // Merged output
331
+ playbooks: resolvedFolders.length ? playbooks : playbooks,
332
+ folders: resolvedFolders,
333
+ constraints,
334
+ concerns,
335
+ deps,
336
+ devDeps,
337
+ env,
338
+
339
+ // Convenience flags — derived, not hardcoded per stack
340
+ isMobile: (fe.platform === 'mobile'),
341
+ needsPackage: (be.id === 'springboot'),
342
+ }
343
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'fs-extra'
2
+
3
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
4
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
5
+
6
+ export async function loadCompatibility(file, requestedProfile) {
7
+ const catalog = await fs.readJson(file)
8
+ validateCompatibility(catalog)
9
+ const profileId = requestedProfile || catalog.defaultProfile
10
+ const profile = catalog.profiles[profileId]
11
+ if (!profile) throw new Error(`Unknown compatibility profile: ${profileId}`)
12
+ return { catalog, profile: { id: profileId, ...profile } }
13
+ }
14
+
15
+ export function validateCompatibility(catalog) {
16
+ if (catalog?.schemaVersion !== 1) throw new Error('Unsupported compatibility catalog schema')
17
+ if (!catalog.profiles || typeof catalog.profiles !== 'object') throw new Error('Compatibility profiles are required')
18
+ if (!catalog.profiles[catalog.defaultProfile]) throw new Error('Default compatibility profile does not exist')
19
+
20
+ const statuses = new Map()
21
+ let packageNames
22
+ let composerPackageNames
23
+ for (const [id, profile] of Object.entries(catalog.profiles)) {
24
+ if (!/^\d{4}\.\d{2}$/.test(id)) throw new Error(`Invalid compatibility profile id: ${id}`)
25
+ if (!['current', 'previous'].includes(profile.status)) throw new Error(`Invalid status for profile ${id}`)
26
+ if (!ISO_DATE.test(profile.supportedUntil || '') || Number.isNaN(Date.parse(`${profile.supportedUntil}T00:00:00Z`))) {
27
+ throw new Error(`Profile ${id} needs a valid supportedUntil date`)
28
+ }
29
+ if (statuses.has(profile.status)) throw new Error(`Duplicate ${profile.status} compatibility profile`)
30
+ statuses.set(profile.status, id)
31
+ if (!EXACT_VERSION.test(profile.springBoot || '')) throw new Error(`Profile ${id} needs an exact Spring Boot version`)
32
+ if (!EXACT_VERSION.test(profile.springModulith || '')) throw new Error(`Profile ${id} needs an exact Spring Modulith version`)
33
+ for (const key of ['node', 'java', 'maven', 'postgres', 'php', 'composer']) {
34
+ if (!profile.runtimes?.[key]) throw new Error(`Profile ${id} is missing runtime ${key}`)
35
+ }
36
+ for (const key of ['node', 'maven', 'java', 'postgres', 'nginx', 'php', 'composer']) {
37
+ const image = profile.images?.[key]
38
+ if (!image?.repository || !image?.tag || image.tag === 'latest') {
39
+ throw new Error(`Profile ${id} has an invalid ${key} image`)
40
+ }
41
+ }
42
+ if (profile.images.node.repository !== 'node' || !profile.images.node.tag.startsWith(`${profile.runtimes.node}-`)) {
43
+ throw new Error(`Profile ${id} Node image must match runtime ${profile.runtimes.node}`)
44
+ }
45
+ if (profile.images.maven.repository !== 'maven' || !profile.images.maven.tag.startsWith(`${profile.runtimes.maven}-`)) {
46
+ throw new Error(`Profile ${id} Maven image must match runtime ${profile.runtimes.maven}`)
47
+ }
48
+ if (profile.images.java.repository !== 'eclipse-temurin' || !profile.images.java.tag.startsWith(`${profile.runtimes.java}-`)) {
49
+ throw new Error(`Profile ${id} Java image must match runtime ${profile.runtimes.java}`)
50
+ }
51
+ if (profile.images.postgres.repository !== 'postgres' || !profile.images.postgres.tag.startsWith(`${profile.runtimes.postgres}-`)) {
52
+ throw new Error(`Profile ${id} PostgreSQL image must match runtime ${profile.runtimes.postgres}`)
53
+ }
54
+ if (profile.images.php.repository !== 'php' || !profile.images.php.tag.startsWith(`${profile.runtimes.php}-`)) {
55
+ throw new Error(`Profile ${id} PHP image must match runtime ${profile.runtimes.php}`)
56
+ }
57
+ if (profile.images.composer.repository !== 'composer' || profile.images.composer.tag !== profile.runtimes.composer) {
58
+ throw new Error(`Profile ${id} Composer image must match runtime ${profile.runtimes.composer}`)
59
+ }
60
+ const names = Object.keys(profile.packages || {}).sort()
61
+ if (!names.length) throw new Error(`Profile ${id} has no package versions`)
62
+ for (const [name, value] of Object.entries(profile.packages)) {
63
+ const versions = typeof value === 'string'
64
+ ? [value]
65
+ : [value?.default, ...Object.values(value?.overrides || {})]
66
+ if (!versions.length || versions.some((version) => !EXACT_VERSION.test(version || ''))) {
67
+ throw new Error(`Profile ${id} package ${name} must use exact versions`)
68
+ }
69
+ }
70
+ if (packageNames && JSON.stringify(names) !== JSON.stringify(packageNames)) {
71
+ throw new Error(`Profile ${id} does not own the same package set as the current profile`)
72
+ }
73
+ packageNames = names
74
+ const composerNames = Object.keys(profile.composerPackages || {}).sort()
75
+ if (!composerNames.length) throw new Error(`Profile ${id} has no Composer package versions`)
76
+ for (const [name, version] of Object.entries(profile.composerPackages)) {
77
+ if (!EXACT_VERSION.test(version || '')) throw new Error(`Profile ${id} Composer package ${name} must use an exact version`)
78
+ }
79
+ if (composerPackageNames && JSON.stringify(composerNames) !== JSON.stringify(composerPackageNames)) {
80
+ throw new Error(`Profile ${id} does not own the same Composer package set as the current profile`)
81
+ }
82
+ composerPackageNames = composerNames
83
+ }
84
+ if (!statuses.has('current') || !statuses.has('previous')) {
85
+ throw new Error('Exactly one current and one previous compatibility profile are required')
86
+ }
87
+ if (statuses.get('current') !== catalog.defaultProfile) {
88
+ throw new Error('The default compatibility profile must have current status')
89
+ }
90
+ const previousId = statuses.get('previous')
91
+ if (catalog.profiles[previousId].supersededBy !== catalog.defaultProfile) {
92
+ throw new Error(`Previous profile ${previousId} must point to current profile ${catalog.defaultProfile}`)
93
+ }
94
+ }
95
+
96
+ export function resolvePackages(names, profile, owner) {
97
+ if (!Array.isArray(names)) throw new Error(`${owner} packages must be an array of package names`)
98
+ return Object.fromEntries(names.map((name) => {
99
+ const manifestId = owner.split('.')[0]
100
+ return [name, packageVersion(profile, name, manifestId, owner)]
101
+ }))
102
+ }
103
+
104
+ export function packageVersion(profile, name, capability, owner = capability) {
105
+ const value = profile.packages[name]
106
+ if (!value) throw new Error(`${owner} requires ${name}, missing from compatibility profile ${profile.id}`)
107
+ const version = typeof value === 'string' ? value : (value.overrides?.[capability] || value.default)
108
+ if (!version) throw new Error(`${owner} has no compatible version for ${name} in profile ${profile.id}`)
109
+ return version
110
+ }
111
+
112
+ export function composerPackageVersion(profile, name, owner = name) {
113
+ const version = profile.composerPackages?.[name]
114
+ if (!version) throw new Error(`${owner} requires ${name}, missing from compatibility profile ${profile.id}`)
115
+ return version
116
+ }
@@ -0,0 +1,3 @@
1
+ // Deprecated: stack composition now lives in playbooks/*.manifest.json + lib/catalog.js.
2
+ // Kept as an empty module so any stray import does not break resolution.
3
+ export {}
package/lib/doctor.js ADDED
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ function isOnPath(command) {
6
+ const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe', '.bat'] : ['']
7
+ return (process.env.PATH || '').split(path.delimiter).some((directory) =>
8
+ suffixes.some((suffix) => existsSync(path.join(directory, `${command}${suffix}`)))
9
+ )
10
+ }
11
+
12
+ function probe(command, args = ['--version']) {
13
+ const result = spawnSync(command, args, { encoding: 'utf8', shell: false })
14
+ const version = `${result.stdout || result.stderr || ''}`.trim().split('\n')[0]
15
+ if (!version) return isOnPath(command) ? 'available (version unavailable)' : null
16
+ if (result.status !== 0) return null
17
+ return version
18
+ }
19
+
20
+ export function collectDiagnostics(profile) {
21
+ const nodeVersion = process.versions.node
22
+ const npmVersion = probe('npm')
23
+ const dockerVersion = probe('docker')
24
+ const composeVersion = dockerVersion ? probe('docker', ['compose', 'version']) : null
25
+ const javaVersion = probe('java', ['-version'])
26
+ return [
27
+ { name: 'Node.js', found: nodeVersion, expected: profile.runtimes.node, required: false },
28
+ { name: 'npm', found: npmVersion, required: false },
29
+ { name: 'Docker', found: dockerVersion, required: false },
30
+ { name: 'Docker Compose', found: composeVersion, required: false },
31
+ { name: 'Java', found: javaVersion, expected: profile.runtimes.java, required: false },
32
+ ]
33
+ }
34
+
35
+ export function printDoctor(profile, output = console.log) {
36
+ output(`create-win-project doctor (profile ${profile.id})`)
37
+ output('Use either the Node/npm lane or the Docker/Compose lane; Java is only needed for host-run Spring projects.')
38
+ for (const item of collectDiagnostics(profile)) {
39
+ const expectation = item.expected ? ` (tested: ${item.expected})` : ''
40
+ output(`${item.found ? '✓' : '○'} ${item.name}: ${item.found || 'not found'}${expectation}`)
41
+ }
42
+ output('')
43
+ output('Node lane: npm ci && npm start')
44
+ output('Docker lane: docker compose build && docker compose run --rm app')
45
+ }