create-win-project 1.4.0 → 2.0.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 +1 -1
- package/README.md +54 -147
- package/checks/check-compatibility.js +37 -8
- package/checks/check-generated-project.js +27 -3
- package/checks/check-library.js +13 -3
- package/checks/check-package.js +15 -0
- package/checks/classify-changes.js +5 -15
- package/checks/run-compatibility-shard.js +21 -0
- package/docs/README.md +20 -0
- package/docs/capabilities.md +13 -0
- package/docs/compatibility.md +13 -0
- package/docs/generated-project.md +12 -0
- package/docs/getting-started.md +26 -0
- package/docs/{ARCHITECTURE.md → maintainers/architecture.md} +3 -3
- package/docs/maintainers/ci-strategy.md +46 -0
- package/docs/{CONTRIBUTING.md → maintainers/contributing.md} +7 -7
- package/docs/migration-v2.md +13 -0
- package/docs/production-contract.md +24 -0
- package/library/INDEX.md +1 -1
- package/library/compatibility-impact.json +14 -0
- package/library/development-tools/devops/makefile/commands.md +11 -0
- package/library/development-tools/devops/makefile/definition.json +2 -2
- package/library/development-tools/devops/makefile/docker.md +9 -0
- package/library/development-tools/devops/makefile/validation.md +5 -0
- package/library/features/sqlalchemy-alembic.md +17 -0
- package/library/optional-features/concerns/zod/errors.md +5 -0
- package/library/optional-features/concerns/zod/testing.md +5 -0
- package/library/optional-features/concerns/zod/validation.md +21 -0
- package/library/optional-features/styling/css-modules/definition.json +3 -2
- package/library/optional-features/styling/css-modules/responsive.md +5 -0
- package/library/optional-features/styling/css-modules/theme.md +7 -0
- package/library/stacks/expo/definition.json +2 -2
- package/library/stacks/fastapi/architecture.md +40 -0
- package/library/stacks/fastapi/definition.json +39 -0
- package/library/stacks/fastapi/runtime.md +25 -0
- package/library/stacks/fastapi/security.md +26 -0
- package/library/stacks/fastapi/structure.md +27 -0
- package/library/stacks/fastapi/testing.md +23 -0
- package/library/stacks/nextjs/definition.json +2 -2
- package/library/stacks/no-frontend/definition.json +1 -1
- package/library/stacks/react-vite/definition.json +2 -2
- package/library/tested-versions.json +42 -2
- package/library/universal/coding-rules/definition.json +3 -3
- package/library/universal/coding-rules/hygiene.md +9 -0
- package/library/universal/coding-rules/naming.md +17 -0
- package/library/universal/git-conventions/branches.md +5 -0
- package/library/universal/git-conventions/commits.md +7 -0
- package/library/universal/git-conventions/definition.json +4 -2
- package/library/universal/git-conventions/workflow.md +5 -0
- package/library/universal/typescript/boundaries.md +13 -0
- package/library/universal/typescript/definition.json +3 -3
- package/library/universal/typescript/errors.md +5 -0
- package/library/universal/typescript/patterns.md +7 -0
- package/package.json +4 -6
- package/src/cli/arguments.js +11 -0
- package/src/cli/main.js +18 -0
- package/src/cli/questions.js +16 -15
- package/src/cli/system-check.js +22 -2
- package/src/engine/load-library.js +3 -2
- package/src/engine/project-files.js +10 -1
- package/src/engine/project-guidance.js +2 -1
- package/src/engine/project-shapes.js +4 -4
- package/src/engine/render-templates.js +3 -0
- package/src/engine/tested-versions.js +21 -2
- package/src/engine/upgrade-report.js +20 -0
- package/src/stacks/available-stacks.js +2 -0
- package/src/stacks/backends/fastapi/ci.js +3 -0
- package/src/stacks/backends/fastapi/create-files.js +874 -0
- package/src/stacks/backends/fastapi/docker.js +75 -0
- package/src/stacks/backends/fastapi/environment.js +3 -0
- package/src/stacks/backends/fastapi/index.js +32 -0
- package/src/stacks/compose-files.js +19 -1
- package/src/stacks/create-project.js +128 -7
- package/src/stacks/frontends/nextjs/index.js +1 -1
- package/src/stacks/frontends/react-native/create-files.js +4 -1
- package/src/stacks/frontends/react-native/environment.js +1 -1
- package/src/stacks/frontends/react-native/index.js +1 -1
- package/src/stacks/frontends/react-vite/environment.js +1 -1
- package/src/stacks/frontends/react-vite/index.js +1 -1
- package/src/stacks/shared/capability-packs.js +31 -0
- package/src/stacks/shared/environment.js +12 -4
- package/src/stacks/shared/javascript-package.js +6 -0
- package/templates/ci/fastapi.yml +62 -0
- package/templates/docker/compose-prod/fastapi.yml +52 -0
- package/templates/docker/compose-prod/springboot.yml +19 -1
- package/templates/docker/dockerfile/fastapi.dev.dockerfile +9 -0
- package/templates/docker/dockerfile/fastapi.prod.dockerfile +11 -0
- package/templates/docker/dockerfile/nextjs.prod.dockerfile +1 -0
- package/templates/docker/dockerfile/springboot.prod.dockerfile +4 -1
- package/templates/docker/dockerfile/vite.prod.dockerfile +2 -1
- package/templates/makefile/fastapi.mk +97 -0
- package/library/development-tools/devops/makefile/makefile.md +0 -556
- package/library/optional-features/concerns/zod.md +0 -174
- package/library/optional-features/styling/css-modules/css-modules-extensions.md +0 -267
- package/library/universal/coding-rules/coding-rules.md +0 -281
- package/library/universal/git-conventions/git-conventions.md +0 -186
- package/library/universal/typescript/typescript.md +0 -272
- /package/docs/{CONTENT_MODEL.md → maintainers/content-model.md} +0 -0
- /package/docs/{DEPENDENCY_MAINTENANCE.md → maintainers/dependencies.md} +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function dockerContributions() {
|
|
2
|
+
return [{ template: 'fastapi', developmentPath: 'backend/Dockerfile.dev', productionPath: 'backend/Dockerfile' }]
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function fastapiCompose(answers, stack, vars) {
|
|
6
|
+
const apiDir = stack.frontendKey === 'no-frontend' ? '.' : './backend'
|
|
7
|
+
const projectName = vars.PROJECT_NAME
|
|
8
|
+
const frontend = stack.frontendKey === 'react'
|
|
9
|
+
? ` frontend:\n container_name: ${projectName}-frontend\n build:\n context: ./frontend\n dockerfile: Dockerfile.dev\n ports:\n - "\${FRONTEND_HOST_PORT:-5173}:5173"\n volumes:\n - ./frontend:/app\n - frontend-node-modules:/app/node_modules\n environment:\n VITE_API_URL: http://backend:8000\n depends_on:\n - backend\n networks:\n - app-network\n\n`
|
|
10
|
+
: stack.frontendKey === 'nextjs'
|
|
11
|
+
? ` frontend:\n container_name: ${projectName}-frontend\n build:\n context: .\n dockerfile: Dockerfile.dev\n ports:\n - "\${FRONTEND_HOST_PORT:-3000}:3000"\n volumes:\n - .:/app\n - /app/backend\n - frontend-node-modules:/app/node_modules\n environment:\n NEXT_PUBLIC_API_URL: http://backend:8000\n depends_on:\n - backend\n networks:\n - app-network\n\n`
|
|
12
|
+
: ''
|
|
13
|
+
const frontendVolume = frontend ? ' frontend-node-modules:\n' : ''
|
|
14
|
+
return `# docker-compose.yml — ${projectName} (${vars.STACK}) — Dev
|
|
15
|
+
services:
|
|
16
|
+
${frontend} backend:
|
|
17
|
+
container_name: ${projectName}-backend
|
|
18
|
+
build:
|
|
19
|
+
context: ${apiDir}
|
|
20
|
+
dockerfile: Dockerfile.dev
|
|
21
|
+
ports:
|
|
22
|
+
- "\${BACKEND_HOST_PORT:-8000}:8000"
|
|
23
|
+
volumes:
|
|
24
|
+
- ${apiDir}:/app
|
|
25
|
+
- uv-cache:/root/.cache/uv
|
|
26
|
+
environment:
|
|
27
|
+
- DATABASE_URL=postgresql+asyncpg://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@db:5432/\${POSTGRES_DB}
|
|
28
|
+
- POSTGRES_USER=\${POSTGRES_USER}
|
|
29
|
+
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
|
|
30
|
+
- OIDC_ISSUER=\${OIDC_ISSUER}
|
|
31
|
+
- OIDC_AUDIENCE=\${OIDC_AUDIENCE}
|
|
32
|
+
- OIDC_ALGORITHMS=\${OIDC_ALGORITHMS}
|
|
33
|
+
- OIDC_JWKS_URL=\${OIDC_JWKS_URL}
|
|
34
|
+
- CORS_ALLOWED_ORIGINS=\${CORS_ALLOWED_ORIGINS}
|
|
35
|
+
depends_on:
|
|
36
|
+
db:
|
|
37
|
+
condition: service_healthy
|
|
38
|
+
networks:
|
|
39
|
+
- app-network
|
|
40
|
+
healthcheck:
|
|
41
|
+
test: ["CMD-SHELL", "python -c \\"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\\""]
|
|
42
|
+
interval: 10s
|
|
43
|
+
timeout: 5s
|
|
44
|
+
retries: 5
|
|
45
|
+
|
|
46
|
+
db:
|
|
47
|
+
container_name: ${projectName}-db
|
|
48
|
+
image: ${vars.POSTGRES_IMAGE}
|
|
49
|
+
ports:
|
|
50
|
+
- "\${POSTGRES_HOST_PORT:-5432}:5432"
|
|
51
|
+
environment:
|
|
52
|
+
- POSTGRES_USER=\${POSTGRES_USER}
|
|
53
|
+
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
|
|
54
|
+
- POSTGRES_DB=\${POSTGRES_DB}
|
|
55
|
+
volumes:
|
|
56
|
+
- postgres-data:/var/lib/postgresql/data
|
|
57
|
+
networks:
|
|
58
|
+
- app-network
|
|
59
|
+
healthcheck:
|
|
60
|
+
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
|
|
61
|
+
interval: 10s
|
|
62
|
+
timeout: 5s
|
|
63
|
+
retries: 5
|
|
64
|
+
|
|
65
|
+
volumes:
|
|
66
|
+
${frontendVolume} postgres-data:
|
|
67
|
+
name: ${projectName}-postgres-data
|
|
68
|
+
uv-cache:
|
|
69
|
+
name: ${projectName}-uv-cache
|
|
70
|
+
|
|
71
|
+
networks:
|
|
72
|
+
app-network:
|
|
73
|
+
name: ${projectName}-network
|
|
74
|
+
`
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineStackAdapter } from '../../rules.js'
|
|
2
|
+
import { buildFastApiFiles } from './create-files.js'
|
|
3
|
+
import { ciContributions } from './ci.js'
|
|
4
|
+
import { dockerContributions } from './docker.js'
|
|
5
|
+
import { environmentContributions } from './environment.js'
|
|
6
|
+
|
|
7
|
+
export const fastapiAdapter = defineStackAdapter({
|
|
8
|
+
id: 'fastapi',
|
|
9
|
+
kind: 'backend',
|
|
10
|
+
label: 'FastAPI',
|
|
11
|
+
compatibleWith: { frontend: ['nextjs', 'react', 'react-native', 'no-frontend'] },
|
|
12
|
+
capabilities: {
|
|
13
|
+
applicationShapes: ['separate', 'api', 'mobile'],
|
|
14
|
+
architectureProfiles: ['small', 'medium', 'large'],
|
|
15
|
+
authenticationModels: ['public', 'undecided', 'oidc'],
|
|
16
|
+
runtime: 'python',
|
|
17
|
+
},
|
|
18
|
+
contributes: {
|
|
19
|
+
files: ({ answers, stack, vars }) => Object.entries(buildFastApiFiles(answers, vars, stack)),
|
|
20
|
+
environment: environmentContributions,
|
|
21
|
+
install: (context = {}) => [{ cwd: context.stack?.frontendKey === 'no-frontend' ? '.' : 'backend', command: 'uv', args: ['sync'] }],
|
|
22
|
+
docker: dockerContributions,
|
|
23
|
+
ci: ciContributions,
|
|
24
|
+
verification: () => [
|
|
25
|
+
{ frontend: 'nextjs', architecture: 'small', authentication: 'public' },
|
|
26
|
+
{ frontend: 'nextjs', architecture: 'medium', authentication: 'oidc' },
|
|
27
|
+
{ frontend: 'react', architecture: 'medium', authentication: 'oidc' },
|
|
28
|
+
{ frontend: 'react-native', architecture: 'large', authentication: 'oidc' },
|
|
29
|
+
{ frontend: 'no-frontend', architecture: 'medium', authentication: 'undecided' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { packageVersion } from './shared/javascript-package.js'
|
|
3
3
|
import { stackRegistry } from './available-stacks.js'
|
|
4
|
+
import { capabilityPackFiles } from './shared/capability-packs.js'
|
|
4
5
|
import { collectContributions } from './shared/contributions.js'
|
|
5
6
|
import { buildSharedTestFiles, buildSupabaseWebFiles } from './backends/supabase/create-files.js'
|
|
6
7
|
import { augmentSupabaseNativeFiles } from './backends/supabase/native.js'
|
|
@@ -30,6 +31,9 @@ function envFiles(answers, stack) {
|
|
|
30
31
|
|
|
31
32
|
function projectReadme(answers, stack) {
|
|
32
33
|
if (stack.frontendKey === 'no-frontend') {
|
|
34
|
+
if (stack.backendKey === 'fastapi') {
|
|
35
|
+
return `# ${answers.projectName}\n\n> ${answers.projectDescription}\n\nGenerated backend-only ${stack.backendLabel} application.\n\n## Start\n\n\`\`\`bash\ncp .env.example .env\nuv sync\nalembic upgrade head\nuv run uvicorn app.main:app --reload\n\`\`\`\n\n## Validate\n\n\`\`\`bash\nuv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest\n\`\`\`\n`
|
|
36
|
+
}
|
|
33
37
|
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`
|
|
34
38
|
}
|
|
35
39
|
const root = stack.frontendKey === 'react' ? 'frontend/' : ''
|
|
@@ -215,8 +219,9 @@ export function buildRunnableFiles(answers, stack, vars) {
|
|
|
215
219
|
let files = stack.isMobile ? nativeFiles(answers, stack) : frontendFiles(answers, stack)
|
|
216
220
|
if (stack.frontendKey === 'react') files = augmentViteFiles(files, stack)
|
|
217
221
|
Object.assign(files, envFiles(answers, stack))
|
|
222
|
+
Object.assign(files, capabilityPackFiles(answers, stack))
|
|
218
223
|
files['create-win-project.profile.json'] = json({
|
|
219
|
-
schemaVersion:
|
|
224
|
+
schemaVersion: 2,
|
|
220
225
|
applicationShape: stack.applicationShape,
|
|
221
226
|
compatibilityProfile: {
|
|
222
227
|
id: stack.profile.id,
|
|
@@ -230,6 +235,19 @@ export function buildRunnableFiles(answers, stack, vars) {
|
|
|
230
235
|
audience: stack.authAudience,
|
|
231
236
|
},
|
|
232
237
|
stack: stack.key,
|
|
238
|
+
productionBaseline: {
|
|
239
|
+
tests: true,
|
|
240
|
+
continuousIntegration: true,
|
|
241
|
+
productionBuild: !stack.isMobile,
|
|
242
|
+
securityRules: true,
|
|
243
|
+
operationsDocumentation: true,
|
|
244
|
+
deployment: stack.isMobile ? 'eas' : 'cloud-neutral-docker',
|
|
245
|
+
},
|
|
246
|
+
capabilities: {
|
|
247
|
+
uploads: answers.uploads || 'none',
|
|
248
|
+
backgroundJobs: answers.backgroundJobs || 'none',
|
|
249
|
+
offline: answers.offline || 'none',
|
|
250
|
+
},
|
|
233
251
|
runtimes: stack.profile.runtimes,
|
|
234
252
|
})
|
|
235
253
|
files['README.md'] = projectReadme(answers, stack)
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '../engine/write-files.js'
|
|
17
17
|
import { writeRenderedFile as writeTemplate } from '../engine/render-templates.js'
|
|
18
18
|
import { laravelCompose } from './backends/laravel/docker.js'
|
|
19
|
+
import { fastapiCompose } from './backends/fastapi/docker.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Main entry point — generates the full project
|
|
@@ -84,9 +85,18 @@ function validateAnswers(answers) {
|
|
|
84
85
|
if (typeof answers.projectDescription !== 'string' || !answers.projectDescription.trim()) {
|
|
85
86
|
throw new Error('Project description is required')
|
|
86
87
|
}
|
|
87
|
-
if (answers.testing && !['
|
|
88
|
+
if (answers.testing && !['basic', 'full'].includes(answers.testing)) {
|
|
88
89
|
throw new Error(`Unknown testing setup: ${answers.testing}`)
|
|
89
90
|
}
|
|
91
|
+
const uploads = answers.uploads || 'none'
|
|
92
|
+
const jobs = answers.backgroundJobs || 'none'
|
|
93
|
+
const offline = answers.offline || 'none'
|
|
94
|
+
if (!['none', 'object-storage'].includes(uploads)) throw new Error(`Unknown uploads requirement: ${uploads}`)
|
|
95
|
+
if (!['none', 'queue'].includes(jobs)) throw new Error(`Unknown background-jobs requirement: ${jobs}`)
|
|
96
|
+
if (!['none', 'cache', 'sync'].includes(offline)) throw new Error(`Unknown offline requirement: ${offline}`)
|
|
97
|
+
if (offline !== 'none' && answers.frontend !== 'react-native') throw new Error('Offline capabilities are supported only for mobile applications')
|
|
98
|
+
if (jobs === 'queue' && !['springboot', 'laravel'].includes(answers.backend)) throw new Error('Queues require a Spring Boot or Laravel backend')
|
|
99
|
+
if (uploads === 'object-storage' && answers.backend === 'none') throw new Error('Object storage uploads require a backend or managed data service')
|
|
90
100
|
if (answers.architecture && !['small', 'medium', 'large'].includes(answers.architecture)) {
|
|
91
101
|
throw new Error(`Unknown architecture profile: ${answers.architecture}`)
|
|
92
102
|
}
|
|
@@ -101,11 +111,11 @@ function validateAnswers(answers) {
|
|
|
101
111
|
// ─── Root files ───────────────────────────────────────────────────────────────
|
|
102
112
|
|
|
103
113
|
async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
104
|
-
await writeTemplate(dest, 'CONTEXT.md',
|
|
114
|
+
await writeTemplate(dest, 'CONTEXT.md', contextMd(vars, answers.expectedConcerns, answers), vars)
|
|
105
115
|
// AGENTS.md — template-driven
|
|
106
116
|
{
|
|
107
117
|
const tpl = await readTemplate(templatesDir, 'agents', stack.agentsTemplate, '.md')
|
|
108
|
-
if (tpl) await writeTemplate(dest, 'AGENTS.md', tpl, vars)
|
|
118
|
+
if (tpl) await writeTemplate(dest, 'AGENTS.md', `${tpl}\n## Deviation policy\n\nAgents may recommend alternatives, but must propose the change and receive explicit approval before changing the selected architecture, provider, authentication model, data boundary, production baseline, or major dependency. Record approved deviations and their rationale in \`CONTEXT.md\`.\n`, vars)
|
|
109
119
|
else await write(dest, 'AGENTS.md', `# AGENTS.md\nStack: ${stack.label}\n`)
|
|
110
120
|
}
|
|
111
121
|
await write(dest, 'PROGRESS.md', progressMd())
|
|
@@ -123,6 +133,10 @@ async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
|
123
133
|
await write(dest, '.editorconfig', editorconfig())
|
|
124
134
|
await write(dest, '.prettierrc', prettierrc())
|
|
125
135
|
if (stack.backendKey === 'springboot') await write(dest, 'backend/.java-version', `${stack.profile.runtimes.java}\n`)
|
|
136
|
+
if (stack.backendKey === 'fastapi') {
|
|
137
|
+
const apiRoot = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
138
|
+
await write(dest, `${apiRoot}.python-version`, `${stack.profile.runtimes.python}\n`)
|
|
139
|
+
}
|
|
126
140
|
if (stack.backendKey === 'laravel') {
|
|
127
141
|
const laravelRoot = stack.frontendKey === 'laravel-ui' || stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
128
142
|
await write(dest, `${laravelRoot}.php-version`, `${stack.profile.runtimes.php}\n`)
|
|
@@ -138,11 +152,13 @@ async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
|
138
152
|
// Mobile frontends run through Expo, but a separate Laravel backend still
|
|
139
153
|
// needs its backend and PostgreSQL services. laravelCompose intentionally
|
|
140
154
|
// omits a frontend service for React Native.
|
|
141
|
-
if (answers.docker && (!stack.isMobile || stack.backendKey
|
|
155
|
+
if (answers.docker && (!stack.isMobile || ['laravel', 'fastapi'].includes(stack.backendKey))) {
|
|
142
156
|
// docker-compose.yml
|
|
143
157
|
let composeTpl = null
|
|
144
158
|
if (stack.backendKey === 'laravel') {
|
|
145
159
|
composeTpl = laravelCompose(answers, stack, vars)
|
|
160
|
+
} else if (stack.backendKey === 'fastapi') {
|
|
161
|
+
composeTpl = fastapiCompose(answers, stack, vars)
|
|
146
162
|
} else if (stack.needsPackage) {
|
|
147
163
|
composeTpl = await readTemplate(templatesDir, 'docker/compose', 'springboot', '.yml')
|
|
148
164
|
} else if (stack.backendKey === 'supabase') {
|
|
@@ -172,6 +188,22 @@ async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
|
172
188
|
if (beProd) await writeTemplate(dest, 'backend/Dockerfile', beProd, vars)
|
|
173
189
|
}
|
|
174
190
|
|
|
191
|
+
if (stack.backendKey === 'fastapi') {
|
|
192
|
+
const apiRoot = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
193
|
+
const prodTpl = await readTemplate(templatesDir, 'docker/compose-prod', 'fastapi', '.yml')
|
|
194
|
+
if (prodTpl) {
|
|
195
|
+
const rendered = stack.frontendKey === 'no-frontend'
|
|
196
|
+
? prodTpl.replaceAll('context: ./backend', 'context: .').replaceAll('./backend:/app', '.:/app')
|
|
197
|
+
: prodTpl
|
|
198
|
+
await writeTemplate(dest, 'docker-compose.prod.yml', rendered, vars)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const beDev = await readTemplate(templatesDir, 'docker/dockerfile', 'fastapi.dev', '.dockerfile')
|
|
202
|
+
if (beDev) await writeTemplate(dest, `${apiRoot}Dockerfile.dev`, beDev, vars)
|
|
203
|
+
const beProd = await readTemplate(templatesDir, 'docker/dockerfile', 'fastapi.prod', '.dockerfile')
|
|
204
|
+
if (beProd) await writeTemplate(dest, `${apiRoot}Dockerfile`, beProd, vars)
|
|
205
|
+
}
|
|
206
|
+
|
|
175
207
|
// frontend dockerfiles — vite vs nextjs
|
|
176
208
|
if (stack.frontendKey === 'react') {
|
|
177
209
|
const viteDev = await readTemplate(templatesDir, 'docker/dockerfile', 'vite.dev', '.dockerfile')
|
|
@@ -179,7 +211,7 @@ async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
|
179
211
|
const viteProd = await readTemplate(templatesDir, 'docker/dockerfile', 'vite.prod', '.dockerfile')
|
|
180
212
|
if (viteProd) {
|
|
181
213
|
await writeTemplate(dest, 'frontend/Dockerfile', viteProd, vars)
|
|
182
|
-
const nginx = `server {\n listen
|
|
214
|
+
const nginx = `server {\n listen 8080;\n server_tokens off;\n root /usr/share/nginx/html;\n add_header X-Content-Type-Options nosniff always;\n add_header Referrer-Policy strict-origin-when-cross-origin always;\n add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;\n add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;\n location /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=31536000, immutable"; }\n location /api/ { add_header Cache-Control "no-store" always; try_files $uri =404; }\n location / { index index.html; try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }\n}\n`
|
|
183
215
|
await write(dest, 'frontend/nginx.conf', nginx)
|
|
184
216
|
}
|
|
185
217
|
} else if (stack.frontendKey === 'nextjs') {
|
|
@@ -190,6 +222,45 @@ async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
|
190
222
|
}
|
|
191
223
|
}
|
|
192
224
|
|
|
225
|
+
// Production artifacts are part of the deployable web contract even when
|
|
226
|
+
// the optional development Docker workflow was not selected.
|
|
227
|
+
if (!answers.docker && !stack.isMobile) {
|
|
228
|
+
if (stack.frontendKey === 'nextjs') {
|
|
229
|
+
const nextProd = await readTemplate(templatesDir, 'docker/dockerfile', 'nextjs.prod', '.dockerfile')
|
|
230
|
+
if (nextProd) await writeTemplate(dest, 'Dockerfile', nextProd, vars)
|
|
231
|
+
}
|
|
232
|
+
if (stack.frontendKey === 'react') {
|
|
233
|
+
const viteProd = await readTemplate(templatesDir, 'docker/dockerfile', 'vite.prod', '.dockerfile')
|
|
234
|
+
if (viteProd) {
|
|
235
|
+
await writeTemplate(dest, 'frontend/Dockerfile', viteProd, vars)
|
|
236
|
+
await write(dest, 'frontend/nginx.conf', `server {\n listen 8080;\n server_tokens off;\n root /usr/share/nginx/html;\n add_header X-Content-Type-Options nosniff always;\n add_header Referrer-Policy strict-origin-when-cross-origin always;\n add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;\n add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;\n location /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=31536000, immutable"; }\n location /api/ { add_header Cache-Control "no-store" always; try_files $uri =404; }\n location / { index index.html; try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }\n}\n`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (stack.backendKey === 'springboot') {
|
|
240
|
+
const backendProd = await readTemplate(templatesDir, 'docker/dockerfile', 'springboot.prod', '.dockerfile')
|
|
241
|
+
if (backendProd) await writeTemplate(dest, 'backend/Dockerfile', backendProd, vars)
|
|
242
|
+
const composeProd = await readTemplate(templatesDir, 'docker/compose-prod', 'springboot', '.yml')
|
|
243
|
+
if (composeProd) await writeTemplate(dest, 'docker-compose.prod.yml', composeProd, vars)
|
|
244
|
+
}
|
|
245
|
+
if (stack.backendKey === 'fastapi') {
|
|
246
|
+
const apiRoot = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
247
|
+
const backendProd = await readTemplate(templatesDir, 'docker/dockerfile', 'fastapi.prod', '.dockerfile')
|
|
248
|
+
if (backendProd) await writeTemplate(dest, `${apiRoot}Dockerfile`, backendProd, vars)
|
|
249
|
+
const composeProd = await readTemplate(templatesDir, 'docker/compose-prod', 'fastapi', '.yml')
|
|
250
|
+
if (composeProd) {
|
|
251
|
+
const rendered = stack.frontendKey === 'no-frontend'
|
|
252
|
+
? composeProd.replaceAll('context: ./backend', 'context: .')
|
|
253
|
+
: composeProd
|
|
254
|
+
await writeTemplate(dest, 'docker-compose.prod.yml', rendered, vars)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (stack.backendKey === 'laravel') {
|
|
258
|
+
const laravelRoot = ['laravel-ui', 'no-frontend'].includes(stack.frontendKey) ? '' : 'backend/'
|
|
259
|
+
const laravelProd = await readTemplate(templatesDir, 'docker/dockerfile', 'laravel.prod', '.dockerfile')
|
|
260
|
+
if (laravelProd) await writeTemplate(dest, `${laravelRoot}Dockerfile`, laravelProd, vars)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
193
264
|
// PR template
|
|
194
265
|
if (answers.githubActions) {
|
|
195
266
|
await write(dest, '.github/PULL_REQUEST_TEMPLATE.md', prTemplate())
|
|
@@ -216,6 +287,11 @@ function toolchainGuide(answers, stack) {
|
|
|
216
287
|
rows.push(`| PHP | ${stack.profile.runtimes.php} | Host-run Laravel backend | \`${root}.php-version\` |`)
|
|
217
288
|
rows.push(`| Composer | ${stack.profile.runtimes.composer} | Host-run Laravel backend | \`${root}composer.json\` |`)
|
|
218
289
|
}
|
|
290
|
+
if (stack.backendKey === 'fastapi') {
|
|
291
|
+
const root = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
292
|
+
rows.push(`| Python | ${stack.profile.runtimes.python} | Host-run FastAPI backend | \`${root}.python-version\` |`)
|
|
293
|
+
rows.push(`| uv | ${stack.profile.runtimes.uv} | Host-run FastAPI backend | \`${root}pyproject.toml\` |`)
|
|
294
|
+
}
|
|
219
295
|
if (answers.docker || ['supabase', 'postgres'].includes(stack.backendKey)) {
|
|
220
296
|
rows.push('| Docker with Compose | Current supported release | Generated containers and local managed services | `docker-compose.yml` when selected |')
|
|
221
297
|
}
|
|
@@ -247,6 +323,8 @@ async function generateDocs(dest, answers, stack) {
|
|
|
247
323
|
for (const [filePath, title, description] of docs) {
|
|
248
324
|
await write(dest, filePath, docPlaceholder(title, description))
|
|
249
325
|
}
|
|
326
|
+
await write(dest, 'docs/guides/deployment.md', `# Production deployment and rollback\n\nBuild immutable images from the committed lockfiles and deploy \`docker-compose.prod.yml\` where generated. Validate required environment variables before starting; secrets belong in the deployment platform, never images or client bundles. Run readiness checks before routing traffic and allow the documented graceful-shutdown window during replacement.\n\n## Database preflight\n\nBack up PostgreSQL with encryption before migrations, check available connections and migration compatibility, then run migrations as a single release task. Application instances use a bounded connection pool; size the total across replicas below the database limit.\n\n## Rollback\n\nRetain the previous image digest and a compatible database restore point. Stop routing to the failed release, restore the prior image, and restore data only when the migration is not backward compatible. Test restores automatically on a separate database and document retention and recovery ownership.\n`)
|
|
327
|
+
await write(dest, 'docs/guides/operations.md', `# Operations\n\nEvery request crossing an HTTP boundary receives or creates an \`X-Request-ID\` and returns it in responses. Errors use a stable JSON shape: \`{ "error": { "code": "stable_code", "message": "safe message", "requestId": "..." } }\`. Never expose stack traces.\n\nList endpoints use cursor pagination with explicit maximum page sizes. Outbound calls have connection and response timeouts; retry only bounded idempotent operations with jitter. Readiness checks include required downstream dependencies while liveness checks remain process-local.\n\nCORS uses an explicit origin allowlist. Cookie-authenticated browser writes require SameSite cookies plus CSRF validation; bearer-token APIs do not use wildcard origins with credentials. Static fingerprinted assets are immutable, HTML revalidates, and API/auth responses default to \`no-store\`.\n`)
|
|
250
328
|
|
|
251
329
|
const frontendRoot = stack.frontendKey === 'react' ? 'frontend/' : ''
|
|
252
330
|
const validation = stack.isMobile
|
|
@@ -274,6 +352,16 @@ async function generateDocs(dest, answers, stack) {
|
|
|
274
352
|
: ''
|
|
275
353
|
setupGuide = `# Local Setup Guide\n\n## Default local setup\n\nUse PHP ${stack.profile.runtimes.php}, Composer ${stack.profile.runtimes.composer}, and PostgreSQL ${stack.profile.runtimes.postgres}.\n\n\`\`\`bash\ncd ${laravelDir || '.'}\ncomposer install\ncp .env.example .env\nphp artisan key:generate\nphp artisan migrate\nphp artisan serve\n\`\`\`\n${frontendSetup}${answers.docker ? `\n## Optional Docker setup\n\nFrom the repository root:\n\n\`\`\`bash\ndocker compose build\ndocker compose up -d\ndocker compose exec backend php artisan key:generate\ndocker compose exec backend php artisan migrate\n\`\`\`\n\nLater runs use \`docker compose up -d\`; rebuilding remains explicit.\n` : ''}\n## Validate\n\n\`\`\`bash\n${laravelDir ? `cd ${laravelDir}\n` : ''}composer check\n\`\`\`\n\nCommit \`composer.lock\`${stack.frontendKey === 'laravel-ui' && stack.laravelUi === 'inertia-react' ? ' and `package-lock.json`' : ''}.\n`
|
|
276
354
|
}
|
|
355
|
+
if (stack.backendKey === 'fastapi') {
|
|
356
|
+
const apiDir = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
357
|
+
const frontendSetup = stack.frontendKey === 'react'
|
|
358
|
+
? '\nIn another terminal:\n\n```bash\ncd frontend\nnpm install\nnpm run dev\n```\n'
|
|
359
|
+
: stack.frontendKey === 'nextjs'
|
|
360
|
+
? '\nIn another terminal from the repository root:\n\n```bash\nnpm install\nnpm run dev\n```\n'
|
|
361
|
+
: ''
|
|
362
|
+
setupGuide = `# Local Setup Guide\n\n## Default local setup\n\nUse Python ${stack.profile.runtimes.python}, uv ${stack.profile.runtimes.uv}, and PostgreSQL ${stack.profile.runtimes.postgres}.\n\n\`\`\`bash\ncd ${apiDir || '.'}\nuv sync\ncp .env.example .env\nalembic upgrade head\nuv run uvicorn app.main:app --reload\n\`\`\`\n${frontendSetup}${answers.docker ? `\n## Optional Docker setup\n\nFrom the repository root:\n\n\`\`\`bash\ndocker compose build\ndocker compose up -d\ndocker compose exec backend uv run alembic upgrade head\n\`\`\`\n\nLater runs use \`docker compose up -d\`; rebuilding remains explicit.\n` : ''}\n## Validate\n\n\`\`\`bash\n${apiDir ? `cd ${apiDir}\n` : ''}uv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest\n\`\`\`\n\nCommit the generated \`uv.lock\`; CI uses \`uv sync --frozen\`.\n`
|
|
363
|
+
setupGuide = setupGuide.replace('\nalembic upgrade head\n', '\nuv run alembic upgrade head\n')
|
|
364
|
+
}
|
|
277
365
|
await fs.writeFile(setupPath, setupGuide, 'utf8')
|
|
278
366
|
await write(dest, 'docs/guides/toolchain.md', toolchainGuide(answers, stack))
|
|
279
367
|
await write(dest, 'docs/guides/development-environments.md', developmentEnvironmentGuide(answers, stack))
|
|
@@ -289,7 +377,9 @@ async function generateDocs(dest, answers, stack) {
|
|
|
289
377
|
? 'GET http://localhost:8080/api/health'
|
|
290
378
|
: stack.backendKey === 'laravel'
|
|
291
379
|
? 'GET http://localhost:8000/api/health'
|
|
292
|
-
: stack.
|
|
380
|
+
: stack.backendKey === 'fastapi'
|
|
381
|
+
? 'GET http://localhost:8000/health'
|
|
382
|
+
: stack.frontendKey === 'nextjs' ? 'GET /api/health' : '(No custom HTTP API is generated for this client-only starter.)'
|
|
293
383
|
await write(dest, 'docs/api/overview.md', `# API Overview\n\n## Health\n\n\`${health}\`\n\nAuthentication and authorization behavior is documented in \`docs/architecture/auth-flow.md\`. Add endpoints to \`docs/api/endpoints.md\` in the same change that adds their implementation and tests.\n`)
|
|
294
384
|
}
|
|
295
385
|
|
|
@@ -297,7 +387,7 @@ function environmentPurpose(name) {
|
|
|
297
387
|
if (name.endsWith('SUPABASE_URL')) return 'Supabase project URL.'
|
|
298
388
|
if (name.endsWith('SUPABASE_PUBLISHABLE_KEY')) return 'Public Supabase key; RLS protects data.'
|
|
299
389
|
if (name.endsWith('API_URL')) return 'Base URL of the application API.'
|
|
300
|
-
if (name === 'DATABASE_URL') return 'Server-side PostgreSQL
|
|
390
|
+
if (name === 'DATABASE_URL') return 'Server-side PostgreSQL connection URL.'
|
|
301
391
|
if (name === 'POSTGRES_USER') return 'Local/deployed database user.'
|
|
302
392
|
if (name === 'POSTGRES_PASSWORD') return 'Database credential; replace the development example.'
|
|
303
393
|
if (name === 'POSTGRES_DB') return 'Database name.'
|
|
@@ -305,7 +395,10 @@ function environmentPurpose(name) {
|
|
|
305
395
|
if (name === 'SPRING_SECURITY_USER_NAME') return 'Development-only generated Spring login name; replace with the product identity store.'
|
|
306
396
|
if (name === 'SPRING_SECURITY_USER_PASSWORD') return 'Development-only Spring login credential; never commit a real value.'
|
|
307
397
|
if (name === 'OIDC_ISSUER_URI') return 'Trusted OpenID Connect issuer used to validate access tokens.'
|
|
398
|
+
if (name === 'OIDC_ISSUER') return 'Trusted OpenID Connect issuer used to validate access tokens.'
|
|
308
399
|
if (name === 'OIDC_AUDIENCE') return 'Required audience for tokens accepted by this API.'
|
|
400
|
+
if (name === 'OIDC_ALGORITHMS') return 'Allowed signing algorithms for accepted access tokens.'
|
|
401
|
+
if (name === 'OIDC_JWKS_URL') return 'JWKS endpoint for access-token signature validation; defaults to the issuer well-known location.'
|
|
309
402
|
if (name === 'SESSION_DOMAIN') return 'Exact domain scope for the secure session cookie.'
|
|
310
403
|
if (name === 'SANCTUM_STATEFUL_DOMAINS') return 'Comma-separated first-party browser hosts allowed to use Sanctum session authentication.'
|
|
311
404
|
if (name === 'CORS_ALLOWED_ORIGINS') return 'Exact browser origins allowed to make credentialed API requests.'
|
|
@@ -323,11 +416,24 @@ function authDocumentation(stack) {
|
|
|
323
416
|
if (stack.authentication === 'laravel-session') return `# Authentication Flow\n\nLaravel owns the website session. Login regenerates the session identifier, logout invalidates the session and rotates the CSRF token, and protected routes use server-side authorization. The browser receives an HttpOnly session cookie; there is no browser refresh token. Keep CSRF protection enabled for every cookie-authenticated mutation.\n`
|
|
324
417
|
if (stack.authentication === 'sanctum-spa') return `# Authentication Flow\n\nLaravel Sanctum uses Laravel's secure session cookie for this first-party browser application. The SPA first requests \`/sanctum/csrf-cookie\`, then sends credentialed login and API requests. Sanctum does not give this browser a custom bearer/refresh-token system. Configure exact stateful domains and CORS origins, and enforce resource authorization in Laravel.\n`
|
|
325
418
|
if (stack.authentication === 'laravel-oidc') return `# Authentication Flow\n\nAuth0 owns login, access/refresh-token issuance, rotation, revocation, and recovery. Clients use Authorization Code with PKCE. Laravel uses the pinned Auth0 resource-server adapter to accept access tokens only and validate signature, issuer, audience, and time claims. Refresh tokens never go to this API. Configure \`AUTH0_DOMAIN\` and \`AUTH0_AUDIENCE\`; resource ownership and permission checks remain application responsibilities.\n`
|
|
419
|
+
if (stack.backendKey === 'fastapi') return `# Authentication Flow\n\nAn external OpenID Connect provider owns login, access/refresh token issuance, rotation, revocation, and recovery. Clients use Authorization Code with PKCE. FastAPI validates bearer access tokens: issuer, audience, algorithm, JWKS signature, expiry, and required claims on every protected request. Refresh tokens never go to the FastAPI resource API. Configure \`OIDC_ISSUER\` and \`OIDC_AUDIENCE\`, then test invalid and authorized tokens.\n`
|
|
326
420
|
return `# Authentication Flow\n\nAn external OpenID Connect provider owns login, access/refresh token issuance, rotation, revocation, and recovery. Clients use Authorization Code with PKCE. Spring is an OAuth2 Resource Server: it accepts bearer access tokens and validates signature, issuer, audience, time, and authorities. Refresh tokens never go to the Spring resource API. Configure \`OIDC_ISSUER_URI\` and \`OIDC_AUDIENCE\`, then test invalid and authorized tokens.\n`
|
|
327
421
|
}
|
|
328
422
|
|
|
329
423
|
// ─── CI — template-driven ─────────────────────────────────────────────────────
|
|
330
424
|
|
|
425
|
+
function generatedSecurityWorkflow(stack) {
|
|
426
|
+
const hasJavaScript = stack.frontendKey !== 'no-frontend' &&
|
|
427
|
+
!(stack.frontendKey === 'laravel-ui' && stack.laravelUi !== 'inertia-react')
|
|
428
|
+
const npmDirectory = stack.frontendKey === 'react' ? 'frontend' : '.'
|
|
429
|
+
const laravelDirectory = ['laravel-ui', 'no-frontend'].includes(stack.frontendKey) ? '.' : 'backend'
|
|
430
|
+
const codeqlLanguages = [hasJavaScript && 'javascript-typescript', stack.backendKey === 'springboot' && 'java-kotlin'].filter(Boolean)
|
|
431
|
+
const npmAudit = hasJavaScript ? `\n npm-audit:\n runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: ${npmDirectory}\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4\n with:\n node-version: "${stack.profile.runtimes.node}"\n cache: npm\n cache-dependency-path: ${npmDirectory === '.' ? 'package-lock.json' : `${npmDirectory}/package-lock.json`}\n - run: npm ci\n - run: npm audit --audit-level=high\n` : ''
|
|
432
|
+
const composerAudit = stack.backendKey === 'laravel' ? `\n composer-audit:\n runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: ${laravelDirectory}\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4\n - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2\n with:\n php-version: "${stack.profile.runtimes.php}"\n coverage: none\n - run: composer install --no-interaction --prefer-dist\n - run: composer audit --locked\n` : ''
|
|
433
|
+
const codeql = codeqlLanguages.length ? `\n codeql:\n permissions:\n contents: read\n security-events: write\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4\n - uses: github/codeql-action/init@5ba2889ada762081db2c4f32a729827dce632c7b # v3\n with:\n languages: ${codeqlLanguages.join(',')}\n - uses: github/codeql-action/analyze@5ba2889ada762081db2c4f32a729827dce632c7b # v3\n` : ''
|
|
434
|
+
return `name: Security\n\non:\n pull_request:\n branches: [dev, main]\n push:\n branches: [dev, main]\n schedule:\n - cron: '31 4 * * 1'\n\npermissions:\n contents: read\n\njobs:\n dependency-review:\n if: github.event_name == 'pull_request'\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4\n - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0\n with:\n fail-on-severity: high\n\n secret-scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4\n with:\n fetch-depth: 0\n - uses: trufflesecurity/trufflehog@466da5b0bb161144f6afca9afe5d57975828c410 # v3.90.8\n with:\n extra_args: --results=verified,unknown\n${npmAudit}${composerAudit}${codeql}`
|
|
435
|
+
}
|
|
436
|
+
|
|
331
437
|
async function generateCI(dest, stack, ciDir, answers, vars) {
|
|
332
438
|
// Frontend CI — read from ci/{ciTemplate}.yml
|
|
333
439
|
const feTpl = path.join(ciDir, `${stack.ciTemplate}.yml`)
|
|
@@ -377,6 +483,21 @@ async function generateCI(dest, stack, ciDir, answers, vars) {
|
|
|
377
483
|
await write(dest, `.github/workflows/ci-backend.yml`, render(content, vars))
|
|
378
484
|
}
|
|
379
485
|
}
|
|
486
|
+
if (stack.backendKey === 'fastapi') {
|
|
487
|
+
const beTpl = path.join(ciDir, 'fastapi.yml')
|
|
488
|
+
if (await fs.pathExists(beTpl)) {
|
|
489
|
+
let content = await fs.readFile(beTpl, 'utf-8')
|
|
490
|
+
if (stack.frontendKey === 'no-frontend') {
|
|
491
|
+
content = content.replaceAll(' - backend/**', " - '**'")
|
|
492
|
+
.replaceAll('working-directory: backend', 'working-directory: .')
|
|
493
|
+
}
|
|
494
|
+
if (answers.testing === 'none') {
|
|
495
|
+
content = content.replace(' - name: Run tests\n run: uv run pytest\n', '')
|
|
496
|
+
}
|
|
497
|
+
await write(dest, `.github/workflows/ci-backend.yml`, render(content, vars))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
await write(dest, '.github/workflows/security.yml', generatedSecurityWorkflow(stack))
|
|
380
501
|
}
|
|
381
502
|
|
|
382
503
|
// ─── Runnable application files ─────────────────────────────────────────────────
|
|
@@ -16,7 +16,7 @@ export const nextjsAdapter = defineStackAdapter({
|
|
|
16
16
|
kind: 'frontend',
|
|
17
17
|
label: 'Next.js',
|
|
18
18
|
compatibleWith: {
|
|
19
|
-
backend: ['none', 'postgres', 'supabase', 'springboot', 'laravel'],
|
|
19
|
+
backend: ['none', 'postgres', 'supabase', 'springboot', 'laravel', 'fastapi'],
|
|
20
20
|
},
|
|
21
21
|
capabilities: {
|
|
22
22
|
applicationShapes: ['fullstack', 'separate'],
|
|
@@ -7,7 +7,10 @@ export function buildReactNativeFiles(answers, stack, shared) {
|
|
|
7
7
|
'package.json': packageFile(answers, stack),
|
|
8
8
|
'.node-version': `${stack.profile.runtimes.node}\n`,
|
|
9
9
|
'.npmrc': 'engine-strict=true\n',
|
|
10
|
-
'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 } } }),
|
|
10
|
+
'app.json': json({ expo: { name: answers.projectName, slug: answers.projectName, version: '1.0.0', orientation: 'portrait', scheme: answers.projectName, userInterfaceStyle: 'automatic', runtimeVersion: { policy: 'appVersion' }, updates: { checkAutomatically: 'ON_LOAD', fallbackToCacheTimeout: 0 }, plugins: stack.authentication === 'supabase' ? ['expo-router', 'expo-secure-store'] : ['expo-router'], experiments: { typedRoutes: true } } }),
|
|
11
|
+
'eas.json': json({ cli: { version: '>= 16.0.0', appVersionSource: 'remote' }, build: { development: { developmentClient: true, distribution: 'internal', channel: 'development' }, preview: { distribution: 'internal', channel: 'preview' }, production: { autoIncrement: true, channel: 'production' } }, submit: { production: {} } }),
|
|
12
|
+
'lib/deep-links.ts': `const allowedRoutes = new Set(['/', '/login'])\n\nexport function acceptDeepLink(value: string, scheme = '${answers.projectName}') {\n try {\n const url = new URL(value)\n if (url.protocol !== \`${'${scheme}'}:\`) return false\n const route = \`/${'${url.host}'}${'${url.pathname}'}\`.replace(/\\/$/, '') || '/'\n return allowedRoutes.has(route)\n } catch { return false }\n}\n`,
|
|
13
|
+
'docs/mobile/production.md': `# Mobile production\n\nEAS development, preview, and production builds use separate channels and environment variables. Keep signing credentials, service-role keys, and privileged operations outside the client. Sessions use platform SecureStore where authentication is enabled. Only allowlisted custom-scheme routes are accepted.\n\nRuntime compatibility follows the app version; publish updates only after testing the matching native runtime. Request the minimum native permissions and keep store privacy disclosures aligned with actual collection. Verify authentication, deep links, notifications, permissions, updates, and offline behavior on physical iOS and Android devices before release.\n`,
|
|
11
14
|
'tsconfig.json': json({ extends: 'expo/tsconfig.base', compilerOptions: { strict: true, types: ['jest'], paths: { '@/*': ['./*'] } }, include: ['**/*.ts', '**/*.tsx', '.expo/types/**/*.ts', 'expo-env.d.ts'] }),
|
|
12
15
|
'expo-env.d.ts': "/// <reference types=\"expo/types\" />\n",
|
|
13
16
|
'app/_layout.tsx': stack.authentication === 'supabase'
|
|
@@ -2,6 +2,6 @@ import { partitionEnvironment, renderEnvironment } from '../../shared/environmen
|
|
|
2
2
|
|
|
3
3
|
export function buildEnvironmentFiles(answers, stack) {
|
|
4
4
|
const { publicNames, serverNames } = partitionEnvironment(stack)
|
|
5
|
-
const names = stack.backendKey
|
|
5
|
+
const names = ['springboot', 'fastapi'].includes(stack.backendKey) ? [...publicNames, ...serverNames] : publicNames
|
|
6
6
|
return { '.env.example': renderEnvironment(names, answers) }
|
|
7
7
|
}
|
|
@@ -7,7 +7,7 @@ export const reactNativeAdapter = defineStackAdapter({
|
|
|
7
7
|
id: 'react-native',
|
|
8
8
|
kind: 'frontend',
|
|
9
9
|
label: 'React Native (Expo)',
|
|
10
|
-
compatibleWith: { backend: ['none', 'supabase', 'springboot', 'laravel'] },
|
|
10
|
+
compatibleWith: { backend: ['none', 'supabase', 'springboot', 'laravel', 'fastapi'] },
|
|
11
11
|
capabilities: {
|
|
12
12
|
applicationShapes: ['mobile'],
|
|
13
13
|
architectureProfiles: ['small', 'medium', 'large'],
|
|
@@ -3,6 +3,6 @@ import { partitionEnvironment, renderEnvironment } from '../../shared/environmen
|
|
|
3
3
|
export function buildEnvironmentFiles(answers, stack) {
|
|
4
4
|
const { publicNames, serverNames } = partitionEnvironment(stack)
|
|
5
5
|
const files = { 'frontend/.env.example': renderEnvironment(publicNames, answers) }
|
|
6
|
-
if (stack.backendKey
|
|
6
|
+
if (['springboot', 'fastapi'].includes(stack.backendKey)) files['.env.example'] = renderEnvironment(serverNames, answers)
|
|
7
7
|
return files
|
|
8
8
|
}
|
|
@@ -14,7 +14,7 @@ export const reactViteAdapter = defineStackAdapter({
|
|
|
14
14
|
id: 'react',
|
|
15
15
|
kind: 'frontend',
|
|
16
16
|
label: 'React + Vite',
|
|
17
|
-
compatibleWith: { backend: ['none', 'supabase', 'springboot', 'laravel'] },
|
|
17
|
+
compatibleWith: { backend: ['none', 'supabase', 'springboot', 'laravel', 'fastapi'] },
|
|
18
18
|
capabilities: {
|
|
19
19
|
applicationShapes: ['separate', 'frontend'],
|
|
20
20
|
architectureProfiles: ['small', 'medium', 'large'],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`
|
|
2
|
+
|
|
3
|
+
export function capabilityPackFiles(answers, stack) {
|
|
4
|
+
const files = {}
|
|
5
|
+
if (answers.uploads === 'object-storage') {
|
|
6
|
+
files['config/capabilities/uploads.json'] = json({
|
|
7
|
+
visibility: 'private', authorizeEveryOperation: true, maximumBytes: 10_485_760,
|
|
8
|
+
acceptedTypes: [], generatedObjectNames: true, verifySignature: true,
|
|
9
|
+
quarantineBeforeUse: true, malwareScanRequired: true, abandonedUploadCleanupHours: 24,
|
|
10
|
+
})
|
|
11
|
+
files['docs/capabilities/uploads.md'] = '# Private uploads\n\nAuthorize initiation, completion, download, and deletion. Validate declared type and file signature, generate object names, enforce size limits, quarantine until scanning succeeds, and remove abandoned or rejected objects. Tests must cover unauthorized access, oversize and signature rejection, quarantine, scan failure, and cleanup.\n'
|
|
12
|
+
}
|
|
13
|
+
if (answers.backgroundJobs === 'queue') {
|
|
14
|
+
files['config/capabilities/queue.json'] = json({
|
|
15
|
+
adapter: stack.backendKey === 'laravel' ? 'laravel-queue' : 'spring-task',
|
|
16
|
+
attempts: 5, timeoutSeconds: 30, exponentialBackoff: true,
|
|
17
|
+
idempotencyRequired: true, failedJobStore: true, correlationIds: true,
|
|
18
|
+
})
|
|
19
|
+
files['docs/capabilities/queue.md'] = '# Durable background jobs\n\nJobs require stable idempotency keys, bounded exponential retry with jitter, explicit timeouts, a failed-job store, correlation IDs, metrics, and an operator replay procedure. Tests cover duplicate delivery, timeout, retry exhaustion, and safe replay.\n'
|
|
20
|
+
}
|
|
21
|
+
if (answers.offline && answers.offline !== 'none') {
|
|
22
|
+
files['config/capabilities/offline.json'] = json({
|
|
23
|
+
mode: answers.offline, owner: 'mobile-client', containsSensitiveData: false,
|
|
24
|
+
keyNamespace: `${answers.projectName}:v1`, ttlSeconds: 3600,
|
|
25
|
+
invalidation: answers.offline === 'sync' ? 'server-version-and-user-signout' : 'ttl-and-user-signout',
|
|
26
|
+
conflictPolicy: answers.offline === 'sync' ? 'server-authoritative-with-explicit-user-resolution' : 'not-applicable',
|
|
27
|
+
})
|
|
28
|
+
files['docs/capabilities/offline.md'] = `# Offline ${answers.offline}\n\nThe mobile client owns this store. Namespace keys by user and schema version, purge on sign-out, exclude secrets, enforce TTL, expose offline/stale/synchronizing/error states, and test reconnect behavior. ${answers.offline === 'sync' ? 'Synchronize through idempotent server operations and surface conflicts; never silently overwrite divergent user data.' : 'This is a measured read cache, not a source of truth.'}\n`
|
|
29
|
+
}
|
|
30
|
+
return files
|
|
31
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export const environmentHeader = '# Copy to .env or .env.local. Never commit real credentials.\n\n'
|
|
2
2
|
|
|
3
3
|
export function environmentLine(name, answers) {
|
|
4
|
+
const isFastApi = answers.backend === 'fastapi'
|
|
5
|
+
const apiPort = isFastApi ? '8000' : '8080'
|
|
4
6
|
const defaults = {
|
|
5
|
-
NEXT_PUBLIC_API_URL:
|
|
6
|
-
VITE_API_URL:
|
|
7
|
-
EXPO_PUBLIC_API_URL:
|
|
8
|
-
DATABASE_URL:
|
|
7
|
+
NEXT_PUBLIC_API_URL: `http://localhost:${apiPort}`,
|
|
8
|
+
VITE_API_URL: `http://localhost:${apiPort}`,
|
|
9
|
+
EXPO_PUBLIC_API_URL: `http://localhost:${apiPort}`,
|
|
10
|
+
DATABASE_URL: isFastApi
|
|
11
|
+
? `postgresql+asyncpg://localhost:5432/${answers.projectName.replaceAll('-', '_')}`
|
|
12
|
+
: `jdbc:postgresql://localhost:5432/${answers.projectName.replaceAll('-', '_')}`,
|
|
9
13
|
POSTGRES_USER: 'postgres',
|
|
10
14
|
POSTGRES_PASSWORD: 'change-me',
|
|
11
15
|
POSTGRES_DB: answers.projectName.replaceAll('-', '_'),
|
|
@@ -13,7 +17,11 @@ export function environmentLine(name, answers) {
|
|
|
13
17
|
SPRING_SECURITY_USER_NAME: 'developer',
|
|
14
18
|
SPRING_SECURITY_USER_PASSWORD: 'change-me-before-production',
|
|
15
19
|
OIDC_ISSUER_URI: 'http://localhost:9090/realms/app',
|
|
20
|
+
OIDC_ISSUER: '',
|
|
16
21
|
OIDC_AUDIENCE: 'api',
|
|
22
|
+
OIDC_ALGORITHMS: 'RS256',
|
|
23
|
+
OIDC_JWKS_URL: '',
|
|
24
|
+
CORS_ALLOWED_ORIGINS: 'http://localhost:3000',
|
|
17
25
|
}
|
|
18
26
|
return `${name}=${defaults[name] || ''}`
|
|
19
27
|
}
|
|
@@ -13,6 +13,12 @@ export function composerPackageVersion(profile, name, owner = name) {
|
|
|
13
13
|
return resolved
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function pythonPackageVersion(profile, name, owner = name) {
|
|
17
|
+
const resolved = profile.pythonPackages?.[name]
|
|
18
|
+
if (!resolved) throw new Error(`${owner} requires ${name} in compatibility profile ${profile.id}`)
|
|
19
|
+
return resolved
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
export function json(value) {
|
|
17
23
|
return `${JSON.stringify(value, null, 2)}\n`
|
|
18
24
|
}
|