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.
- package/bin/create-cwa.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +314 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/index.ts +395 -0
- package/src/types.ts +41 -0
- package/tsconfig.json +15 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|