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.
@@ -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
+ }
@@ -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
+ };
@@ -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
+ }
@@ -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
+ }