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/files.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// ─── Generated File Contents ──────────────────────────────────────────────────
|
|
2
|
+
// Each function returns the content for a specific generated file.
|
|
3
|
+
// Static helpers only — no stack identity checks. Stack-specific files are
|
|
4
|
+
// now template-driven via templates/ and readTemplate().
|
|
5
|
+
|
|
6
|
+
export function contextMd(vars, expectedConcerns) {
|
|
7
|
+
const concernsBlock = (expectedConcerns && expectedConcerns.length)
|
|
8
|
+
? expectedConcerns.map((c) => `- ${c}`).join('\n')
|
|
9
|
+
: '- (none selected — all optional concerns remain available)'
|
|
10
|
+
return `# CONTEXT.md
|
|
11
|
+
|
|
12
|
+
## Project
|
|
13
|
+
**Name:** {{PROJECT_NAME}}
|
|
14
|
+
**Description:** {{PROJECT_DESCRIPTION}}
|
|
15
|
+
**Stack:** {{STACK}}
|
|
16
|
+
**Styling:** {{STYLE_MODE}}
|
|
17
|
+
**Compatibility profile:** {{COMPATIBILITY_PROFILE}}
|
|
18
|
+
**Year:** {{YEAR}}
|
|
19
|
+
|
|
20
|
+
## Goals
|
|
21
|
+
<!-- What this project does and who it's for -->
|
|
22
|
+
|
|
23
|
+
## Key Decisions
|
|
24
|
+
<!-- Architecture and tech decisions made during the project -->
|
|
25
|
+
|
|
26
|
+
## Out of Scope
|
|
27
|
+
<!-- What this project explicitly does NOT do -->
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
<!-- Anything else agents or contributors should know -->
|
|
31
|
+
|
|
32
|
+
## Expected Concerns (advisory)
|
|
33
|
+
${concernsBlock}
|
|
34
|
+
`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function progressMd() {
|
|
38
|
+
return `# PROGRESS.md
|
|
39
|
+
|
|
40
|
+
## Status
|
|
41
|
+
🟡 In Progress
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Completed
|
|
46
|
+
<!-- Move items here when done -->
|
|
47
|
+
|
|
48
|
+
## In Progress
|
|
49
|
+
<!-- Current work -->
|
|
50
|
+
|
|
51
|
+
## Up Next
|
|
52
|
+
<!-- Planned work -->
|
|
53
|
+
|
|
54
|
+
## Blocked
|
|
55
|
+
<!-- Anything blocking progress -->
|
|
56
|
+
|
|
57
|
+
## Decisions Made
|
|
58
|
+
<!-- Key decisions logged here -->
|
|
59
|
+
`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function docPlaceholder(title, description) {
|
|
63
|
+
return `# ${title}
|
|
64
|
+
|
|
65
|
+
> ${description}
|
|
66
|
+
|
|
67
|
+
<!-- Add content here -->
|
|
68
|
+
`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function editorconfig() {
|
|
72
|
+
return `root = true
|
|
73
|
+
|
|
74
|
+
[*]
|
|
75
|
+
end_of_line = lf
|
|
76
|
+
insert_final_newline = true
|
|
77
|
+
charset = utf-8
|
|
78
|
+
trim_trailing_whitespace = true
|
|
79
|
+
|
|
80
|
+
[*.{js,ts,jsx,tsx,json,css,md,yml,yaml}]
|
|
81
|
+
indent_style = space
|
|
82
|
+
indent_size = 2
|
|
83
|
+
|
|
84
|
+
[*.java]
|
|
85
|
+
indent_style = space
|
|
86
|
+
indent_size = 4
|
|
87
|
+
|
|
88
|
+
[Makefile]
|
|
89
|
+
indent_style = tab
|
|
90
|
+
`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function prettierrc() {
|
|
94
|
+
return `{
|
|
95
|
+
"$schema": "https://json.schemastore.org/prettierrc",
|
|
96
|
+
"semi": false,
|
|
97
|
+
"singleQuote": true,
|
|
98
|
+
"jsxSingleQuote": false,
|
|
99
|
+
"trailingComma": "es5",
|
|
100
|
+
"printWidth": 100,
|
|
101
|
+
"tabWidth": 2,
|
|
102
|
+
"useTabs": false,
|
|
103
|
+
"bracketSpacing": true,
|
|
104
|
+
"bracketSameLine": false,
|
|
105
|
+
"arrowParens": "always",
|
|
106
|
+
"endOfLine": "lf"
|
|
107
|
+
}
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── PR Template ─────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function prTemplate() {
|
|
114
|
+
return `## What does this PR do?
|
|
115
|
+
|
|
116
|
+
<!-- Describe the change clearly. What problem does it solve? -->
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Type of change
|
|
121
|
+
- [ ] \`feat\` — new feature
|
|
122
|
+
- [ ] \`fix\` — bug fix
|
|
123
|
+
- [ ] \`refactor\` — restructure without behavior change
|
|
124
|
+
- [ ] \`chore\` — deps, config, tooling
|
|
125
|
+
- [ ] \`docs\` — documentation only
|
|
126
|
+
- [ ] \`test\` — adding or updating tests
|
|
127
|
+
|
|
128
|
+
## Scope
|
|
129
|
+
- [ ] \`frontend\`
|
|
130
|
+
- [ ] \`backend\`
|
|
131
|
+
- [ ] \`mobile\`
|
|
132
|
+
- [ ] \`ci\`
|
|
133
|
+
- [ ] \`docs\`
|
|
134
|
+
- [ ] \`deps\`
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## How to test?
|
|
139
|
+
1.
|
|
140
|
+
2.
|
|
141
|
+
3.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Checklist
|
|
146
|
+
- [ ] Branched off \`dev\`
|
|
147
|
+
- [ ] Commits follow \`type(scope): description\`
|
|
148
|
+
- [ ] No \`console.log\` or debug code
|
|
149
|
+
- [ ] No hardcoded secrets
|
|
150
|
+
- [ ] Lint passes
|
|
151
|
+
- [ ] Tests pass
|
|
152
|
+
- [ ] Docs updated if endpoints or rules changed
|
|
153
|
+
- [ ] Compatibility matrix passes if a profile, dependency, runtime, or container changed
|
|
154
|
+
- [ ] Major upgrades include migration notes
|
|
155
|
+
|
|
156
|
+
Closes #
|
|
157
|
+
`
|
|
158
|
+
}
|
package/lib/generator.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { loadCatalog, resolveStack } from './catalog.js'
|
|
5
|
+
import { loadCompatibility } from './compatibility.js'
|
|
6
|
+
import { buildRulesIndex, copySelectedPlaybooks } from './playbooks.js'
|
|
7
|
+
import { buildVars, render, readTemplate } from './template.js'
|
|
8
|
+
import { buildRunnableFiles } from './scaffold.js'
|
|
9
|
+
import { buildLaravelFiles } from './laravel-scaffold.js'
|
|
10
|
+
import {
|
|
11
|
+
contextMd, progressMd, docPlaceholder,
|
|
12
|
+
editorconfig, prettierrc, prTemplate,
|
|
13
|
+
} from './files.js'
|
|
14
|
+
|
|
15
|
+
function laravelCompose(answers, stack, vars) {
|
|
16
|
+
const laravelDir = stack.frontendKey === 'laravel-ui' || stack.frontendKey === 'no-frontend' ? '.' : './backend'
|
|
17
|
+
const frontend = stack.frontendKey === 'react'
|
|
18
|
+
? ` frontend:\n build:\n context: ./frontend\n dockerfile: Dockerfile.dev\n ports:\n - "5173:5173"\n volumes:\n - ./frontend:/app\n - frontend-node-modules:/app/node_modules\n environment:\n VITE_API_URL: http://localhost:8000\n depends_on:\n - backend\n\n`
|
|
19
|
+
: stack.frontendKey === 'nextjs'
|
|
20
|
+
? ` frontend:\n build:\n context: .\n dockerfile: Dockerfile.dev\n ports:\n - "3000:3000"\n volumes:\n - .:/app\n - /app/backend\n - frontend-node-modules:/app/node_modules\n environment:\n NEXT_PUBLIC_API_URL: http://localhost:8000\n depends_on:\n - backend\n\n`
|
|
21
|
+
: ''
|
|
22
|
+
const frontendVolume = frontend ? ' frontend-node-modules:\n' : ''
|
|
23
|
+
return `services:\n${frontend} backend:\n build:\n context: ${laravelDir}\n dockerfile: Dockerfile.dev\n ports:\n - "8000:8000"\n volumes:\n - ${laravelDir}:/app\n - laravel-vendor:/app/vendor\n environment:\n APP_ENV: local\n APP_DEBUG: "true"\n APP_KEY: \${APP_KEY:-}\n DB_CONNECTION: pgsql\n DB_HOST: db\n DB_PORT: 5432\n DB_DATABASE: \${POSTGRES_DB}\n DB_USERNAME: \${POSTGRES_USER}\n DB_PASSWORD: \${POSTGRES_PASSWORD}\n depends_on:\n db:\n condition: service_healthy\n\n db:\n image: ${vars.POSTGRES_IMAGE}\n ports:\n - "5432:5432"\n environment:\n POSTGRES_DB: \${POSTGRES_DB}\n POSTGRES_USER: \${POSTGRES_USER}\n POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]\n interval: 5s\n timeout: 5s\n retries: 10\n\nvolumes:\n${frontendVolume} laravel-vendor:\n postgres-data:\n`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Main entry point — generates the full project
|
|
28
|
+
*/
|
|
29
|
+
export async function generateProject(answers, cliRoot) {
|
|
30
|
+
validateAnswers(answers)
|
|
31
|
+
const finalDest = path.join(process.cwd(), answers.projectName)
|
|
32
|
+
const dest = path.join(process.cwd(), `.${answers.projectName}.tmp-${randomUUID()}`)
|
|
33
|
+
const playbooksDir = path.join(cliRoot, 'playbooks')
|
|
34
|
+
const ciDir = path.join(cliRoot, 'ci')
|
|
35
|
+
const { profile } = await loadCompatibility(
|
|
36
|
+
path.join(cliRoot, 'compatibility/profiles.json'),
|
|
37
|
+
answers.compatibilityProfile,
|
|
38
|
+
)
|
|
39
|
+
const catalog = await loadCatalog(playbooksDir, profile)
|
|
40
|
+
|
|
41
|
+
// Ensure styling is resolved before passing to resolveStack
|
|
42
|
+
const resolvedAnswers = {
|
|
43
|
+
...answers,
|
|
44
|
+
styling: answers.styling || catalog.byId[answers.frontend]?.stylingOptions?.[0] || 'tailwind',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stack = resolveStack(resolvedAnswers, catalog)
|
|
48
|
+
const vars = buildVars(resolvedAnswers, stack)
|
|
49
|
+
const templatesDir = path.join(cliRoot, 'templates')
|
|
50
|
+
|
|
51
|
+
// 1. Refuse to merge into an existing project. Generation must never silently
|
|
52
|
+
// overwrite user work.
|
|
53
|
+
if (await fs.pathExists(finalDest)) {
|
|
54
|
+
throw new Error(`Destination already exists: ${finalDest}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// 2. Staging folder. It is moved into place only after every generation
|
|
59
|
+
// step succeeds, so failures never leave a half-written project.
|
|
60
|
+
await fs.ensureDir(dest)
|
|
61
|
+
|
|
62
|
+
// 3. Root documentation and repository files. Directories are created only
|
|
63
|
+
// when a real generated file needs them; empty architecture theatre is not
|
|
64
|
+
// part of the scaffold contract.
|
|
65
|
+
await generateRootFiles(dest, resolvedAnswers, vars, stack, templatesDir)
|
|
66
|
+
|
|
67
|
+
// 4. Small, runnable framework foundations. These files are the executable
|
|
68
|
+
// contract that the playbooks describe.
|
|
69
|
+
await generateRunnableFiles(dest, resolvedAnswers, stack, vars)
|
|
70
|
+
|
|
71
|
+
// 5. Product documentation
|
|
72
|
+
await generateDocs(dest, resolvedAnswers, stack)
|
|
73
|
+
|
|
74
|
+
// 6. GitHub Actions CI (template-driven)
|
|
75
|
+
if (resolvedAnswers.githubActions) {
|
|
76
|
+
await generateCI(dest, stack, ciDir, resolvedAnswers, vars)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 7. Copy selected playbooks (including concern files)
|
|
80
|
+
await copySelectedPlaybooks(playbooksDir, dest, stack)
|
|
81
|
+
|
|
82
|
+
// 8. RULES.md
|
|
83
|
+
const rulesContent = await buildRulesIndex(stack, catalog, playbooksDir)
|
|
84
|
+
await write(dest, 'RULES.md', rulesContent)
|
|
85
|
+
await fs.move(dest, finalDest)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
await fs.remove(dest)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validateAnswers(answers) {
|
|
93
|
+
if (!answers || typeof answers !== 'object') throw new Error('Project answers are required')
|
|
94
|
+
if (!/^[a-z0-9-]+$/.test(answers.projectName || '')) {
|
|
95
|
+
throw new Error('Project name must use lowercase letters, numbers, and hyphens only')
|
|
96
|
+
}
|
|
97
|
+
if (typeof answers.projectDescription !== 'string' || !answers.projectDescription.trim()) {
|
|
98
|
+
throw new Error('Project description is required')
|
|
99
|
+
}
|
|
100
|
+
if (answers.testing && !['none', 'basic', 'full'].includes(answers.testing)) {
|
|
101
|
+
throw new Error(`Unknown testing setup: ${answers.testing}`)
|
|
102
|
+
}
|
|
103
|
+
if (answers.architecture && !['small', 'medium', 'large'].includes(answers.architecture)) {
|
|
104
|
+
throw new Error(`Unknown architecture profile: ${answers.architecture}`)
|
|
105
|
+
}
|
|
106
|
+
if (answers.authentication && !['yes', 'not-yet', 'none'].includes(answers.authentication)) {
|
|
107
|
+
throw new Error(`Unknown authentication choice: ${answers.authentication}`)
|
|
108
|
+
}
|
|
109
|
+
if (answers.backend === 'springboot' && !/^[a-z]+(\.[a-z][a-z0-9]*)+$/.test(answers.packageName || '')) {
|
|
110
|
+
throw new Error('Java package name must look like com.example')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Root files ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async function generateRootFiles(dest, answers, vars, stack, templatesDir) {
|
|
117
|
+
await writeTemplate(dest, 'CONTEXT.md', contextMd(vars, answers.expectedConcerns), vars)
|
|
118
|
+
// AGENTS.md — template-driven
|
|
119
|
+
{
|
|
120
|
+
const tpl = await readTemplate(templatesDir, 'agents', stack.agentsTemplate, '.md')
|
|
121
|
+
if (tpl) await writeTemplate(dest, 'AGENTS.md', tpl, vars)
|
|
122
|
+
else await write(dest, 'AGENTS.md', `# AGENTS.md\nStack: ${stack.label}\n`)
|
|
123
|
+
}
|
|
124
|
+
await write(dest, 'PROGRESS.md', progressMd())
|
|
125
|
+
// README.md is generated with the runnable framework files. Keeping one
|
|
126
|
+
// owner prevents a generic template from drifting away from real commands.
|
|
127
|
+
// .gitignore — template-driven
|
|
128
|
+
{
|
|
129
|
+
const tpl = await readTemplate(templatesDir, 'gitignore', stack.gitignoreTemplate, '.gitignore')
|
|
130
|
+
if (tpl) await writeTemplate(dest, '.gitignore', tpl, vars)
|
|
131
|
+
else {
|
|
132
|
+
const baseTpl = await readTemplate(templatesDir, 'gitignore', 'base', '.gitignore')
|
|
133
|
+
if (baseTpl) await write(dest, '.gitignore', baseTpl)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await write(dest, '.editorconfig', editorconfig())
|
|
137
|
+
await write(dest, '.prettierrc', prettierrc())
|
|
138
|
+
|
|
139
|
+
// Makefile — template-driven (web only)
|
|
140
|
+
if (answers.makefile && !stack.isMobile) {
|
|
141
|
+
const tpl = await readTemplate(templatesDir, 'makefile', stack.makefileTemplate, '.mk')
|
|
142
|
+
if (tpl) await writeTemplate(dest, 'Makefile', tpl, vars)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Docker — template-driven
|
|
146
|
+
// Mobile frontends run through Expo, but a separate Laravel backend still
|
|
147
|
+
// needs its backend and PostgreSQL services. laravelCompose intentionally
|
|
148
|
+
// omits a frontend service for React Native.
|
|
149
|
+
if (answers.docker && (!stack.isMobile || stack.backendKey === 'laravel')) {
|
|
150
|
+
// docker-compose.yml
|
|
151
|
+
let composeTpl = null
|
|
152
|
+
if (stack.backendKey === 'laravel') {
|
|
153
|
+
composeTpl = laravelCompose(answers, stack, vars)
|
|
154
|
+
} else if (stack.needsPackage) {
|
|
155
|
+
composeTpl = await readTemplate(templatesDir, 'docker/compose', 'springboot', '.yml')
|
|
156
|
+
} else if (stack.backendKey === 'supabase') {
|
|
157
|
+
composeTpl = await readTemplate(templatesDir, 'docker/compose', 'supabase', '.yml')
|
|
158
|
+
} else {
|
|
159
|
+
composeTpl = await readTemplate(templatesDir, 'docker/compose', 'postgres', '.yml')
|
|
160
|
+
}
|
|
161
|
+
if (composeTpl) await writeTemplate(dest, 'docker-compose.yml', composeTpl, vars)
|
|
162
|
+
|
|
163
|
+
if (stack.backendKey === 'laravel') {
|
|
164
|
+
const laravelRoot = stack.frontendKey === 'laravel-ui' || stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
165
|
+
const dockerfileName = stack.frontendKey === 'laravel-ui' && stack.laravelUi === 'inertia-react' ? 'laravel-inertia.dev' : 'laravel.dev'
|
|
166
|
+
const laravelDev = await readTemplate(templatesDir, 'docker/dockerfile', dockerfileName, '.dockerfile')
|
|
167
|
+
if (laravelDev) await writeTemplate(dest, `${laravelRoot}Dockerfile.dev`, laravelDev, vars)
|
|
168
|
+
const laravelProd = await readTemplate(templatesDir, 'docker/dockerfile', 'laravel.prod', '.dockerfile')
|
|
169
|
+
if (laravelProd) await writeTemplate(dest, `${laravelRoot}Dockerfile`, laravelProd, vars)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// docker-compose.prod.yml — only for springboot
|
|
173
|
+
if (stack.needsPackage) {
|
|
174
|
+
const prodTpl = await readTemplate(templatesDir, 'docker/compose-prod', 'springboot', '.yml')
|
|
175
|
+
if (prodTpl) await writeTemplate(dest, 'docker-compose.prod.yml', prodTpl, vars)
|
|
176
|
+
|
|
177
|
+
const beDev = await readTemplate(templatesDir, 'docker/dockerfile', 'springboot.dev', '.dockerfile')
|
|
178
|
+
if (beDev) await writeTemplate(dest, 'backend/Dockerfile.dev', beDev, vars)
|
|
179
|
+
const beProd = await readTemplate(templatesDir, 'docker/dockerfile', 'springboot.prod', '.dockerfile')
|
|
180
|
+
if (beProd) await writeTemplate(dest, 'backend/Dockerfile', beProd, vars)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// frontend dockerfiles — vite vs nextjs
|
|
184
|
+
if (stack.frontendKey === 'react') {
|
|
185
|
+
const viteDev = await readTemplate(templatesDir, 'docker/dockerfile', 'vite.dev', '.dockerfile')
|
|
186
|
+
if (viteDev) await writeTemplate(dest, 'frontend/Dockerfile.dev', viteDev, vars)
|
|
187
|
+
const viteProd = await readTemplate(templatesDir, 'docker/dockerfile', 'vite.prod', '.dockerfile')
|
|
188
|
+
if (viteProd) {
|
|
189
|
+
await writeTemplate(dest, 'frontend/Dockerfile', viteProd, vars)
|
|
190
|
+
const nginx = `server {\n listen 80;\n location / {\n root /usr/share/nginx/html;\n index index.html;\n try_files $uri $uri/ /index.html;\n }\n}\n`
|
|
191
|
+
await write(dest, 'frontend/nginx.conf', nginx)
|
|
192
|
+
}
|
|
193
|
+
} else if (stack.frontendKey === 'nextjs') {
|
|
194
|
+
const nextDev = await readTemplate(templatesDir, 'docker/dockerfile', 'nextjs.dev', '.dockerfile')
|
|
195
|
+
if (nextDev) await writeTemplate(dest, 'Dockerfile.dev', nextDev, vars)
|
|
196
|
+
const nextProd = await readTemplate(templatesDir, 'docker/dockerfile', 'nextjs.prod', '.dockerfile')
|
|
197
|
+
if (nextProd) await writeTemplate(dest, 'Dockerfile', nextProd, vars)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// PR template
|
|
202
|
+
if (answers.githubActions) {
|
|
203
|
+
await write(dest, '.github/PULL_REQUEST_TEMPLATE.md', prTemplate())
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Doc placeholders ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async function generateDocs(dest, answers, stack) {
|
|
210
|
+
const docs = [
|
|
211
|
+
['docs/api/overview.md', 'API Overview', 'Base URL, authentication method, and response format.'],
|
|
212
|
+
['docs/api/endpoints.md', 'API Endpoints', 'All endpoint documentation goes here.'],
|
|
213
|
+
['docs/api/errors.md', 'Error Code Registry', 'All error codes with status, category, and description.'],
|
|
214
|
+
['docs/architecture/overview.md', 'Architecture Overview', 'System design and how the pieces connect.'],
|
|
215
|
+
['docs/architecture/database-schema.md', 'Database Schema', 'Tables, columns, relationships, and ERD.'],
|
|
216
|
+
['docs/architecture/auth-flow.md', 'Auth Flow', 'Step-by-step authentication flow.'],
|
|
217
|
+
['docs/guides/setup.md', 'Local Setup Guide', 'Prerequisites and step-by-step local setup.'],
|
|
218
|
+
['docs/guides/deployment.md', 'Deployment Guide', 'How to deploy to production.'],
|
|
219
|
+
['docs/guides/env-variables.md', 'Environment Variables', 'Every variable: name, required, description, example.'],
|
|
220
|
+
]
|
|
221
|
+
for (const [filePath, title, description] of docs) {
|
|
222
|
+
await write(dest, filePath, docPlaceholder(title, description))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const frontendRoot = stack.frontendKey === 'react' ? 'frontend/' : ''
|
|
226
|
+
const validation = stack.isMobile
|
|
227
|
+
? `npm run typecheck\n${answers.testing === 'none' ? '' : 'npm test -- --runInBand\n'}npm run build -- --platform web`
|
|
228
|
+
: 'npm run lint\nnpm run typecheck\nnpm run test --if-present\nnpm run build'
|
|
229
|
+
await write(dest, 'docs/guides/setup.md', `# Local Setup Guide\n\n## Prerequisites\n\n- Node.js 20 or newer\n- npm\n${stack.backendKey === 'springboot' ? '- Java 21 and Maven 3.9, or Docker\n- PostgreSQL 16, or Docker\n' : ''}${stack.backendKey === 'supabase' ? '- A running Docker-compatible runtime for the pinned local Supabase CLI\n' : ''}\n## Install and start\n\n\`\`\`bash\n${frontendRoot ? `cd ${frontendRoot}\n` : ''}cp .env.example ${stack.isMobile ? '.env' : '.env.local'}\nnpm install\nnpm run dev\n\`\`\`\n${stack.backendKey === 'springboot' ? `\nIn another terminal from the repository root:\n\n\`\`\`bash\n${stack.frontendKey === 'react' ? 'cp .env.example .env\n' : ''}docker compose up -d db\ncd backend\nmvn spring-boot:run\n\`\`\`\n` : ''}${stack.backendKey === 'supabase' ? `\nStart the local Supabase stack from the npm package directory:\n\n\`\`\`bash\n${frontendRoot ? `cd ${frontendRoot}\n` : ''}npm run supabase:start\n\`\`\`\n` : ''}\n## Validate\n\n\`\`\`bash\n${frontendRoot ? `cd ${frontendRoot}\n` : ''}${validation}\n\`\`\`\n\nCommit the generated \`package-lock.json\`; CI uses \`npm ci\`.\n`)
|
|
230
|
+
|
|
231
|
+
const setupPath = path.join(dest, 'docs/guides/setup.md')
|
|
232
|
+
let setupGuide = (await fs.readFile(setupPath, 'utf8'))
|
|
233
|
+
.replace('Node.js 20 or newer', `Node.js ${stack.profile.runtimes.node}`)
|
|
234
|
+
.replace('Java 21 and Maven 3.9, or Docker', `Java ${stack.profile.runtimes.java}; Maven ${stack.profile.runtimes.maven} or Docker is used through the generated launcher`)
|
|
235
|
+
.replace('PostgreSQL 16, or Docker', `PostgreSQL ${stack.profile.runtimes.postgres}, or Docker`)
|
|
236
|
+
.replace('mvn spring-boot:run', './mvnw spring-boot:run # use mvnw.cmd on Windows')
|
|
237
|
+
if (stack.backendKey === 'laravel') {
|
|
238
|
+
const laravelDir = stack.frontendKey === 'laravel-ui' || stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
239
|
+
setupGuide = `# Local Setup Guide\n\n## Docker-first setup\n\nOnly Docker with Compose is required. Make is optional; every Make target has a direct Docker equivalent.\n\n\`\`\`bash\ncp ${laravelDir}.env.example ${laravelDir}.env\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 is explicit with \`docker compose build\`. If Make is available, \`make setup\` performs the first-run sequence and \`make run\` starts existing images without rebuilding.\n\n## Validate\n\n\`\`\`bash\ndocker compose exec backend composer check\n\`\`\`\n\nWhen dependencies are installed outside Docker, commit \`composer.lock\`${stack.frontendKey === 'laravel-ui' && stack.laravelUi === 'inertia-react' ? ' and `package-lock.json`' : ''}.\n`
|
|
240
|
+
}
|
|
241
|
+
await fs.writeFile(setupPath, setupGuide, 'utf8')
|
|
242
|
+
|
|
243
|
+
const envLocation = stack.frontendKey === 'react' ? '`frontend/.env` for client values' : stack.isMobile ? '`.env`' : '`.env.local`'
|
|
244
|
+
await write(dest, 'docs/guides/env-variables.md', `# Environment Variables\n\nCopy the generated example before starting. Client environment location: ${envLocation}.\n\n| Variable | Visibility | Required | Purpose |\n|---|---|---:|---|\n${stack.env.map((name) => `| \`${name}\` | ${name.startsWith(stack.envPrefix) ? 'client/public' : 'server only'} | yes | ${environmentPurpose(name)} |`).join('\n')}\n\nValues with \`${stack.envPrefix}\` are bundled into client code and must never contain secrets. Keep real environment files out of version control.\n`)
|
|
245
|
+
|
|
246
|
+
await write(dest, 'docs/architecture/overview.md', `# Architecture Overview\n\n## Runtime shape\n\n- Frontend: ${stack.frontendLabel}\n- Backend/data: ${stack.backendLabel}\n- Platform: ${stack.platform}\n- Architecture profile: ${stack.architecture}\n- Authentication: ${stack.authentication}\n\nThe generated application is intentionally a small vertical slice. Add domain features only after recording product goals and boundaries in \`CONTEXT.md\`. Keep entry points thin, validate at trust boundaries, and enforce authorization beside protected data or side effects.\n\n## Verification boundary\n\nThe starter is considered healthy when its lint/typecheck/tests/build commands pass. Documentation explains those executable patterns; it does not override working code and tests.\n`)
|
|
247
|
+
|
|
248
|
+
await write(dest, 'docs/architecture/auth-flow.md', authDocumentation(stack))
|
|
249
|
+
|
|
250
|
+
const health = stack.backendKey === 'springboot'
|
|
251
|
+
? 'GET http://localhost:8080/api/health'
|
|
252
|
+
: stack.backendKey === 'laravel'
|
|
253
|
+
? 'GET http://localhost:8000/api/health'
|
|
254
|
+
: stack.frontendKey === 'nextjs' ? 'GET /api/health' : '(No custom HTTP API is generated for this client-only starter.)'
|
|
255
|
+
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`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function environmentPurpose(name) {
|
|
259
|
+
if (name.endsWith('SUPABASE_URL')) return 'Supabase project URL.'
|
|
260
|
+
if (name.endsWith('SUPABASE_PUBLISHABLE_KEY')) return 'Public Supabase key; RLS protects data.'
|
|
261
|
+
if (name.endsWith('API_URL')) return 'Base URL of the application API.'
|
|
262
|
+
if (name === 'DATABASE_URL') return 'Server-side PostgreSQL JDBC connection URL.'
|
|
263
|
+
if (name === 'POSTGRES_USER') return 'Local/deployed database user.'
|
|
264
|
+
if (name === 'POSTGRES_PASSWORD') return 'Database credential; replace the development example.'
|
|
265
|
+
if (name === 'POSTGRES_DB') return 'Database name.'
|
|
266
|
+
if (name === 'SPRING_PROFILES_ACTIVE') return 'Active Spring configuration profile.'
|
|
267
|
+
if (name === 'SPRING_SECURITY_USER_NAME') return 'Development-only generated Spring login name; replace with the product identity store.'
|
|
268
|
+
if (name === 'SPRING_SECURITY_USER_PASSWORD') return 'Development-only Spring login credential; never commit a real value.'
|
|
269
|
+
if (name === 'OIDC_ISSUER_URI') return 'Trusted OpenID Connect issuer used to validate access tokens.'
|
|
270
|
+
if (name === 'OIDC_AUDIENCE') return 'Required audience for tokens accepted by this API.'
|
|
271
|
+
if (name === 'SESSION_DOMAIN') return 'Exact domain scope for the secure session cookie.'
|
|
272
|
+
if (name === 'SANCTUM_STATEFUL_DOMAINS') return 'Comma-separated first-party browser hosts allowed to use Sanctum session authentication.'
|
|
273
|
+
if (name === 'CORS_ALLOWED_ORIGINS') return 'Exact browser origins allowed to make credentialed API requests.'
|
|
274
|
+
if (name === 'AUTH0_DOMAIN') return 'Trusted Auth0 tenant domain used for access-token signature and issuer validation.'
|
|
275
|
+
if (name === 'AUTH0_AUDIENCE') return 'Exact API audience required in accepted Auth0 access tokens.'
|
|
276
|
+
return 'Stack configuration.'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function authDocumentation(stack) {
|
|
280
|
+
if (stack.authentication === 'public') return `# Authentication Flow\n\nThis project intentionally has no user accounts. No login, session, or refresh mechanism is generated. Application endpoints are public; add authentication and resource authorization before introducing protected data. Do not rerun the generator over this project to add auth—change the existing project deliberately with its stack security playbook and tests.\n`
|
|
281
|
+
if (stack.authentication === 'undecided') return `# Authentication Flow\n\nAuthentication is intentionally undecided. No login or pretend role state is generated. ${stack.backendKey === 'springboot' ? 'Spring permits health endpoints and denies every other request.' : 'The client starts without a user session; any remote service remains responsible for access control.'}\n\nWhen auth is added, document login, storage, expiry, refresh ownership, logout, recovery, request-integrity boundaries, and resource authorization here. Do not rerun the generator over this project.\n`
|
|
282
|
+
if (stack.authentication === 'supabase' && stack.frontendKey === 'nextjs') return `# Authentication Flow\n\n## Generated Supabase SSR flow\n\n1. Browser code uses \`src/lib/supabase/client.ts\` with the publishable key.\n2. Server code uses the cookie-aware client in \`src/lib/supabase/server.ts\`.\n3. \`src/proxy.ts\` refreshes/verifies with \`getClaims()\` and synchronizes request and response cookies.\n4. The PKCE callback exchanges an authorization code and redirects only to the application root.\n5. Every server operation authorizes again near data access; RLS is the final row boundary.\n\nProxy is not authorization. Never authorize from \`getSession()\` alone. See \`playbooks/capabilities/supabase/nextjs.md\`.\n`
|
|
283
|
+
if (stack.authentication === 'supabase') return `# Authentication Flow\n\nSupabase Auth owns access-token and refresh-token rotation. The project uses only the publishable key and RLS remains the authorization boundary. ${stack.isMobile ? 'Expo stores the session in SecureStore and binds automatic refresh to application foreground/background lifecycle.' : 'The browser SDK owns persistence and refresh; do not build a second interceptor.'}\n\nRegister exact callback/deep-link URLs and test expiry, revocation, logout, recovery, and RLS allow/deny cases. See the selected Supabase capability playbook.\n`
|
|
284
|
+
if (stack.authentication === 'session') return `# Authentication Flow\n\nSpring Security owns a server-side browser session. The browser receives an HttpOnly session cookie; Spring rotates the session identifier after authentication, keeps CSRF protection enabled, and invalidates the session on logout. The generated default login is development scaffolding—replace it with the product identity store before production. Authorization remains server-side and includes resource ownership. There is no browser refresh token.\n`
|
|
285
|
+
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`
|
|
286
|
+
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`
|
|
287
|
+
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`
|
|
288
|
+
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`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── CI — template-driven ─────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async function generateCI(dest, stack, ciDir, answers, vars) {
|
|
294
|
+
// Frontend CI — read from ci/{ciTemplate}.yml
|
|
295
|
+
const feTpl = path.join(ciDir, `${stack.ciTemplate}.yml`)
|
|
296
|
+
if (await fs.pathExists(feTpl)) {
|
|
297
|
+
let content = await fs.readFile(feTpl, 'utf-8')
|
|
298
|
+
if (answers.testing === 'none') {
|
|
299
|
+
content = content.replace(/^\s*- run: npm (?:run )?test.*\n/gm, '')
|
|
300
|
+
}
|
|
301
|
+
if (stack.frontendKey === 'nextjs' && stack.backendKey !== 'supabase') {
|
|
302
|
+
content = content.replace(/\n\s+env:\n\s+NEXT_PUBLIC_SUPABASE_URL:.*\n\s+NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY:.*\n/, '\n')
|
|
303
|
+
}
|
|
304
|
+
if (stack.frontendKey === 'react' && stack.backendKey === 'supabase') {
|
|
305
|
+
content = content.replace(
|
|
306
|
+
' VITE_API_URL: http://localhost:8080',
|
|
307
|
+
' VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}\n VITE_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_KEY }}',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
if (answers.testing === 'full' && !stack.isMobile) {
|
|
311
|
+
content += ' - name: Install Playwright browser\n run: npx playwright install --with-deps chromium\n'
|
|
312
|
+
content += ' - name: Run end-to-end tests\n run: npm run test:e2e\n'
|
|
313
|
+
if (stack.frontendKey === 'nextjs' && stack.backendKey === 'supabase') {
|
|
314
|
+
content += ' env:\n NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}\n NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}\n'
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await write(dest, `.github/workflows/ci-frontend.yml`, render(content, vars))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Backend CI — always springboot.yml if backend is Spring Boot
|
|
321
|
+
if (stack.needsPackage) {
|
|
322
|
+
const beTpl = path.join(ciDir, 'springboot.yml')
|
|
323
|
+
if (await fs.pathExists(beTpl)) {
|
|
324
|
+
let content = await fs.readFile(beTpl, 'utf-8')
|
|
325
|
+
if (answers.testing === 'none') {
|
|
326
|
+
content = content.replace(' - name: Run tests\n run: ./mvnw --batch-mode test\n', '')
|
|
327
|
+
}
|
|
328
|
+
await write(dest, `.github/workflows/ci-backend.yml`, render(content, vars))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (stack.backendKey === 'laravel') {
|
|
332
|
+
const beTpl = path.join(ciDir, 'laravel.yml')
|
|
333
|
+
if (await fs.pathExists(beTpl)) {
|
|
334
|
+
let content = await fs.readFile(beTpl, 'utf-8')
|
|
335
|
+
if (answers.testing === 'none') content = content.replace(' - run: composer check\n', ' - run: composer format:check\n - run: composer analyse\n')
|
|
336
|
+
if (stack.frontendKey === 'laravel-ui' && stack.laravelUi === 'inertia-react') {
|
|
337
|
+
content += ` - uses: actions/setup-node@v4\n with:\n node-version: "${stack.profile.runtimes.node}"\n cache: npm\n cache-dependency-path: package-lock.json\n - run: npm ci\n - run: npm run build\n`
|
|
338
|
+
}
|
|
339
|
+
await write(dest, `.github/workflows/ci-backend.yml`, render(content, vars))
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── Runnable application files ─────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
async function generateRunnableFiles(dest, answers, stack, vars) {
|
|
347
|
+
const files = { ...buildRunnableFiles(answers, stack, vars), ...buildLaravelFiles(answers, stack, vars) }
|
|
348
|
+
for (const [filePath, content] of Object.entries(files)) await write(dest, filePath, content)
|
|
349
|
+
if (stack.backendKey === 'springboot') await fs.chmod(path.join(dest, 'backend/mvnw'), 0o755)
|
|
350
|
+
if (stack.backendKey === 'laravel') {
|
|
351
|
+
const artisan = stack.frontendKey === 'laravel-ui' || stack.frontendKey === 'no-frontend' ? 'artisan' : 'backend/artisan'
|
|
352
|
+
await fs.chmod(path.join(dest, artisan), 0o755)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
async function write(dest, filePath, content) {
|
|
359
|
+
const fullPath = path.join(dest, filePath)
|
|
360
|
+
await fs.ensureDir(path.dirname(fullPath))
|
|
361
|
+
await fs.writeFile(fullPath, content, 'utf-8')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function writeTemplate(dest, filePath, content, vars) {
|
|
365
|
+
await write(dest, filePath, render(content, vars))
|
|
366
|
+
}
|
package/lib/interview.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const BACK = Symbol('back')
|
|
2
|
+
|
|
3
|
+
function enabled(question, answers) {
|
|
4
|
+
return typeof question.when === 'function' ? question.when(answers) : question.when !== false
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function resolved(value, answers) {
|
|
8
|
+
return typeof value === 'function' ? value(answers) : value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clearFrom(questions, answers, index) {
|
|
12
|
+
for (let cursor = index; cursor < questions.length; cursor += 1) {
|
|
13
|
+
delete answers[questions[cursor].name]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs Inquirer questions one at a time so users can safely revisit earlier answers.
|
|
19
|
+
* List/checkbox/confirm prompts expose a visible Back choice; text prompts accept `:back`.
|
|
20
|
+
*/
|
|
21
|
+
export async function promptWithBack(inquirer, questions, initialAnswers = {}) {
|
|
22
|
+
const answers = { ...initialAnswers }
|
|
23
|
+
const history = []
|
|
24
|
+
let index = 0
|
|
25
|
+
|
|
26
|
+
while (index < questions.length) {
|
|
27
|
+
const source = questions[index]
|
|
28
|
+
if (!enabled(source, answers)) { index += 1; continue }
|
|
29
|
+
|
|
30
|
+
const canGoBack = history.length > 0
|
|
31
|
+
const question = { ...source, when: undefined }
|
|
32
|
+
if (answers[source.name] !== undefined && question.default === undefined) {
|
|
33
|
+
question.default = answers[source.name]
|
|
34
|
+
}
|
|
35
|
+
if (question.type === 'input' && canGoBack) {
|
|
36
|
+
question.message = `${question.message} (type :back to return)`
|
|
37
|
+
} else if (question.type === 'confirm') {
|
|
38
|
+
const defaultValue = await resolved(question.default, answers)
|
|
39
|
+
question.type = 'list'
|
|
40
|
+
question.choices = [
|
|
41
|
+
{ name: 'Yes', value: true },
|
|
42
|
+
{ name: 'No', value: false },
|
|
43
|
+
...(canGoBack ? [{ name: '← Back', value: BACK }] : []),
|
|
44
|
+
]
|
|
45
|
+
question.default = defaultValue ? 0 : 1
|
|
46
|
+
} else if (['list', 'checkbox'].includes(question.type) && canGoBack) {
|
|
47
|
+
const choices = await resolved(question.choices, answers)
|
|
48
|
+
question.choices = [...choices, new inquirer.Separator(), { name: '← Back', value: BACK }]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof question.default === 'function') question.default = await question.default(answers)
|
|
52
|
+
const result = await inquirer.prompt([question], answers)
|
|
53
|
+
const value = result[source.name]
|
|
54
|
+
const requestedBack = value === BACK || value === ':back' || (Array.isArray(value) && value.includes(BACK))
|
|
55
|
+
if (requestedBack) {
|
|
56
|
+
const target = history.pop()
|
|
57
|
+
if (target === undefined) continue
|
|
58
|
+
clearFrom(questions, answers, target)
|
|
59
|
+
index = target
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
answers[source.name] = value
|
|
64
|
+
history.push(index)
|
|
65
|
+
index += 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return answers
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function configurationDecisionChoices() {
|
|
72
|
+
return [
|
|
73
|
+
{ name: 'Create project', value: 'create' },
|
|
74
|
+
{ name: '← Back and edit', value: 'back' },
|
|
75
|
+
{ name: 'Cancel', value: 'cancel' },
|
|
76
|
+
]
|
|
77
|
+
}
|