forgedev 1.4.0 → 1.4.1

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/chainproof-bridge.js +9 -2
  3. package/src/ci-mode.js +3 -4
  4. package/src/composer.js +228 -242
  5. package/src/doctor-checks-chainproof.js +14 -11
  6. package/src/doctor-checks.js +3 -19
  7. package/src/index.js +6 -34
  8. package/src/recommender.js +319 -319
  9. package/src/uat-generator.js +105 -93
  10. package/src/utils.js +245 -214
  11. package/templates/auth/jwt-custom/backend/app/api/auth.py.template +39 -45
  12. package/templates/auth/jwt-custom/backend/app/core/security.py.template +44 -37
  13. package/templates/backend/express/package.json.template +35 -33
  14. package/templates/backend/express/src/lib/prisma.ts.template +32 -0
  15. package/templates/backend/express/src/routes/health.ts.template +35 -27
  16. package/templates/backend/fastapi/backend/app/main.py.template +67 -60
  17. package/templates/backend/fastapi/backend/requirements.txt.template +18 -16
  18. package/templates/backend/hono/package.json.template +33 -31
  19. package/templates/backend/hono/src/lib/prisma.ts.template +32 -0
  20. package/templates/backend/hono/src/routes/health.ts.template +38 -27
  21. package/templates/base/.gitignore.template +32 -32
  22. package/templates/claude-code/commands/workflows.md +52 -52
  23. package/templates/database/prisma-postgres/prisma/schema.prisma.template +19 -18
  24. package/templates/database/sqlalchemy-postgres/backend/alembic/env.py.template +39 -40
  25. package/templates/database/sqlalchemy-postgres/backend/alembic.ini.template +38 -36
  26. package/templates/database/sqlalchemy-postgres/backend/app/db/session.py.template +50 -48
  27. package/templates/frontend/nextjs/package.json.template +45 -43
  28. package/templates/frontend/remix/package.json.template +41 -39
  29. package/templates/infra/docker/.dockerignore.template +16 -0
  30. package/templates/infra/docker-compose/docker-compose.yml.template +22 -19
  31. package/templates/infra/github-actions/.github/workflows/ci.yml.template +61 -52
  32. package/templates/infra/k8s/k8s/deployment.yml.template +70 -70
  33. package/templates/infra/k8s/k8s/hpa.yml.template +24 -24
  34. package/templates/infra/k8s/k8s/ingress.yml.template +26 -26
  35. package/templates/infra/k8s/k8s/kustomization.yml.template +13 -13
  36. package/templates/infra/k8s/k8s/namespace.yml.template +4 -4
  37. package/templates/infra/k8s/k8s/networkpolicy.yml.template +41 -41
  38. package/templates/infra/k8s/k8s/secrets.yml.template +10 -10
  39. package/templates/infra/k8s/k8s/service.yml.template +15 -15
  40. package/templates/testing/load/k6/README.md.template +48 -48
  41. package/templates/testing/load/k6/load-test.js.template +57 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedev",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Universal, AI-first project scaffolding CLI with Claude Code infrastructure",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,8 +43,15 @@ export function verifySignature(payload, signatureB64, publicKeyPem) {
43
43
  publicKeyPem,
44
44
  Buffer.from(signatureB64, 'base64')
45
45
  );
46
- } catch {
47
- // Invalid key or malformed signature
46
+ } catch (err) {
47
+ // Verification should never throw — any failure means "not verified"
48
+ if (err?.code?.startsWith?.('ERR_OSSL') ||
49
+ err?.code?.startsWith?.('ERR_CRYPTO') ||
50
+ err instanceof TypeError) {
51
+ return false;
52
+ }
53
+ // Log unexpected errors for debugging but still return false
54
+ console.warn('Unexpected crypto error during signature verification:', err?.code || err?.message);
48
55
  return false;
49
56
  }
50
57
  }
package/src/ci-mode.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { log } from './utils.js';
5
- import { scanProject, detectStack } from './scanner.js';
5
+ import { scanProject } from './scanner.js';
6
6
  import { runAllChecks } from './doctor-checks.js';
7
7
  import { generateReport } from './doctor-prompts.js';
8
8
 
@@ -18,10 +18,9 @@ export async function runCI(projectDir) {
18
18
  log.info(' DevForge CI Automated project health check');
19
19
  console.log('');
20
20
 
21
- // Scan project
21
+ // Scan project (scanProject already calls detectStack internally)
22
22
  const scan = scanProject(resolvedDir);
23
- const stack = detectStack(resolvedDir, scan);
24
- log.dim(` Stack: ${stack || 'unknown'}`);
23
+ log.dim(` Stack: ${scan.stackId || 'unknown'}`);
25
24
 
26
25
  // Run all checks
27
26
  const issues = runAllChecks(resolvedDir, scan);
package/src/composer.js CHANGED
@@ -1,242 +1,228 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { ROOT_DIR, ensureDir, readTemplate, replaceVars, toPascalCase, toSnakeCase, getStackMetadata, log } from './utils.js';
4
-
5
- const TEMPLATES_DIR = path.join(ROOT_DIR, 'templates');
6
-
7
- export async function compose(outputDir, stackConfig) {
8
- const variables = buildVariables(stackConfig);
9
-
10
- ensureDir(outputDir);
11
-
12
- for (const mod of stackConfig.templateModules) {
13
- const templateDir = path.join(TEMPLATES_DIR, mod.path);
14
- if (!fs.existsSync(templateDir)) {
15
- log.warn(`Template module not found: ${mod.path}, skipping`);
16
- continue;
17
- }
18
- processTemplateDir(templateDir, outputDir, variables, mod.prefix || '');
19
- }
20
-
21
- // Inject auth dependencies into package.json if auth is enabled
22
- injectAuthDependencies(outputDir, stackConfig);
23
-
24
- // Apply user plugins from ~/.devforge/templates/ if they exist
25
- applyUserPlugins(outputDir, variables, stackConfig);
26
- }
27
-
28
- export function buildVariables(stackConfig) {
29
- const vars = {
30
- PROJECT_NAME: stackConfig.projectName,
31
- PROJECT_NAME_PASCAL: toPascalCase(stackConfig.projectName),
32
- PROJECT_NAME_SNAKE: toSnakeCase(stackConfig.projectName),
33
- };
34
-
35
- if (stackConfig.frontend) {
36
- vars.FRONTEND_FRAMEWORK = stackConfig.frontend.framework;
37
- vars.FRONTEND_LANGUAGE = stackConfig.frontend.language;
38
- vars.FRONTEND_STYLING = stackConfig.frontend.styling;
39
- vars.FRONTEND_UI = stackConfig.frontend.ui || '';
40
- }
41
-
42
- if (stackConfig.backend) {
43
- vars.BACKEND_FRAMEWORK = stackConfig.backend.framework;
44
- vars.BACKEND_LANGUAGE = stackConfig.backend.language;
45
- vars.BACKEND_ORM = stackConfig.backend.orm;
46
- }
47
-
48
- if (stackConfig.database) {
49
- vars.DATABASE_TYPE = stackConfig.database.type;
50
- vars.DATABASE_ORM = stackConfig.database.orm;
51
- }
52
-
53
- vars.AUTH_TYPE = stackConfig.auth || 'none';
54
- vars.DEPLOYMENT = stackConfig.deployment || 'docker';
55
-
56
- // All per-stack values come from a single metadata source
57
- const meta = getStackMetadata(stackConfig.stackId);
58
- if (meta) {
59
- Object.assign(vars, meta.commands);
60
- vars.STACK_DESCRIPTION = meta.description;
61
- vars.EXTRA_IGNORES = meta.extraIgnores;
62
- vars.APP_PORT = meta.port;
63
- vars.SETUP_COMMANDS = meta.setupCommands();
64
- vars.AVAILABLE_SCRIPTS = meta.availableScripts;
65
- } else {
66
- vars.STACK_DESCRIPTION = '';
67
- vars.EXTRA_IGNORES = '';
68
- vars.APP_PORT = '3000';
69
- vars.SETUP_COMMANDS = '';
70
- vars.AVAILABLE_SCRIPTS = '';
71
- }
72
-
73
- vars.IMAGE_TAG = '0.1.0';
74
-
75
- return vars;
76
- }
77
-
78
- export function processTemplateDir(templateDir, outputDir, variables, prefix) {
79
- const entries = walkDir(templateDir);
80
-
81
- for (const filePath of entries) {
82
- const relativePath = path.relative(templateDir, filePath);
83
- const outputRelative = prefix ? path.join(prefix, relativePath) : relativePath;
84
-
85
- // Guard against path traversal (e.g. ../../etc/passwd in plugin templates)
86
- const resolved = path.resolve(outputDir, outputRelative);
87
- if (!resolved.startsWith(path.resolve(outputDir))) {
88
- log.warn(`Skipping "${outputRelative}" (path traversal detected)`);
89
- continue;
90
- }
91
-
92
- if (outputRelative.endsWith('.template')) {
93
- // Process template: replace vars, strip .template extension
94
- const content = readTemplate(filePath);
95
- const processed = replaceVars(content, variables);
96
- const outputPath = path.join(outputDir, outputRelative.replace(/\.template$/, ''));
97
- ensureDir(path.dirname(outputPath));
98
- fs.writeFileSync(outputPath, processed, 'utf-8');
99
- } else if (path.basename(filePath) === '.gitkeep') {
100
- // Create the directory but don't copy .gitkeep itself
101
- ensureDir(path.join(outputDir, path.dirname(outputRelative)));
102
- } else {
103
- // Binary copy
104
- const outputPath = path.join(outputDir, outputRelative);
105
- ensureDir(path.dirname(outputPath));
106
- fs.copyFileSync(filePath, outputPath);
107
- }
108
- }
109
- }
110
-
111
- function walkDir(dir, depth = 0) {
112
- if (depth > 20) return []; // guard against deeply nested or circular structures
113
- const results = [];
114
- const entries = fs.readdirSync(dir, { withFileTypes: true });
115
- for (const entry of entries) {
116
- if (entry.isSymbolicLink()) continue; // skip symlinks to prevent infinite loops
117
- const fullPath = path.join(dir, entry.name);
118
- if (entry.isDirectory()) {
119
- results.push(...walkDir(fullPath, depth + 1));
120
- } else {
121
- results.push(fullPath);
122
- }
123
- }
124
- return results;
125
- }
126
-
127
- function injectAuthDependencies(outputDir, stackConfig) {
128
- if (!stackConfig.auth || stackConfig.auth === 'none') return;
129
-
130
- // Next.js projects with nextauth
131
- if (stackConfig.auth === 'nextauth' || stackConfig.auth === 'both') {
132
- const pkgPaths = [
133
- path.join(outputDir, 'package.json'),
134
- path.join(outputDir, 'frontend', 'package.json'),
135
- ];
136
- for (const pkgPath of pkgPaths) {
137
- if (!fs.existsSync(pkgPath)) continue;
138
- let pkg;
139
- try {
140
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
141
- } catch {
142
- log.warn(`Skipping auth dependency injection could not parse ${pkgPath}`);
143
- continue;
144
- }
145
- pkg.dependencies = pkg.dependencies || {};
146
- pkg.dependencies['next-auth'] = '^5.0.0-beta.25';
147
- pkg.dependencies['@auth/prisma-adapter'] = '^2.7.4';
148
- pkg.dependencies['bcryptjs'] = '^2.4.3';
149
- pkg.devDependencies = pkg.devDependencies || {};
150
- pkg.devDependencies['@types/bcryptjs'] = '^2.4.6';
151
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
152
- }
153
- }
154
-
155
- // FastAPI projects with jwt-custom
156
- if (stackConfig.auth === 'jwt-custom' || stackConfig.auth === 'both') {
157
- const reqPaths = [
158
- path.join(outputDir, 'requirements.txt'),
159
- path.join(outputDir, 'backend', 'requirements.txt'),
160
- ];
161
- for (const reqPath of reqPaths) {
162
- if (!fs.existsSync(reqPath)) continue;
163
- const content = fs.readFileSync(reqPath, 'utf-8');
164
- const authDeps = ['python-jose[cryptography]', 'passlib[bcrypt]', 'python-multipart'];
165
- const additions = authDeps.filter(dep => !content.includes(dep.split('[')[0]));
166
- if (additions.length > 0) {
167
- fs.writeFileSync(reqPath, content.trimEnd() + '\n' + additions.join('\n') + '\n', 'utf-8');
168
- }
169
- }
170
- }
171
- }
172
-
173
- function applyUserPlugins(outputDir, variables, stackConfig) {
174
- const homeDir = process.env.HOME || process.env.USERPROFILE;
175
- if (!homeDir) return;
176
-
177
- const pluginDir = path.join(homeDir, '.devforge');
178
- if (!fs.existsSync(pluginDir)) return;
179
-
180
- // User templates: ~/.devforge/templates/<stackId>/overlay onto output
181
- const userTemplatesDir = path.join(pluginDir, 'templates', stackConfig.stackId);
182
- if (fs.existsSync(userTemplatesDir)) {
183
- processTemplateDir(userTemplatesDir, outputDir, variables, '');
184
- log.dim(` Applied user templates from ~/.devforge/templates/${stackConfig.stackId}/`);
185
- }
186
-
187
- // Universal user templates: ~/.devforge/templates/universal/ → always applied
188
- const universalDir = path.join(pluginDir, 'templates', 'universal');
189
- if (fs.existsSync(universalDir)) {
190
- processTemplateDir(universalDir, outputDir, variables, '');
191
- log.dim(' Applied user templates from ~/.devforge/templates/universal/');
192
- }
193
-
194
- // User agents: ~/.devforge/agents/*.md → copy into .claude/agents/
195
- const userAgentsDir = path.join(pluginDir, 'agents');
196
- if (fs.existsSync(userAgentsDir)) {
197
- const agents = fs.readdirSync(userAgentsDir).filter(f => f.endsWith('.md'));
198
- for (const agent of agents) {
199
- const src = path.join(userAgentsDir, agent);
200
- const dest = path.join(outputDir, '.claude', 'agents', agent);
201
- ensureDir(path.dirname(dest));
202
- fs.copyFileSync(src, dest);
203
- }
204
- if (agents.length > 0) {
205
- log.dim(` Applied ${agents.length} user agent(s) from ~/.devforge/agents/`);
206
- }
207
- }
208
-
209
- // User commands: ~/.devforge/commands/*.md → copy into .claude/commands/
210
- const userCommandsDir = path.join(pluginDir, 'commands');
211
- if (fs.existsSync(userCommandsDir)) {
212
- const commands = fs.readdirSync(userCommandsDir).filter(f => f.endsWith('.md'));
213
- for (const cmd of commands) {
214
- const src = path.join(userCommandsDir, cmd);
215
- const dest = path.join(outputDir, '.claude', 'commands', cmd);
216
- ensureDir(path.dirname(dest));
217
- fs.copyFileSync(src, dest);
218
- }
219
- if (commands.length > 0) {
220
- log.dim(` Applied ${commands.length} user command(s) from ~/.devforge/commands/`);
221
- }
222
- }
223
-
224
- // User skills: ~/.devforge/skills/<name>/SKILL.md copy into .claude/skills/
225
- const userSkillsDir = path.join(pluginDir, 'skills');
226
- if (fs.existsSync(userSkillsDir)) {
227
- const skills = fs.readdirSync(userSkillsDir, { withFileTypes: true })
228
- .filter(d => d.isDirectory())
229
- .map(d => d.name);
230
- for (const skill of skills) {
231
- const skillFile = path.join(userSkillsDir, skill, 'SKILL.md');
232
- if (fs.existsSync(skillFile)) {
233
- const dest = path.join(outputDir, '.claude', 'skills', skill, 'SKILL.md');
234
- ensureDir(path.dirname(dest));
235
- fs.copyFileSync(skillFile, dest);
236
- }
237
- }
238
- if (skills.length > 0) {
239
- log.dim(` Applied ${skills.length} user skill(s) from ~/.devforge/skills/`);
240
- }
241
- }
242
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROOT_DIR, ensureDir, readTemplate, replaceVars, toPascalCase, toSnakeCase, getStackMetadata, walkDir, log } from './utils.js';
4
+
5
+ const TEMPLATES_DIR = path.join(ROOT_DIR, 'templates');
6
+
7
+ export async function compose(outputDir, stackConfig) {
8
+ const variables = buildVariables(stackConfig);
9
+
10
+ ensureDir(outputDir);
11
+
12
+ for (const mod of stackConfig.templateModules) {
13
+ const templateDir = path.join(TEMPLATES_DIR, mod.path);
14
+ if (!fs.existsSync(templateDir)) {
15
+ log.warn(`Template module not found: ${mod.path}, skipping`);
16
+ continue;
17
+ }
18
+ processTemplateDir(templateDir, outputDir, variables, mod.prefix || '');
19
+ }
20
+
21
+ // Inject auth dependencies into package.json if auth is enabled
22
+ injectAuthDependencies(outputDir, stackConfig);
23
+
24
+ // Apply user plugins from ~/.devforge/templates/ if they exist
25
+ applyUserPlugins(outputDir, variables, stackConfig);
26
+ }
27
+
28
+ export function buildVariables(stackConfig) {
29
+ const vars = {
30
+ PROJECT_NAME: stackConfig.projectName,
31
+ PROJECT_NAME_PASCAL: toPascalCase(stackConfig.projectName),
32
+ PROJECT_NAME_SNAKE: toSnakeCase(stackConfig.projectName),
33
+ };
34
+
35
+ if (stackConfig.frontend) {
36
+ vars.FRONTEND_FRAMEWORK = stackConfig.frontend.framework;
37
+ vars.FRONTEND_LANGUAGE = stackConfig.frontend.language;
38
+ vars.FRONTEND_STYLING = stackConfig.frontend.styling;
39
+ vars.FRONTEND_UI = stackConfig.frontend.ui || '';
40
+ }
41
+
42
+ if (stackConfig.backend) {
43
+ vars.BACKEND_FRAMEWORK = stackConfig.backend.framework;
44
+ vars.BACKEND_LANGUAGE = stackConfig.backend.language;
45
+ vars.BACKEND_ORM = stackConfig.backend.orm;
46
+ }
47
+
48
+ if (stackConfig.database) {
49
+ vars.DATABASE_TYPE = stackConfig.database.type;
50
+ vars.DATABASE_ORM = stackConfig.database.orm;
51
+ }
52
+
53
+ vars.AUTH_TYPE = stackConfig.auth || 'none';
54
+ vars.DEPLOYMENT = stackConfig.deployment || 'docker';
55
+
56
+ // All per-stack values come from a single metadata source
57
+ const meta = getStackMetadata(stackConfig.stackId);
58
+ if (meta) {
59
+ Object.assign(vars, meta.commands);
60
+ vars.STACK_DESCRIPTION = meta.description;
61
+ vars.EXTRA_IGNORES = meta.extraIgnores;
62
+ vars.APP_PORT = meta.port;
63
+ vars.SETUP_COMMANDS = meta.setupCommands();
64
+ vars.AVAILABLE_SCRIPTS = meta.availableScripts;
65
+ } else {
66
+ vars.STACK_DESCRIPTION = '';
67
+ vars.EXTRA_IGNORES = '';
68
+ vars.APP_PORT = '3000';
69
+ vars.SETUP_COMMANDS = '';
70
+ vars.AVAILABLE_SCRIPTS = '';
71
+ }
72
+
73
+ vars.IMAGE_TAG = '0.1.0';
74
+
75
+ return vars;
76
+ }
77
+
78
+ export function processTemplateDir(templateDir, outputDir, variables, prefix) {
79
+ const entries = walkDir(templateDir);
80
+
81
+ for (const filePath of entries) {
82
+ const relativePath = path.relative(templateDir, filePath);
83
+ const outputRelative = prefix ? path.join(prefix, relativePath) : relativePath;
84
+
85
+ // Guard against path traversal (e.g. ../../etc/passwd in plugin templates)
86
+ const resolved = path.resolve(outputDir, outputRelative);
87
+ if (!resolved.startsWith(path.resolve(outputDir))) {
88
+ log.warn(`Skipping "${outputRelative}" (path traversal detected)`);
89
+ continue;
90
+ }
91
+
92
+ if (outputRelative.endsWith('.template')) {
93
+ // Process template: replace vars, strip .template extension
94
+ const content = readTemplate(filePath);
95
+ const processed = replaceVars(content, variables);
96
+ const outputPath = path.join(outputDir, outputRelative.replace(/\.template$/, ''));
97
+ ensureDir(path.dirname(outputPath));
98
+ fs.writeFileSync(outputPath, processed, 'utf-8');
99
+ } else if (path.basename(filePath) === '.gitkeep') {
100
+ // Create the directory but don't copy .gitkeep itself
101
+ ensureDir(path.join(outputDir, path.dirname(outputRelative)));
102
+ } else {
103
+ // Binary copy
104
+ const outputPath = path.join(outputDir, outputRelative);
105
+ ensureDir(path.dirname(outputPath));
106
+ fs.copyFileSync(filePath, outputPath);
107
+ }
108
+ }
109
+ }
110
+
111
+ // walkDir is imported from utils.js (shared with doctor-checks.js)
112
+
113
+ function injectAuthDependencies(outputDir, stackConfig) {
114
+ if (!stackConfig.auth || stackConfig.auth === 'none') return;
115
+
116
+ // Next.js projects with nextauth
117
+ if (stackConfig.auth === 'nextauth' || stackConfig.auth === 'both') {
118
+ const pkgPaths = [
119
+ path.join(outputDir, 'package.json'),
120
+ path.join(outputDir, 'frontend', 'package.json'),
121
+ ];
122
+ for (const pkgPath of pkgPaths) {
123
+ if (!fs.existsSync(pkgPath)) continue;
124
+ let pkg;
125
+ try {
126
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
127
+ } catch {
128
+ log.warn(`Skipping auth dependency injection could not parse ${pkgPath}`);
129
+ continue;
130
+ }
131
+ pkg.dependencies = pkg.dependencies || {};
132
+ pkg.dependencies['next-auth'] = '^5.0.0-beta.25';
133
+ pkg.dependencies['@auth/prisma-adapter'] = '^2.7.4';
134
+ pkg.dependencies['bcryptjs'] = '^2.4.3';
135
+ pkg.devDependencies = pkg.devDependencies || {};
136
+ pkg.devDependencies['@types/bcryptjs'] = '^2.4.6';
137
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
138
+ }
139
+ }
140
+
141
+ // FastAPI projects with jwt-custom
142
+ if (stackConfig.auth === 'jwt-custom' || stackConfig.auth === 'both') {
143
+ const reqPaths = [
144
+ path.join(outputDir, 'requirements.txt'),
145
+ path.join(outputDir, 'backend', 'requirements.txt'),
146
+ ];
147
+ for (const reqPath of reqPaths) {
148
+ if (!fs.existsSync(reqPath)) continue;
149
+ const content = fs.readFileSync(reqPath, 'utf-8');
150
+ const authDeps = ['python-jose[cryptography]', 'passlib[bcrypt]', 'python-multipart'];
151
+ const additions = authDeps.filter(dep => !content.includes(dep.split('[')[0]));
152
+ if (additions.length > 0) {
153
+ fs.writeFileSync(reqPath, content.trimEnd() + '\n' + additions.join('\n') + '\n', 'utf-8');
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ function applyUserPlugins(outputDir, variables, stackConfig) {
160
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
161
+ if (!homeDir) return;
162
+
163
+ const pluginDir = path.join(homeDir, '.devforge');
164
+ if (!fs.existsSync(pluginDir)) return;
165
+
166
+ // User templates: ~/.devforge/templates/<stackId>/ → overlay onto output
167
+ const userTemplatesDir = path.join(pluginDir, 'templates', stackConfig.stackId);
168
+ if (fs.existsSync(userTemplatesDir)) {
169
+ processTemplateDir(userTemplatesDir, outputDir, variables, '');
170
+ log.dim(` Applied user templates from ~/.devforge/templates/${stackConfig.stackId}/`);
171
+ }
172
+
173
+ // Universal user templates: ~/.devforge/templates/universal/ → always applied
174
+ const universalDir = path.join(pluginDir, 'templates', 'universal');
175
+ if (fs.existsSync(universalDir)) {
176
+ processTemplateDir(universalDir, outputDir, variables, '');
177
+ log.dim(' Applied user templates from ~/.devforge/templates/universal/');
178
+ }
179
+
180
+ // User agents: ~/.devforge/agents/*.mdcopy into .claude/agents/
181
+ const userAgentsDir = path.join(pluginDir, 'agents');
182
+ if (fs.existsSync(userAgentsDir)) {
183
+ const agents = fs.readdirSync(userAgentsDir).filter(f => f.endsWith('.md'));
184
+ for (const agent of agents) {
185
+ const src = path.join(userAgentsDir, agent);
186
+ const dest = path.join(outputDir, '.claude', 'agents', agent);
187
+ ensureDir(path.dirname(dest));
188
+ fs.copyFileSync(src, dest);
189
+ }
190
+ if (agents.length > 0) {
191
+ log.dim(` Applied ${agents.length} user agent(s) from ~/.devforge/agents/`);
192
+ }
193
+ }
194
+
195
+ // User commands: ~/.devforge/commands/*.md → copy into .claude/commands/
196
+ const userCommandsDir = path.join(pluginDir, 'commands');
197
+ if (fs.existsSync(userCommandsDir)) {
198
+ const commands = fs.readdirSync(userCommandsDir).filter(f => f.endsWith('.md'));
199
+ for (const cmd of commands) {
200
+ const src = path.join(userCommandsDir, cmd);
201
+ const dest = path.join(outputDir, '.claude', 'commands', cmd);
202
+ ensureDir(path.dirname(dest));
203
+ fs.copyFileSync(src, dest);
204
+ }
205
+ if (commands.length > 0) {
206
+ log.dim(` Applied ${commands.length} user command(s) from ~/.devforge/commands/`);
207
+ }
208
+ }
209
+
210
+ // User skills: ~/.devforge/skills/<name>/SKILL.md → copy into .claude/skills/
211
+ const userSkillsDir = path.join(pluginDir, 'skills');
212
+ if (fs.existsSync(userSkillsDir)) {
213
+ const skills = fs.readdirSync(userSkillsDir, { withFileTypes: true })
214
+ .filter(d => d.isDirectory())
215
+ .map(d => d.name);
216
+ for (const skill of skills) {
217
+ const skillFile = path.join(userSkillsDir, skill, 'SKILL.md');
218
+ if (fs.existsSync(skillFile)) {
219
+ const dest = path.join(outputDir, '.claude', 'skills', skill, 'SKILL.md');
220
+ ensureDir(path.dirname(dest));
221
+ fs.copyFileSync(skillFile, dest);
222
+ }
223
+ }
224
+ if (skills.length > 0) {
225
+ log.dim(` Applied ${skills.length} user skill(s) from ~/.devforge/skills/`);
226
+ }
227
+ }
228
+ }
@@ -66,17 +66,20 @@ export function checkChainproofIntegrity(projectDir) {
66
66
  effort: 'medium',
67
67
  }];
68
68
  }
69
- } catch {
70
- // Malformed chain.json
71
- return [{
72
- severity: 'critical',
73
- title: 'ChainProof chain.json is malformed',
74
- impact: 'Cannot verify trust chain integrity',
75
- files: ['.chainproof/chain.json'],
76
- autoFixable: false,
77
- promptId: 'CHAINPROOF_MALFORMED',
78
- effort: 'medium',
79
- }];
69
+ } catch (err) {
70
+ // JSON parse errors → malformed file; anything else should propagate
71
+ if (err instanceof SyntaxError) {
72
+ return [{
73
+ severity: 'critical',
74
+ title: 'ChainProof chain.json is malformed',
75
+ impact: 'Cannot verify trust chain integrity',
76
+ files: ['.chainproof/chain.json'],
77
+ autoFixable: false,
78
+ promptId: 'CHAINPROOF_MALFORMED',
79
+ effort: 'medium',
80
+ }];
81
+ }
82
+ throw err;
80
83
  }
81
84
 
82
85
  return [];
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { walkDir, DEFAULT_SKIP_DIRS } from './utils.js';
3
4
  import {
4
5
  checkChainproofExists,
5
6
  checkChainproofIntegrity,
@@ -739,24 +740,7 @@ function checkAIPrompts(projectDir) {
739
740
  // Helpers
740
741
 
741
742
  function findFiles(dir, ext) {
742
- const results = [];
743
- if (!fs.existsSync(dir)) return results;
744
-
745
- try {
746
- const entries = fs.readdirSync(dir, { withFileTypes: true });
747
- for (const entry of entries) {
748
- const fullPath = path.join(dir, entry.name);
749
- if (entry.isDirectory()) {
750
- if (['node_modules', '.next', '__pycache__', '.git', 'venv', '.venv', 'dist', 'build'].includes(entry.name)) continue;
751
- results.push(...findFiles(fullPath, ext));
752
- } else if (entry.name.endsWith(ext)) {
753
- results.push(fullPath);
754
- }
755
- }
756
- } catch {
757
- // Permission errors, etc.
758
- }
759
-
760
- return results;
743
+ if (!fs.existsSync(dir)) return [];
744
+ return walkDir(dir, { ext, skipDirs: DEFAULT_SKIP_DIRS });
761
745
  }
762
746
 
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
4
  import chalk from 'chalk';
5
- import { log, toKebabCase, copyEnvCmd } from './utils.js';
5
+ import { log, toKebabCase, getStackMetadata } from './utils.js';
6
6
  import { askServiceType, askRefinements, askNewMode, confirmStack } from './prompts.js';
7
7
  import { recommend } from './recommender.js';
8
8
  import { compose } from './composer.js';
@@ -123,39 +123,11 @@ export function printNextSteps(projectName, config, isGuided = false) {
123
123
  console.log(chalk.bold(' Next steps:'));
124
124
  console.log(` cd ${projectName}`);
125
125
 
126
- if (config.stackId === 'nextjs-fullstack') {
127
- console.log(' npm install');
128
- console.log(` ${copyEnvCmd()}`);
129
- console.log(' npx prisma db push');
130
- console.log(' npm run dev');
131
- } else if (config.stackId === 'fastapi-backend') {
132
- console.log(' cd backend');
133
- console.log(' python -m venv venv');
134
- console.log(' source venv/bin/activate # or venv\\Scripts\\activate on Windows');
135
- console.log(' pip install -r requirements.txt');
136
- console.log(` ${copyEnvCmd()}`);
137
- console.log(' uvicorn app.main:app --reload');
138
- } else if (config.stackId === 'polyglot-fullstack') {
139
- console.log(' docker compose up -d postgres');
140
- console.log(' # Frontend:');
141
- console.log(' cd frontend && npm install && npm run dev');
142
- console.log(' # Backend:');
143
- console.log(' cd backend && pip install -r requirements.txt && uvicorn app.main:app --reload');
144
- } else if (config.stackId === 'react-express') {
145
- console.log(' # Frontend:');
146
- console.log(' cd frontend && npm install && npm run dev');
147
- console.log(' # Backend (in a separate terminal):');
148
- console.log(` cd backend && npm install && ${copyEnvCmd()} && npx prisma db push && npm run dev`);
149
- } else if (config.stackId === 'remix-fullstack') {
150
- console.log(' npm install');
151
- console.log(` ${copyEnvCmd()}`);
152
- console.log(' npx prisma db push');
153
- console.log(' npm run dev');
154
- } else if (config.stackId === 'hono-api') {
155
- console.log(' npm install');
156
- console.log(` ${copyEnvCmd()}`);
157
- console.log(' npx prisma db push');
158
- console.log(' npm run dev');
126
+ const meta = getStackMetadata(config.stackId);
127
+ if (meta) {
128
+ for (const line of meta.setupCommands().split('\n')) {
129
+ console.log(` ${line}`);
130
+ }
159
131
  }
160
132
 
161
133
  if (config.claudeCode) {