create-cwa 0.1.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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/index.js').catch((err) => {
3
+ console.error(err)
4
+ process.exit(1)
5
+ })
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ import { intro, outro, text, select, multiselect, confirm, spinner, cancel, isCancel, note, log, } from '@clack/prompts';
2
+ import { downloadTemplate } from 'giget';
3
+ import fse from 'fs-extra';
4
+ import fg from 'fast-glob';
5
+ import { readFile, writeFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { spawnSync } from 'node:child_process';
11
+ const MANIFEST_URL = 'https://raw.githubusercontent.com/components-web-app/components-web-app/main/cwa-manifest.json';
12
+ async function fetchManifest() {
13
+ try {
14
+ const res = await fetch(MANIFEST_URL);
15
+ if (!res.ok)
16
+ throw new Error(`HTTP ${res.status}`);
17
+ return (await res.json());
18
+ }
19
+ catch {
20
+ throw new Error('Could not fetch the CWA manifest. Check your internet connection and try again.');
21
+ }
22
+ }
23
+ function checkCancel(value) {
24
+ if (isCancel(value)) {
25
+ cancel('Cancelled.');
26
+ process.exit(0);
27
+ }
28
+ return value;
29
+ }
30
+ function commandExists(cmd) {
31
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
32
+ stdio: 'pipe',
33
+ });
34
+ return result.status === 0;
35
+ }
36
+ function run(cmd, args, cwd) {
37
+ const result = spawnSync(cmd, args, { stdio: 'inherit', cwd });
38
+ return result.status === 0;
39
+ }
40
+ async function askQuestions(manifest, argv) {
41
+ const targetArg = argv[2];
42
+ const projectName = checkCancel(await text({
43
+ message: 'Project name?',
44
+ placeholder: 'my-cwa-app',
45
+ initialValue: targetArg ?? '',
46
+ validate: (v) => (v.trim() ? undefined : 'Project name is required'),
47
+ }));
48
+ const ci = checkCancel(await select({
49
+ message: 'CI/CD pipeline?',
50
+ options: [
51
+ { value: 'github', label: 'GitHub Actions' },
52
+ { value: 'gitlab', label: 'GitLab CI' },
53
+ { value: 'none', label: 'None' },
54
+ ],
55
+ }));
56
+ const featureChoices = manifest.questions
57
+ .find((q) => q.id === 'features')
58
+ ?.choices?.map((c) => ({ value: c.value, label: c.label })) ?? [];
59
+ const features = checkCancel(await multiselect({
60
+ message: 'Which features to include? (space to toggle, enter to confirm)',
61
+ options: featureChoices,
62
+ required: false,
63
+ }));
64
+ // Auto-include required features
65
+ const resolved = new Set(features);
66
+ for (const feat of features) {
67
+ const requires = manifest.questions
68
+ .find((q) => q.id === 'features')
69
+ ?.choices?.find((c) => c.value === feat)?.requires ?? [];
70
+ requires.forEach((r) => resolved.add(r));
71
+ }
72
+ if (resolved.size > features.length) {
73
+ const added = [...resolved].filter((f) => !features.includes(f));
74
+ note(`Also enabling: ${added.join(', ')} (required by selected features)`, 'Dependencies');
75
+ }
76
+ const fixtures = checkCancel(await confirm({
77
+ message: 'Include database fixtures for selected features?',
78
+ initialValue: true,
79
+ }));
80
+ return { projectName: projectName.trim(), ci, features: [...resolved], fixtures };
81
+ }
82
+ async function downloadRepo(manifest, tempDir) {
83
+ const s = spinner();
84
+ s.start('Downloading template...');
85
+ try {
86
+ await downloadTemplate(`github:${manifest.repo}#${manifest.branch}`, {
87
+ dir: tempDir,
88
+ force: true,
89
+ });
90
+ s.stop('Template downloaded.');
91
+ }
92
+ catch (err) {
93
+ s.stop('Download failed.');
94
+ throw err;
95
+ }
96
+ }
97
+ async function removeExcluded(tempDir, excludePaths) {
98
+ for (const pattern of excludePaths) {
99
+ const fullPath = join(tempDir, pattern);
100
+ if (existsSync(fullPath)) {
101
+ await fse.remove(fullPath);
102
+ }
103
+ }
104
+ }
105
+ async function processNuxtConfig(tempDir, selectedFeatures) {
106
+ const configPath = join(tempDir, 'app', 'nuxt.config.ts');
107
+ if (!existsSync(configPath))
108
+ return;
109
+ let content = await readFile(configPath, 'utf-8');
110
+ const allFeatureNames = content
111
+ .match(/\/\/ @cwa-if:([a-z-]+)/g)
112
+ ?.map((m) => m.replace('// @cwa-if:', '')) ?? [];
113
+ for (const feature of [...new Set(allFeatureNames)]) {
114
+ if (!selectedFeatures.includes(feature)) {
115
+ const regex = new RegExp(`[ \\t]*\\/\\/ @cwa-if:${feature}[^\\n]*\\n[\\s\\S]*?[ \\t]*\\/\\/ @cwa-end:${feature}[^\\n]*\\n?`, 'g');
116
+ content = content.replace(regex, '');
117
+ }
118
+ else {
119
+ content = content.replace(new RegExp(`[ \\t]*\\/\\/ @cwa-if:${feature}[^\\n]*\\n`, 'g'), '');
120
+ content = content.replace(new RegExp(`[ \\t]*\\/\\/ @cwa-end:${feature}[^\\n]*\\n?`, 'g'), '');
121
+ }
122
+ }
123
+ content = content.replace(/\n{3,}/g, '\n\n');
124
+ await writeFile(configPath, content, 'utf-8');
125
+ }
126
+ async function replaceProjectName(tempDir, projectName) {
127
+ const slug = projectName.toLowerCase().replace(/\s+/g, '-');
128
+ const targets = await fg(['app/nuxt.config.ts', 'app/package.json', 'helm/cwa/Chart.yaml'], {
129
+ cwd: tempDir,
130
+ absolute: true,
131
+ });
132
+ for (const file of targets) {
133
+ let content = await readFile(file, 'utf-8');
134
+ content = content.replace(/CWA Preview Web App/g, projectName);
135
+ content = content.replace(/cwa-preview-web-app/g, slug);
136
+ await writeFile(file, content, 'utf-8');
137
+ }
138
+ }
139
+ async function generateReadme(tempDir, answers) {
140
+ const featureList = answers.features.length
141
+ ? answers.features.map((f) => `- ${f}`).join('\n')
142
+ : '- Core (Title component)';
143
+ const ciSection = answers.ci === 'github'
144
+ ? '## CI/CD\n\nGitHub Actions workflows are in `.github/workflows/`. See the workflow files for required secrets and variables.\n'
145
+ : answers.ci === 'gitlab'
146
+ ? '## CI/CD\n\nGitLab CI is configured in `.gitlab-ci.yml`. See `bin/devops/` for the reusable pipeline scripts.\n'
147
+ : '';
148
+ const fixturesSection = answers.fixtures
149
+ ? `## Fixtures
150
+
151
+ Sample content is bundled. Load it after the API container is healthy:
152
+
153
+ \`\`\`bash
154
+ docker compose exec php bin/console doctrine:fixtures:load
155
+ \`\`\`
156
+ `
157
+ : '';
158
+ const readme = `# ${answers.projectName}
159
+
160
+ A [CWA](https://github.com/components-web-app/components-web-app) project.
161
+
162
+ ## Features
163
+
164
+ ${featureList}
165
+
166
+ ## Getting started
167
+
168
+ \`\`\`bash
169
+ # 1. Start the stack
170
+ # composer install, database wait, and migrations all run automatically
171
+ docker compose up -d
172
+
173
+ # 2. Install Nuxt app dependencies (run locally, not inside Docker)
174
+ cd app
175
+ pnpm install
176
+
177
+ # 3. Start the dev server
178
+ pnpm dev
179
+ \`\`\`
180
+
181
+ | URL | Description |
182
+ |---|---|
183
+ | https://localhost | Nuxt app |
184
+ | https://localhost/_api | API (JSON-LD / HAL) |
185
+ | https://localhost/admin | CWA admin panel |
186
+
187
+ > **SSL:** The dev stack uses self-signed certs. Accept the browser warning or trust the CA at \`api/frankenphp/caddy/certs/\`.
188
+
189
+ ${fixturesSection}
190
+ ${ciSection}## Learn more
191
+
192
+ - [CWA documentation](https://cwa.rocks)
193
+ - [API Components Bundle](https://github.com/silverbackdan/api-components-bundle)
194
+ - [\`@cwa/nuxt\` module](https://github.com/components-web-app/cwa-nuxt-3-module)
195
+ `;
196
+ await writeFile(join(tempDir, 'README.md'), readme, 'utf-8');
197
+ }
198
+ async function moveToTarget(tempDir, targetDir) {
199
+ if (existsSync(targetDir)) {
200
+ const entries = await fse.readdir(targetDir);
201
+ if (entries.length > 0) {
202
+ throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
203
+ }
204
+ }
205
+ await fse.move(tempDir, targetDir, { overwrite: false });
206
+ }
207
+ async function main() {
208
+ intro('create-cwa — Components Web App scaffolder');
209
+ const s = spinner();
210
+ s.start('Fetching manifest...');
211
+ let manifest;
212
+ try {
213
+ manifest = await fetchManifest();
214
+ s.stop('Manifest loaded.');
215
+ }
216
+ catch (err) {
217
+ s.stop(err.message);
218
+ process.exit(1);
219
+ }
220
+ const answers = await askQuestions(manifest, process.argv);
221
+ const targetDir = resolve(process.cwd(), answers.projectName);
222
+ const tempDir = join(tmpdir(), `cwa-${randomBytes(6).toString('hex')}`);
223
+ await downloadRepo(manifest, tempDir);
224
+ const s2 = spinner();
225
+ s2.start('Configuring project...');
226
+ try {
227
+ await removeExcluded(tempDir, manifest.alwaysExclude);
228
+ const ciExclude = manifest.ci[answers.ci]?.exclude ?? [];
229
+ await removeExcluded(tempDir, ciExclude);
230
+ for (const [feature, config] of Object.entries(manifest.features)) {
231
+ if (!answers.features.includes(feature)) {
232
+ await removeExcluded(tempDir, config.exclude);
233
+ }
234
+ }
235
+ if (!answers.fixtures) {
236
+ await removeExcluded(tempDir, ['api/src/DataFixtures/Parts/']);
237
+ }
238
+ await processNuxtConfig(tempDir, answers.features);
239
+ await replaceProjectName(tempDir, answers.projectName);
240
+ await generateReadme(tempDir, answers);
241
+ await moveToTarget(tempDir, targetDir);
242
+ s2.stop('Project created.');
243
+ }
244
+ catch (err) {
245
+ s2.stop('Failed.');
246
+ await fse.remove(tempDir).catch(() => { });
247
+ throw err;
248
+ }
249
+ note([
250
+ 'When Docker starts, the entrypoint automatically:',
251
+ ' • runs composer install (when vendor/ is empty)',
252
+ ' • waits for PostgreSQL to be ready',
253
+ ' • runs database migrations',
254
+ '',
255
+ 'You only need to run pnpm install and pnpm dev locally.',
256
+ answers.fixtures
257
+ ? '\nFixtures are NOT loaded automatically — you will be reminded below.'
258
+ : '',
259
+ ]
260
+ .join('\n')
261
+ .trim(), 'What Docker handles for you');
262
+ // Offer to start Docker
263
+ const hasDocker = commandExists('docker');
264
+ if (hasDocker) {
265
+ const startDocker = checkCancel(await confirm({ message: 'Start the Docker stack now? (docker compose up -d)', initialValue: true }));
266
+ if (startDocker) {
267
+ log.step('Running: docker compose up -d');
268
+ const ok = run('docker', ['compose', 'up', '-d'], targetDir);
269
+ if (!ok) {
270
+ log.warn('docker compose up -d failed. Start it manually before running pnpm dev.');
271
+ }
272
+ }
273
+ }
274
+ else {
275
+ log.warn('docker not found — install Docker Desktop, then run: docker compose up -d');
276
+ }
277
+ // Offer to install Nuxt deps
278
+ const hasPnpm = commandExists('pnpm');
279
+ if (hasPnpm) {
280
+ const installDeps = checkCancel(await confirm({
281
+ message: 'Install Nuxt app dependencies now? (pnpm install in app/)',
282
+ initialValue: true,
283
+ }));
284
+ if (installDeps) {
285
+ log.step('Running: pnpm install');
286
+ const ok = run('pnpm', ['install'], join(targetDir, 'app'));
287
+ if (!ok) {
288
+ log.warn('pnpm install failed. Run it manually inside app/.');
289
+ }
290
+ }
291
+ }
292
+ else {
293
+ log.warn('pnpm not found — run: npm i -g pnpm, then pnpm install inside app/');
294
+ }
295
+ const outroLines = [
296
+ `Start the dev server:`,
297
+ ` cd ${answers.projectName}/app && pnpm dev`,
298
+ '',
299
+ 'Then visit:',
300
+ ' https://localhost — app',
301
+ ' https://localhost/_api — API',
302
+ ' https://localhost/admin — admin panel',
303
+ ];
304
+ if (answers.fixtures) {
305
+ outroLines.push('');
306
+ outroLines.push('Load sample content (once the API container is healthy):');
307
+ outroLines.push(' docker compose exec php bin/console doctrine:fixtures:load');
308
+ }
309
+ outro(outroLines.join('\n'));
310
+ }
311
+ main().catch((err) => {
312
+ console.error(err instanceof Error ? err.message : err);
313
+ process.exit(1);
314
+ });
@@ -0,0 +1,36 @@
1
+ export interface ManifestChoice {
2
+ value: string;
3
+ label: string;
4
+ requires?: string[];
5
+ }
6
+ export interface ManifestQuestion {
7
+ id: string;
8
+ type: 'text' | 'select' | 'multiselect' | 'confirm';
9
+ message: string;
10
+ placeholder?: string;
11
+ hint?: string;
12
+ default?: boolean;
13
+ choices?: ManifestChoice[];
14
+ }
15
+ export interface ManifestFeature {
16
+ requires?: string[];
17
+ exclude: string[];
18
+ }
19
+ export interface ManifestCi {
20
+ exclude: string[];
21
+ }
22
+ export interface Manifest {
23
+ version: string;
24
+ repo: string;
25
+ branch: string;
26
+ alwaysExclude: string[];
27
+ questions: ManifestQuestion[];
28
+ features: Record<string, ManifestFeature>;
29
+ ci: Record<string, ManifestCi>;
30
+ }
31
+ export interface Answers {
32
+ projectName: string;
33
+ ci: string;
34
+ features: string[];
35
+ fixtures: boolean;
36
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "create-cwa",
3
+ "version": "0.1.0",
4
+ "description": "Create a new CWA (Components Web App) project",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-cwa": "./bin/create-cwa.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "keywords": [
11
+ "cwa",
12
+ "nuxt",
13
+ "api-platform",
14
+ "symfony",
15
+ "scaffolder"
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/components-web-app/components-web-app.git",
21
+ "directory": "packages/create-cwa"
22
+ },
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
26
+ "dependencies": {
27
+ "@clack/prompts": "^0.9.0",
28
+ "fast-glob": "^3.3.0",
29
+ "fs-extra": "^11.2.0",
30
+ "giget": "^1.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/fs-extra": "^11.0.4",
34
+ "@types/node": "^20.0.0",
35
+ "tsx": "^4.0.0",
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "dev": "tsx src/index.ts"
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ allowBuilds:
2
+ esbuild: true
package/src/index.ts ADDED
@@ -0,0 +1,395 @@
1
+ import {
2
+ intro,
3
+ outro,
4
+ text,
5
+ select,
6
+ multiselect,
7
+ confirm,
8
+ spinner,
9
+ cancel,
10
+ isCancel,
11
+ note,
12
+ log,
13
+ } from '@clack/prompts'
14
+ import { downloadTemplate } from 'giget'
15
+ import fse from 'fs-extra'
16
+ import fg from 'fast-glob'
17
+ import { readFile, writeFile } from 'node:fs/promises'
18
+ import { existsSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { tmpdir } from 'node:os'
21
+ import { randomBytes } from 'node:crypto'
22
+ import { spawnSync } from 'node:child_process'
23
+ import type { Manifest, Answers } from './types.js'
24
+
25
+ const MANIFEST_URL =
26
+ 'https://raw.githubusercontent.com/components-web-app/components-web-app/main/cwa-manifest.json'
27
+
28
+ async function fetchManifest(): Promise<Manifest> {
29
+ try {
30
+ const res = await fetch(MANIFEST_URL)
31
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
32
+ return (await res.json()) as Manifest
33
+ } catch {
34
+ throw new Error(
35
+ 'Could not fetch the CWA manifest. Check your internet connection and try again.'
36
+ )
37
+ }
38
+ }
39
+
40
+ function checkCancel<T>(value: T | symbol): T {
41
+ if (isCancel(value)) {
42
+ cancel('Cancelled.')
43
+ process.exit(0)
44
+ }
45
+ return value as T
46
+ }
47
+
48
+ function commandExists(cmd: string): boolean {
49
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
50
+ stdio: 'pipe',
51
+ })
52
+ return result.status === 0
53
+ }
54
+
55
+ function run(cmd: string, args: string[], cwd: string): boolean {
56
+ const result = spawnSync(cmd, args, { stdio: 'inherit', cwd })
57
+ return result.status === 0
58
+ }
59
+
60
+ async function askQuestions(manifest: Manifest, argv: string[]): Promise<Answers> {
61
+ const targetArg = argv[2]
62
+
63
+ const projectName = checkCancel(
64
+ await text({
65
+ message: 'Project name?',
66
+ placeholder: 'my-cwa-app',
67
+ initialValue: targetArg ?? '',
68
+ validate: (v) => (v.trim() ? undefined : 'Project name is required'),
69
+ })
70
+ ) as string
71
+
72
+ const ci = checkCancel(
73
+ await select({
74
+ message: 'CI/CD pipeline?',
75
+ options: [
76
+ { value: 'github', label: 'GitHub Actions' },
77
+ { value: 'gitlab', label: 'GitLab CI' },
78
+ { value: 'none', label: 'None' },
79
+ ],
80
+ })
81
+ ) as string
82
+
83
+ const featureChoices =
84
+ manifest.questions
85
+ .find((q) => q.id === 'features')
86
+ ?.choices?.map((c) => ({ value: c.value, label: c.label })) ?? []
87
+
88
+ const features = checkCancel(
89
+ await multiselect({
90
+ message: 'Which features to include? (space to toggle, enter to confirm)',
91
+ options: featureChoices,
92
+ required: false,
93
+ })
94
+ ) as string[]
95
+
96
+ // Auto-include required features
97
+ const resolved = new Set(features)
98
+ for (const feat of features) {
99
+ const requires =
100
+ manifest.questions
101
+ .find((q) => q.id === 'features')
102
+ ?.choices?.find((c) => c.value === feat)?.requires ?? []
103
+ requires.forEach((r) => resolved.add(r))
104
+ }
105
+
106
+ if (resolved.size > features.length) {
107
+ const added = [...resolved].filter((f) => !features.includes(f))
108
+ note(`Also enabling: ${added.join(', ')} (required by selected features)`, 'Dependencies')
109
+ }
110
+
111
+ const fixtures = checkCancel(
112
+ await confirm({
113
+ message: 'Include database fixtures for selected features?',
114
+ initialValue: true,
115
+ })
116
+ ) as boolean
117
+
118
+ return { projectName: projectName.trim(), ci, features: [...resolved], fixtures }
119
+ }
120
+
121
+ async function downloadRepo(manifest: Manifest, tempDir: string): Promise<void> {
122
+ const s = spinner()
123
+ s.start('Downloading template...')
124
+ try {
125
+ await downloadTemplate(`github:${manifest.repo}#${manifest.branch}`, {
126
+ dir: tempDir,
127
+ force: true,
128
+ })
129
+ s.stop('Template downloaded.')
130
+ } catch (err) {
131
+ s.stop('Download failed.')
132
+ throw err
133
+ }
134
+ }
135
+
136
+ async function removeExcluded(tempDir: string, excludePaths: string[]): Promise<void> {
137
+ for (const pattern of excludePaths) {
138
+ const fullPath = join(tempDir, pattern)
139
+ if (existsSync(fullPath)) {
140
+ await fse.remove(fullPath)
141
+ }
142
+ }
143
+ }
144
+
145
+ async function processNuxtConfig(tempDir: string, selectedFeatures: string[]): Promise<void> {
146
+ const configPath = join(tempDir, 'app', 'nuxt.config.ts')
147
+ if (!existsSync(configPath)) return
148
+
149
+ let content = await readFile(configPath, 'utf-8')
150
+
151
+ const allFeatureNames =
152
+ content
153
+ .match(/\/\/ @cwa-if:([a-z-]+)/g)
154
+ ?.map((m) => m.replace('// @cwa-if:', '')) ?? []
155
+
156
+ for (const feature of [...new Set(allFeatureNames)]) {
157
+ if (!selectedFeatures.includes(feature)) {
158
+ const regex = new RegExp(
159
+ `[ \\t]*\\/\\/ @cwa-if:${feature}[^\\n]*\\n[\\s\\S]*?[ \\t]*\\/\\/ @cwa-end:${feature}[^\\n]*\\n?`,
160
+ 'g'
161
+ )
162
+ content = content.replace(regex, '')
163
+ } else {
164
+ content = content.replace(new RegExp(`[ \\t]*\\/\\/ @cwa-if:${feature}[^\\n]*\\n`, 'g'), '')
165
+ content = content.replace(
166
+ new RegExp(`[ \\t]*\\/\\/ @cwa-end:${feature}[^\\n]*\\n?`, 'g'),
167
+ ''
168
+ )
169
+ }
170
+ }
171
+
172
+ content = content.replace(/\n{3,}/g, '\n\n')
173
+ await writeFile(configPath, content, 'utf-8')
174
+ }
175
+
176
+ async function replaceProjectName(tempDir: string, projectName: string): Promise<void> {
177
+ const slug = projectName.toLowerCase().replace(/\s+/g, '-')
178
+ const targets = await fg(['app/nuxt.config.ts', 'app/package.json', 'helm/cwa/Chart.yaml'], {
179
+ cwd: tempDir,
180
+ absolute: true,
181
+ })
182
+
183
+ for (const file of targets) {
184
+ let content = await readFile(file, 'utf-8')
185
+ content = content.replace(/CWA Preview Web App/g, projectName)
186
+ content = content.replace(/cwa-preview-web-app/g, slug)
187
+ await writeFile(file, content, 'utf-8')
188
+ }
189
+ }
190
+
191
+ async function generateReadme(tempDir: string, answers: Answers): Promise<void> {
192
+ const featureList = answers.features.length
193
+ ? answers.features.map((f) => `- ${f}`).join('\n')
194
+ : '- Core (Title component)'
195
+
196
+ const ciSection =
197
+ answers.ci === 'github'
198
+ ? '## CI/CD\n\nGitHub Actions workflows are in `.github/workflows/`. See the workflow files for required secrets and variables.\n'
199
+ : answers.ci === 'gitlab'
200
+ ? '## CI/CD\n\nGitLab CI is configured in `.gitlab-ci.yml`. See `bin/devops/` for the reusable pipeline scripts.\n'
201
+ : ''
202
+
203
+ const fixturesSection = answers.fixtures
204
+ ? `## Fixtures
205
+
206
+ Sample content is bundled. Load it after the API container is healthy:
207
+
208
+ \`\`\`bash
209
+ docker compose exec php bin/console doctrine:fixtures:load
210
+ \`\`\`
211
+ `
212
+ : ''
213
+
214
+ const readme = `# ${answers.projectName}
215
+
216
+ A [CWA](https://github.com/components-web-app/components-web-app) project.
217
+
218
+ ## Features
219
+
220
+ ${featureList}
221
+
222
+ ## Getting started
223
+
224
+ \`\`\`bash
225
+ # 1. Start the stack
226
+ # composer install, database wait, and migrations all run automatically
227
+ docker compose up -d
228
+
229
+ # 2. Install Nuxt app dependencies (run locally, not inside Docker)
230
+ cd app
231
+ pnpm install
232
+
233
+ # 3. Start the dev server
234
+ pnpm dev
235
+ \`\`\`
236
+
237
+ | URL | Description |
238
+ |---|---|
239
+ | https://localhost | Nuxt app |
240
+ | https://localhost/_api | API (JSON-LD / HAL) |
241
+ | https://localhost/admin | CWA admin panel |
242
+
243
+ > **SSL:** The dev stack uses self-signed certs. Accept the browser warning or trust the CA at \`api/frankenphp/caddy/certs/\`.
244
+
245
+ ${fixturesSection}
246
+ ${ciSection}## Learn more
247
+
248
+ - [CWA documentation](https://cwa.rocks)
249
+ - [API Components Bundle](https://github.com/silverbackdan/api-components-bundle)
250
+ - [\`@cwa/nuxt\` module](https://github.com/components-web-app/cwa-nuxt-3-module)
251
+ `
252
+
253
+ await writeFile(join(tempDir, 'README.md'), readme, 'utf-8')
254
+ }
255
+
256
+ async function moveToTarget(tempDir: string, targetDir: string): Promise<void> {
257
+ if (existsSync(targetDir)) {
258
+ const entries = await fse.readdir(targetDir)
259
+ if (entries.length > 0) {
260
+ throw new Error(`Directory "${targetDir}" already exists and is not empty.`)
261
+ }
262
+ }
263
+ await fse.move(tempDir, targetDir, { overwrite: false })
264
+ }
265
+
266
+ async function main(): Promise<void> {
267
+ intro('create-cwa — Components Web App scaffolder')
268
+
269
+ const s = spinner()
270
+ s.start('Fetching manifest...')
271
+ let manifest: Manifest
272
+ try {
273
+ manifest = await fetchManifest()
274
+ s.stop('Manifest loaded.')
275
+ } catch (err) {
276
+ s.stop((err as Error).message)
277
+ process.exit(1)
278
+ }
279
+
280
+ const answers = await askQuestions(manifest, process.argv)
281
+ const targetDir = resolve(process.cwd(), answers.projectName)
282
+ const tempDir = join(tmpdir(), `cwa-${randomBytes(6).toString('hex')}`)
283
+
284
+ await downloadRepo(manifest, tempDir)
285
+
286
+ const s2 = spinner()
287
+ s2.start('Configuring project...')
288
+
289
+ try {
290
+ await removeExcluded(tempDir, manifest.alwaysExclude)
291
+
292
+ const ciExclude = manifest.ci[answers.ci]?.exclude ?? []
293
+ await removeExcluded(tempDir, ciExclude)
294
+
295
+ for (const [feature, config] of Object.entries(manifest.features)) {
296
+ if (!answers.features.includes(feature)) {
297
+ await removeExcluded(tempDir, config.exclude)
298
+ }
299
+ }
300
+
301
+ if (!answers.fixtures) {
302
+ await removeExcluded(tempDir, ['api/src/DataFixtures/Parts/'])
303
+ }
304
+
305
+ await processNuxtConfig(tempDir, answers.features)
306
+ await replaceProjectName(tempDir, answers.projectName)
307
+ await generateReadme(tempDir, answers)
308
+ await moveToTarget(tempDir, targetDir)
309
+
310
+ s2.stop('Project created.')
311
+ } catch (err) {
312
+ s2.stop('Failed.')
313
+ await fse.remove(tempDir).catch(() => {})
314
+ throw err
315
+ }
316
+
317
+ note(
318
+ [
319
+ 'When Docker starts, the entrypoint automatically:',
320
+ ' • runs composer install (when vendor/ is empty)',
321
+ ' • waits for PostgreSQL to be ready',
322
+ ' • runs database migrations',
323
+ '',
324
+ 'You only need to run pnpm install and pnpm dev locally.',
325
+ answers.fixtures
326
+ ? '\nFixtures are NOT loaded automatically — you will be reminded below.'
327
+ : '',
328
+ ]
329
+ .join('\n')
330
+ .trim(),
331
+ 'What Docker handles for you'
332
+ )
333
+
334
+ // Offer to start Docker
335
+ const hasDocker = commandExists('docker')
336
+ if (hasDocker) {
337
+ const startDocker = checkCancel(
338
+ await confirm({ message: 'Start the Docker stack now? (docker compose up -d)', initialValue: true })
339
+ ) as boolean
340
+
341
+ if (startDocker) {
342
+ log.step('Running: docker compose up -d')
343
+ const ok = run('docker', ['compose', 'up', '-d'], targetDir)
344
+ if (!ok) {
345
+ log.warn('docker compose up -d failed. Start it manually before running pnpm dev.')
346
+ }
347
+ }
348
+ } else {
349
+ log.warn('docker not found — install Docker Desktop, then run: docker compose up -d')
350
+ }
351
+
352
+ // Offer to install Nuxt deps
353
+ const hasPnpm = commandExists('pnpm')
354
+ if (hasPnpm) {
355
+ const installDeps = checkCancel(
356
+ await confirm({
357
+ message: 'Install Nuxt app dependencies now? (pnpm install in app/)',
358
+ initialValue: true,
359
+ })
360
+ ) as boolean
361
+
362
+ if (installDeps) {
363
+ log.step('Running: pnpm install')
364
+ const ok = run('pnpm', ['install'], join(targetDir, 'app'))
365
+ if (!ok) {
366
+ log.warn('pnpm install failed. Run it manually inside app/.')
367
+ }
368
+ }
369
+ } else {
370
+ log.warn('pnpm not found — run: npm i -g pnpm, then pnpm install inside app/')
371
+ }
372
+
373
+ const outroLines = [
374
+ `Start the dev server:`,
375
+ ` cd ${answers.projectName}/app && pnpm dev`,
376
+ '',
377
+ 'Then visit:',
378
+ ' https://localhost — app',
379
+ ' https://localhost/_api — API',
380
+ ' https://localhost/admin — admin panel',
381
+ ]
382
+
383
+ if (answers.fixtures) {
384
+ outroLines.push('')
385
+ outroLines.push('Load sample content (once the API container is healthy):')
386
+ outroLines.push(' docker compose exec php bin/console doctrine:fixtures:load')
387
+ }
388
+
389
+ outro(outroLines.join('\n'))
390
+ }
391
+
392
+ main().catch((err) => {
393
+ console.error(err instanceof Error ? err.message : err)
394
+ process.exit(1)
395
+ })
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export interface ManifestChoice {
2
+ value: string
3
+ label: string
4
+ requires?: string[]
5
+ }
6
+
7
+ export interface ManifestQuestion {
8
+ id: string
9
+ type: 'text' | 'select' | 'multiselect' | 'confirm'
10
+ message: string
11
+ placeholder?: string
12
+ hint?: string
13
+ default?: boolean
14
+ choices?: ManifestChoice[]
15
+ }
16
+
17
+ export interface ManifestFeature {
18
+ requires?: string[]
19
+ exclude: string[]
20
+ }
21
+
22
+ export interface ManifestCi {
23
+ exclude: string[]
24
+ }
25
+
26
+ export interface Manifest {
27
+ version: string
28
+ repo: string
29
+ branch: string
30
+ alwaysExclude: string[]
31
+ questions: ManifestQuestion[]
32
+ features: Record<string, ManifestFeature>
33
+ ci: Record<string, ManifestCi>
34
+ }
35
+
36
+ export interface Answers {
37
+ projectName: string
38
+ ci: string
39
+ features: string[]
40
+ fixtures: boolean
41
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }