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.
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/ci/expo.yml +28 -0
- package/ci/laravel.yml +49 -0
- package/ci/nextjs.yml +29 -0
- package/ci/springboot.yml +33 -0
- package/ci/vite.yml +36 -0
- package/compatibility/profiles.json +237 -0
- package/docs/ARCHITECTURE.md +118 -0
- package/docs/CONTENT_MODEL.md +36 -0
- package/docs/DEPENDENCY_MAINTENANCE.md +31 -0
- package/index.js +431 -0
- package/lib/application-shapes.js +95 -0
- package/lib/banner.js +45 -0
- package/lib/catalog.js +343 -0
- package/lib/compatibility.js +116 -0
- package/lib/constants.js +3 -0
- package/lib/doctor.js +45 -0
- package/lib/files.js +158 -0
- package/lib/generator.js +366 -0
- package/lib/interview.js +77 -0
- package/lib/laravel-scaffold.js +3 -0
- package/lib/playbooks.js +172 -0
- package/lib/project-location.js +18 -0
- package/lib/scaffold.js +467 -0
- package/lib/stacks/context.js +27 -0
- package/lib/stacks/contract.js +142 -0
- package/lib/stacks/index.js +8 -0
- package/lib/stacks/laravel/architecture.js +27 -0
- package/lib/stacks/laravel/auth/oidc.js +13 -0
- package/lib/stacks/laravel/auth/public.js +28 -0
- package/lib/stacks/laravel/auth/sanctum.js +14 -0
- package/lib/stacks/laravel/auth/session.js +17 -0
- package/lib/stacks/laravel/composer.js +50 -0
- package/lib/stacks/laravel/generate.js +362 -0
- package/lib/stacks/laravel/index.js +18 -0
- package/lib/stacks/laravel/ui/blade.js +15 -0
- package/lib/stacks/laravel/ui/index.js +29 -0
- package/lib/stacks/laravel/ui/inertia-react.js +35 -0
- package/lib/stacks/laravel/ui/livewire.js +39 -0
- package/lib/stacks/laravel/ui/shared.js +14 -0
- package/lib/stacks/registry.js +42 -0
- package/lib/stacks/shared/contributions.js +25 -0
- package/lib/template.js +62 -0
- package/package.json +49 -0
- package/playbooks/INDEX.md +58 -0
- package/playbooks/capabilities/auth/oidc-resource-server.md +15 -0
- package/playbooks/capabilities/auth/spring-session.md +15 -0
- package/playbooks/capabilities/ci/github-actions.manifest.json +10 -0
- package/playbooks/capabilities/ci/github-actions.md +61 -0
- package/playbooks/capabilities/docker/docker.manifest.json +11 -0
- package/playbooks/capabilities/docker/overview.md +55 -0
- package/playbooks/capabilities/flyway/environments.md +5 -0
- package/playbooks/capabilities/flyway/flyway.manifest.json +12 -0
- package/playbooks/capabilities/flyway/migrations.md +7 -0
- package/playbooks/capabilities/flyway/testing.md +5 -0
- package/playbooks/capabilities/laravel/database.md +8 -0
- package/playbooks/capabilities/laravel/migrations.md +15 -0
- package/playbooks/capabilities/laravel/observability.md +8 -0
- package/playbooks/capabilities/laravel/oidc-resource-server.md +10 -0
- package/playbooks/capabilities/laravel/queues.md +8 -0
- package/playbooks/capabilities/laravel/sanctum-spa.md +8 -0
- package/playbooks/capabilities/laravel/scheduler.md +7 -0
- package/playbooks/capabilities/laravel/session-auth.md +8 -0
- package/playbooks/capabilities/laravel/storage-uploads.md +7 -0
- package/playbooks/capabilities/postgresql/architecture.md +5 -0
- package/playbooks/capabilities/postgresql/migrations.md +5 -0
- package/playbooks/capabilities/postgresql/postgresql.manifest.json +15 -0
- package/playbooks/capabilities/postgresql/schema-design.md +7 -0
- package/playbooks/capabilities/postgresql/security.md +5 -0
- package/playbooks/capabilities/postgresql/testing.md +5 -0
- package/playbooks/capabilities/prisma/architecture.md +5 -0
- package/playbooks/capabilities/prisma/migrations.md +11 -0
- package/playbooks/capabilities/prisma/prisma.manifest.json +14 -0
- package/playbooks/capabilities/prisma/runtime.md +5 -0
- package/playbooks/capabilities/prisma/schema.md +5 -0
- package/playbooks/capabilities/prisma/testing.md +5 -0
- package/playbooks/capabilities/supabase/architecture.md +5 -0
- package/playbooks/capabilities/supabase/authentication.md +5 -0
- package/playbooks/capabilities/supabase/expo.md +16 -0
- package/playbooks/capabilities/supabase/migrations.md +7 -0
- package/playbooks/capabilities/supabase/nextjs.md +24 -0
- package/playbooks/capabilities/supabase/rls.md +7 -0
- package/playbooks/capabilities/supabase/supabase.manifest.json +32 -0
- package/playbooks/capabilities/supabase/testing.md +5 -0
- package/playbooks/capabilities/supabase/vite.md +14 -0
- package/playbooks/concerns/axios.md +123 -0
- package/playbooks/concerns/next-safe-action.md +66 -0
- package/playbooks/concerns/next-themes.md +87 -0
- package/playbooks/concerns/nuqs.md +59 -0
- package/playbooks/concerns/t3-env.md +48 -0
- package/playbooks/concerns/tanstack-query.md +145 -0
- package/playbooks/concerns/zod.md +174 -0
- package/playbooks/concerns/zustand.md +93 -0
- package/playbooks/devops/makefile.manifest.json +10 -0
- package/playbooks/devops/makefile.md +556 -0
- package/playbooks/devops/pr-template.manifest.json +10 -0
- package/playbooks/devops/pr-template.md +106 -0
- package/playbooks/platform/laravel-ui/blade/architecture.md +5 -0
- package/playbooks/platform/laravel-ui/blade/runtime.md +5 -0
- package/playbooks/platform/laravel-ui/blade/security.md +5 -0
- package/playbooks/platform/laravel-ui/blade/structure.md +5 -0
- package/playbooks/platform/laravel-ui/blade/testing.md +5 -0
- package/playbooks/platform/laravel-ui/inertia-react/architecture.md +5 -0
- package/playbooks/platform/laravel-ui/inertia-react/runtime.md +5 -0
- package/playbooks/platform/laravel-ui/inertia-react/security.md +5 -0
- package/playbooks/platform/laravel-ui/inertia-react/structure.md +5 -0
- package/playbooks/platform/laravel-ui/inertia-react/testing.md +5 -0
- package/playbooks/platform/laravel-ui/livewire/architecture.md +5 -0
- package/playbooks/platform/laravel-ui/livewire/runtime.md +5 -0
- package/playbooks/platform/laravel-ui/livewire/security.md +5 -0
- package/playbooks/platform/laravel-ui/livewire/structure.md +5 -0
- package/playbooks/platform/laravel-ui/livewire/testing.md +5 -0
- package/playbooks/platform/mobile.manifest.json +17 -0
- package/playbooks/platform/mobile.md +15 -0
- package/playbooks/platform/web.manifest.json +17 -0
- package/playbooks/platform/web.md +17 -0
- package/playbooks/stack/expo/architecture.md +27 -0
- package/playbooks/stack/expo/runtime.md +14 -0
- package/playbooks/stack/expo/security.md +18 -0
- package/playbooks/stack/expo/structure.md +26 -0
- package/playbooks/stack/expo/testing.md +13 -0
- package/playbooks/stack/laravel/architecture.md +34 -0
- package/playbooks/stack/laravel/runtime.md +27 -0
- package/playbooks/stack/laravel/security.md +23 -0
- package/playbooks/stack/laravel/structure.md +29 -0
- package/playbooks/stack/laravel/testing.md +22 -0
- package/playbooks/stack/laravel-ui.manifest.json +42 -0
- package/playbooks/stack/laravel.manifest.json +35 -0
- package/playbooks/stack/nextjs/architecture.md +62 -0
- package/playbooks/stack/nextjs/runtime.md +31 -0
- package/playbooks/stack/nextjs/security.md +32 -0
- package/playbooks/stack/nextjs/structure.md +50 -0
- package/playbooks/stack/nextjs/testing.md +20 -0
- package/playbooks/stack/nextjs.manifest.json +49 -0
- package/playbooks/stack/no-frontend.manifest.json +25 -0
- package/playbooks/stack/none.manifest.json +21 -0
- package/playbooks/stack/react-native.manifest.json +49 -0
- package/playbooks/stack/react-vite/architecture.md +33 -0
- package/playbooks/stack/react-vite/runtime.md +13 -0
- package/playbooks/stack/react-vite/security.md +16 -0
- package/playbooks/stack/react-vite/structure.md +23 -0
- package/playbooks/stack/react-vite/testing.md +12 -0
- package/playbooks/stack/react-vite.manifest.json +46 -0
- package/playbooks/stack/springboot/architecture.md +40 -0
- package/playbooks/stack/springboot/runtime.md +28 -0
- package/playbooks/stack/springboot/security.md +28 -0
- package/playbooks/stack/springboot/structure.md +30 -0
- package/playbooks/stack/springboot/testing.md +22 -0
- package/playbooks/stack/springboot.manifest.json +38 -0
- package/playbooks/styling/css-modules-extensions.md +267 -0
- package/playbooks/styling/css-modules.manifest.json +11 -0
- package/playbooks/styling/native-styles.manifest.json +9 -0
- package/playbooks/styling/native-styles.md +29 -0
- package/playbooks/styling/tailwind-extensions.md +182 -0
- package/playbooks/styling/tailwind.manifest.json +11 -0
- package/playbooks/universal/accessibility.manifest.json +11 -0
- package/playbooks/universal/accessibility.md +45 -0
- package/playbooks/universal/coding-rules.manifest.json +12 -0
- package/playbooks/universal/coding-rules.md +281 -0
- package/playbooks/universal/error-handling.manifest.json +10 -0
- package/playbooks/universal/error-handling.md +21 -0
- package/playbooks/universal/git-conventions.manifest.json +10 -0
- package/playbooks/universal/git-conventions.md +186 -0
- package/playbooks/universal/observability.manifest.json +10 -0
- package/playbooks/universal/observability.md +29 -0
- package/playbooks/universal/security.manifest.json +11 -0
- package/playbooks/universal/security.md +30 -0
- package/playbooks/universal/typescript.manifest.json +11 -0
- package/playbooks/universal/typescript.md +272 -0
- package/scripts/compatibility-matrix.mjs +48 -0
- package/scripts/validate-content.mjs +76 -0
- package/scripts/verify-generated.mjs +153 -0
- package/templates/agents/nextjs.md +47 -0
- package/templates/agents/react-native.md +48 -0
- package/templates/agents/react-vite.md +48 -0
- package/templates/docker/compose/postgres.yml +29 -0
- package/templates/docker/compose/springboot.yml +69 -0
- package/templates/docker/compose/supabase.yml +18 -0
- package/templates/docker/compose-prod/springboot.yml +64 -0
- package/templates/docker/dockerfile/laravel-inertia.dev.dockerfile +21 -0
- package/templates/docker/dockerfile/laravel.dev.dockerfile +12 -0
- package/templates/docker/dockerfile/laravel.prod.dockerfile +13 -0
- package/templates/docker/dockerfile/nextjs.dev.dockerfile +6 -0
- package/templates/docker/dockerfile/nextjs.prod.dockerfile +18 -0
- package/templates/docker/dockerfile/springboot.dev.dockerfile +5 -0
- package/templates/docker/dockerfile/springboot.prod.dockerfile +10 -0
- package/templates/docker/dockerfile/vite.dev.dockerfile +6 -0
- package/templates/docker/dockerfile/vite.prod.dockerfile +12 -0
- package/templates/gitignore/base.gitignore +32 -0
- package/templates/gitignore/nextjs.gitignore +40 -0
- package/templates/gitignore/react-native.gitignore +43 -0
- package/templates/gitignore/react-vite.gitignore +39 -0
- package/templates/makefile/frontend.mk +31 -0
- package/templates/makefile/laravel.mk +47 -0
- package/templates/makefile/postgres.mk +40 -0
- package/templates/makefile/springboot.mk +125 -0
- 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
|
+
}
|
package/lib/constants.js
ADDED
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
|
+
}
|