create-epinoetics-app 1.0.3 → 1.0.6

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.
@@ -5,6 +5,7 @@ import { readFileSync } from 'fs';
5
5
  import { resolve, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { run } from '../src/index.js';
8
+ import { runAdd } from '../src/adder.js';
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8'));
@@ -12,21 +13,32 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8
12
13
  program
13
14
  .name('create-epinoetics-app')
14
15
  .description('Scaffold a new project from your boilerplate repos')
15
- .version(pkg.version)
16
- .argument('[project-name]', 'Name of the project to create')
16
+ .version(pkg.version);
17
+
18
+ // ── create (default) ──────────────────────────────────────────────
19
+ program
20
+ .command('create [project-name]', { isDefault: true })
21
+ .description('Scaffold a new project')
17
22
  .option('-b, --backend <id>', 'Backend to use: api-nest | api-dotnet')
18
23
  .option('-f, --frontend <id>', 'Frontend to use: web-next | web-astro (when available)')
19
24
  .option('--dry-run', 'Preview without cloning anything')
20
25
  .option('--yes', 'Skip confirmation prompt')
21
- .parse(process.argv);
26
+ .action((projectName, opts) => {
27
+ run({
28
+ projectName,
29
+ backend: opts.backend,
30
+ frontend: opts.frontend,
31
+ dryRun: opts.dryRun ?? false,
32
+ yes: opts.yes ?? false,
33
+ });
34
+ });
22
35
 
23
- const opts = program.opts();
24
- const [projectName] = program.args;
36
+ // ── add ───────────────────────────────────────────────────────────
37
+ program
38
+ .command('add')
39
+ .description('Add a feature to an existing project (run from code/api-nest)')
40
+ .action(() => {
41
+ runAdd();
42
+ });
25
43
 
26
- run({
27
- projectName,
28
- backend: opts.backend,
29
- frontend: opts.frontend,
30
- dryRun: opts.dryRun ?? false,
31
- yes: opts.yes ?? false,
32
- });
44
+ program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-epinoetics-app",
3
- "version": "1.0.3",
3
+ "version": "1.0.6",
4
4
  "description": "Scaffold a new project from your boilerplate repos",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adder.js ADDED
@@ -0,0 +1,245 @@
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
+ import os from 'os';
7
+ import { NEST_FEATURES, NEST_DATABASES } from './repos.js';
8
+
9
+ export async function runAdd() {
10
+ console.log('');
11
+ p.intro(
12
+ chalk.bgHex('#7c3aed').white(' create-epinoetics-app add ') +
13
+ chalk.dim(' add a feature to your project')
14
+ );
15
+
16
+ const cwd = process.cwd();
17
+
18
+ // ── 1. Read scaffold.config.json ──────────────────────────────────
19
+ const configPath = join(cwd, 'scaffold.config.json');
20
+ if (!fs.existsSync(configPath)) {
21
+ p.log.error(
22
+ 'No scaffold.config.json found.\n' +
23
+ chalk.dim('Make sure you are inside the code/api-nest directory.')
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
29
+ const database = NEST_DATABASES.find(d => d.id === config.database);
30
+
31
+ if (!database) {
32
+ p.log.error(`Unknown database "${config.database}" in scaffold.config.json.`);
33
+ process.exit(1);
34
+ }
35
+
36
+ // ── 2. Show only features not already installed ───────────────────
37
+ const installedIds = new Set(config.features ?? []);
38
+ const available = NEST_FEATURES.filter(f => !installedIds.has(f.id));
39
+
40
+ if (available.length === 0) {
41
+ p.outro(chalk.green('All available features are already installed!'));
42
+ return;
43
+ }
44
+
45
+ p.note(
46
+ [
47
+ `${chalk.dim('database '.padEnd(14))} ${chalk.cyan(database.label)}`,
48
+ `${chalk.dim('installed'.padEnd(14))} ${config.features.length ? config.features.map(f => chalk.cyan(f)).join(chalk.dim(', ')) : chalk.dim('none')}`,
49
+ ].join('\n'),
50
+ 'Current project'
51
+ );
52
+
53
+ // ── 3. Pick features to add ───────────────────────────────────────
54
+ const featureIds = await p.multiselect({
55
+ message: 'Which features do you want to add?',
56
+ options: available.map(f => ({
57
+ value: f.id,
58
+ label: f.label,
59
+ hint: f.hint,
60
+ })),
61
+ required: true,
62
+ });
63
+ if (p.isCancel(featureIds)) return cancel();
64
+
65
+ const toAdd = NEST_FEATURES.filter(f => featureIds.includes(f.id));
66
+
67
+ // ── 4. Confirm ────────────────────────────────────────────────────
68
+ const confirm = await p.confirm({
69
+ message: `Add ${toAdd.map(f => chalk.cyan(f.label)).join(', ')} to this project?`,
70
+ });
71
+ if (p.isCancel(confirm) || !confirm) return cancel();
72
+
73
+ // ── 5. Clone the db branch into a temp directory ──────────────────
74
+ const tmpDir = join(os.tmpdir(), `epinoetics-add-${Date.now()}`);
75
+ const spinner = p.spinner();
76
+
77
+ spinner.start(`Fetching features from ${chalk.dim(database.branch)}...`);
78
+
79
+ try {
80
+ await simpleGit().clone(config.repoUrl, tmpDir, ['--branch', database.branch, '--single-branch', '--depth=1']);
81
+ spinner.stop(`${chalk.green('✓')} Fetched boilerplate`);
82
+ } catch (err) {
83
+ spinner.stop(`${chalk.red('✗')} Clone failed ${chalk.dim(err.message)}`);
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ process.exit(1);
86
+ }
87
+
88
+ // ── 6. Copy each feature folder into the project ──────────────────
89
+ for (const feature of toAdd) {
90
+ const srcDir = join(tmpDir, 'src', 'features', feature.id);
91
+ const destDir = join(cwd, 'src', 'features', feature.id);
92
+
93
+ if (!fs.existsSync(srcDir)) {
94
+ p.log.warn(`Feature "${feature.id}" not found in repo — skipping.`);
95
+ continue;
96
+ }
97
+
98
+ if (fs.existsSync(destDir)) {
99
+ p.log.warn(`Feature "${feature.id}" already exists locally — skipping.`);
100
+ continue;
101
+ }
102
+
103
+ fs.cpSync(srcDir, destDir, { recursive: true });
104
+ p.log.step(`${chalk.green('✓')} Copied ${chalk.cyan(feature.label)} ${chalk.dim(`→ src/features/${feature.id}`)}`);
105
+ }
106
+
107
+ // ── 7. Patch schema ───────────────────────────────────────────────
108
+ if (database.id === 'drizzle') {
109
+ addDrizzleExports({ cwd, tmpDir, toAdd });
110
+ } else if (database.id === 'prisma') {
111
+ addPrismaModels({ cwd, tmpDir, toAdd });
112
+ }
113
+
114
+ // ── 8. Patch app.module.ts ────────────────────────────────────────
115
+ addAppModuleImports({ cwd, toAdd });
116
+
117
+ // ── 9. Cleanup temp dir ───────────────────────────────────────────
118
+ fs.rmSync(tmpDir, { recursive: true, force: true });
119
+
120
+ // ── 10. Update scaffold.config.json ──────────────────────────────
121
+ config.features = [...new Set([...config.features, ...featureIds])];
122
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
123
+ p.log.step(`Updated scaffold.config.json`);
124
+
125
+ p.outro(
126
+ chalk.green('Done!') + chalk.dim(' Run your migrations and you\'re good to go.')
127
+ );
128
+ }
129
+
130
+ // ── Drizzle: append export lines to schema.ts ─────────────────────
131
+ function addDrizzleExports({ cwd, tmpDir, toAdd }) {
132
+ const candidates = [
133
+ join(cwd, 'src', 'database', 'schema.ts'),
134
+ join(cwd, 'src', 'db', 'schema.ts'),
135
+ join(cwd, 'src', 'schema.ts'),
136
+ ];
137
+
138
+ const schemaPath = candidates.find(f => fs.existsSync(f));
139
+ if (!schemaPath) {
140
+ p.log.warn('Could not find schema.ts — add exports manually.');
141
+ return;
142
+ }
143
+
144
+ // Get the export lines from the tmp clone's schema.ts
145
+ const tmpCandidates = [
146
+ join(tmpDir, 'src', 'database', 'schema.ts'),
147
+ join(tmpDir, 'src', 'db', 'schema.ts'),
148
+ join(tmpDir, 'src', 'schema.ts'),
149
+ ];
150
+
151
+ const tmpSchemaPath = tmpCandidates.find(f => fs.existsSync(f));
152
+ if (!tmpSchemaPath) return;
153
+
154
+ const tmpContent = fs.readFileSync(tmpSchemaPath, 'utf8');
155
+ let additions = '';
156
+
157
+ for (const feature of toAdd) {
158
+ const pattern = new RegExp(
159
+ `^export \\* from ['"].*features\\/${feature.id}\\/.*['"];?$`,
160
+ 'gm'
161
+ );
162
+ const matches = tmpContent.match(pattern);
163
+ if (matches) additions += matches.join('\n') + '\n';
164
+ }
165
+
166
+ if (additions) {
167
+ fs.appendFileSync(schemaPath, '\n' + additions, 'utf8');
168
+ p.log.step(`Updated schema.ts ${chalk.dim(`(added ${toAdd.length} export line(s))`)}`);
169
+ }
170
+ }
171
+
172
+ // ── Prisma: append model blocks to schema.prisma ──────────────────
173
+ function addPrismaModels({ cwd, tmpDir, toAdd }) {
174
+ const candidates = [
175
+ join(cwd, 'prisma', 'schema.prisma'),
176
+ join(cwd, 'schema.prisma'),
177
+ ];
178
+
179
+ const schemaPath = candidates.find(f => fs.existsSync(f));
180
+ if (!schemaPath) {
181
+ p.log.warn('Could not find schema.prisma — add models manually.');
182
+ return;
183
+ }
184
+
185
+ const tmpCandidates = [
186
+ join(tmpDir, 'prisma', 'schema.prisma'),
187
+ join(tmpDir, 'schema.prisma'),
188
+ ];
189
+
190
+ const tmpSchemaPath = tmpCandidates.find(f => fs.existsSync(f));
191
+ if (!tmpSchemaPath) return;
192
+
193
+ const tmpContent = fs.readFileSync(tmpSchemaPath, 'utf8');
194
+ let additions = '';
195
+
196
+ for (const feature of toAdd) {
197
+ const pattern = new RegExp(
198
+ `\\/\\/ @feature:${feature.id}\\n[\\s\\S]*?\\/\\/ @endfeature:${feature.id}`,
199
+ 'g'
200
+ );
201
+ const matches = tmpContent.match(pattern);
202
+ if (matches) additions += '\n' + matches.join('\n') + '\n';
203
+ }
204
+
205
+ if (additions) {
206
+ fs.appendFileSync(schemaPath, additions, 'utf8');
207
+ p.log.step(`Updated schema.prisma ${chalk.dim(`(added ${toAdd.length} model block(s))`)}`);
208
+ }
209
+ }
210
+
211
+ // ── Add import + module to app.module.ts ──────────────────────────
212
+ function addAppModuleImports({ cwd, toAdd }) {
213
+ const appModulePath = join(cwd, 'src', 'app.module.ts');
214
+ if (!fs.existsSync(appModulePath)) {
215
+ p.log.warn('Could not find app.module.ts — add imports manually.');
216
+ return;
217
+ }
218
+
219
+ let content = fs.readFileSync(appModulePath, 'utf8');
220
+
221
+ for (const feature of toAdd) {
222
+ const moduleName = feature.id.charAt(0).toUpperCase() + feature.id.slice(1) + 'Module';
223
+
224
+ // Add import line after the last existing import
225
+ const importLine = `import { ${moduleName} } from './features/${feature.id}/${feature.id}.module';`;
226
+ content = content.replace(
227
+ /(import [^\n]+\n)(?!import)/,
228
+ `$1${importLine}\n`
229
+ );
230
+
231
+ // Add module to imports array before ConfigModule
232
+ content = content.replace(
233
+ /(\/\/ internal\n)/,
234
+ `$1 ${moduleName},\n`
235
+ );
236
+ }
237
+
238
+ fs.writeFileSync(appModulePath, content, 'utf8');
239
+ p.log.step(`Updated app.module.ts ${chalk.dim(`(added ${toAdd.length} module(s))`)}`);
240
+ }
241
+
242
+ function cancel() {
243
+ p.cancel('Cancelled.');
244
+ process.exit(0);
245
+ }
package/src/cloner.js CHANGED
@@ -64,18 +64,47 @@ export async function cloneAndMergeNest({ projectName, database, selectedFeature
64
64
  );
65
65
  }
66
66
 
67
- // 3. Prune schema.tsremove export lines for deleted features
68
- pruneSchema({ dest, toRemove });
67
+ // 3. Prune schema — Drizzle or Prisma
68
+ if (database.id === 'drizzle') {
69
+ pruneDrizzleSchema({ dest, toRemove });
70
+ } else if (database.id === 'prisma') {
71
+ prunePrismaSchema({ dest, toRemove });
72
+ }
69
73
 
70
- // 4. Prune app.module.ts — remove imports and module references
74
+ // 4. Prune app.module.ts
71
75
  pruneAppModule({ dest, toRemove });
72
76
 
73
- // 5. Strip .git
77
+ // 5. Remove migrations directory
78
+ pruneMigrations({ dest, database });
79
+
80
+ // 6. Write scaffold.config.json
81
+ writeScaffoldConfig({ dest, database, selectedFeatures, repoUrl });
82
+
83
+ // 7. Strip .git
74
84
  fs.rmSync(join(dest, '.git'), { recursive: true, force: true });
75
85
  }
76
86
 
77
- // ── Remove export * lines from schema.ts for deleted features ─────
78
- function pruneSchema({ dest, toRemove }) {
87
+ // ── Write scaffold.config.json ────────────────────────────────────
88
+ function writeScaffoldConfig({ dest, database, selectedFeatures, repoUrl }) {
89
+ const config = {
90
+ database: database.id,
91
+ branch: database.branch,
92
+ features: selectedFeatures.map(f => f.id),
93
+ repoUrl,
94
+ createdAt: new Date().toISOString(),
95
+ };
96
+
97
+ fs.writeFileSync(
98
+ join(dest, 'scaffold.config.json'),
99
+ JSON.stringify(config, null, 2),
100
+ 'utf8'
101
+ );
102
+
103
+ p.log.step(`Written scaffold.config.json`);
104
+ }
105
+
106
+ // ── Drizzle: remove export * lines from schema.ts ─────────────────
107
+ function pruneDrizzleSchema({ dest, toRemove }) {
79
108
  if (toRemove.length === 0) return;
80
109
 
81
110
  const candidates = [
@@ -94,7 +123,6 @@ function pruneSchema({ dest, toRemove }) {
94
123
  const before = content;
95
124
 
96
125
  for (const feature of toRemove) {
97
- // Removes: export * from '../features/posts/post.schema';
98
126
  const pattern = new RegExp(
99
127
  `^export \\* from ['"].*features\\/${feature.id}\\/.*['"];?\\n?`,
100
128
  'gm'
@@ -108,6 +136,38 @@ function pruneSchema({ dest, toRemove }) {
108
136
  }
109
137
  }
110
138
 
139
+ // ── Prisma: remove model blocks from schema.prisma ────────────────
140
+ function prunePrismaSchema({ dest, toRemove }) {
141
+ if (toRemove.length === 0) return;
142
+
143
+ const candidates = [
144
+ join(dest, 'prisma', 'schema.prisma'),
145
+ join(dest, 'schema.prisma'),
146
+ ];
147
+
148
+ const schemaPath = candidates.find(f => fs.existsSync(f));
149
+ if (!schemaPath) {
150
+ p.log.warn('Could not find schema.prisma — remove unused models manually.');
151
+ return;
152
+ }
153
+
154
+ let content = fs.readFileSync(schemaPath, 'utf8');
155
+ const before = content;
156
+
157
+ for (const feature of toRemove) {
158
+ const pattern = new RegExp(
159
+ `\\/\\/ @feature:${feature.id}\\n[\\s\\S]*?\\/\\/ @endfeature:${feature.id}\\n?`,
160
+ 'g'
161
+ );
162
+ content = content.replace(pattern, '');
163
+ }
164
+
165
+ if (content !== before) {
166
+ fs.writeFileSync(schemaPath, content, 'utf8');
167
+ p.log.step(`Pruned schema.prisma ${chalk.dim(`(removed ${toRemove.length} model block(s))`)}`);
168
+ }
169
+ }
170
+
111
171
  // ── Remove import + Module reference from app.module.ts ───────────
112
172
  function pruneAppModule({ dest, toRemove }) {
113
173
  if (toRemove.length === 0) return;
@@ -122,21 +182,14 @@ function pruneAppModule({ dest, toRemove }) {
122
182
  const before = content;
123
183
 
124
184
  for (const feature of toRemove) {
125
- // Derive the module class name from the feature id:
126
- // e.g. posts → PostsModule, seo → SeoModule
127
185
  const moduleName = feature.id.charAt(0).toUpperCase() + feature.id.slice(1) + 'Module';
128
186
 
129
- // Remove the import line:
130
- // import { PostsModule } from './features/posts/posts.module';
131
187
  const importPattern = new RegExp(
132
188
  `^import \\{[^}]*${moduleName}[^}]*\\} from ['"].*features\\/${feature.id}\\/.*['"];?\\n?`,
133
189
  'gm'
134
190
  );
135
191
  content = content.replace(importPattern, '');
136
192
 
137
- // Remove the module from the imports array:
138
- // PostsModule, (with optional trailing comma + whitespace/newline)
139
- // or ,PostsModule (preceded by comma)
140
193
  const refPattern = new RegExp(
141
194
  `\\n?\\s*${moduleName},?|,?\\s*${moduleName}`,
142
195
  'g'
@@ -148,4 +201,18 @@ function pruneAppModule({ dest, toRemove }) {
148
201
  fs.writeFileSync(appModulePath, content, 'utf8');
149
202
  p.log.step(`Pruned app.module.ts ${chalk.dim(`(removed ${toRemove.length} module(s))`)}`);
150
203
  }
204
+ }
205
+
206
+ // ── Remove migrations directory ───────────────────────────────────
207
+ function pruneMigrations({ dest, database }) {
208
+ const candidates = database.id === 'drizzle'
209
+ ? [join(dest, 'migrations'), join(dest, 'drizzle')]
210
+ : [join(dest, 'prisma', 'migrations')];
211
+
212
+ for (const dir of candidates) {
213
+ if (fs.existsSync(dir)) {
214
+ fs.rmSync(dir, { recursive: true, force: true });
215
+ p.log.step(`Removed migrations ${chalk.dim(dir.replace(dest, ''))}`);
216
+ }
217
+ }
151
218
  }
package/src/index.js CHANGED
@@ -42,7 +42,6 @@ export async function run(flags) {
42
42
  let features = [];
43
43
 
44
44
  if (backendId === 'api-nest') {
45
- // Database
46
45
  const dbId = await p.select({
47
46
  message: 'Database',
48
47
  options: NEST_DATABASES.map(d => ({
@@ -54,7 +53,6 @@ export async function run(flags) {
54
53
  if (p.isCancel(dbId)) return cancel();
55
54
  database = NEST_DATABASES.find(d => d.id === dbId);
56
55
 
57
- // Features
58
56
  const featureIds = await p.multiselect({
59
57
  message: 'Features (space to select, enter to confirm)',
60
58
  options: NEST_FEATURES.map(f => ({
@@ -141,20 +139,17 @@ export async function run(flags) {
141
139
  function buildPlainRepos({ backendId, frontendId }) {
142
140
  const list = [];
143
141
 
144
- // Dashboard — always included
145
142
  for (const group of Object.values(REPOS)) {
146
143
  for (const repo of group) {
147
144
  if (repo.fixed) list.push(repo);
148
145
  }
149
146
  }
150
147
 
151
- // .NET (NestJS is handled separately)
152
148
  if (backendId === 'api-dotnet') {
153
149
  const dotnet = REPOS.backend.find(r => r.id === 'api-dotnet');
154
150
  if (dotnet) list.push(dotnet);
155
151
  }
156
152
 
157
- // Frontend
158
153
  if (frontendId && REPOS.frontend) {
159
154
  const frontend = REPOS.frontend.find(r => r.id === frontendId);
160
155
  if (frontend) list.push(frontend);