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
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { packageVersion } from './compatibility.js'
|
|
3
|
+
|
|
4
|
+
function json(value) {
|
|
5
|
+
return `${JSON.stringify(value, null, 2)}\n`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function html(value) {
|
|
9
|
+
return String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function envLine(name, answers) {
|
|
13
|
+
const defaults = {
|
|
14
|
+
NEXT_PUBLIC_API_URL: 'http://localhost:8080',
|
|
15
|
+
VITE_API_URL: 'http://localhost:8080',
|
|
16
|
+
EXPO_PUBLIC_API_URL: 'http://localhost:8080',
|
|
17
|
+
DATABASE_URL: `jdbc:postgresql://localhost:5432/${answers.projectName.replaceAll('-', '_')}`,
|
|
18
|
+
POSTGRES_USER: 'postgres', POSTGRES_PASSWORD: 'change-me',
|
|
19
|
+
POSTGRES_DB: answers.projectName.replaceAll('-', '_'), SPRING_PROFILES_ACTIVE: 'dev',
|
|
20
|
+
SPRING_SECURITY_USER_NAME: 'developer', SPRING_SECURITY_USER_PASSWORD: 'change-me-before-production',
|
|
21
|
+
OIDC_ISSUER_URI: 'http://localhost:9090/realms/app', OIDC_AUDIENCE: 'api',
|
|
22
|
+
}
|
|
23
|
+
return `${name}=${defaults[name] || ''}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function envFiles(answers, stack) {
|
|
27
|
+
const header = '# Copy to .env or .env.local. Never commit real credentials.\n\n'
|
|
28
|
+
const publicNames = stack.env.filter((name) => name.startsWith(stack.envPrefix))
|
|
29
|
+
const serverNames = stack.env.filter((name) => !name.startsWith(stack.envPrefix))
|
|
30
|
+
const output = {}
|
|
31
|
+
if (stack.frontendKey === 'no-frontend') {
|
|
32
|
+
output['.env.example'] = `${header}${serverNames.map((name) => envLine(name, answers)).join('\n')}\n`
|
|
33
|
+
} else if (stack.frontendKey === 'react') {
|
|
34
|
+
output['frontend/.env.example'] = `${header}${publicNames.map((name) => envLine(name, answers)).join('\n')}\n`
|
|
35
|
+
if (stack.backendKey === 'springboot') output['.env.example'] = `${header}${serverNames.map((name) => envLine(name, answers)).join('\n')}\n`
|
|
36
|
+
} else if (stack.isMobile) {
|
|
37
|
+
const names = stack.backendKey === 'springboot' ? [...publicNames, ...serverNames] : publicNames
|
|
38
|
+
output['.env.example'] = `${header}${names.map((name) => envLine(name, answers)).join('\n')}\n`
|
|
39
|
+
} else {
|
|
40
|
+
const names = stack.backendKey === 'supabase'
|
|
41
|
+
? stack.env
|
|
42
|
+
: [...publicNames, ...serverNames]
|
|
43
|
+
output['.env.example'] = `${header}${names.map((name) => envLine(name, answers)).join('\n')}\n`
|
|
44
|
+
}
|
|
45
|
+
return output
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function projectReadme(answers, stack) {
|
|
49
|
+
if (stack.frontendKey === 'no-frontend') {
|
|
50
|
+
return `# ${answers.projectName}\n\n> ${answers.projectDescription}\n\nGenerated backend-only ${stack.backendLabel} application.\n\n## Start\n\n\`\`\`bash\ncp .env.example .env\ncd backend\n./mvnw spring-boot:run # use mvnw.cmd on Windows\n\`\`\`\n\n## Validate\n\n\`\`\`bash\ncd backend\n./mvnw --batch-mode test\n./mvnw --batch-mode package -DskipTests\n\`\`\`\n`
|
|
51
|
+
}
|
|
52
|
+
const root = stack.frontendKey === 'react' ? 'frontend/' : ''
|
|
53
|
+
const quality = stack.isMobile
|
|
54
|
+
? `npm run typecheck\n${answers.testing === 'none' ? '' : 'npm test -- --runInBand\n'}npm run build -- --platform web`
|
|
55
|
+
: `npm run lint\nnpm run typecheck\nnpm run test --if-present\nnpm run build`
|
|
56
|
+
const backend = stack.backendKey === 'springboot'
|
|
57
|
+
? `\nThe backend lives in \`backend/\`:\n\n\`\`\`bash\ncd backend\nmvn spring-boot:run\n# verify: curl http://localhost:8080/api/health\n\`\`\`\n`
|
|
58
|
+
: ''
|
|
59
|
+
return `# ${answers.projectName}\n\n> ${answers.projectDescription}\n\nGenerated with create-win-project. This repository includes a runnable application, tests, CI guidance, and task-routed agent playbooks.\n\n## Start\n\n\`\`\`bash\n${root ? `cd ${root}\n` : ''}cp .env.example .env.local 2>/dev/null || cp .env.example .env\nnpm install\nnpm run dev\n\`\`\`\n${backend}\n## Validate\n\n\`\`\`bash\n${root ? `cd ${root}\n` : ''}${quality}\n\`\`\`\n\nCommit the generated lockfile before enabling CI; CI intentionally uses \`npm ci\`.\n\n## Agent-assisted work\n\n1. Put product goals and boundaries in \`CONTEXT.md\`.\n2. Read \`AGENTS.md\` for commands and authority boundaries.\n3. Use \`RULES.md\` to open only the relevant playbook section.\n4. Treat tests and application behavior as the source of truth when prose drifts.\n\n## Important files\n\n- \`AGENTS.md\`: small always-on operating contract.\n- \`RULES.md\`: concern-to-playbook router.\n- \`CONTEXT.md\`: project-specific intent and decisions.\n- \`docs/\`: architecture, API, setup, and deployment documentation.\n`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function testingPackage(stack, level) {
|
|
63
|
+
const scripts = { ...stack.scripts, start: stack.frontendKey === 'nextjs' ? 'next start' : undefined }
|
|
64
|
+
const devDeps = { ...stack.devDeps }
|
|
65
|
+
|
|
66
|
+
if (level === 'none') delete scripts.test
|
|
67
|
+
if (level !== 'none') {
|
|
68
|
+
if (stack.isMobile) {
|
|
69
|
+
Object.assign(devDeps, {
|
|
70
|
+
jest: packageVersion(stack.profile, 'jest', 'react-native'),
|
|
71
|
+
'@types/jest': packageVersion(stack.profile, '@types/jest', 'react-native'),
|
|
72
|
+
'jest-expo': packageVersion(stack.profile, 'jest-expo', 'react-native'),
|
|
73
|
+
'@testing-library/react-native': packageVersion(stack.profile, '@testing-library/react-native', 'react-native'),
|
|
74
|
+
})
|
|
75
|
+
} else {
|
|
76
|
+
Object.assign(devDeps, {
|
|
77
|
+
vitest: packageVersion(stack.profile, 'vitest', stack.frontendKey),
|
|
78
|
+
jsdom: packageVersion(stack.profile, 'jsdom', stack.frontendKey),
|
|
79
|
+
'@testing-library/react': packageVersion(stack.profile, '@testing-library/react', stack.frontendKey),
|
|
80
|
+
'@testing-library/jest-dom': packageVersion(stack.profile, '@testing-library/jest-dom', stack.frontendKey),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (level === 'full' && !stack.isMobile) {
|
|
85
|
+
scripts['test:e2e'] = 'playwright test'
|
|
86
|
+
devDeps['@playwright/test'] = packageVersion(stack.profile, '@playwright/test', stack.frontendKey)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
scripts.typecheck = 'tsc --noEmit'
|
|
90
|
+
scripts.format = 'prettier --write .'
|
|
91
|
+
scripts['format:check'] = 'prettier --check .'
|
|
92
|
+
if (!stack.isMobile) scripts.lint = 'eslint .'
|
|
93
|
+
if (stack.architecture === 'large') scripts['check:boundaries'] = 'node scripts/check-boundaries.mjs'
|
|
94
|
+
if (stack.backendKey === 'supabase') {
|
|
95
|
+
const workdir = stack.frontendKey === 'react' ? ' --workdir ..' : ''
|
|
96
|
+
scripts['supabase:start'] = `supabase${workdir} start`
|
|
97
|
+
scripts['supabase:stop'] = `supabase${workdir} stop`
|
|
98
|
+
scripts['supabase:status'] = `supabase${workdir} status`
|
|
99
|
+
scripts['supabase:reset'] = `supabase${workdir} db reset`
|
|
100
|
+
scripts['supabase:test'] = `supabase${workdir} test db`
|
|
101
|
+
const typePath = stack.frontendKey === 'react' ? 'src/types/database.types.ts' : stack.isMobile ? 'types/database.types.ts' : 'src/types/database.types.ts'
|
|
102
|
+
scripts['supabase:types'] = `supabase${workdir} gen types typescript --local > ${typePath}`
|
|
103
|
+
}
|
|
104
|
+
const boundaryCheck = stack.architecture === 'large' ? ' && npm run check:boundaries' : ''
|
|
105
|
+
scripts.check = stack.isMobile
|
|
106
|
+
? `npm run format:check && npm run typecheck${level === 'none' ? '' : ' && npm test -- --runInBand'}${boundaryCheck}`
|
|
107
|
+
: `npm run format:check && npm run lint && npm run typecheck${level === 'none' ? '' : ' && npm test'}${boundaryCheck}`
|
|
108
|
+
Object.keys(scripts).forEach((key) => scripts[key] === undefined && delete scripts[key])
|
|
109
|
+
return { scripts, devDeps }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function packageFile(answers, stack) {
|
|
113
|
+
const { scripts, devDeps } = testingPackage(stack, answers.testing || 'basic')
|
|
114
|
+
if (stack.backendKey === 'postgres') {
|
|
115
|
+
scripts['prisma:generate'] = 'prisma generate'
|
|
116
|
+
scripts['db:migrate'] = 'prisma migrate dev'
|
|
117
|
+
scripts['db:deploy'] = 'prisma migrate deploy'
|
|
118
|
+
scripts['db:reset'] = 'prisma migrate reset --force'
|
|
119
|
+
scripts['db:studio'] = 'prisma studio'
|
|
120
|
+
scripts.dev = 'prisma generate && next dev'
|
|
121
|
+
scripts.build = 'prisma generate && next build'
|
|
122
|
+
scripts.typecheck = 'prisma generate && tsc --noEmit'
|
|
123
|
+
}
|
|
124
|
+
if (stack.styleId === 'tailwind') {
|
|
125
|
+
devDeps.tailwindcss = packageVersion(stack.profile, 'tailwindcss', stack.frontendKey)
|
|
126
|
+
if (stack.frontendKey === 'nextjs') devDeps['@tailwindcss/postcss'] = packageVersion(stack.profile, '@tailwindcss/postcss', 'nextjs')
|
|
127
|
+
if (stack.frontendKey === 'react') devDeps['@tailwindcss/vite'] = packageVersion(stack.profile, '@tailwindcss/vite', 'react')
|
|
128
|
+
}
|
|
129
|
+
const dependencies = { ...stack.deps }
|
|
130
|
+
if (stack.frontendKey !== 'nextjs') delete dependencies['@supabase/ssr']
|
|
131
|
+
if (stack.isMobile && stack.authentication === 'supabase') {
|
|
132
|
+
dependencies['expo-secure-store'] = packageVersion(stack.profile, 'expo-secure-store', 'react-native')
|
|
133
|
+
}
|
|
134
|
+
const packageJson = {
|
|
135
|
+
name: answers.projectName,
|
|
136
|
+
version: '0.1.0',
|
|
137
|
+
private: true,
|
|
138
|
+
type: 'module',
|
|
139
|
+
main: stack.isMobile ? 'expo-router/entry' : undefined,
|
|
140
|
+
scripts,
|
|
141
|
+
dependencies,
|
|
142
|
+
devDependencies: devDeps,
|
|
143
|
+
}
|
|
144
|
+
if (!packageJson.main) delete packageJson.main
|
|
145
|
+
return json(packageJson)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function boundaryScript(sourceRoot) {
|
|
149
|
+
return `/* eslint-disable no-undef -- node script runs outside linted frontend bundle */
|
|
150
|
+
import fs from 'node:fs'\nimport path from 'node:path'\n\nconst root = path.resolve(${JSON.stringify(sourceRoot)}, 'features')\nconst violations = []\nif (fs.existsSync(root)) {\n for (const feature of fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name)) {\n const featureRoot = path.join(root, feature)\n const visit = (directory) => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const file = path.join(directory, entry.name)\n if (entry.isDirectory()) visit(file)\n else if (/\\.[cm]?[jt]sx?$/.test(entry.name)) {\n const content = fs.readFileSync(file, 'utf8')\n for (const match of content.matchAll(/from\\s+['\"]@\\/features\\/([^/'\"]+)(\\/[^'\"]+)?['\"]/g)) {\n if (match[1] !== feature && match[2]) violations.push(\`${'${file}'} deep-imports feature ${'${match[1]}'}; import its public API instead\`)\n }\n }\n }\n }\n visit(featureRoot)\n }\n}\nif (violations.length) { console.error(violations.join('\\n')); process.exit(1) }\nconsole.log('Feature boundaries are valid.')\n`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function statusFeatureFiles(root, stack) {
|
|
154
|
+
if (stack.architecture === 'small') return {}
|
|
155
|
+
const prefix = root ? `${root}/` : ''
|
|
156
|
+
const files = {
|
|
157
|
+
[`${prefix}src/features/status/types.ts`]: "export interface StarterStatus { heading: string; profile: 'medium' | 'large' }\n",
|
|
158
|
+
[`${prefix}src/features/status/services/getStarterStatus.ts`]: `import type { StarterStatus } from '../types'\n\nexport function getStarterStatus(): StarterStatus {\n return { heading: 'Your starter is running', profile: '${stack.architecture}' }\n}\n`,
|
|
159
|
+
[`${prefix}src/features/status/components/StarterStatus.tsx`]: "import type { StarterStatus as Status } from '../types'\n\nexport function StarterStatus({ status }: { status: Status }) {\n return <><h1>{status.heading}</h1><p>Architecture: {status.profile}</p></>\n}\n",
|
|
160
|
+
}
|
|
161
|
+
if (stack.backendKey === 'springboot') {
|
|
162
|
+
const baseUrl = stack.frontendKey === 'nextjs' ? 'process.env.NEXT_PUBLIC_API_URL' : 'import.meta.env.VITE_API_URL'
|
|
163
|
+
files[`${prefix}src/features/status/api/getBackendStatus.ts`] = `export interface BackendStatus { status: string }\n\nexport async function getBackendStatus(signal?: AbortSignal): Promise<BackendStatus> {\n const response = await fetch(\`${'${' + baseUrl + '}'}/api/health\`, { signal })\n if (!response.ok) throw new Error(\`Backend health request failed (${'${response.status}'})\`)\n return response.json() as Promise<BackendStatus>\n}\n`
|
|
164
|
+
}
|
|
165
|
+
if (stack.architecture === 'large') {
|
|
166
|
+
files[`${prefix}src/features/status/index.ts`] = "export { StarterStatus } from './components/StarterStatus'\nexport { getStarterStatus } from './services/getStarterStatus'\nexport type { StarterStatus as StarterStatusModel } from './types'\n"
|
|
167
|
+
files[`${prefix}scripts/check-boundaries.mjs`] = boundaryScript(prefix ? 'src' : 'src')
|
|
168
|
+
}
|
|
169
|
+
return files
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function nativeStatusFeatureFiles(stack) {
|
|
173
|
+
if (stack.architecture === 'small') return {}
|
|
174
|
+
const files = {
|
|
175
|
+
'features/status/types.ts': "export interface StarterStatus { heading: string; profile: 'medium' | 'large' }\n",
|
|
176
|
+
'features/status/services/getStarterStatus.ts': `import type { StarterStatus } from '../types'\n\nexport function getStarterStatus(): StarterStatus {\n return { heading: 'Your starter is running', profile: '${stack.architecture}' }\n}\n`,
|
|
177
|
+
'features/status/components/StarterStatus.tsx': "import { Text } from 'react-native'\nimport type { StarterStatus as Status } from '../types'\n\nexport function StarterStatus({ status }: { status: Status }) {\n return <><Text accessibilityRole=\"header\">{status.heading}</Text><Text>Architecture: {status.profile}</Text></>\n}\n",
|
|
178
|
+
}
|
|
179
|
+
if (stack.backendKey === 'springboot') {
|
|
180
|
+
files['features/status/api/getBackendStatus.ts'] = "export interface BackendStatus { status: string }\n\nexport async function getBackendStatus(signal?: AbortSignal): Promise<BackendStatus> {\n const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/health`, { signal })\n if (!response.ok) throw new Error(`Backend health request failed (${response.status})`)\n return response.json() as Promise<BackendStatus>\n}\n"
|
|
181
|
+
}
|
|
182
|
+
if (stack.architecture === 'large') {
|
|
183
|
+
files['features/status/index.ts'] = "export { StarterStatus } from './components/StarterStatus'\nexport { getStarterStatus } from './services/getStarterStatus'\nexport type { StarterStatus as StarterStatusModel } from './types'\n"
|
|
184
|
+
files['scripts/check-boundaries.mjs'] = boundaryScript('.')
|
|
185
|
+
}
|
|
186
|
+
return files
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function supabaseProjectFiles(stack) {
|
|
190
|
+
if (stack.backendKey !== 'supabase') return {}
|
|
191
|
+
const authenticated = stack.authentication === 'supabase'
|
|
192
|
+
const policies = authenticated
|
|
193
|
+
? `grant select, insert, update, delete on table public.examples to authenticated;\n\ncreate policy "owners read examples" on public.examples for select to authenticated using ((select auth.uid()) = user_id);\ncreate policy "owners create examples" on public.examples for insert to authenticated with check ((select auth.uid()) = user_id);\ncreate policy "owners update examples" on public.examples for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);\ncreate policy "owners delete examples" on public.examples for delete to authenticated using ((select auth.uid()) = user_id);`
|
|
194
|
+
: stack.authentication === 'public'
|
|
195
|
+
? `grant select on table public.examples to anon, authenticated;\ncreate policy "public reads examples" on public.examples for select to anon, authenticated using (true);`
|
|
196
|
+
: '-- No grants or policies: authentication is intentionally undecided and access is fail-closed.'
|
|
197
|
+
return {
|
|
198
|
+
'supabase/config.toml': `project_id = "${stack.key}"\n\n[api]\nenabled = true\nport = 54321\nschemas = ["public", "graphql_public"]\nextra_search_path = ["public", "extensions"]\n\n[db]\nport = 54322\nmajor_version = 16\n\n[studio]\nenabled = true\nport = 54323\n`,
|
|
199
|
+
'supabase/migrations/00000000000000_create_examples.sql': `create table public.examples (\n id uuid primary key default gen_random_uuid(),\n user_id uuid references auth.users(id) on delete cascade,\n name text not null check (char_length(name) between 1 and 120),\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\nalter table public.examples enable row level security;\nrevoke all on table public.examples from anon, authenticated;\ncreate index examples_user_id_idx on public.examples (user_id);\n\n${policies}\n`,
|
|
200
|
+
'supabase/tests/examples_rls.test.sql': authenticated
|
|
201
|
+
? `begin;\nselect plan(5);\nselect ok((select relrowsecurity from pg_class where oid = 'public.examples'::regclass), 'examples has RLS enabled');\nselect ok((select count(*) >= 1 from pg_indexes where schemaname = 'public' and tablename = 'examples' and indexdef like '%user_id%'), 'RLS ownership column is indexed');\n\ninsert into auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at) values\n ('11111111-1111-1111-1111-111111111111', '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'owner@example.test', '', now(), now()),\n ('22222222-2222-2222-2222-222222222222', '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'other@example.test', '', now(), now());\ninsert into public.examples (user_id, name) values ('11111111-1111-1111-1111-111111111111', 'owned row');\n\nset local role authenticated;\nselect set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);\nselect results_eq('select name from public.examples order by name', $$values ('owned row'::text)$$, 'owner can read their row');\nselect set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);\nselect is((select count(*) from public.examples), 0::bigint, 'non-owner cannot read the row');\nreset role;\nset local role anon;\nselect is((select count(*) from public.examples), 0::bigint, 'anonymous caller cannot read the row');\nreset role;\n\nselect * from finish();\nrollback;\n`
|
|
202
|
+
: `begin;\nselect plan(2);\nselect ok((select relrowsecurity from pg_class where oid = 'public.examples'::regclass), 'examples has RLS enabled');\nselect ok((select count(*) >= 1 from pg_indexes where schemaname = 'public' and tablename = 'examples' and indexdef like '%user_id%'), 'RLS policy column is indexed');\nselect * from finish();\nrollback;\n`,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function testFiles(files, root, stack, level) {
|
|
207
|
+
if (level === 'none') return
|
|
208
|
+
if (stack.isMobile) {
|
|
209
|
+
files[path.join(root, 'jest.config.js')] = "export default { preset: 'jest-expo' }\n"
|
|
210
|
+
files[path.join(root, 'app/index.test.tsx')] = `import { render } from '@testing-library/react-native'\nimport HomeScreen from './index'\n\ntest('renders the starter heading', async () => {\n const view = await render(<HomeScreen />)\n expect(view.getByText('Your starter is running')).toBeTruthy()\n})\n`
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
files[path.join(root, 'vitest.config.ts')] = `import { fileURLToPath, URL } from 'node:url'\nimport { defineConfig } from 'vitest/config'\n\nexport default defineConfig({\n resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },\n test: { environment: 'jsdom', setupFiles: './src/test/setup.ts', exclude: ['e2e/**', 'node_modules/**'] },\n})\n`
|
|
215
|
+
files[path.join(root, 'src/test/setup.ts')] = "import '@testing-library/jest-dom/vitest'\n"
|
|
216
|
+
if (level === 'full') {
|
|
217
|
+
files[path.join(root, 'playwright.config.ts')] = `import { defineConfig, devices } from '@playwright/test'\n\nexport default defineConfig({\n testDir: './e2e',\n use: { baseURL: 'http://127.0.0.1:${stack.frontendPort}' },\n webServer: { command: 'npm run dev -- ${stack.frontendKey === 'nextjs' ? '--hostname' : '--host'} 127.0.0.1 --port ${stack.frontendPort}', url: 'http://127.0.0.1:${stack.frontendPort}', reuseExistingServer: true, timeout: 120_000 },\n projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],\n})\n`
|
|
218
|
+
files[path.join(root, 'e2e/home.spec.ts')] = `import { expect, test } from '@playwright/test'\n\ntest('loads the starter', async ({ page }) => {\n await page.goto('/')\n await expect(page.getByRole('heading', { name: 'Your starter is running' })).toBeVisible()\n})\n`
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function nextFiles(answers, stack) {
|
|
223
|
+
const files = {}
|
|
224
|
+
files['package.json'] = packageFile(answers, stack)
|
|
225
|
+
files['tsconfig.json'] = json({
|
|
226
|
+
compilerOptions: {
|
|
227
|
+
target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], allowJs: false,
|
|
228
|
+
skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true,
|
|
229
|
+
module: 'esnext', moduleResolution: 'bundler', resolveJsonModule: true,
|
|
230
|
+
isolatedModules: true, jsx: 'react-jsx', incremental: true,
|
|
231
|
+
plugins: [{ name: 'next' }], paths: { '@/*': ['./src/*'] },
|
|
232
|
+
},
|
|
233
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts', '.next/dev/types/**/*.ts'],
|
|
234
|
+
exclude: ['node_modules'],
|
|
235
|
+
})
|
|
236
|
+
files['next-env.d.ts'] = "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n"
|
|
237
|
+
files['eslint.config.mjs'] = "import { defineConfig, globalIgnores } from 'eslint/config'\nimport nextVitals from 'eslint-config-next/core-web-vitals'\nimport nextTs from 'eslint-config-next/typescript'\n\nexport default defineConfig([\n ...nextVitals,\n ...nextTs,\n globalIgnores(['.next/**', 'out/**', 'next-env.d.ts']),\n])\n"
|
|
238
|
+
files['next.config.ts'] = `import type { NextConfig } from 'next'\n\nconst securityHeaders = [\n { key: 'X-Content-Type-Options', value: 'nosniff' },\n { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },\n { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },\n { key: 'X-Frame-Options', value: 'DENY' },\n]\n\nconst nextConfig: NextConfig = {\n reactStrictMode: true,\n output: 'standalone',\n async headers() { return [{ source: '/(.*)', headers: securityHeaders }] },\n}\n\nexport default nextConfig\n`
|
|
239
|
+
files['postcss.config.mjs'] = stack.styleId === 'tailwind'
|
|
240
|
+
? "const config = { plugins: { '@tailwindcss/postcss': {} } }\nexport default config\n"
|
|
241
|
+
: "const config = { plugins: {} }\nexport default config\n"
|
|
242
|
+
files['src/app/globals.css'] = `${stack.styleId === 'tailwind' ? '@import "tailwindcss";\n\n' : ''}:root { color-scheme: light dark; font-family: system-ui, sans-serif; }\n* { box-sizing: border-box; }\nbody { margin: 0; min-height: 100vh; }\nmain { max-width: 48rem; margin: 0 auto; padding: 4rem 1.5rem; }\na { color: inherit; }\n`
|
|
243
|
+
files['src/app/layout.tsx'] = `import type { Metadata } from 'next'\nimport type { ReactNode } from 'react'\nimport './globals.css'\n\nexport const metadata: Metadata = { title: ${JSON.stringify(answers.projectName)}, description: ${JSON.stringify(answers.projectDescription)} }\n\nexport default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {\n return <html lang="en"><body>{children}</body></html>\n}\n`
|
|
244
|
+
files['src/app/page.tsx'] = stack.architecture === 'small'
|
|
245
|
+
? `export default function HomePage() {\n return (\n <main>\n <p>create-win-project</p>\n <h1>Your starter is running</h1>\n <p>{${JSON.stringify(answers.projectDescription)}}</p>\n <p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p>\n </main>\n )\n}\n`
|
|
246
|
+
: `${stack.architecture === 'large'
|
|
247
|
+
? "import { getStarterStatus, StarterStatus } from '@/features/status'"
|
|
248
|
+
: "import { StarterStatus } from '@/features/status/components/StarterStatus'\nimport { getStarterStatus } from '@/features/status/services/getStarterStatus'"}\n\nexport default function HomePage() {\n const status = getStarterStatus()\n return <main><p>create-win-project</p><StarterStatus status={status} /><p>{${JSON.stringify(answers.projectDescription)}}</p><p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p></main>\n}\n`
|
|
249
|
+
files['src/app/api/health/route.ts'] = "export function GET() {\n return Response.json({ status: 'ok' })\n}\n"
|
|
250
|
+
files['src/app/page.test.tsx'] = `import { expect, test } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport HomePage from './page'\n\ntest('renders the starter heading', () => {\n render(<HomePage />)\n expect(screen.getByRole('heading', { name: 'Your starter is running' })).toBeInTheDocument()\n})\n`
|
|
251
|
+
if ((answers.testing || 'basic') === 'none') delete files['src/app/page.test.tsx']
|
|
252
|
+
testFiles(files, '', stack, answers.testing || 'basic')
|
|
253
|
+
Object.assign(files, statusFeatureFiles('', stack))
|
|
254
|
+
if (stack.backendKey === 'supabase') Object.assign(files, supabaseWebFiles(true, stack.authentication === 'supabase'))
|
|
255
|
+
if (stack.backendKey === 'postgres') Object.assign(files, prismaFiles())
|
|
256
|
+
return files
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function supabaseWebFiles(isNext, withAuth = false) {
|
|
260
|
+
if (!isNext) return {
|
|
261
|
+
'src/lib/supabase.ts': `import { createClient } from '@supabase/supabase-js'\n\nconst url = import.meta.env.VITE_SUPABASE_URL\nconst key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY\nif (!url || !key) throw new Error('Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY')\nexport const supabase = createClient(url, key)\n`,
|
|
262
|
+
}
|
|
263
|
+
const files = {
|
|
264
|
+
'src/lib/supabase/client.ts': `import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n )\n}\n`,
|
|
265
|
+
'src/lib/supabase/server.ts': `import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\nexport async function createClient() {\n const store = await cookies()\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n { cookies: { getAll: () => store.getAll(), setAll: (values) => {\n try { values.forEach(({ name, value, options }) => store.set(name, value, options)) } catch { /* Proxy owns refresh writes. */ }\n } } },\n )\n}\n`,
|
|
266
|
+
}
|
|
267
|
+
if (withAuth) Object.assign(files, {
|
|
268
|
+
'src/lib/supabase/proxy.ts': `import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let response = NextResponse.next({ request })\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n { cookies: {\n getAll: () => request.cookies.getAll(),\n setAll: (values) => {\n values.forEach(({ name, value }) => request.cookies.set(name, value))\n response = NextResponse.next({ request })\n values.forEach(({ name, value, options }) => response.cookies.set(name, value, options))\n },\n } },\n )\n await supabase.auth.getClaims()\n return response\n}\n`,
|
|
269
|
+
'src/proxy.ts': `import type { NextRequest } from 'next/server'\nimport { updateSession } from '@/lib/supabase/proxy'\n\nexport async function proxy(request: NextRequest) { return updateSession(request) }\n\nexport const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'] }\n`,
|
|
270
|
+
'src/app/auth/callback/route.ts': `import { NextResponse, type NextRequest } from 'next/server'\nimport { createClient } from '@/lib/supabase/server'\n\nexport async function GET(request: NextRequest) {\n const code = request.nextUrl.searchParams.get('code')\n const destination = new URL('/', request.url)\n if (!code) return NextResponse.redirect(destination)\n const supabase = await createClient()\n const { error } = await supabase.auth.exchangeCodeForSession(code)\n if (error) destination.searchParams.set('authError', 'callback_failed')\n return NextResponse.redirect(destination)\n}\n`,
|
|
271
|
+
'src/app/login/actions.ts': `'use server'\n\nimport { redirect } from 'next/navigation'\nimport { createClient } from '@/lib/supabase/server'\n\nexport async function signIn(formData: FormData) {\n const email = String(formData.get('email') || '').trim()\n const password = String(formData.get('password') || '')\n if (!email || !password) redirect('/login?error=missing_credentials')\n const supabase = await createClient()\n const { error } = await supabase.auth.signInWithPassword({ email, password })\n if (error) redirect('/login?error=invalid_credentials')\n redirect('/')\n}\n\nexport async function signOut() {\n const supabase = await createClient()\n await supabase.auth.signOut()\n redirect('/login')\n}\n`,
|
|
272
|
+
'src/app/login/page.tsx': `import { signIn } from './actions'\n\nexport default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {\n const { error } = await searchParams\n return <main><h1>Sign in</h1>{error ? <p role="alert">Sign-in failed. Check your details and try again.</p> : null}<form action={signIn}><label>Email <input name="email" type="email" autoComplete="email" required /></label><label>Password <input name="password" type="password" autoComplete="current-password" required /></label><button type="submit">Sign in</button></form></main>\n}\n`,
|
|
273
|
+
})
|
|
274
|
+
return files
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function prismaFiles() {
|
|
278
|
+
return {
|
|
279
|
+
'prisma.config.ts': `import 'dotenv/config'\nimport { defineConfig, env } from 'prisma/config'\n\nexport default defineConfig({\n schema: 'prisma/schema.prisma',\n migrations: { path: 'prisma/migrations' },\n datasource: { url: env('DATABASE_URL') },\n})\n`,
|
|
280
|
+
'prisma/schema.prisma': `generator client {\n provider = "prisma-client"\n output = "../src/generated/prisma"\n}\n\ndatasource db {\n provider = "postgresql"\n}\n\nmodel Example {\n id String @id @default(uuid())\n createdAt DateTime @default(now()) @map("created_at")\n updatedAt DateTime @updatedAt @map("updated_at")\n\n @@map("examples")\n}\n`,
|
|
281
|
+
'src/lib/prisma.ts': `import { PrismaPg } from '@prisma/adapter-pg'\nimport { PrismaClient } from '@/generated/prisma/client'\n\nconst connectionString = process.env.DATABASE_URL\nif (!connectionString) throw new Error('DATABASE_URL is required')\nconst globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }\nexport const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter: new PrismaPg({ connectionString }) })\nif (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma\n`,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function viteFiles(answers, stack) {
|
|
286
|
+
const root = 'frontend'
|
|
287
|
+
const files = {}
|
|
288
|
+
files[`${root}/package.json`] = packageFile(answers, stack)
|
|
289
|
+
files[`${root}/tsconfig.json`] = json({ compilerOptions: { target: 'ES2022', useDefineForClassFields: true, lib: ['ES2022', 'DOM', 'DOM.Iterable'], allowJs: false, skipLibCheck: true, esModuleInterop: true, allowSyntheticDefaultImports: true, strict: true, forceConsistentCasingInFileNames: true, module: 'ESNext', moduleResolution: 'Bundler', resolveJsonModule: true, isolatedModules: true, noEmit: true, jsx: 'react-jsx', paths: { '@/*': ['./src/*'] } }, include: ['src', 'vite.config.ts', 'vitest.config.ts'] })
|
|
290
|
+
files[`${root}/index.html`] = `<!doctype html>\n<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="description" content="${html(answers.projectDescription)}" /><title>${html(answers.projectName)}</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`
|
|
291
|
+
files[`${root}/vite.config.ts`] = `import { fileURLToPath, URL } from 'node:url'\nimport { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n${stack.styleId === 'tailwind' ? "import tailwindcss from '@tailwindcss/vite'\n" : ''}\nexport default defineConfig({\n plugins: [react()${stack.styleId === 'tailwind' ? ', tailwindcss()' : ''}],\n resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },\n})\n`
|
|
292
|
+
files[`${root}/eslint.config.js`] = `import js from '@eslint/js'\nimport tseslint from 'typescript-eslint'\n\nexport default tseslint.config(\n { ignores: ['dist', 'coverage', 'playwright-report'] },\n js.configs.recommended,\n ...tseslint.configs.recommended,\n { files: ['**/*.{ts,tsx}'], languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } },\n)\n`
|
|
293
|
+
files[`${root}/src/vite-env.d.ts`] = "/// <reference types=\"vite/client\" />\n"
|
|
294
|
+
files[`${root}/src/main.tsx`] = `import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { App } from './App'\nimport './styles.css'\n\ncreateRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>)\n`
|
|
295
|
+
files[`${root}/src/App.tsx`] = stack.architecture === 'small'
|
|
296
|
+
? `export function App() {\n return <main><p>create-win-project</p><h1>Your starter is running</h1><p>{${JSON.stringify(answers.projectDescription)}}</p><p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p></main>\n}\n`
|
|
297
|
+
: `${stack.architecture === 'large'
|
|
298
|
+
? "import { getStarterStatus, StarterStatus } from '@/features/status'"
|
|
299
|
+
: "import { StarterStatus } from '@/features/status/components/StarterStatus'\nimport { getStarterStatus } from '@/features/status/services/getStarterStatus'"}\n\nexport function App() {\n const status = getStarterStatus()\n return <main><p>create-win-project</p><StarterStatus status={status} /><p>{${JSON.stringify(answers.projectDescription)}}</p><p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p></main>\n}\n`
|
|
300
|
+
files[`${root}/src/styles.css`] = `${stack.styleId === 'tailwind' ? '@import "tailwindcss";\n' : ''}:root { color-scheme: light dark; font-family: system-ui, sans-serif; }\n* { box-sizing: border-box; }\nbody { margin: 0; }\nmain { max-width: 48rem; margin: 0 auto; padding: 4rem 1.5rem; }\n`
|
|
301
|
+
if ((answers.testing || 'basic') !== 'none') files[`${root}/src/App.test.tsx`] = `import { expect, test } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { App } from './App'\n\ntest('renders the starter heading', () => {\n render(<App />)\n expect(screen.getByRole('heading', { name: 'Your starter is running' })).toBeInTheDocument()\n})\n`
|
|
302
|
+
testFiles(files, root, stack, answers.testing || 'basic')
|
|
303
|
+
Object.assign(files, statusFeatureFiles(root, stack))
|
|
304
|
+
if (stack.backendKey === 'supabase') {
|
|
305
|
+
for (const [name, value] of Object.entries(supabaseWebFiles(false, stack.authentication === 'supabase'))) files[`${root}/${name}`] = value
|
|
306
|
+
if (stack.authentication === 'supabase') {
|
|
307
|
+
files[`${root}/src/features/auth/services/auth.ts`] = `import { supabase } from '@/lib/supabase'\n\nexport async function signIn(email: string, password: string) {\n const { error } = await supabase.auth.signInWithPassword({ email, password })\n if (error) throw new Error('Unable to sign in with those credentials.')\n}\n\nexport async function signOut() {\n const { error } = await supabase.auth.signOut()\n if (error) throw new Error('Unable to sign out.')\n}\n`
|
|
308
|
+
files[`${root}/src/features/auth/components/AuthPanel.tsx`] = `import { useState, type FormEvent } from 'react'\nimport { signIn } from '../services/auth'\n\nexport function AuthPanel() {\n const [error, setError] = useState('')\n const [pending, setPending] = useState(false)\n async function submit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault()\n setPending(true)\n setError('')\n const data = new FormData(event.currentTarget)\n try { await signIn(String(data.get('email') || ''), String(data.get('password') || '')) }\n catch (reason) { setError(reason instanceof Error ? reason.message : 'Unable to sign in.') }\n finally { setPending(false) }\n }\n return <section aria-labelledby="login-heading"><h2 id="login-heading">Sign in</h2>{error ? <p role="alert">{error}</p> : null}<form onSubmit={submit}><label>Email <input name="email" type="email" autoComplete="email" required /></label><label>Password <input name="password" type="password" autoComplete="current-password" required /></label><button disabled={pending} type="submit">{pending ? 'Signing in…' : 'Sign in'}</button></form></section>\n}\n`
|
|
309
|
+
files[`${root}/src/App.tsx`] = `import { AuthPanel } from '@/features/auth/components/AuthPanel'\n${files[`${root}/src/App.tsx`].replace('<p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p>', '<p>Read <code>AGENTS.md</code> before your first agent-assisted change.</p><AuthPanel />')}`
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return files
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function nativeFiles(answers, stack) {
|
|
316
|
+
const files = {
|
|
317
|
+
'package.json': packageFile(answers, stack),
|
|
318
|
+
'app.json': json({ expo: { name: answers.projectName, slug: answers.projectName, version: '1.0.0', orientation: 'portrait', scheme: answers.projectName, userInterfaceStyle: 'automatic', plugins: stack.authentication === 'supabase' ? ['expo-router', 'expo-secure-store'] : ['expo-router'], experiments: { typedRoutes: true } } }),
|
|
319
|
+
'tsconfig.json': json({ extends: 'expo/tsconfig.base', compilerOptions: { strict: true, types: ['jest'], paths: { '@/*': ['./*'] } }, include: ['**/*.ts', '**/*.tsx', '.expo/types/**/*.ts', 'expo-env.d.ts'] }),
|
|
320
|
+
'expo-env.d.ts': "/// <reference types=\"expo/types\" />\n",
|
|
321
|
+
'app/_layout.tsx': stack.authentication === 'supabase'
|
|
322
|
+
? `import { Stack } from 'expo-router'\nimport { useEffect } from 'react'\nimport { bindSupabaseAuthLifecycle } from '@/lib/supabase-lifecycle'\n\nexport default function RootLayout() {\n useEffect(() => bindSupabaseAuthLifecycle(), [])\n return <Stack screenOptions={{ headerTitle: '${answers.projectName}' }} />\n}\n`
|
|
323
|
+
: `import { Stack } from 'expo-router'\n\nexport default function RootLayout() { return <Stack screenOptions={{ headerTitle: '${answers.projectName}' }} /> }\n`,
|
|
324
|
+
'app/index.tsx': stack.architecture === 'small'
|
|
325
|
+
? `import { StyleSheet, Text, View } from 'react-native'\nimport { SafeAreaView } from 'react-native-safe-area-context'\n\nexport default function HomeScreen() {\n return <SafeAreaView style={styles.safe}><View style={styles.container}><Text>create-win-project</Text><Text accessibilityRole="header">Your starter is running</Text><Text>{${JSON.stringify(answers.projectDescription)}}</Text></View></SafeAreaView>\n}\nconst styles = StyleSheet.create({ safe: { flex: 1 }, container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 } })\n`
|
|
326
|
+
: `import { StyleSheet, Text, View } from 'react-native'\nimport { SafeAreaView } from 'react-native-safe-area-context'\n${stack.architecture === 'large'
|
|
327
|
+
? "import { getStarterStatus, StarterStatus } from '@/features/status'"
|
|
328
|
+
: "import { StarterStatus } from '@/features/status/components/StarterStatus'\nimport { getStarterStatus } from '@/features/status/services/getStarterStatus'"}\n\nexport default function HomeScreen() {\n const status = getStarterStatus()\n return <SafeAreaView style={styles.safe}><View style={styles.container}><Text>create-win-project</Text><StarterStatus status={status} /><Text>{${JSON.stringify(answers.projectDescription)}}</Text></View></SafeAreaView>\n}\nconst styles = StyleSheet.create({ safe: { flex: 1 }, container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 } })\n`,
|
|
329
|
+
}
|
|
330
|
+
testFiles(files, '', stack, answers.testing || 'basic')
|
|
331
|
+
Object.assign(files, nativeStatusFeatureFiles(stack))
|
|
332
|
+
if (stack.backendKey === 'supabase') {
|
|
333
|
+
files['lib/supabase.ts'] = stack.authentication === 'supabase'
|
|
334
|
+
? `import { createClient } from '@supabase/supabase-js'\nimport * as SecureStore from 'expo-secure-store'\n\nconst storage = { getItem: SecureStore.getItemAsync, setItem: SecureStore.setItemAsync, removeItem: SecureStore.deleteItemAsync }\nexport const supabase = createClient(process.env.EXPO_PUBLIC_SUPABASE_URL!, process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { auth: { storage, persistSession: true, autoRefreshToken: true, detectSessionInUrl: false } })\n`
|
|
335
|
+
: `import { createClient } from '@supabase/supabase-js'\n\nexport const supabase = createClient(process.env.EXPO_PUBLIC_SUPABASE_URL!, process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } })\n`
|
|
336
|
+
if (stack.authentication === 'supabase') {
|
|
337
|
+
files['lib/supabase-lifecycle.ts'] = `import { AppState, type AppStateStatus } from 'react-native'\nimport { supabase } from './supabase'\n\nexport function bindSupabaseAuthLifecycle() {\n const update = (state: AppStateStatus) => {\n if (state === 'active') supabase.auth.startAutoRefresh()\n else supabase.auth.stopAutoRefresh()\n }\n update(AppState.currentState)\n const subscription = AppState.addEventListener('change', update)\n return () => { subscription.remove(); supabase.auth.stopAutoRefresh() }\n}\n`
|
|
338
|
+
files['app/login.tsx'] = `import { useState } from 'react'\nimport { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'\nimport { supabase } from '@/lib/supabase'\n\nexport default function LoginScreen() {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState('')\n const [pending, setPending] = useState(false)\n async function signIn() {\n setPending(true); setError('')\n const result = await supabase.auth.signInWithPassword({ email: email.trim(), password })\n if (result.error) setError('Unable to sign in with those credentials.')\n setPending(false)\n }\n return <View style={styles.container}><Text accessibilityRole="header">Sign in</Text>{error ? <Text accessibilityRole="alert">{error}</Text> : null}<TextInput accessibilityLabel="Email" autoCapitalize="none" autoComplete="email" keyboardType="email-address" onChangeText={setEmail} value={email} /><TextInput accessibilityLabel="Password" autoComplete="current-password" onChangeText={setPassword} secureTextEntry value={password} /><Pressable accessibilityRole="button" disabled={pending} onPress={signIn}><Text>{pending ? 'Signing in…' : 'Sign in'}</Text></Pressable></View>\n}\nconst styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 } })\n`
|
|
339
|
+
if ((answers.testing || 'basic') !== 'none') files['lib/supabase-lifecycle.test.ts'] = `import { AppState } from 'react-native'\nimport { bindSupabaseAuthLifecycle } from './supabase-lifecycle'\nimport { supabase } from './supabase'\n\njest.mock('./supabase', () => ({ supabase: { auth: { startAutoRefresh: jest.fn(), stopAutoRefresh: jest.fn() } } }))\n\ntest('starts refresh while active and stops it on cleanup', () => {\n const remove = jest.fn()\n jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove } as never)\n Object.defineProperty(AppState, 'currentState', { configurable: true, value: 'active' })\n const cleanup = bindSupabaseAuthLifecycle()\n expect(supabase.auth.startAutoRefresh).toHaveBeenCalled()\n cleanup()\n expect(remove).toHaveBeenCalled()\n expect(supabase.auth.stopAutoRefresh).toHaveBeenCalled()\n})\n`
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return files
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function springFiles(answers, vars, stack) {
|
|
346
|
+
const pkg = vars.PACKAGE_NAME
|
|
347
|
+
const pkgPath = vars.PACKAGE_PATH
|
|
348
|
+
const appName = `${answers.projectName.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')}Application`
|
|
349
|
+
const isLarge = stack.architecture === 'large'
|
|
350
|
+
const withTests = (answers.testing || 'basic') !== 'none'
|
|
351
|
+
const withFullTests = answers.testing === 'full'
|
|
352
|
+
const healthWebMvcTest = stack.authentication === 'oidc'
|
|
353
|
+
? '@WebMvcTest(controllers = HealthController.class, properties = "spring.autoconfigure.exclude=org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration")'
|
|
354
|
+
: '@WebMvcTest(HealthController.class)'
|
|
355
|
+
const integrationSecurityImports = stack.authentication === 'oidc'
|
|
356
|
+
? `\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.doThrow;\nimport org.springframework.security.oauth2.jwt.JwtDecoder;\nimport org.springframework.security.oauth2.jwt.BadJwtException;\nimport org.springframework.security.oauth2.jwt.JwtException;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;`
|
|
357
|
+
: stack.authentication === 'session'
|
|
358
|
+
? `\nimport static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;\nimport static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;`
|
|
359
|
+
: ''
|
|
360
|
+
const integrationSecurityFields = stack.authentication === 'oidc'
|
|
361
|
+
? '\n @MockitoBean JwtDecoder jwtDecoder;'
|
|
362
|
+
: ''
|
|
363
|
+
const integrationSecurityTests = stack.authentication === 'oidc'
|
|
364
|
+
? `\n @Test void protectedRoutesRejectMissingBearerToken() throws Exception { mvc.perform(get("/api/private-probe")).andExpect(status().isUnauthorized()); }\n @Test void invalidBearerTokenIsRejected() throws Exception { doThrow(new BadJwtException("invalid")).when(jwtDecoder).decode(anyString()); mvc.perform(get("/api/private-probe").header("Authorization", "Bearer invalid")).andExpect(status().isUnauthorized()); }`
|
|
365
|
+
: stack.authentication === 'session'
|
|
366
|
+
? `\n @Test void protectedRoutesRequireASession() throws Exception { mvc.perform(get("/api/private-probe")).andExpect(status().is3xxRedirection()); }\n @Test void authenticatedRequestsReachTheApplication() throws Exception { mvc.perform(get("/api/private-probe").with(user("test"))).andExpect(status().isNotFound()); }\n @Test void logoutRequiresCsrf() throws Exception { mvc.perform(post("/api/auth/logout").with(user("test"))).andExpect(status().isForbidden()); mvc.perform(post("/api/auth/logout").with(user("test")).with(csrf())).andExpect(status().is3xxRedirection()); }`
|
|
367
|
+
: stack.authentication === 'undecided'
|
|
368
|
+
? `\n @Test void undecidedAuthenticationFailsClosed() throws Exception { mvc.perform(get("/api/private-probe")).andExpect(status().isForbidden()); }`
|
|
369
|
+
: `\n @Test void publicApplicationsDoNotInventAuthentication() throws Exception { mvc.perform(get("/api/private-probe")).andExpect(status().isNotFound()); }`
|
|
370
|
+
const securityDependency = stack.authentication === 'oidc'
|
|
371
|
+
? '\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency>'
|
|
372
|
+
: ''
|
|
373
|
+
const modulithManagement = isLarge
|
|
374
|
+
? `\n <dependencyManagement><dependencies><dependency><groupId>org.springframework.modulith</groupId><artifactId>spring-modulith-bom</artifactId><version>${vars.SPRING_MODULITH_VERSION}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>`
|
|
375
|
+
: ''
|
|
376
|
+
const modulithDependency = isLarge && withTests
|
|
377
|
+
? '\n <dependency><groupId>org.springframework.modulith</groupId><artifactId>spring-modulith-starter-test</artifactId><scope>test</scope></dependency>'
|
|
378
|
+
: ''
|
|
379
|
+
const testDependencies = withTests
|
|
380
|
+
? `\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>\n <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>${modulithDependency}${withFullTests ? `\n <dependency><groupId>org.testcontainers</groupId><artifactId>junit-jupiter</artifactId><version>${vars.TESTCONTAINERS_VERSION}</version><scope>test</scope></dependency>\n <dependency><groupId>org.testcontainers</groupId><artifactId>postgresql</artifactId><version>${vars.TESTCONTAINERS_VERSION}</version><scope>test</scope></dependency>` : ''}`
|
|
381
|
+
: ''
|
|
382
|
+
const excludeDefaultUser = stack.authentication === 'session' ? '' : '\nimport org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration;'
|
|
383
|
+
const applicationAnnotation = stack.authentication === 'session'
|
|
384
|
+
? '@SpringBootApplication'
|
|
385
|
+
: '@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)'
|
|
386
|
+
const accessRule = stack.authentication === 'public'
|
|
387
|
+
? '.anyRequest().permitAll()'
|
|
388
|
+
: stack.authentication === 'undecided' ? '.anyRequest().denyAll()' : '.anyRequest().authenticated()'
|
|
389
|
+
const authConfig = stack.authentication === 'session'
|
|
390
|
+
? '\n .formLogin(Customizer.withDefaults())\n .logout(logout -> logout.logoutUrl("/api/auth/logout").invalidateHttpSession(true).deleteCookies("JSESSIONID"))'
|
|
391
|
+
: stack.authentication === 'oidc'
|
|
392
|
+
? '\n .csrf(csrf -> csrf.disable())\n .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))\n .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))'
|
|
393
|
+
: stack.authentication === 'public' ? '\n .csrf(csrf -> csrf.disable())' : ''
|
|
394
|
+
const securityImports = `${['session', 'oidc'].includes(stack.authentication) ? '\nimport org.springframework.security.config.Customizer;' : ''}${stack.authentication === 'oidc' ? '\nimport org.springframework.security.config.http.SessionCreationPolicy;' : ''}`
|
|
395
|
+
const files = {
|
|
396
|
+
'backend/mvnw': `#!/bin/sh\nset -eu\nif command -v mvn >/dev/null 2>&1; then exec mvn "$@"; fi\nif command -v docker >/dev/null 2>&1; then exec docker run --rm -v "$PWD:/workspace" -w /workspace ${vars.MAVEN_IMAGE} mvn "$@"; fi\necho "Maven is unavailable. Install Maven ${vars.MAVEN_VERSION} or Docker." >&2\nexit 1\n`,
|
|
397
|
+
'backend/mvnw.cmd': `@echo off\r\nwhere mvn >nul 2>nul\r\nif %errorlevel% equ 0 (mvn %* & exit /b %errorlevel%)\r\nwhere docker >nul 2>nul\r\nif %errorlevel% equ 0 (docker run --rm -v "%cd%:/workspace" -w /workspace ${vars.MAVEN_IMAGE} mvn %* & exit /b %errorlevel%)\r\necho Maven is unavailable. Install Maven ${vars.MAVEN_VERSION} or Docker. 1>&2\r\nexit /b 1\r\n`,
|
|
398
|
+
'backend/pom.xml': `<?xml version="1.0" encoding="UTF-8"?>\n<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">\n <modelVersion>4.0.0</modelVersion>\n <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>${vars.SPRING_BOOT_VERSION}</version><relativePath/></parent>\n <groupId>${pkg}</groupId><artifactId>${answers.projectName}</artifactId><version>0.0.1-SNAPSHOT</version>\n <properties><java.version>${vars.JAVA_VERSION}</java.version></properties>${modulithManagement}\n <dependencies>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>\n <dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>\n <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>${securityDependency}${testDependencies}\n </dependencies>\n <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>\n</project>\n`,
|
|
399
|
+
[`backend/src/main/java/${pkgPath}/${appName}.java`]: `package ${pkg};\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;${excludeDefaultUser}\n\n${applicationAnnotation}\npublic class ${appName} {\n public static void main(String[] args) { SpringApplication.run(${appName}.class, args); }\n}\n`,
|
|
400
|
+
[`backend/src/main/java/${pkgPath}/health/HealthController.java`]: `package ${pkg}.health;\n\nimport java.util.Map;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\npublic class HealthController {\n @GetMapping("/api/health")\n Map<String, String> health() { return Map.of("status", "ok"); }\n}\n`,
|
|
401
|
+
[`backend/src/main/java/${pkgPath}/config/SecurityConfig.java`]: `package ${pkg}.config;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;${securityImports}\nimport org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.web.SecurityFilterChain;\n\n@Configuration\n@EnableMethodSecurity\npublic class SecurityConfig {\n @Bean\n SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {\n return http\n .authorizeHttpRequests(auth -> auth\n .requestMatchers("/api/health", "/actuator/health").permitAll()\n ${accessRule}\n )${authConfig}\n .build();\n }\n}\n`,
|
|
402
|
+
[`backend/src/main/java/${pkgPath}/common/error/ErrorCode.java`]: `package ${pkg}.common.error;\n\npublic enum ErrorCode { RESOURCE_NOT_FOUND, CONFLICT, OPERATION_FORBIDDEN }\n`,
|
|
403
|
+
[`backend/src/main/java/${pkgPath}/common/error/AppException.java`]: `package ${pkg}.common.error;\n\npublic final class AppException extends RuntimeException {\n private final ErrorCode code;\n public AppException(ErrorCode code, String message) { super(message); this.code = code; }\n public ErrorCode code() { return code; }\n}\n`,
|
|
404
|
+
[`backend/src/main/java/${pkgPath}/common/error/ApiExceptionHandler.java`]: `package ${pkg}.common.error;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ProblemDetail;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\n\n@RestControllerAdvice\npublic class ApiExceptionHandler {\n @ExceptionHandler(AppException.class)\n ProblemDetail handle(AppException exception, HttpServletRequest request) {\n HttpStatus status = switch (exception.code()) {\n case RESOURCE_NOT_FOUND -> HttpStatus.NOT_FOUND;\n case CONFLICT -> HttpStatus.CONFLICT;\n case OPERATION_FORBIDDEN -> HttpStatus.FORBIDDEN;\n };\n ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, exception.getMessage());\n problem.setTitle(status.getReasonPhrase());\n problem.setProperty("code", exception.code().name());\n return problem;\n }\n}\n`,
|
|
405
|
+
'backend/src/main/resources/application.yml': `spring:\n application:\n name: ${answers.projectName}\n datasource:\n url: \${DATABASE_URL:jdbc:postgresql://localhost:5432/${answers.projectName.replaceAll('-', '_')}}\n username: \${POSTGRES_USER:postgres}\n password: \${POSTGRES_PASSWORD:postgres}\n jpa:\n open-in-view: false\n hibernate:\n ddl-auto: validate\n flyway:\n enabled: true\nmanagement:\n endpoints:\n web:\n exposure:\n include: health,info\n endpoint:\n health:\n probes:\n enabled: true\nserver:\n error:\n include-message: never\n`,
|
|
406
|
+
'backend/src/main/resources/db/migration/V1__baseline.sql': 'CREATE TABLE examples (\n id UUID PRIMARY KEY,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n',
|
|
407
|
+
}
|
|
408
|
+
if (stack.authentication === 'oidc') {
|
|
409
|
+
files['backend/src/main/resources/application.yml'] += '\nspring.security.oauth2.resourceserver.jwt.issuer-uri: ${OIDC_ISSUER_URI:http://localhost:9090/realms/app}\nspring.security.oauth2.resourceserver.jwt.audiences: ${OIDC_AUDIENCE:api}\n'
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (stack.architecture !== 'small') {
|
|
413
|
+
if (isLarge) {
|
|
414
|
+
files[`backend/src/main/java/${pkgPath}/system/SystemStatus.java`] = `package ${pkg}.system;\n\npublic interface SystemStatus { Status current(); record Status(String status, String architecture) {} }\n`
|
|
415
|
+
files[`backend/src/main/java/${pkgPath}/system/internal/SystemStatusService.java`] = `package ${pkg}.system.internal;\n\nimport org.springframework.stereotype.Service;\nimport ${pkg}.system.SystemStatus;\n\n@Service\nclass SystemStatusService implements SystemStatus {\n public Status current() { return new Status("ok", "large"); }\n}\n`
|
|
416
|
+
files[`backend/src/main/java/${pkgPath}/system/internal/SystemStatusController.java`] = `package ${pkg}.system.internal;\n\nimport ${pkg}.system.SystemStatus;\nimport ${pkg}.system.SystemStatus.Status;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\n@RequestMapping("/api/status")\nclass SystemStatusController {\n private final SystemStatus status;\n SystemStatusController(SystemStatus status) { this.status = status; }\n @GetMapping Status current() { return status.current(); }\n}\n`
|
|
417
|
+
} else {
|
|
418
|
+
files[`backend/src/main/java/${pkgPath}/system/api/SystemStatusResponse.java`] = `package ${pkg}.system.api;\n\npublic record SystemStatusResponse(String status, String architecture) {}\n`
|
|
419
|
+
files[`backend/src/main/java/${pkgPath}/system/service/SystemStatusService.java`] = `package ${pkg}.system.service;\n\nimport ${pkg}.system.api.SystemStatusResponse;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class SystemStatusService {\n public SystemStatusResponse current() { return new SystemStatusResponse("ok", "medium"); }\n}\n`
|
|
420
|
+
files[`backend/src/main/java/${pkgPath}/system/api/SystemStatusController.java`] = `package ${pkg}.system.api;\n\nimport ${pkg}.system.service.SystemStatusService;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\n@RequestMapping("/api/status")\npublic class SystemStatusController {\n private final SystemStatusService service;\n public SystemStatusController(SystemStatusService service) { this.service = service; }\n @GetMapping SystemStatusResponse current() { return service.current(); }\n}\n`
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (withTests) {
|
|
425
|
+
files[`backend/src/test/java/${pkgPath}/health/HealthControllerTest.java`] = `package ${pkg}.health;\n\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.web.servlet.MockMvc;\n\n${healthWebMvcTest}\n@AutoConfigureMockMvc(addFilters = false)\nclass HealthControllerTest {\n @Autowired MockMvc mvc;\n @Test void healthContractIsStable() throws Exception { mvc.perform(get("/api/health")).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("ok")); }\n}\n`
|
|
426
|
+
if (isLarge) {
|
|
427
|
+
files[`backend/src/test/java/${pkgPath}/ArchitectureTest.java`] = `package ${pkg};\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.modulith.core.ApplicationModules;\n\nclass ArchitectureTest {\n @Test void modulesRespectTheirPublicApis() { ApplicationModules.of(${appName}.class).verify(); }\n}\n`
|
|
428
|
+
}
|
|
429
|
+
if (withFullTests) {
|
|
430
|
+
files[`backend/src/test/java/${pkgPath}/PostgresIntegrationTest.java`] = `package ${pkg};\n\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;${integrationSecurityImports}\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.test.context.DynamicPropertyRegistry;\nimport org.springframework.test.context.DynamicPropertySource;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.testcontainers.containers.PostgreSQLContainer;\nimport org.testcontainers.junit.jupiter.Container;\nimport org.testcontainers.junit.jupiter.Testcontainers;\n\n@SpringBootTest\n@AutoConfigureMockMvc\n@Testcontainers\nclass PostgresIntegrationTest {\n @Container static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("${vars.POSTGRES_IMAGE}");\n @Autowired MockMvc mvc;${integrationSecurityFields}\n @DynamicPropertySource static void database(DynamicPropertyRegistry registry) {\n registry.add("spring.datasource.url", postgres::getJdbcUrl);\n registry.add("spring.datasource.username", postgres::getUsername);\n registry.add("spring.datasource.password", postgres::getPassword);\n }\n @Test void migrationsAndPostgresContextStart() {}${integrationSecurityTests}\n}\n`
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return files
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function buildRunnableFiles(answers, stack, vars) {
|
|
437
|
+
let files
|
|
438
|
+
if (stack.frontendKey === 'no-frontend' || stack.frontendKey === 'laravel-ui') files = {}
|
|
439
|
+
else if (stack.frontendKey === 'nextjs') files = nextFiles(answers, stack)
|
|
440
|
+
else if (stack.frontendKey === 'react') files = viteFiles(answers, stack)
|
|
441
|
+
else files = nativeFiles(answers, stack)
|
|
442
|
+
if (stack.backendKey === 'springboot') Object.assign(files, springFiles(answers, vars, stack))
|
|
443
|
+
Object.assign(files, supabaseProjectFiles(stack))
|
|
444
|
+
Object.assign(files, envFiles(answers, stack))
|
|
445
|
+
files['create-win-project.profile.json'] = json({
|
|
446
|
+
schemaVersion: 3,
|
|
447
|
+
applicationShape: stack.applicationShape,
|
|
448
|
+
compatibilityProfile: {
|
|
449
|
+
id: stack.profile.id,
|
|
450
|
+
status: stack.profile.status,
|
|
451
|
+
supportedUntil: stack.profile.supportedUntil,
|
|
452
|
+
},
|
|
453
|
+
architectureProfile: stack.architecture,
|
|
454
|
+
authentication: {
|
|
455
|
+
intent: stack.authenticationIntent,
|
|
456
|
+
model: stack.authentication,
|
|
457
|
+
audience: stack.authAudience,
|
|
458
|
+
},
|
|
459
|
+
stack: stack.key,
|
|
460
|
+
runtimes: stack.profile.runtimes,
|
|
461
|
+
})
|
|
462
|
+
files['README.md'] = projectReadme(answers, stack)
|
|
463
|
+
if (stack.backendKey === 'springboot') {
|
|
464
|
+
files['README.md'] = files['README.md'].replace('mvn spring-boot:run', './mvnw spring-boot:run # use mvnw.cmd on Windows')
|
|
465
|
+
}
|
|
466
|
+
return files
|
|
467
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function frozenCopy(value = {}) {
|
|
2
|
+
return Object.freeze({ ...value })
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the read-only input shared by adapter contribution hooks.
|
|
7
|
+
* Filesystem destinations and writer functions are intentionally excluded.
|
|
8
|
+
*/
|
|
9
|
+
export function createStackContext({ answers, profile, catalog, frontend, backend, data = [] }) {
|
|
10
|
+
if (!answers || typeof answers !== 'object') throw new Error('Stack context answers are required')
|
|
11
|
+
if (!profile || typeof profile !== 'object') throw new Error('Stack context compatibility profile is required')
|
|
12
|
+
if (!frontend || frontend.kind !== 'frontend') throw new Error('Stack context requires a frontend adapter')
|
|
13
|
+
if (!backend || backend.kind !== 'backend') throw new Error('Stack context requires a backend adapter')
|
|
14
|
+
if (!Array.isArray(data) || data.some((adapter) => adapter.kind !== 'data')) {
|
|
15
|
+
throw new Error('Stack context data must contain data adapters')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
answers: frozenCopy(answers),
|
|
20
|
+
profile,
|
|
21
|
+
catalog,
|
|
22
|
+
frontend,
|
|
23
|
+
backend,
|
|
24
|
+
data: Object.freeze([...data]),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|