create-elnop-stack 0.1.0 → 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 +5 -2
- package/dist/applyFileEdit.js +6 -4
- package/dist/stackRefs.json +2 -1
- package/dist/stacks/fastifyStack.js +125 -0
- package/dist/stacks/stacksConfig.js +2 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,8 +13,11 @@ Node 26 is required, like the templates.
|
|
|
13
13
|
| Stack | Template | Options |
|
|
14
14
|
|---|---|---|
|
|
15
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` |
|
|
16
17
|
|
|
17
|
-
`example` keeps the todo list. Without it you get the same structure with empty `src/features
|
|
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.
|
|
18
21
|
|
|
19
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.
|
|
20
23
|
|
|
@@ -40,7 +43,7 @@ npm create elnop-stack my-app -- --preset minimal.json --yes
|
|
|
40
43
|
|
|
41
44
|
## How it works
|
|
42
45
|
|
|
43
|
-
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`: 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.
|
|
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.
|
|
44
47
|
|
|
45
48
|
## Developing
|
|
46
49
|
|
package/dist/applyFileEdit.js
CHANGED
|
@@ -21,15 +21,17 @@ function dropLines(text, needle) {
|
|
|
21
21
|
throw new Error(`no line contains "${needle}"`);
|
|
22
22
|
return kept.join('\n').replaceAll(/\n{3,}/gu, '\n\n');
|
|
23
23
|
}
|
|
24
|
-
function
|
|
24
|
+
function replaceText(text, search, replacement, all) {
|
|
25
25
|
const count = text.split(search).length - 1;
|
|
26
|
-
if (count
|
|
26
|
+
if (all && count === 0)
|
|
27
|
+
throw new Error(`"${search}" not found`);
|
|
28
|
+
if (!all && count !== 1)
|
|
27
29
|
throw new Error(`expected "${search}" once, found it ${count} times`);
|
|
28
|
-
return text.replace(search, () => replacement);
|
|
30
|
+
return all ? text.replaceAll(search, () => replacement) : text.replace(search, () => replacement);
|
|
29
31
|
}
|
|
30
32
|
export function applyFileEdit(text, edit) {
|
|
31
33
|
if ('replace' in edit)
|
|
32
|
-
return
|
|
34
|
+
return replaceText(text, edit.replace, edit.with, edit.all === true);
|
|
33
35
|
if ('dropLines' in edit)
|
|
34
36
|
return dropLines(text, edit.dropLines);
|
|
35
37
|
return dropSection(text, edit.dropSection);
|
package/dist/stackRefs.json
CHANGED
|
@@ -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,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
|
+
});
|