create-elnop-stack 0.0.0-stage → 0.2.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/README.md +57 -2
- package/dist/CliTypes.js +1 -0
- package/dist/ScaffoldConfig.js +5 -0
- package/dist/StackTypes.js +1 -0
- package/dist/applyFileEdit.js +38 -0
- package/dist/applyStackChanges.js +96 -0
- package/dist/cli.js +118 -0
- package/dist/errorMessage.js +3 -0
- package/dist/fetchTemplate.js +21 -0
- package/dist/isRecord.js +3 -0
- package/dist/matrixCombinations.js +13 -0
- package/dist/readArgs.js +56 -0
- package/dist/readStackRefs.js +11 -0
- package/dist/resolveAnswers.js +28 -0
- package/dist/runCommand.js +7 -0
- package/dist/scaffoldProject.js +23 -0
- package/dist/stackRefs.json +4 -0
- package/dist/stacks/fastifyStack.js +125 -0
- package/dist/stacks/stacksConfig.js +3 -0
- package/dist/stacks/viteStack.js +192 -0
- package/overlays/fastify/example-no-swagger/src/app/buildApp.test.ts +12 -0
- package/overlays/fastify/memory-store/src/shared-features/todos/todosQueries.ts +47 -0
- package/overlays/fastify/skeleton-no-swagger/src/app/buildApp.test.ts +11 -0
- package/overlays/fastify/skeleton-swagger/src/app/buildApp.test.ts +14 -0
- package/overlays/vite/skeleton/src/app/App.tsx +12 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-elnop-stack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Creates a project from one of the [elnop-stacks](https://github.com/Elnop/elnop-stacks) templates and lets you drop the parts you do not need. Whatever you pick, the project passes `npm run check` on the first run.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm create elnop-stack my-app
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Node 26 is required, like the templates.
|
|
10
|
+
|
|
11
|
+
## Stacks and options
|
|
12
|
+
|
|
13
|
+
| Stack | Template | Options |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `vite` | [template-vite](https://github.com/Elnop/template-vite) | `example`, `zustand`, `component-tests`, `e2e`, `claude-hook`, `ci` |
|
|
16
|
+
| `fastify` | [template-fastify](https://github.com/Elnop/template-fastify) | `example`, `swagger`, `database`, `claude-hook`, `ci` |
|
|
17
|
+
|
|
18
|
+
`example` keeps the todo list. Without it you get the same structure with empty `src/features/` and `src/shared-features/` folders. On vite, `App.tsx` then only shows the project name, and zustand can only be removed together with the example, since the example uses it. On fastify, `buildApp.ts` only creates the server, and the Swagger UI, if kept, is titled with the project name.
|
|
19
|
+
|
|
20
|
+
On fastify, `database` keeps `node:sqlite` and the `DATABASE_PATH` variable. Without it, the example keeps its todos in a `Map`: `todosQueries.ts` has the same functions, so the services and the routes do not change. Without `swagger`, `@fastify/swagger` and `openapi-types` stay installed, because `fastify-type-provider-zod` needs them.
|
|
21
|
+
|
|
22
|
+
The CLI also asks whether to run `git init` and `npm install`. Without git, the `prepare` script that installs the pre-commit hook is removed. The next steps tell you how to set it up later.
|
|
23
|
+
|
|
24
|
+
## Without questions
|
|
25
|
+
|
|
26
|
+
Every option has a flag, `--<option>` or `--no-<option>`. `--yes` takes the default for every question left:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm create elnop-stack my-app -- --stack vite --no-example --no-e2e --yes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A preset file holds the same answers, and flags override it:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{ "stack": "vite", "git": true, "install": true, "options": { "example": false, "e2e": false } }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm create elnop-stack my-app -- --preset minimal.json --yes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`npm create elnop-stack -- --help` lists the stacks and their options.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
Each version of the package downloads every template at the commit its submodule points to in elnop-stacks (`dist/stackRefs.json`, written at build time), so a given version always produces the same project. The options are described in `src/stacks/viteStack.ts` and `src/stacks/fastifyStack.ts`: files to remove, text to replace in the docs and configs, scripts and dependencies to drop, and files to copy from `overlays/`. A replacement whose text cannot be found stops the CLI with the file name and the missing text, rather than producing a half-edited project.
|
|
47
|
+
|
|
48
|
+
## Developing
|
|
49
|
+
|
|
50
|
+
From the root of elnop-stacks:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm install
|
|
54
|
+
node create-elnop-stack/src/cli.ts ../my-app --local
|
|
55
|
+
npm run matrix
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--local` copies the templates from the submodules, local changes included. `npm run matrix` creates a project for every combination of options (options marked `independent` are switched on and off without being crossed with the others), installs it, then runs `npm run check` and `npm test`. `--e2e` adds `npm run e2e` (use `--jobs 1`, Playwright needs port 4173), `--keep` keeps the projects in `.matrix/`.
|
package/dist/CliTypes.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
export const PROJECT_NAME_TOKEN = '__PROJECT_NAME__';
|
|
3
|
+
export const OVERLAYS_ROOT = fileURLToPath(new URL('../overlays', import.meta.url));
|
|
4
|
+
export const LOCAL_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
5
|
+
export const STACK_REFS_URL = new URL('stackRefs.json', import.meta.url);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function headingLevel(line) {
|
|
2
|
+
return /^(#+) /u.exec(line)?.[1]?.length ?? 0;
|
|
3
|
+
}
|
|
4
|
+
function dropSection(text, heading) {
|
|
5
|
+
const lines = text.split('\n');
|
|
6
|
+
const start = lines.indexOf(heading);
|
|
7
|
+
if (start === -1)
|
|
8
|
+
throw new Error(`section "${heading}" not found`);
|
|
9
|
+
const level = headingLevel(heading);
|
|
10
|
+
const length = lines.slice(start + 1).findIndex((line) => {
|
|
11
|
+
const lineLevel = headingLevel(line);
|
|
12
|
+
return lineLevel > 0 && lineLevel <= level;
|
|
13
|
+
});
|
|
14
|
+
lines.splice(start, length === -1 ? lines.length - start : length + 1);
|
|
15
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
16
|
+
}
|
|
17
|
+
function dropLines(text, needle) {
|
|
18
|
+
const lines = text.split('\n');
|
|
19
|
+
const kept = lines.filter((line) => !line.includes(needle));
|
|
20
|
+
if (kept.length === lines.length)
|
|
21
|
+
throw new Error(`no line contains "${needle}"`);
|
|
22
|
+
return kept.join('\n').replaceAll(/\n{3,}/gu, '\n\n');
|
|
23
|
+
}
|
|
24
|
+
function replaceText(text, search, replacement, all) {
|
|
25
|
+
const count = text.split(search).length - 1;
|
|
26
|
+
if (all && count === 0)
|
|
27
|
+
throw new Error(`"${search}" not found`);
|
|
28
|
+
if (!all && count !== 1)
|
|
29
|
+
throw new Error(`expected "${search}" once, found it ${count} times`);
|
|
30
|
+
return all ? text.replaceAll(search, () => replacement) : text.replace(search, () => replacement);
|
|
31
|
+
}
|
|
32
|
+
export function applyFileEdit(text, edit) {
|
|
33
|
+
if ('replace' in edit)
|
|
34
|
+
return replaceText(text, edit.replace, edit.with, edit.all === true);
|
|
35
|
+
if ('dropLines' in edit)
|
|
36
|
+
return dropLines(text, edit.dropLines);
|
|
37
|
+
return dropSection(text, edit.dropSection);
|
|
38
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { applyFileEdit } from './applyFileEdit.js';
|
|
4
|
+
import { errorMessage } from './errorMessage.js';
|
|
5
|
+
import { isRecord } from './isRecord.js';
|
|
6
|
+
import { PROJECT_NAME_TOKEN } from './ScaffoldConfig.js';
|
|
7
|
+
function matches(change, answers) {
|
|
8
|
+
return Object.entries(change.when).every(([id, value]) => answers[id] === value);
|
|
9
|
+
}
|
|
10
|
+
function removePaths(dir, patterns) {
|
|
11
|
+
for (const pattern of patterns) {
|
|
12
|
+
const found = globSync(pattern, { cwd: dir });
|
|
13
|
+
if (found.length === 0)
|
|
14
|
+
throw new Error(`nothing matches ${pattern}`);
|
|
15
|
+
for (const path of found)
|
|
16
|
+
rmSync(join(dir, path), { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function editFiles(dir, edits, projectName) {
|
|
20
|
+
for (const edit of edits) {
|
|
21
|
+
const path = join(dir, edit.file);
|
|
22
|
+
if (!existsSync(path))
|
|
23
|
+
throw new Error(`${edit.file} does not exist`);
|
|
24
|
+
const named = 'with' in edit ? { ...edit, with: edit.with.replaceAll(PROJECT_NAME_TOKEN, projectName) } : edit;
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(path, applyFileEdit(readFileSync(path, 'utf8'), named));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error(`${edit.file}: ${errorMessage(error)}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function sortedRecord(record) {
|
|
34
|
+
return Object.fromEntries(Object.entries(record).toSorted(([a], [b]) => a.localeCompare(b)));
|
|
35
|
+
}
|
|
36
|
+
export function updatePackageJson(dir, update) {
|
|
37
|
+
const path = join(dir, 'package.json');
|
|
38
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
if (!isRecord(pkg))
|
|
40
|
+
throw new Error('package.json is not an object');
|
|
41
|
+
update(pkg);
|
|
42
|
+
writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
43
|
+
}
|
|
44
|
+
function updatePackage(dir, change) {
|
|
45
|
+
updatePackageJson(dir, (pkg) => {
|
|
46
|
+
for (const script of change.scripts ?? []) {
|
|
47
|
+
if (pkg.scripts?.[script] === undefined)
|
|
48
|
+
throw new Error(`package.json has no script ${script}`);
|
|
49
|
+
delete pkg.scripts[script];
|
|
50
|
+
}
|
|
51
|
+
for (const name of change.dependencies ?? []) {
|
|
52
|
+
const field = pkg.dependencies?.[name] === undefined ? pkg.devDependencies : pkg.dependencies;
|
|
53
|
+
if (field?.[name] === undefined)
|
|
54
|
+
throw new Error(`package.json has no dependency ${name}`);
|
|
55
|
+
delete field[name];
|
|
56
|
+
}
|
|
57
|
+
if (change.devDependencies !== undefined)
|
|
58
|
+
pkg.devDependencies = sortedRecord({ ...pkg.devDependencies, ...change.devDependencies });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function copyOverlay(from, to, projectName) {
|
|
62
|
+
for (const entry of readdirSync(from)) {
|
|
63
|
+
const source = join(from, entry);
|
|
64
|
+
const target = join(to, entry);
|
|
65
|
+
if (statSync(source).isDirectory()) {
|
|
66
|
+
mkdirSync(target, { recursive: true });
|
|
67
|
+
copyOverlay(source, target, projectName);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
cpSync(source, target);
|
|
71
|
+
writeFileSync(target, readFileSync(target, 'utf8').replaceAll(PROJECT_NAME_TOKEN, projectName));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function keepFolders(dir, folders) {
|
|
76
|
+
for (const folder of folders) {
|
|
77
|
+
const path = join(dir, folder);
|
|
78
|
+
mkdirSync(path, { recursive: true });
|
|
79
|
+
if (readdirSync(path).length === 0)
|
|
80
|
+
writeFileSync(join(path, '.gitkeep'), '');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function applyStackChanges(dir, stack, answers, overlaysRoot, projectName) {
|
|
84
|
+
for (const change of stack.changes.filter((candidate) => matches(candidate, answers))) {
|
|
85
|
+
removePaths(dir, change.remove ?? []);
|
|
86
|
+
editFiles(dir, change.edits ?? [], projectName);
|
|
87
|
+
if (change.scripts !== undefined || change.dependencies !== undefined || change.devDependencies !== undefined)
|
|
88
|
+
updatePackage(dir, change);
|
|
89
|
+
if (change.overlay !== undefined)
|
|
90
|
+
copyOverlay(join(overlaysRoot, stack.id, change.overlay), dir, projectName);
|
|
91
|
+
keepFolders(dir, change.keep ?? []);
|
|
92
|
+
}
|
|
93
|
+
updatePackageJson(dir, (pkg) => {
|
|
94
|
+
pkg['name'] = projectName;
|
|
95
|
+
});
|
|
96
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { cancel, confirm, intro, isCancel, log, note, outro, select, spinner, text } from '@clack/prompts';
|
|
5
|
+
import { errorMessage } from './errorMessage.js';
|
|
6
|
+
import { readArgs } from './readArgs.js';
|
|
7
|
+
import { readStackRefs } from './readStackRefs.js';
|
|
8
|
+
import { resolveAnswers } from './resolveAnswers.js';
|
|
9
|
+
import { scaffoldProject } from './scaffoldProject.js';
|
|
10
|
+
import { LOCAL_ROOT } from './ScaffoldConfig.js';
|
|
11
|
+
import { stacks } from './stacks/stacksConfig.js';
|
|
12
|
+
const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
13
|
+
const USAGE = `Usage: npm create elnop-stack [dir] -- [flags]
|
|
14
|
+
|
|
15
|
+
--stack <id> ${stacks.map((stack) => stack.id).join(', ')}
|
|
16
|
+
--preset <file> JSON file with stack, options, git and install
|
|
17
|
+
--yes, -y take the default of every question left
|
|
18
|
+
--no-git skip git init (the pre-commit hook is dropped)
|
|
19
|
+
--no-install skip npm install
|
|
20
|
+
--local copy the templates from this elnop-stacks checkout
|
|
21
|
+
|
|
22
|
+
Each stack option has a flag: --<option> or --no-<option>.
|
|
23
|
+
${stacks.map((stack) => ` ${stack.id}: ${stack.options.map((option) => option.id).join(', ')}`).join('\n')}`;
|
|
24
|
+
function stopCancelled() {
|
|
25
|
+
cancel('Cancelled.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
function validateDir(value) {
|
|
29
|
+
if (value === undefined || value === '')
|
|
30
|
+
return 'Give a folder name.';
|
|
31
|
+
if (!PROJECT_NAME.test(basename(resolve(value))))
|
|
32
|
+
return 'Use lowercase letters, digits, dots, dashes and underscores.';
|
|
33
|
+
if (existsSync(value) && readdirSync(value).length > 0)
|
|
34
|
+
return `${value} is not empty.`;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
async function pickDir(given, yes) {
|
|
38
|
+
if (given !== undefined || yes) {
|
|
39
|
+
const error = validateDir(given);
|
|
40
|
+
if (error !== undefined)
|
|
41
|
+
throw new Error(error);
|
|
42
|
+
return given ?? '';
|
|
43
|
+
}
|
|
44
|
+
const value = await text({ message: 'Project folder', placeholder: 'my-app', validate: validateDir });
|
|
45
|
+
return isCancel(value) ? stopCancelled() : value;
|
|
46
|
+
}
|
|
47
|
+
async function pickStack(id, yes) {
|
|
48
|
+
if (id !== undefined) {
|
|
49
|
+
const found = stacks.find((stack) => stack.id === id);
|
|
50
|
+
if (found === undefined)
|
|
51
|
+
throw new Error(`Unknown stack ${id}. Choose one of: ${stacks.map((stack) => stack.id).join(', ')}`);
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
if (yes)
|
|
55
|
+
throw new Error('--yes needs --stack');
|
|
56
|
+
const value = await select({ message: 'Stack', options: stacks.map((stack) => ({ value: stack, label: stack.label })) });
|
|
57
|
+
return isCancel(value) ? stopCancelled() : value;
|
|
58
|
+
}
|
|
59
|
+
async function askOption(option) {
|
|
60
|
+
const value = await confirm({ message: option.message, initialValue: option.default });
|
|
61
|
+
return isCancel(value) ? stopCancelled() : value;
|
|
62
|
+
}
|
|
63
|
+
async function askYesNo(given, yes, message) {
|
|
64
|
+
if (given !== undefined)
|
|
65
|
+
return given;
|
|
66
|
+
if (yes)
|
|
67
|
+
return true;
|
|
68
|
+
const value = await confirm({ message, initialValue: true });
|
|
69
|
+
return isCancel(value) ? stopCancelled() : value;
|
|
70
|
+
}
|
|
71
|
+
function templateSource(stack, local) {
|
|
72
|
+
if (local)
|
|
73
|
+
return { kind: 'local', root: LOCAL_ROOT };
|
|
74
|
+
const ref = readStackRefs().get(stack.id);
|
|
75
|
+
if (ref === undefined)
|
|
76
|
+
throw new Error(`No template commit recorded for ${stack.id}: run from a published build or pass --local`);
|
|
77
|
+
return { kind: 'github', ref };
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
const argv = process.argv.slice(2);
|
|
81
|
+
const first = readArgs(argv, undefined);
|
|
82
|
+
if (first.help) {
|
|
83
|
+
process.stdout.write(`${USAGE}\n`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
intro('create-elnop-stack');
|
|
87
|
+
const dir = await pickDir(first.dir, first.yes);
|
|
88
|
+
const stack = await pickStack(first.stack, first.yes);
|
|
89
|
+
const args = readArgs(argv, stack);
|
|
90
|
+
const answers = await resolveAnswers(stack.options, args.options, args.yes ? undefined : askOption);
|
|
91
|
+
const git = await askYesNo(args.git, args.yes, 'Run git init?');
|
|
92
|
+
const install = await askYesNo(args.install, args.yes, 'Run npm install?');
|
|
93
|
+
const existed = existsSync(dir);
|
|
94
|
+
const progress = spinner();
|
|
95
|
+
progress.start('Creating the project');
|
|
96
|
+
try {
|
|
97
|
+
await scaffoldProject({ dir: resolve(dir), projectName: basename(resolve(dir)), stack, answers, source: templateSource(stack, args.local), git, install }, (message) => progress.message(message));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
progress.error('Failed');
|
|
101
|
+
if (existed)
|
|
102
|
+
for (const entry of readdirSync(dir))
|
|
103
|
+
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
104
|
+
else
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
progress.stop(`Created ${dir}`);
|
|
109
|
+
note([`cd ${dir}`, ...(install ? [] : ['npm install']), ...(git ? [] : ['git init && git config core.hooksPath .githooks']), ...stack.nextSteps(answers)].join('\n'), 'Next steps');
|
|
110
|
+
outro('Run npm run check before each commit.');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await main();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
log.error(errorMessage(error));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { downloadTemplate } from 'giget';
|
|
4
|
+
import { runCommand } from './runCommand.js';
|
|
5
|
+
function copyWorkingTree(from, to) {
|
|
6
|
+
const files = runCommand('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], from, true).split('\0').filter(Boolean);
|
|
7
|
+
for (const file of files) {
|
|
8
|
+
const source = join(from, file);
|
|
9
|
+
if (!existsSync(source))
|
|
10
|
+
continue;
|
|
11
|
+
mkdirSync(dirname(join(to, file)), { recursive: true });
|
|
12
|
+
cpSync(source, join(to, file));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function fetchTemplate(stack, dir, source) {
|
|
16
|
+
if (source.kind === 'local') {
|
|
17
|
+
copyWorkingTree(join(source.root, stack.submodule), dir);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
await downloadTemplate(`github:${stack.repository}#${source.ref}`, { dir, silent: true });
|
|
21
|
+
}
|
package/dist/isRecord.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function satisfiesRequirements(options, answers) {
|
|
2
|
+
return options.every((option) => !answers[option.id] || Object.entries(option.requires ?? {}).every(([id, value]) => answers[id] === value));
|
|
3
|
+
}
|
|
4
|
+
export function matrixCombinations(options) {
|
|
5
|
+
const crossed = options.filter((option) => option.independent !== true);
|
|
6
|
+
const independent = options.filter((option) => option.independent === true);
|
|
7
|
+
let combinations = [{}];
|
|
8
|
+
for (const option of crossed)
|
|
9
|
+
combinations = combinations.flatMap((answers) => [true, false].map((value) => ({ ...answers, [option.id]: value })));
|
|
10
|
+
return combinations
|
|
11
|
+
.filter((answers) => satisfiesRequirements(options, answers))
|
|
12
|
+
.map((answers, index) => ({ ...answers, ...Object.fromEntries(independent.map((option, position) => [option.id, Math.floor(index / 2 ** position) % 2 === 0])) }));
|
|
13
|
+
}
|
package/dist/readArgs.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { isRecord } from './isRecord.js';
|
|
4
|
+
const BASE_OPTIONS = {
|
|
5
|
+
stack: { type: 'string' },
|
|
6
|
+
preset: { type: 'string' },
|
|
7
|
+
yes: { type: 'boolean', short: 'y' },
|
|
8
|
+
local: { type: 'boolean' },
|
|
9
|
+
help: { type: 'boolean', short: 'h' },
|
|
10
|
+
git: { type: 'boolean' },
|
|
11
|
+
install: { type: 'boolean' },
|
|
12
|
+
};
|
|
13
|
+
function booleanEntries(value) {
|
|
14
|
+
if (!isRecord(value))
|
|
15
|
+
return {};
|
|
16
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'boolean'));
|
|
17
|
+
}
|
|
18
|
+
function readPreset(path) {
|
|
19
|
+
if (typeof path !== 'string')
|
|
20
|
+
return { options: {} };
|
|
21
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
22
|
+
if (!isRecord(parsed))
|
|
23
|
+
throw new Error(`${path} is not a JSON object`);
|
|
24
|
+
const { stack, git, install } = parsed;
|
|
25
|
+
return {
|
|
26
|
+
...(typeof stack === 'string' ? { stack } : {}),
|
|
27
|
+
...(typeof git === 'boolean' ? { git } : {}),
|
|
28
|
+
...(typeof install === 'boolean' ? { install } : {}),
|
|
29
|
+
options: booleanEntries(parsed['options']),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function optionalBoolean(value, fallback) {
|
|
33
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
34
|
+
}
|
|
35
|
+
export function readArgs(argv, stack) {
|
|
36
|
+
const stackOptions = Object.fromEntries((stack?.options ?? []).map((option) => [option.id, { type: 'boolean' }]));
|
|
37
|
+
const { values, positionals } = parseArgs({ args: argv, options: { ...BASE_OPTIONS, ...stackOptions }, allowPositionals: true, allowNegative: true, strict: stack !== undefined });
|
|
38
|
+
const preset = readPreset(values['preset']);
|
|
39
|
+
const options = { ...preset.options };
|
|
40
|
+
for (const option of stack?.options ?? []) {
|
|
41
|
+
const value = values[option.id];
|
|
42
|
+
if (typeof value === 'boolean')
|
|
43
|
+
options[option.id] = value;
|
|
44
|
+
}
|
|
45
|
+
const stackId = values['stack'];
|
|
46
|
+
return {
|
|
47
|
+
dir: positionals[0],
|
|
48
|
+
stack: typeof stackId === 'string' ? stackId : preset.stack,
|
|
49
|
+
yes: values['yes'] === true,
|
|
50
|
+
local: values['local'] === true,
|
|
51
|
+
help: values['help'] === true,
|
|
52
|
+
git: optionalBoolean(values['git'], preset.git),
|
|
53
|
+
install: optionalBoolean(values['install'], preset.install),
|
|
54
|
+
options,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isRecord } from './isRecord.js';
|
|
3
|
+
import { STACK_REFS_URL } from './ScaffoldConfig.js';
|
|
4
|
+
export function readStackRefs() {
|
|
5
|
+
if (!existsSync(STACK_REFS_URL))
|
|
6
|
+
return new Map();
|
|
7
|
+
const parsed = JSON.parse(readFileSync(STACK_REFS_URL, 'utf8'));
|
|
8
|
+
if (!isRecord(parsed))
|
|
9
|
+
throw new Error('stackRefs.json is not an object');
|
|
10
|
+
return new Map(Object.entries(parsed).filter((entry) => typeof entry[1] === 'string'));
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function forcedValue(option, options, answers) {
|
|
2
|
+
for (const other of options) {
|
|
3
|
+
const required = other.requires?.[option.id];
|
|
4
|
+
if (answers[other.id] && required !== undefined)
|
|
5
|
+
return { value: required, by: other.id };
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
function flag(id, value) {
|
|
10
|
+
return value ? `--${id}` : `--no-${id}`;
|
|
11
|
+
}
|
|
12
|
+
export async function resolveAnswers(options, given, ask) {
|
|
13
|
+
const answers = {};
|
|
14
|
+
for (const option of options) {
|
|
15
|
+
const forced = forcedValue(option, options, answers);
|
|
16
|
+
const chosen = given[option.id];
|
|
17
|
+
if (forced !== undefined && chosen !== undefined && chosen !== forced.value) {
|
|
18
|
+
throw new Error(`${flag(option.id, chosen)} cannot be used with ${flag(forced.by, true)}`);
|
|
19
|
+
}
|
|
20
|
+
if (forced !== undefined)
|
|
21
|
+
answers[option.id] = forced.value;
|
|
22
|
+
else if (chosen !== undefined)
|
|
23
|
+
answers[option.id] = chosen;
|
|
24
|
+
else
|
|
25
|
+
answers[option.id] = ask === undefined ? option.default : await ask(option);
|
|
26
|
+
}
|
|
27
|
+
return answers;
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
export function runCommand(command, args, cwd, quiet = false) {
|
|
3
|
+
const result = spawnSync(command, args, { cwd, encoding: 'utf8', shell: process.platform === 'win32', stdio: quiet ? 'pipe' : 'inherit' });
|
|
4
|
+
if (result.status !== 0)
|
|
5
|
+
throw new Error(`${command} ${args.join(' ')} failed${quiet ? `: ${result.stderr}` : ''}`);
|
|
6
|
+
return result.stdout ?? '';
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { applyStackChanges, updatePackageJson } from './applyStackChanges.js';
|
|
2
|
+
import { fetchTemplate } from './fetchTemplate.js';
|
|
3
|
+
import { runCommand } from './runCommand.js';
|
|
4
|
+
import { OVERLAYS_ROOT } from './ScaffoldConfig.js';
|
|
5
|
+
export async function scaffoldProject(plan, step) {
|
|
6
|
+
step(`Downloading ${plan.stack.repository}`);
|
|
7
|
+
await fetchTemplate(plan.stack, plan.dir, plan.source);
|
|
8
|
+
step('Applying the options');
|
|
9
|
+
applyStackChanges(plan.dir, plan.stack, plan.answers, OVERLAYS_ROOT, plan.projectName);
|
|
10
|
+
if (plan.git) {
|
|
11
|
+
step('Initializing git');
|
|
12
|
+
runCommand('git', ['init', '--quiet'], plan.dir);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
updatePackageJson(plan.dir, (pkg) => {
|
|
16
|
+
delete pkg.scripts?.['prepare'];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (plan.install) {
|
|
20
|
+
step('Installing dependencies');
|
|
21
|
+
runCommand('npm', ['install'], plan.dir);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const NPM_TEST_ROW = '`node:test`: queries on an in-memory database, routes through `inject()`';
|
|
2
|
+
const ROW_ID_SENTENCE = " That is also why `src/shared-features/todos/todosQueries.ts` reads `row['id']`.";
|
|
3
|
+
const TEST_FILES_OPENING_A_DATABASE = ['src/shared-features/todos/todosQueries.test.ts', 'src/features/todo-list/todoListRoutes.test.ts', 'src/features/todo-stats/todoStatsRoutes.test.ts'];
|
|
4
|
+
export const fastifyStack = {
|
|
5
|
+
id: 'fastify',
|
|
6
|
+
label: 'Fastify API',
|
|
7
|
+
repository: 'Elnop/template-fastify',
|
|
8
|
+
submodule: 'template-fastify',
|
|
9
|
+
options: [
|
|
10
|
+
{ id: 'example', message: 'Keep the todo list API example?', default: true },
|
|
11
|
+
{ id: 'swagger', message: 'Serve a Swagger UI on /docs?', default: true },
|
|
12
|
+
{ id: 'database', message: 'Use SQLite (node:sqlite)? Without it, the example keeps its todos in memory.', default: true },
|
|
13
|
+
{ id: 'claude-hook', message: 'Add the Claude Code hook that lints each edited file?', default: true, independent: true },
|
|
14
|
+
{ id: 'ci', message: 'Add the GitHub Actions workflow?', default: true, independent: true },
|
|
15
|
+
],
|
|
16
|
+
changes: [
|
|
17
|
+
{
|
|
18
|
+
when: {},
|
|
19
|
+
edits: [{ file: 'README.md', replace: '# template-fastify\n', with: '# __PROJECT_NAME__\n' }],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
when: { example: false },
|
|
23
|
+
remove: ['src/features', 'src/shared-features/todos', 'src/shared/ErrorSchemas.ts'],
|
|
24
|
+
edits: [
|
|
25
|
+
{ file: 'src/app/buildApp.ts', dropLines: '#src/features/' },
|
|
26
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'createTodosQueries' },
|
|
27
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'Routes(create' },
|
|
28
|
+
{ file: 'src/app/buildApp.ts', replace: "'Todos API'", with: "'__PROJECT_NAME__'", all: true },
|
|
29
|
+
{ file: 'README.md', dropLines: 'The example is the todo list API' },
|
|
30
|
+
{ file: 'README.md', dropSection: '## Endpoints' },
|
|
31
|
+
{
|
|
32
|
+
file: 'README.md',
|
|
33
|
+
replace: ' `src/shared-features/todos/` holds the schemas, the types and `todosQueries.ts`, the only file that runs SQL on the todos table. `src/features/todo-list/` and `src/features/todo-stats/` both use it without knowing each other.',
|
|
34
|
+
with: '',
|
|
35
|
+
},
|
|
36
|
+
{ file: 'README.md', replace: NPM_TEST_ROW, with: '`node:test`, routes through `inject()`' },
|
|
37
|
+
{ file: 'README.md', dropLines: '1. Delete `src/features/`' },
|
|
38
|
+
{ file: 'README.md', replace: '2. Add a shared feature', with: '1. Add a shared feature' },
|
|
39
|
+
{ file: 'README.md', replace: '3. Run `npm run check` often', with: '2. Run `npm run check` often' },
|
|
40
|
+
{ file: 'docs/verifications.md', replace: ROW_ID_SENTENCE, with: '' },
|
|
41
|
+
{ file: 'docs/verifications.md', dropLines: '| Queries |' },
|
|
42
|
+
{ file: 'docs/verifications.md', dropLines: '| Routes |' },
|
|
43
|
+
],
|
|
44
|
+
keep: ['src/features', 'src/shared-features'],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
when: { example: false, swagger: true },
|
|
48
|
+
overlay: 'skeleton-swagger',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
when: { example: false, swagger: false },
|
|
52
|
+
overlay: 'skeleton-no-swagger',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
when: { example: true, swagger: false },
|
|
56
|
+
overlay: 'example-no-swagger',
|
|
57
|
+
edits: [{ file: 'README.md', replace: ', serialize the output and produce the OpenAPI document served on `/docs/json`.', with: ' and serialize the output.' }],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
when: { swagger: false },
|
|
61
|
+
remove: ['src/shared/swaggerTheme.css'],
|
|
62
|
+
edits: [
|
|
63
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'readFileSync' },
|
|
64
|
+
{ file: 'src/app/buildApp.ts', dropLines: '@fastify/swagger' },
|
|
65
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'jsonSchemaTransform' },
|
|
66
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'SWAGGER_THEME' },
|
|
67
|
+
{ file: 'README.md', replace: ', and the Swagger UI on `/docs`.', with: '.' },
|
|
68
|
+
{ file: 'README.md', replace: ', routes, Swagger', with: ', routes' },
|
|
69
|
+
{ file: 'docs/verifications.md', replace: 'the full wiring and the OpenAPI document', with: 'the full wiring' },
|
|
70
|
+
],
|
|
71
|
+
dependencies: ['@fastify/swagger-ui'],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
when: { example: true, database: false },
|
|
75
|
+
overlay: 'memory-store',
|
|
76
|
+
edits: [
|
|
77
|
+
...TEST_FILES_OPENING_A_DATABASE.flatMap((file) => [
|
|
78
|
+
{ file, dropLines: "import { openDatabase } from '#src/shared/lib/openDatabase.ts';" },
|
|
79
|
+
{ file, replace: "createTodosQueries(openDatabase(':memory:'))", with: 'createTodosQueries()' },
|
|
80
|
+
]),
|
|
81
|
+
{ file: 'src/app/buildApp.ts', replace: 'createTodosQueries(database)', with: 'createTodosQueries()' },
|
|
82
|
+
{ file: 'README.md', replace: 'the only file that runs SQL on the todos table', with: 'which keeps the todos in memory' },
|
|
83
|
+
{ file: 'README.md', replace: NPM_TEST_ROW, with: '`node:test`: queries, routes through `inject()`' },
|
|
84
|
+
{ file: 'docs/verifications.md', replace: ROW_ID_SENTENCE, with: '' },
|
|
85
|
+
{ file: 'docs/verifications.md', replace: 'the SQL, on a `:memory:` database', with: 'the in-memory store' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
when: { database: false },
|
|
90
|
+
remove: ['src/shared/lib/openDatabase.ts'],
|
|
91
|
+
edits: [
|
|
92
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'openDatabase' },
|
|
93
|
+
{ file: 'src/app/buildApp.ts', dropLines: 'database.close()' },
|
|
94
|
+
{ file: 'src/app/buildApp.ts', replace: '{ databasePath, logLevel }', with: '{ logLevel }' },
|
|
95
|
+
{ file: 'src/app/buildApp.test.ts', replace: "databasePath: ':memory:', ", with: '', all: true },
|
|
96
|
+
{ file: 'src/app/AppTypes.ts', replace: "'databasePath' | 'logLevel'", with: "'logLevel'" },
|
|
97
|
+
{ file: 'src/shared/EnvTypes.ts', dropLines: 'databasePath' },
|
|
98
|
+
{ file: 'src/shared/lib/readEnv.ts', dropLines: 'DATABASE_PATH: z' },
|
|
99
|
+
{ file: 'src/shared/lib/readEnv.ts', replace: ', databasePath: env.DATABASE_PATH', with: '' },
|
|
100
|
+
{ file: 'src/shared/lib/readEnv.test.ts', replace: ", databasePath: 'todos.db'", with: '' },
|
|
101
|
+
{ file: 'src/shared/lib/readEnv.test.ts', replace: " DATABASE_PATH: ':memory:',", with: '' },
|
|
102
|
+
{ file: 'src/shared/lib/readEnv.test.ts', dropLines: "databasePath: ':memory:'" },
|
|
103
|
+
{ file: 'README.md', dropLines: '| `DATABASE_PATH` |' },
|
|
104
|
+
{ file: 'README.md', replace: 'wires everything: database, ', with: 'wires everything: ' },
|
|
105
|
+
{ file: 'README.md', replace: 'env, database connection, server factory', with: 'env, server factory' },
|
|
106
|
+
{ file: 'docs/verifications.md', replace: 'it holds three plain modules', with: 'it holds two plain modules' },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
when: { 'claude-hook': false },
|
|
111
|
+
remove: ['.claude'],
|
|
112
|
+
edits: [
|
|
113
|
+
{ file: 'knip.json', replace: '"@elnop/agent-hook", ', with: '' },
|
|
114
|
+
{ file: 'README.md', dropSection: '## Working with an agent' },
|
|
115
|
+
],
|
|
116
|
+
dependencies: ['@elnop/agent-hook'],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
when: { ci: false },
|
|
120
|
+
remove: ['.github'],
|
|
121
|
+
edits: [{ file: 'docs/verifications.md', replace: 'runs locally, in the pre-commit hook and in CI.', with: 'runs locally and in the pre-commit hook.' }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
nextSteps: () => ['npm run dev'],
|
|
125
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
const CHROMIUM_LINE = ' - run: npx playwright install --with-deps --only-shell chromium\n';
|
|
2
|
+
const E2E_JOB = `
|
|
3
|
+
e2e:
|
|
4
|
+
runs-on: ubuntu-latest
|
|
5
|
+
steps:
|
|
6
|
+
- uses: actions/checkout@v7
|
|
7
|
+
- uses: actions/setup-node@v7
|
|
8
|
+
with:
|
|
9
|
+
node-version: 26
|
|
10
|
+
cache: npm
|
|
11
|
+
- run: npm ci
|
|
12
|
+
${CHROMIUM_LINE} - run: npm run e2e
|
|
13
|
+
`;
|
|
14
|
+
const UNIT_PROJECT = "{ extends: true, test: { name: 'unit', environment: 'node', include: ['src/**/*.test.ts'] } }";
|
|
15
|
+
const COMPONENT_PROJECT = ` {
|
|
16
|
+
extends: true,
|
|
17
|
+
test: {
|
|
18
|
+
name: 'component',
|
|
19
|
+
include: ['src/**/*.test.tsx'],
|
|
20
|
+
browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: 'chromium' }] },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
`;
|
|
24
|
+
const CHROMIUM_DOC = 'Component tests and e2e need headless Chromium: `npx playwright install --only-shell chromium`.';
|
|
25
|
+
export const viteStack = {
|
|
26
|
+
id: 'vite',
|
|
27
|
+
label: 'Vite + React',
|
|
28
|
+
repository: 'Elnop/template-vite',
|
|
29
|
+
submodule: 'template-vite',
|
|
30
|
+
options: [
|
|
31
|
+
{ id: 'example', message: 'Keep the todo list example?', default: true, requires: { zustand: true } },
|
|
32
|
+
{ id: 'zustand', message: 'Add zustand for state?', default: true },
|
|
33
|
+
{ id: 'component-tests', message: 'Add component tests (Vitest in Chromium)?', default: true },
|
|
34
|
+
{ id: 'e2e', message: 'Add e2e tests (Playwright)?', default: true },
|
|
35
|
+
{ id: 'claude-hook', message: 'Add the Claude Code hook that lints each edited file?', default: true, independent: true },
|
|
36
|
+
{ id: 'ci', message: 'Add the GitHub Actions workflow?', default: true },
|
|
37
|
+
],
|
|
38
|
+
changes: [
|
|
39
|
+
{
|
|
40
|
+
when: {},
|
|
41
|
+
edits: [{ file: 'README.md', replace: '# template-vite\n', with: '# __PROJECT_NAME__\n' }],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
when: { example: false },
|
|
45
|
+
remove: ['src/features', 'src/shared-features/todos', 'src/shared/lib'],
|
|
46
|
+
edits: [
|
|
47
|
+
{
|
|
48
|
+
file: 'README.md',
|
|
49
|
+
replace: 'The example app is a todo list. It is there so every rule has real code to check: delete it and keep the structure. The look',
|
|
50
|
+
with: 'The look',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
file: 'README.md',
|
|
54
|
+
replace: 'In the example, `src/shared-features/todos/` holds the tasks and the current filter, used by both `src/features/todo-list/` (add, check, rename, delete, filter) and `src/features/todo-stats/` (counters, clear done). Renaming a task is a sub-feature: `src/features/todo-list/features/todo-editor/`. ',
|
|
55
|
+
with: '',
|
|
56
|
+
},
|
|
57
|
+
{ file: 'README.md', dropLines: '1. Delete `src/features/`' },
|
|
58
|
+
{ file: 'README.md', replace: '2. Write your first feature', with: '1. Write your first feature' },
|
|
59
|
+
{ file: 'README.md', replace: '3. Run `npm run check` often', with: '2. Run `npm run check` often' },
|
|
60
|
+
{ file: 'docs/verifications.md', replace: '`src/shared/lib/normalizeTitle.test.ts` next to its source', with: 'next to its source' },
|
|
61
|
+
{ file: 'docs/verifications.md', replace: '`src/features/todo-list/components/presenters/TodoInput.test.tsx`', with: 'next to the presenter' },
|
|
62
|
+
],
|
|
63
|
+
overlay: 'skeleton',
|
|
64
|
+
keep: ['src/features', 'src/shared-features', 'src/shared'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
when: { example: false, e2e: true },
|
|
68
|
+
remove: ['e2e/todos.spec.ts'],
|
|
69
|
+
edits: [
|
|
70
|
+
{
|
|
71
|
+
file: 'docs/verifications.md',
|
|
72
|
+
replace: ' `e2e/todos.spec.ts` covers the example: the same two checks while a task is being renamed, the counters while adding, checking, filtering and clearing tasks, and renaming.',
|
|
73
|
+
with: '',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
when: { example: false, zustand: true },
|
|
79
|
+
edits: [{ file: 'knip.json', replace: '"ignoreDependencies": ["@swc/core", ', with: '"ignoreDependencies": ["@swc/core", "zustand", ' }],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
when: { example: false, 'component-tests': true },
|
|
83
|
+
edits: [{ file: 'knip.json', replace: '"ignoreDependencies": ["@swc/core", ', with: '"ignoreDependencies": ["@swc/core", "vitest-browser-react", ' }],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
when: { zustand: false },
|
|
87
|
+
dependencies: ['zustand'],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
when: { 'component-tests': false, example: true },
|
|
91
|
+
remove: ['src/**/*.test.tsx'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
when: { 'component-tests': false },
|
|
95
|
+
edits: [
|
|
96
|
+
{ file: 'vitest.config.ts', replace: "import { playwright } from '@vitest/browser-playwright';\n", with: '' },
|
|
97
|
+
{ file: 'vitest.config.ts', replace: ` projects: [\n ${UNIT_PROJECT},\n${COMPONENT_PROJECT} ],\n`, with: ` projects: [${UNIT_PROJECT}],\n` },
|
|
98
|
+
{ file: 'tsconfig.test.json', replace: ', "@vitest/browser-playwright"]', with: ']' },
|
|
99
|
+
{ file: 'README.md', replace: 'Vitest: unit tests in Node, component tests in headless Chromium', with: 'Vitest unit tests, in Node' },
|
|
100
|
+
{ file: 'docs/verifications.md', dropLines: '| Component |' },
|
|
101
|
+
{
|
|
102
|
+
file: 'docs/verifications.md',
|
|
103
|
+
replace: 'The extension picks the kind: `.test.ts` runs in Node, `.test.tsx` in Chromium. Only presenters get component tests, since orchestrators read stores and e2e covers them. ',
|
|
104
|
+
with: '',
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
scripts: ['test:component'],
|
|
108
|
+
dependencies: ['@vitest/browser-playwright', 'vitest-browser-react'],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
when: { 'component-tests': false, ci: true },
|
|
112
|
+
edits: [{ file: '.github/workflows/ci.yml', replace: ` - run: npm ci\n${CHROMIUM_LINE} - run: npm run check`, with: ' - run: npm ci\n - run: npm run check' }],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
when: { e2e: false },
|
|
116
|
+
remove: ['e2e', 'playwright.config.ts'],
|
|
117
|
+
edits: [
|
|
118
|
+
{
|
|
119
|
+
file: 'package.json',
|
|
120
|
+
replace: 'oxlint --type-aware src e2e oxlint.config.ts vite.config.ts vitest.config.ts playwright.config.ts',
|
|
121
|
+
with: 'oxlint --type-aware src oxlint.config.ts vite.config.ts vitest.config.ts',
|
|
122
|
+
},
|
|
123
|
+
{ file: 'tsconfig.test.json', replace: '"include": ["src", "e2e",', with: '"include": ["src",' },
|
|
124
|
+
{ file: 'tsconfig.test.json', replace: ', "playwright.config.ts"]', with: ']' },
|
|
125
|
+
{ file: 'tsconfig.test.json', replace: '"@/*": ["./src/*"],\n "@e2e/*": ["./e2e/*"]', with: '"@/*": ["./src/*"]' },
|
|
126
|
+
{ file: 'oxlint.config.ts', replace: 'import through an alias (@/ in src/, @e2e/ in e2e/).', with: 'import through the @/ alias.' },
|
|
127
|
+
{ file: 'README.md', dropLines: '| `npm run e2e` |' },
|
|
128
|
+
{ file: 'docs/verifications.md', replace: " That last one is why `playwright.config.ts` reads `process.env['CI']`.", with: '' },
|
|
129
|
+
{ file: 'docs/verifications.md', replace: 'on tests, e2e and configs', with: 'on tests and configs' },
|
|
130
|
+
{ file: 'docs/verifications.md', replace: '`@/` in `src/`, `@e2e/` in `e2e/`', with: '`@/` in `src/`' },
|
|
131
|
+
{ file: 'docs/verifications.md', dropLines: '| E2e |' },
|
|
132
|
+
{ file: 'docs/verifications.md', dropLines: 'The specs import `test`' },
|
|
133
|
+
],
|
|
134
|
+
scripts: ['e2e'],
|
|
135
|
+
dependencies: ['@playwright/test', '@axe-core/playwright', '@elnop/playwright-fixtures'],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
when: { e2e: false, example: true },
|
|
139
|
+
edits: [
|
|
140
|
+
{
|
|
141
|
+
file: 'README.md',
|
|
142
|
+
replace: ', the files in `src/shared/` and `e2e/todos.spec.ts`. `e2e/app.spec.ts` and `e2e/openApp.ts` work on any app whose first screen has an `h1`.',
|
|
143
|
+
with: ' and the files in `src/shared/`.',
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
when: { e2e: false, ci: true },
|
|
149
|
+
edits: [
|
|
150
|
+
{ file: '.github/workflows/ci.yml', replace: E2E_JOB, with: '' },
|
|
151
|
+
{ file: 'README.md', replace: ' in one job and `e2e` in another.', with: '.' },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
when: { e2e: false, 'component-tests': true },
|
|
156
|
+
devDependencies: { playwright: '1.64.1' },
|
|
157
|
+
edits: [
|
|
158
|
+
{ file: 'docs/verifications.md', replace: CHROMIUM_DOC, with: 'Component tests need headless Chromium: `npx playwright install --only-shell chromium`.' },
|
|
159
|
+
{ file: 'docs/verifications.md', replace: ', since orchestrators read stores and e2e covers them.', with: ', since orchestrators read stores.' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
when: { e2e: true, 'component-tests': false },
|
|
164
|
+
edits: [{ file: 'docs/verifications.md', replace: CHROMIUM_DOC, with: 'E2e needs headless Chromium: `npx playwright install --only-shell chromium`.' }],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
when: { e2e: false, 'component-tests': false },
|
|
168
|
+
edits: [
|
|
169
|
+
{ file: 'docs/verifications.md', replace: ` ${CHROMIUM_DOC}`, with: '' },
|
|
170
|
+
{ file: 'README.md', dropLines: 'npx playwright install' },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
when: { 'claude-hook': false },
|
|
175
|
+
remove: ['.claude'],
|
|
176
|
+
edits: [
|
|
177
|
+
{ file: 'knip.json', replace: '"@elnop/agent-hook", ', with: '' },
|
|
178
|
+
{ file: 'README.md', dropSection: '## Working with an agent' },
|
|
179
|
+
],
|
|
180
|
+
dependencies: ['@elnop/agent-hook'],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
when: { ci: false },
|
|
184
|
+
remove: ['.github'],
|
|
185
|
+
edits: [
|
|
186
|
+
{ file: 'README.md', dropLines: 'CI (`.github/workflows/ci.yml`)' },
|
|
187
|
+
{ file: 'docs/verifications.md', replace: 'runs locally, in the pre-commit hook and in CI.', with: 'runs locally and in the pre-commit hook.' },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
nextSteps: (answers) => [...(answers['e2e'] || answers['component-tests'] ? ['npx playwright install --only-shell chromium'] : []), 'npm run dev'],
|
|
192
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildApp } from '#src/app/buildApp.ts';
|
|
4
|
+
|
|
5
|
+
describe('buildApp', () => {
|
|
6
|
+
it('wires the todo list and the stats on the same todos', async () => {
|
|
7
|
+
const app = await buildApp({ databasePath: ':memory:', logLevel: 'silent' });
|
|
8
|
+
await app.inject({ method: 'POST', url: '/todos', payload: { title: 'Wire the app' } });
|
|
9
|
+
assert.deepEqual((await app.inject({ method: 'GET', url: '/stats' })).json(), { total: 1, completed: 0, remaining: 1 });
|
|
10
|
+
await app.close();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Todo, TodosQueries } from '#src/shared-features/todos/TodosTypes.ts';
|
|
2
|
+
|
|
3
|
+
export function createTodosQueries(): TodosQueries {
|
|
4
|
+
const todos = new Map<number, Todo>();
|
|
5
|
+
let lastId = 0;
|
|
6
|
+
|
|
7
|
+
function all(): Todo[] {
|
|
8
|
+
return [...todos.values()].map((todo) => ({ ...todo }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
list: all,
|
|
13
|
+
listByCompleted: (completed) => all().filter((todo) => todo.completed === completed),
|
|
14
|
+
find: (id) => {
|
|
15
|
+
const todo = todos.get(id);
|
|
16
|
+
return todo === undefined ? null : { ...todo };
|
|
17
|
+
},
|
|
18
|
+
insert: ({ title }) => {
|
|
19
|
+
lastId += 1;
|
|
20
|
+
const todo = { id: lastId, title, completed: false };
|
|
21
|
+
todos.set(todo.id, todo);
|
|
22
|
+
return { ...todo };
|
|
23
|
+
},
|
|
24
|
+
update: (id, { title, completed }) => {
|
|
25
|
+
const todo = todos.get(id);
|
|
26
|
+
if (todo === undefined) return null;
|
|
27
|
+
const updated = { id, title: title ?? todo.title, completed: completed ?? todo.completed };
|
|
28
|
+
todos.set(id, updated);
|
|
29
|
+
return { ...updated };
|
|
30
|
+
},
|
|
31
|
+
remove: (id) => {
|
|
32
|
+
const todo = todos.get(id);
|
|
33
|
+
if (todo === undefined) return null;
|
|
34
|
+
todos.delete(id);
|
|
35
|
+
return todo;
|
|
36
|
+
},
|
|
37
|
+
removeCompleted: () => {
|
|
38
|
+
const completed = all().filter((todo) => todo.completed);
|
|
39
|
+
for (const todo of completed) todos.delete(todo.id);
|
|
40
|
+
return completed.length;
|
|
41
|
+
},
|
|
42
|
+
count: () => {
|
|
43
|
+
const list = all();
|
|
44
|
+
return { total: list.length, completed: list.filter((todo) => todo.completed).length };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildApp } from '#src/app/buildApp.ts';
|
|
4
|
+
|
|
5
|
+
describe('buildApp', () => {
|
|
6
|
+
it('answers 404 on a route nobody registered', async () => {
|
|
7
|
+
const app = await buildApp({ databasePath: ':memory:', logLevel: 'silent' });
|
|
8
|
+
assert.equal((await app.inject({ method: 'GET', url: '/' })).statusCode, 404);
|
|
9
|
+
await app.close();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildApp } from '#src/app/buildApp.ts';
|
|
4
|
+
|
|
5
|
+
describe('buildApp', () => {
|
|
6
|
+
it('serves the OpenAPI document and the Swagger UI', async () => {
|
|
7
|
+
const app = await buildApp({ databasePath: ':memory:', logLevel: 'silent' });
|
|
8
|
+
const spec = await app.inject({ method: 'GET', url: '/docs/json' });
|
|
9
|
+
assert.equal(spec.json<{ info: { title: string } }>().info.title, '__PROJECT_NAME__');
|
|
10
|
+
const ui = await app.inject({ method: 'GET', url: '/docs/' });
|
|
11
|
+
assert.equal(ui.statusCode, 200);
|
|
12
|
+
await app.close();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function App() {
|
|
2
|
+
return (
|
|
3
|
+
<div className="page">
|
|
4
|
+
<header className="page-header">
|
|
5
|
+
<h1>__PROJECT_NAME__</h1>
|
|
6
|
+
</header>
|
|
7
|
+
<main className="page-body">
|
|
8
|
+
<p>Write your first feature in src/features/ and render it here.</p>
|
|
9
|
+
</main>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-elnop-stack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Creates a project from one of the elnop-stacks templates, with the options you pick.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"create",
|
|
7
|
+
"template",
|
|
8
|
+
"vite",
|
|
9
|
+
"react",
|
|
10
|
+
"fastify"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/Elnop/elnop-stacks/tree/main/create-elnop-stack#readme",
|
|
13
|
+
"bugs": "https://github.com/Elnop/elnop-stacks/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Elnop/elnop-stacks.git",
|
|
17
|
+
"directory": "create-elnop-stack"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "elnop",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=26"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"overlays"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"create-elnop-stack": "dist/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.build.json && node scripts/writeStackRefs.ts",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"test": "node --test 'src/**/*.test.ts'"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@clack/prompts": "1.8.1",
|
|
39
|
+
"giget": "3.3.1"
|
|
40
|
+
}
|
|
41
|
+
}
|