create-epinoetics-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-epinoetics-app.js +32 -0
- package/package.json +27 -0
- package/src/cloner.js +33 -0
- package/src/generator.js +105 -0
- package/src/index.js +115 -0
- package/src/merge.js +26 -0
- package/src/repos.js +62 -0
- package/src/scaffold.js +69 -0
- package/src/template.js +33 -0
- package/src/utils.js +6 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { resolve, dirname } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { run } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8'));
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('create-epinoetics-app')
|
|
14
|
+
.description('Scaffold a new project from your boilerplate repos')
|
|
15
|
+
.version(pkg.version)
|
|
16
|
+
.argument('[project-name]', 'Name of the project to create')
|
|
17
|
+
.option('-b, --backend <id>', 'Backend to use: api-nest | api-dotnet')
|
|
18
|
+
.option('-f, --frontend <id>', 'Frontend to use: web-next | web-astro (when available)')
|
|
19
|
+
.option('--dry-run', 'Preview without cloning anything')
|
|
20
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
21
|
+
.parse(process.argv);
|
|
22
|
+
|
|
23
|
+
const opts = program.opts();
|
|
24
|
+
const [projectName] = program.args;
|
|
25
|
+
|
|
26
|
+
run({
|
|
27
|
+
projectName,
|
|
28
|
+
backend: opts.backend,
|
|
29
|
+
frontend: opts.frontend,
|
|
30
|
+
dryRun: opts.dryRun ?? false,
|
|
31
|
+
yes: opts.yes ?? false,
|
|
32
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-epinoetics-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scaffold a new project from your boilerplate repos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-epinoetics-app": "./bin/create-epinoetics-app.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "node bin/create-epinoetics-app.js",
|
|
15
|
+
"test": "node bin/create-epinoetics-app.js --dry-run"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@clack/prompts": "^0.9.0",
|
|
19
|
+
"chalk": "^5.4.1",
|
|
20
|
+
"commander": "^12.1.0",
|
|
21
|
+
"simple-git": "^3.27.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|
package/src/cloner.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { resolve, join } from 'path';
|
|
4
|
+
import { simpleGit } from 'simple-git';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
|
|
7
|
+
export async function cloneAll({ projectName, repos }) {
|
|
8
|
+
const projectDir = resolve(process.cwd(), projectName);
|
|
9
|
+
const codeDir = join(projectDir, 'code');
|
|
10
|
+
|
|
11
|
+
// Create root + code/ dirs
|
|
12
|
+
fs.mkdirSync(codeDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
for (const repo of repos) {
|
|
15
|
+
const dest = join(codeDir, repo.id);
|
|
16
|
+
const spinner = p.spinner();
|
|
17
|
+
|
|
18
|
+
spinner.start(`Cloning ${chalk.cyan(repo.label)} ${chalk.dim(repo.url)}`);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await simpleGit().clone(repo.url, dest, ['--depth=1']);
|
|
22
|
+
|
|
23
|
+
// Remove the .git folder so the clone becomes plain source,
|
|
24
|
+
// not a nested git repo inside the new project.
|
|
25
|
+
fs.rmSync(join(dest, '.git'), { recursive: true, force: true });
|
|
26
|
+
|
|
27
|
+
spinner.stop(`${chalk.green('✓')} ${repo.label} ${chalk.dim(`→ code/${repo.id}`)}`);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
spinner.stop(`${chalk.red('✗')} ${repo.label} ${chalk.dim(err.message)}`);
|
|
30
|
+
throw new Error(`Failed to clone ${repo.label}: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/generator.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { resolve, dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import fse from 'fs-extra';
|
|
5
|
+
import { renderTemplate } from './template.js';
|
|
6
|
+
import { mergePackageJson } from './merge.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const TEMPLATES_DIR = resolve(__dirname, '../templates');
|
|
10
|
+
|
|
11
|
+
export async function generate(config) {
|
|
12
|
+
const { projectName, framework, include, css, pm } = config;
|
|
13
|
+
const dest = resolve(process.cwd(), projectName);
|
|
14
|
+
|
|
15
|
+
// Guard against overwriting existing directory
|
|
16
|
+
if (await fse.pathExists(dest)) {
|
|
17
|
+
throw new Error(`Directory "${projectName}" already exists.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await fse.ensureDir(dest);
|
|
21
|
+
|
|
22
|
+
const ctx = { projectName, framework, include, css, pm };
|
|
23
|
+
|
|
24
|
+
// 1. Copy base template (shared across all projects)
|
|
25
|
+
await copyTemplateDir('base', dest, ctx);
|
|
26
|
+
|
|
27
|
+
// 2. Copy framework-specific template
|
|
28
|
+
await copyTemplateDir(framework, dest, ctx);
|
|
29
|
+
|
|
30
|
+
// 3. Apply each selected feature overlay
|
|
31
|
+
for (const feature of include) {
|
|
32
|
+
const featureDir = join(TEMPLATES_DIR, 'features', feature);
|
|
33
|
+
if (await fse.pathExists(featureDir)) {
|
|
34
|
+
await copyTemplateDir(join('features', feature), dest, ctx);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 4. Merge all package.json fragments together
|
|
39
|
+
await buildPackageJson(dest, config);
|
|
40
|
+
|
|
41
|
+
// 5. Write scaffold config so the project remembers how it was created
|
|
42
|
+
await fse.writeJSON(join(dest, 'scaffold.config.json'), {
|
|
43
|
+
framework,
|
|
44
|
+
include,
|
|
45
|
+
css,
|
|
46
|
+
packageManager: pm,
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
}, { spaces: 2 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function copyTemplateDir(templateName, dest, ctx) {
|
|
52
|
+
const src = join(TEMPLATES_DIR, templateName);
|
|
53
|
+
if (!(await fse.pathExists(src))) return;
|
|
54
|
+
|
|
55
|
+
const entries = await fse.readdir(src, { withFileTypes: true });
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const srcPath = join(src, entry.name);
|
|
59
|
+
// Skip package.json fragments here — merged separately
|
|
60
|
+
if (entry.name === 'package.fragment.json') continue;
|
|
61
|
+
|
|
62
|
+
const destPath = join(dest, entry.name);
|
|
63
|
+
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
await fse.ensureDir(destPath);
|
|
66
|
+
await copyTemplateDir(join(templateName, entry.name), destPath, ctx);
|
|
67
|
+
} else {
|
|
68
|
+
const raw = await fse.readFile(srcPath, 'utf8');
|
|
69
|
+
const rendered = renderTemplate(raw, ctx);
|
|
70
|
+
await fse.outputFile(destPath, rendered, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function buildPackageJson(dest, config) {
|
|
76
|
+
const { projectName, framework, include, css, pm } = config;
|
|
77
|
+
|
|
78
|
+
// Start with base package.json
|
|
79
|
+
const base = await loadFragment('base', config);
|
|
80
|
+
const frameworkFrag = await loadFragment(framework, config);
|
|
81
|
+
|
|
82
|
+
let merged = mergePackageJson(base, frameworkFrag);
|
|
83
|
+
|
|
84
|
+
// Merge CSS fragment if it exists
|
|
85
|
+
const cssFrag = await loadFragment(`css-${css}`, config);
|
|
86
|
+
merged = mergePackageJson(merged, cssFrag);
|
|
87
|
+
|
|
88
|
+
// Merge each feature's fragment
|
|
89
|
+
for (const feature of include) {
|
|
90
|
+
const frag = await loadFragment(join('features', feature), config);
|
|
91
|
+
merged = mergePackageJson(merged, frag);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
merged.name = projectName;
|
|
95
|
+
merged.version = '0.1.0';
|
|
96
|
+
|
|
97
|
+
await fse.writeJSON(join(dest, 'package.json'), merged, { spaces: 2 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadFragment(name, config) {
|
|
101
|
+
const fragPath = join(TEMPLATES_DIR, name, 'package.fragment.json');
|
|
102
|
+
if (!(await fse.pathExists(fragPath))) return {};
|
|
103
|
+
const raw = await fse.readFile(fragPath, 'utf8');
|
|
104
|
+
return JSON.parse(renderTemplate(raw, config));
|
|
105
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { REPOS } from './repos.js';
|
|
4
|
+
import { cloneAll } from './cloner.js';
|
|
5
|
+
import { writeRootFiles } from './scaffold.js';
|
|
6
|
+
import { validateProjectName } from './utils.js';
|
|
7
|
+
|
|
8
|
+
export async function run(flags) {
|
|
9
|
+
console.log('');
|
|
10
|
+
p.intro(
|
|
11
|
+
chalk.bgHex('#7c3aed').white(' create-myapp ') +
|
|
12
|
+
chalk.dim(' assemble your boilerplate')
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// ── 1. Project name ───────────────────────────────────────────────
|
|
16
|
+
let projectName = flags.projectName;
|
|
17
|
+
if (!projectName) {
|
|
18
|
+
projectName = await p.text({
|
|
19
|
+
message: 'Project name',
|
|
20
|
+
placeholder: 'my-app',
|
|
21
|
+
validate: validateProjectName,
|
|
22
|
+
});
|
|
23
|
+
if (p.isCancel(projectName)) return cancel();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── 2. Backend ────────────────────────────────────────────────────
|
|
27
|
+
let backendId = flags.backend;
|
|
28
|
+
if (!backendId) {
|
|
29
|
+
backendId = await p.select({
|
|
30
|
+
message: 'Backend',
|
|
31
|
+
options: REPOS.backend.map(r => ({
|
|
32
|
+
value: r.id,
|
|
33
|
+
label: r.label,
|
|
34
|
+
hint: r.hint,
|
|
35
|
+
})),
|
|
36
|
+
});
|
|
37
|
+
if (p.isCancel(backendId)) return cancel();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── 3. Frontend (only shown if repos are registered) ─────────────
|
|
41
|
+
let frontendId = flags.frontend ?? null;
|
|
42
|
+
if (REPOS.frontend?.length > 0 && !frontendId) {
|
|
43
|
+
frontendId = await p.select({
|
|
44
|
+
message: 'Frontend',
|
|
45
|
+
options: REPOS.frontend.map(r => ({
|
|
46
|
+
value: r.id,
|
|
47
|
+
label: r.label,
|
|
48
|
+
hint: r.hint,
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
51
|
+
if (p.isCancel(frontendId)) return cancel();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Build the final list of repos to clone ────────────────────────
|
|
55
|
+
const selected = buildRepoList({ backendId, frontendId });
|
|
56
|
+
|
|
57
|
+
// ── Summary ───────────────────────────────────────────────────────
|
|
58
|
+
console.log('');
|
|
59
|
+
p.note(
|
|
60
|
+
selected
|
|
61
|
+
.map(r => `${chalk.dim(r.id.padEnd(16))} ${chalk.cyan(r.label)} ${chalk.dim(r.url)}`)
|
|
62
|
+
.join('\n'),
|
|
63
|
+
'Repos to clone'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (flags.dryRun) {
|
|
67
|
+
p.outro(chalk.yellow('Dry run — no files written.'));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!flags.yes) {
|
|
72
|
+
const confirm = await p.confirm({ message: `Create "${projectName}"?` });
|
|
73
|
+
if (p.isCancel(confirm) || !confirm) return cancel();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Clone ─────────────────────────────────────────────────────────
|
|
77
|
+
await cloneAll({ projectName, repos: selected });
|
|
78
|
+
|
|
79
|
+
// ── Write root files (.gitignore, README) ─────────────────────────
|
|
80
|
+
await writeRootFiles({ projectName, selected, backendId, frontendId });
|
|
81
|
+
|
|
82
|
+
// ── Done ──────────────────────────────────────────────────────────
|
|
83
|
+
p.outro(
|
|
84
|
+
chalk.green('Done!') + '\n\n' +
|
|
85
|
+
` ${chalk.dim('cd')} ${chalk.cyan(projectName)}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildRepoList({ backendId, frontendId }) {
|
|
90
|
+
const list = [];
|
|
91
|
+
|
|
92
|
+
// Fixed repos (dashboard — always included)
|
|
93
|
+
for (const group of Object.values(REPOS)) {
|
|
94
|
+
for (const repo of group) {
|
|
95
|
+
if (repo.fixed) list.push(repo);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Chosen backend
|
|
100
|
+
const backend = REPOS.backend.find(r => r.id === backendId);
|
|
101
|
+
if (backend) list.push(backend);
|
|
102
|
+
|
|
103
|
+
// Chosen frontend (optional until repos are added)
|
|
104
|
+
if (frontendId && REPOS.frontend) {
|
|
105
|
+
const frontend = REPOS.frontend.find(r => r.id === frontendId);
|
|
106
|
+
if (frontend) list.push(frontend);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return list;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cancel() {
|
|
113
|
+
p.cancel('Cancelled.');
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
package/src/merge.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-merges two package.json objects.
|
|
3
|
+
* - scripts, dependencies, devDependencies are merged (b wins on conflict)
|
|
4
|
+
* - arrays are concatenated and de-duped
|
|
5
|
+
* - scalars: b wins
|
|
6
|
+
*/
|
|
7
|
+
export function mergePackageJson(a = {}, b = {}) {
|
|
8
|
+
if (!b || Object.keys(b).length === 0) return { ...a };
|
|
9
|
+
|
|
10
|
+
const result = { ...a };
|
|
11
|
+
|
|
12
|
+
for (const key of Object.keys(b)) {
|
|
13
|
+
if (key === 'scripts' || key === 'dependencies' || key === 'devDependencies' ||
|
|
14
|
+
key === 'peerDependencies' || key === 'optionalDependencies') {
|
|
15
|
+
result[key] = { ...(a[key] ?? {}), ...(b[key] ?? {}) };
|
|
16
|
+
} else if (Array.isArray(b[key])) {
|
|
17
|
+
result[key] = [...new Set([...(a[key] ?? []), ...b[key]])];
|
|
18
|
+
} else if (typeof b[key] === 'object' && b[key] !== null) {
|
|
19
|
+
result[key] = mergePackageJson(a[key], b[key]);
|
|
20
|
+
} else {
|
|
21
|
+
result[key] = b[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result;
|
|
26
|
+
}
|
package/src/repos.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of all available boilerplate repos.
|
|
3
|
+
*
|
|
4
|
+
* To add a new repo (e.g. Astro or Next.js frontend):
|
|
5
|
+
* 1. Add an entry to the relevant group below.
|
|
6
|
+
* 2. That's it — the CLI picks it up automatically.
|
|
7
|
+
*
|
|
8
|
+
* Fields:
|
|
9
|
+
* id — internal key, used as the folder name under code/
|
|
10
|
+
* label — shown in the prompt
|
|
11
|
+
* hint — shown as a subtitle in the prompt
|
|
12
|
+
* url — GitHub clone URL
|
|
13
|
+
* fixed — if true, always included (no prompt)
|
|
14
|
+
*/
|
|
15
|
+
export const REPOS = {
|
|
16
|
+
|
|
17
|
+
// ── Backend (pick one) ────────────────────────────────────────────
|
|
18
|
+
backend: [
|
|
19
|
+
{
|
|
20
|
+
id: 'api-nest',
|
|
21
|
+
label: 'NestJS',
|
|
22
|
+
hint: 'TypeScript, REST/GraphQL, modular architecture',
|
|
23
|
+
url: 'https://github.com/tyndareosmasselos/Boilerplater.NestJS',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'api-dotnet',
|
|
27
|
+
label: '.NET Microservices',
|
|
28
|
+
hint: 'C#, microservices, Docker-ready',
|
|
29
|
+
url: 'https://github.com/tyndareosmasselos/Microservices.Boilerplater',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
|
|
33
|
+
// ── Dashboard (fixed — always cloned) ────────────────────────────
|
|
34
|
+
dashboard: [
|
|
35
|
+
{
|
|
36
|
+
id: 'dashboard',
|
|
37
|
+
label: 'Dashboard',
|
|
38
|
+
hint: 'Angular admin panel',
|
|
39
|
+
url: 'https://github.com/tyndareosmasselos/Boilerplater.Web',
|
|
40
|
+
fixed: true,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
// ── Frontend (pick one) ───────────────────────────────────────────
|
|
45
|
+
// Add Next.js and Astro repos here when ready:
|
|
46
|
+
//
|
|
47
|
+
// frontend: [
|
|
48
|
+
// {
|
|
49
|
+
// id: 'web-next',
|
|
50
|
+
// label: 'Next.js',
|
|
51
|
+
// hint: 'React, App Router, SSR',
|
|
52
|
+
// url: 'https://github.com/tyndareosmasselos/Boilerplater.Next',
|
|
53
|
+
// },
|
|
54
|
+
// {
|
|
55
|
+
// id: 'web-astro',
|
|
56
|
+
// label: 'Astro',
|
|
57
|
+
// hint: 'Content-first, islands architecture',
|
|
58
|
+
// url: 'https://github.com/tyndareosmasselos/Boilerplater.Astro',
|
|
59
|
+
// },
|
|
60
|
+
// ],
|
|
61
|
+
|
|
62
|
+
};
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join, resolve } from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
export async function writeRootFiles({ projectName, selected, backendId, frontendId }) {
|
|
5
|
+
const projectDir = resolve(process.cwd(), projectName);
|
|
6
|
+
|
|
7
|
+
// ── .gitignore ────────────────────────────────────────────────────
|
|
8
|
+
const gitignore = [
|
|
9
|
+
'.DS_Store',
|
|
10
|
+
'*.log',
|
|
11
|
+
'node_modules/',
|
|
12
|
+
'.env',
|
|
13
|
+
'.env.local',
|
|
14
|
+
'',
|
|
15
|
+
'# IDE',
|
|
16
|
+
'.idea/',
|
|
17
|
+
'.vscode/',
|
|
18
|
+
'*.suo',
|
|
19
|
+
'*.user',
|
|
20
|
+
].join('\n');
|
|
21
|
+
|
|
22
|
+
fs.writeFileSync(join(projectDir, '.gitignore'), gitignore, 'utf8');
|
|
23
|
+
|
|
24
|
+
// ── README.md ─────────────────────────────────────────────────────
|
|
25
|
+
const repoLines = selected
|
|
26
|
+
.map(r => `| \`code/${r.id}\` | ${r.label} | ${r.url} |`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
|
|
29
|
+
const hasFrontend = !!frontendId;
|
|
30
|
+
|
|
31
|
+
const readme = `# ${projectName}
|
|
32
|
+
|
|
33
|
+
> Scaffolded with \`create-myapp\`
|
|
34
|
+
|
|
35
|
+
## Structure
|
|
36
|
+
|
|
37
|
+
\`\`\`
|
|
38
|
+
${projectName}/
|
|
39
|
+
└── code/
|
|
40
|
+
${selected.map(r => ` ├── ${r.id}/`).join('\n')}
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
## Repos
|
|
44
|
+
|
|
45
|
+
| Folder | Name | Source |
|
|
46
|
+
|--------|------|--------|
|
|
47
|
+
${repoLines}
|
|
48
|
+
|
|
49
|
+
## Getting started
|
|
50
|
+
|
|
51
|
+
Each piece is self-contained. Navigate into a folder and follow its own README:
|
|
52
|
+
|
|
53
|
+
\`\`\`bash
|
|
54
|
+
# Backend
|
|
55
|
+
cd code/${backendId}
|
|
56
|
+
# follow README inside
|
|
57
|
+
|
|
58
|
+
# Dashboard
|
|
59
|
+
cd code/dashboard
|
|
60
|
+
# follow README inside
|
|
61
|
+
${hasFrontend ? `
|
|
62
|
+
# Frontend
|
|
63
|
+
cd code/${frontendId}
|
|
64
|
+
# follow README inside` : ''}
|
|
65
|
+
\`\`\`
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(join(projectDir, 'README.md'), readme, 'utf8');
|
|
69
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a template string by replacing {{variable}} tokens.
|
|
3
|
+
*
|
|
4
|
+
* Available variables in templates:
|
|
5
|
+
* {{projectName}} — the project name
|
|
6
|
+
* {{framework}} — next | astro
|
|
7
|
+
* {{css}} — tailwind | scss | vanilla
|
|
8
|
+
* {{pm}} — pnpm | npm | bun
|
|
9
|
+
* {{year}} — current year
|
|
10
|
+
* {{hasFeature:X}} — "true" | "false" for any feature X
|
|
11
|
+
*/
|
|
12
|
+
export function renderTemplate(content, ctx) {
|
|
13
|
+
const { projectName, framework, include = [], css, pm } = ctx;
|
|
14
|
+
|
|
15
|
+
const vars = {
|
|
16
|
+
projectName,
|
|
17
|
+
framework,
|
|
18
|
+
css,
|
|
19
|
+
pm,
|
|
20
|
+
year: new Date().getFullYear().toString(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Inject hasFeature:X booleans
|
|
24
|
+
const allFeatures = ['dashboard', 'webapi', 'blog', 'seo', 'auth', 'cms', 'i18n', 'analytics'];
|
|
25
|
+
for (const f of allFeatures) {
|
|
26
|
+
vars[`hasFeature:${f}`] = include.includes(f) ? 'true' : 'false';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Replace {{key}} tokens
|
|
30
|
+
return content.replace(/\{\{(\w+(?::\w+)?)\}\}/g, (_, key) => {
|
|
31
|
+
return vars[key] ?? `{{${key}}}`;
|
|
32
|
+
});
|
|
33
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function validateProjectName(value) {
|
|
2
|
+
if (!value || value.trim().length === 0) return 'Project name is required.';
|
|
3
|
+
if (!/^[a-z0-9._-]+$/i.test(value)) return 'Only letters, numbers, hyphens, dots and underscores allowed.';
|
|
4
|
+
if (value.startsWith('-') || value.startsWith('.')) return 'Name cannot start with a hyphen or dot.';
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|