create-elnop-stack 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # create-elnop-stack
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
17
+ `example` keeps the todo list. Without it you get the same structure with empty `src/features/`, `src/shared-features/` and `src/shared/` folders, and an `App.tsx` that only shows the project name. zustand can only be removed together with the example, since the example uses it.
18
+
19
+ 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
+
21
+ ## Without questions
22
+
23
+ Every option has a flag, `--<option>` or `--no-<option>`. `--yes` takes the default for every question left:
24
+
25
+ ```sh
26
+ npm create elnop-stack my-app -- --stack vite --no-example --no-e2e --yes
27
+ ```
28
+
29
+ A preset file holds the same answers, and flags override it:
30
+
31
+ ```json
32
+ { "stack": "vite", "git": true, "install": true, "options": { "example": false, "e2e": false } }
33
+ ```
34
+
35
+ ```sh
36
+ npm create elnop-stack my-app -- --preset minimal.json --yes
37
+ ```
38
+
39
+ `npm create elnop-stack -- --help` lists the stacks and their options.
40
+
41
+ ## How it works
42
+
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.
44
+
45
+ ## Developing
46
+
47
+ From the root of elnop-stacks:
48
+
49
+ ```sh
50
+ npm install
51
+ node create-elnop-stack/src/cli.ts ../my-app --local
52
+ npm run matrix
53
+ ```
54
+
55
+ `--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/`.
@@ -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,36 @@
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 replaceOnce(text, search, replacement) {
25
+ const count = text.split(search).length - 1;
26
+ if (count !== 1)
27
+ throw new Error(`expected "${search}" once, found it ${count} times`);
28
+ return text.replace(search, () => replacement);
29
+ }
30
+ export function applyFileEdit(text, edit) {
31
+ if ('replace' in edit)
32
+ return replaceOnce(text, edit.replace, edit.with);
33
+ if ('dropLines' in edit)
34
+ return dropLines(text, edit.dropLines);
35
+ return dropSection(text, edit.dropSection);
36
+ }
@@ -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,3 @@
1
+ export function errorMessage(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export function isRecord(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
@@ -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
+ }
@@ -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,3 @@
1
+ {
2
+ "vite": "539012b17ec842b65a69f16c9bee7ef146057af2"
3
+ }
@@ -0,0 +1,2 @@
1
+ import { viteStack } from './viteStack.js';
2
+ export const stacks = [viteStack];
@@ -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
+ 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ }