claude-cli-advanced-starter-pack 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/LICENSE +21 -0
- package/OVERVIEW.md +597 -0
- package/README.md +439 -0
- package/bin/gtask.js +282 -0
- package/bin/postinstall.js +53 -0
- package/package.json +69 -0
- package/src/agents/phase-dev-templates.js +1011 -0
- package/src/agents/templates.js +668 -0
- package/src/analysis/checklist-parser.js +414 -0
- package/src/analysis/codebase.js +481 -0
- package/src/cli/menu.js +958 -0
- package/src/commands/claude-audit.js +1482 -0
- package/src/commands/claude-settings.js +2243 -0
- package/src/commands/create-agent.js +681 -0
- package/src/commands/create-command.js +337 -0
- package/src/commands/create-hook.js +262 -0
- package/src/commands/create-phase-dev/codebase-analyzer.js +813 -0
- package/src/commands/create-phase-dev/documentation-generator.js +352 -0
- package/src/commands/create-phase-dev/post-completion.js +404 -0
- package/src/commands/create-phase-dev/scale-calculator.js +344 -0
- package/src/commands/create-phase-dev/wizard.js +492 -0
- package/src/commands/create-phase-dev.js +481 -0
- package/src/commands/create-skill.js +313 -0
- package/src/commands/create.js +446 -0
- package/src/commands/decompose.js +392 -0
- package/src/commands/detect-tech-stack.js +768 -0
- package/src/commands/explore-mcp/claude-md-updater.js +252 -0
- package/src/commands/explore-mcp/mcp-installer.js +346 -0
- package/src/commands/explore-mcp/mcp-registry.js +438 -0
- package/src/commands/explore-mcp.js +638 -0
- package/src/commands/gtask-init.js +641 -0
- package/src/commands/help.js +128 -0
- package/src/commands/init.js +1890 -0
- package/src/commands/install.js +250 -0
- package/src/commands/list.js +116 -0
- package/src/commands/roadmap.js +750 -0
- package/src/commands/setup-wizard.js +482 -0
- package/src/commands/setup.js +351 -0
- package/src/commands/sync.js +534 -0
- package/src/commands/test-run.js +456 -0
- package/src/commands/test-setup.js +456 -0
- package/src/commands/validate.js +67 -0
- package/src/config/tech-stack.defaults.json +182 -0
- package/src/config/tech-stack.schema.json +502 -0
- package/src/github/client.js +359 -0
- package/src/index.js +84 -0
- package/src/templates/claude-command.js +244 -0
- package/src/templates/issue-body.js +284 -0
- package/src/testing/config.js +411 -0
- package/src/utils/template-engine.js +398 -0
- package/src/utils/validate-templates.js +223 -0
- package/src/utils.js +396 -0
- package/templates/commands/ccasp-setup.template.md +113 -0
- package/templates/commands/context-audit.template.md +97 -0
- package/templates/commands/create-task-list.template.md +382 -0
- package/templates/commands/deploy-full.template.md +261 -0
- package/templates/commands/github-task-start.template.md +99 -0
- package/templates/commands/github-update.template.md +69 -0
- package/templates/commands/happy-start.template.md +117 -0
- package/templates/commands/phase-track.template.md +142 -0
- package/templates/commands/tunnel-start.template.md +127 -0
- package/templates/commands/tunnel-stop.template.md +106 -0
- package/templates/hooks/context-guardian.template.js +173 -0
- package/templates/hooks/deployment-orchestrator.template.js +219 -0
- package/templates/hooks/github-progress-hook.template.js +197 -0
- package/templates/hooks/happy-checkpoint-manager.template.js +222 -0
- package/templates/hooks/phase-dev-enforcer.template.js +183 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Engine
|
|
3
|
+
*
|
|
4
|
+
* Handles placeholder replacement in .claude files using tech-stack.json values.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Nested property access: {{frontend.port}}, {{deployment.backend.platform}}
|
|
7
|
+
* - Conditional blocks: {{#if condition}}...{{/if}}
|
|
8
|
+
* - Equality checks: {{#if (eq path "value")}}...{{/if}}
|
|
9
|
+
* - Else blocks: {{#if condition}}...{{else}}...{{/if}}
|
|
10
|
+
* - Each loops: {{#each array}}{{this}}{{/each}}
|
|
11
|
+
* - Path variables: ${CWD}, ${HOME}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
15
|
+
import { join, extname } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get nested property from object using dot notation
|
|
20
|
+
* @param {object} obj - The object to search
|
|
21
|
+
* @param {string} path - Dot-notated path (e.g., "frontend.port")
|
|
22
|
+
* @returns {*} The value or undefined
|
|
23
|
+
*/
|
|
24
|
+
function getNestedValue(obj, path) {
|
|
25
|
+
return path.split('.').reduce((current, key) => {
|
|
26
|
+
return current && current[key] !== undefined ? current[key] : undefined;
|
|
27
|
+
}, obj);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate a condition expression
|
|
32
|
+
* @param {string} condition - The condition to evaluate
|
|
33
|
+
* @param {object} values - Tech stack values
|
|
34
|
+
* @returns {boolean} Whether the condition is truthy
|
|
35
|
+
*/
|
|
36
|
+
function evaluateCondition(condition, values) {
|
|
37
|
+
const trimmed = condition.trim();
|
|
38
|
+
|
|
39
|
+
// Handle (eq path "value") syntax
|
|
40
|
+
const eqMatch = trimmed.match(/^\(eq\s+([^\s]+)\s+["']([^"']+)["']\)$/);
|
|
41
|
+
if (eqMatch) {
|
|
42
|
+
const [, path, expected] = eqMatch;
|
|
43
|
+
const actual = getNestedValue(values, path);
|
|
44
|
+
return actual === expected;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle (neq path "value") syntax
|
|
48
|
+
const neqMatch = trimmed.match(/^\(neq\s+([^\s]+)\s+["']([^"']+)["']\)$/);
|
|
49
|
+
if (neqMatch) {
|
|
50
|
+
const [, path, expected] = neqMatch;
|
|
51
|
+
const actual = getNestedValue(values, path);
|
|
52
|
+
return actual !== expected;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle (not path) syntax
|
|
56
|
+
const notMatch = trimmed.match(/^\(not\s+([^\s]+)\)$/);
|
|
57
|
+
if (notMatch) {
|
|
58
|
+
const [, path] = notMatch;
|
|
59
|
+
const value = getNestedValue(values, path);
|
|
60
|
+
return !value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle (and condition1 condition2) syntax
|
|
64
|
+
const andMatch = trimmed.match(/^\(and\s+(.+)\s+(.+)\)$/);
|
|
65
|
+
if (andMatch) {
|
|
66
|
+
const [, cond1, cond2] = andMatch;
|
|
67
|
+
return evaluateCondition(cond1, values) && evaluateCondition(cond2, values);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Handle (or condition1 condition2) syntax
|
|
71
|
+
const orMatch = trimmed.match(/^\(or\s+(.+)\s+(.+)\)$/);
|
|
72
|
+
if (orMatch) {
|
|
73
|
+
const [, cond1, cond2] = orMatch;
|
|
74
|
+
return evaluateCondition(cond1, values) || evaluateCondition(cond2, values);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Simple path - check if value is truthy
|
|
78
|
+
const value = getNestedValue(values, trimmed);
|
|
79
|
+
return !!value && value !== 'none' && value !== '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Process {{#each array}}...{{/each}} blocks
|
|
84
|
+
* @param {string} content - Content with each blocks
|
|
85
|
+
* @param {object} values - Tech stack values
|
|
86
|
+
* @returns {string} Processed content
|
|
87
|
+
*/
|
|
88
|
+
function processEachBlocks(content, values) {
|
|
89
|
+
// Match {{#each path}}content{{/each}}
|
|
90
|
+
const eachRegex = /\{\{#each\s+([^\}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
|
|
91
|
+
|
|
92
|
+
return content.replace(eachRegex, (match, path, innerContent) => {
|
|
93
|
+
const array = getNestedValue(values, path.trim());
|
|
94
|
+
|
|
95
|
+
if (!Array.isArray(array) || array.length === 0) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return array
|
|
100
|
+
.map((item, index) => {
|
|
101
|
+
let result = innerContent;
|
|
102
|
+
// Replace {{this}} with current item
|
|
103
|
+
result = result.replace(/\{\{this\}\}/g, String(item));
|
|
104
|
+
// Replace {{@index}} with current index
|
|
105
|
+
result = result.replace(/\{\{@index\}\}/g, String(index));
|
|
106
|
+
// Replace {{@first}} with boolean
|
|
107
|
+
result = result.replace(/\{\{@first\}\}/g, String(index === 0));
|
|
108
|
+
// Replace {{@last}} with boolean
|
|
109
|
+
result = result.replace(/\{\{@last\}\}/g, String(index === array.length - 1));
|
|
110
|
+
return result;
|
|
111
|
+
})
|
|
112
|
+
.join('');
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Process {{#if condition}}...{{else}}...{{/if}} blocks
|
|
118
|
+
* @param {string} content - Content with conditional blocks
|
|
119
|
+
* @param {object} values - Tech stack values
|
|
120
|
+
* @returns {string} Processed content
|
|
121
|
+
*/
|
|
122
|
+
function processConditionalBlocks(content, values) {
|
|
123
|
+
// Process from innermost to outermost to handle nested conditionals
|
|
124
|
+
let result = content;
|
|
125
|
+
let previousResult;
|
|
126
|
+
|
|
127
|
+
// Keep processing until no more changes (handles nested blocks)
|
|
128
|
+
do {
|
|
129
|
+
previousResult = result;
|
|
130
|
+
|
|
131
|
+
// Match {{#if condition}}...{{else}}...{{/if}} or {{#if condition}}...{{/if}}
|
|
132
|
+
// Non-greedy match for innermost blocks first
|
|
133
|
+
const ifElseRegex = /\{\{#if\s+([^\}]+)\}\}([\s\S]*?)\{\{else\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
134
|
+
const ifOnlyRegex = /\{\{#if\s+([^\}]+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
135
|
+
|
|
136
|
+
// First handle if-else blocks
|
|
137
|
+
result = result.replace(ifElseRegex, (match, condition, ifContent, elseContent) => {
|
|
138
|
+
const isTruthy = evaluateCondition(condition, values);
|
|
139
|
+
return isTruthy ? ifContent : elseContent;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Then handle if-only blocks (no else)
|
|
143
|
+
result = result.replace(ifOnlyRegex, (match, condition, ifContent) => {
|
|
144
|
+
const isTruthy = evaluateCondition(condition, values);
|
|
145
|
+
return isTruthy ? ifContent : '';
|
|
146
|
+
});
|
|
147
|
+
} while (result !== previousResult);
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Process path variables like ${CWD} and ${HOME}
|
|
154
|
+
* @param {string} content - Content with path variables
|
|
155
|
+
* @returns {string} Content with paths resolved
|
|
156
|
+
*/
|
|
157
|
+
function processPathVariables(content) {
|
|
158
|
+
return content
|
|
159
|
+
.replace(/\$\{CWD\}/g, process.cwd())
|
|
160
|
+
.replace(/\$\{HOME\}/g, homedir())
|
|
161
|
+
.replace(/\$\{CLAUDE_DIR\}/g, join(process.cwd(), '.claude'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Replace all placeholders in a string
|
|
166
|
+
* @param {string} content - Content with {{placeholder}} patterns
|
|
167
|
+
* @param {object} values - Tech stack values object
|
|
168
|
+
* @param {object} options - Options for replacement
|
|
169
|
+
* @returns {string} Content with placeholders replaced
|
|
170
|
+
*/
|
|
171
|
+
export function replacePlaceholders(content, values, options = {}) {
|
|
172
|
+
const { preserveUnknown = false, warnOnMissing = true, processConditionals = true } = options;
|
|
173
|
+
const warnings = [];
|
|
174
|
+
|
|
175
|
+
let result = content;
|
|
176
|
+
|
|
177
|
+
// Step 1: Process path variables (${CWD}, ${HOME}, etc.)
|
|
178
|
+
result = processPathVariables(result);
|
|
179
|
+
|
|
180
|
+
// Step 2: Process {{#each}} blocks
|
|
181
|
+
result = processEachBlocks(result, values);
|
|
182
|
+
|
|
183
|
+
// Step 3: Process {{#if}}...{{/if}} conditional blocks
|
|
184
|
+
if (processConditionals) {
|
|
185
|
+
result = processConditionalBlocks(result, values);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 4: Replace simple {{path.to.value}} placeholders
|
|
189
|
+
result = result.replace(/\{\{([^#\/][^}]*)\}\}/g, (match, path) => {
|
|
190
|
+
const trimmedPath = path.trim();
|
|
191
|
+
|
|
192
|
+
// Skip special syntax that wasn't processed
|
|
193
|
+
if (trimmedPath.startsWith('#') || trimmedPath.startsWith('/') || trimmedPath.startsWith('@')) {
|
|
194
|
+
return match;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const value = getNestedValue(values, trimmedPath);
|
|
198
|
+
|
|
199
|
+
if (value === undefined || value === null || value === '') {
|
|
200
|
+
if (warnOnMissing && !trimmedPath.includes('PLACEHOLDER')) {
|
|
201
|
+
warnings.push(`Missing value for: ${trimmedPath}`);
|
|
202
|
+
}
|
|
203
|
+
return preserveUnknown ? match : `{{${trimmedPath}}}`; // Keep placeholder
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle arrays
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
return value.join(', ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Handle objects - return JSON
|
|
212
|
+
if (typeof value === 'object') {
|
|
213
|
+
return JSON.stringify(value, null, 2);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return String(value);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return { content: result, warnings };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Process a single file
|
|
224
|
+
* @param {string} filePath - Path to the file
|
|
225
|
+
* @param {object} values - Tech stack values
|
|
226
|
+
* @param {object} options - Processing options
|
|
227
|
+
* @returns {object} Result with stats
|
|
228
|
+
*/
|
|
229
|
+
export function processFile(filePath, values, options = {}) {
|
|
230
|
+
const { dryRun = false, verbose = false } = options;
|
|
231
|
+
|
|
232
|
+
const originalContent = readFileSync(filePath, 'utf8');
|
|
233
|
+
const { content: newContent, warnings } = replacePlaceholders(originalContent, values, options);
|
|
234
|
+
|
|
235
|
+
const placeholderCount = (originalContent.match(/\{\{[^}]+\}\}/g) || []).length;
|
|
236
|
+
const replacedCount = placeholderCount - (newContent.match(/\{\{[^}]+\}\}/g) || []).length;
|
|
237
|
+
|
|
238
|
+
const result = {
|
|
239
|
+
file: filePath,
|
|
240
|
+
placeholders: placeholderCount,
|
|
241
|
+
replaced: replacedCount,
|
|
242
|
+
remaining: placeholderCount - replacedCount,
|
|
243
|
+
warnings,
|
|
244
|
+
changed: originalContent !== newContent,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (!dryRun && result.changed) {
|
|
248
|
+
writeFileSync(filePath, newContent, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Process all template files in a directory
|
|
256
|
+
* @param {string} dirPath - Directory path
|
|
257
|
+
* @param {object} values - Tech stack values
|
|
258
|
+
* @param {object} options - Processing options
|
|
259
|
+
* @returns {object[]} Array of results
|
|
260
|
+
*/
|
|
261
|
+
export function processDirectory(dirPath, values, options = {}) {
|
|
262
|
+
const {
|
|
263
|
+
extensions = ['.md', '.json', '.js', '.ts', '.yml', '.yaml'],
|
|
264
|
+
recursive = true,
|
|
265
|
+
exclude = ['node_modules', '.git', 'dist', 'build'],
|
|
266
|
+
} = options;
|
|
267
|
+
|
|
268
|
+
const results = [];
|
|
269
|
+
|
|
270
|
+
function walkDir(dir) {
|
|
271
|
+
const entries = readdirSync(dir);
|
|
272
|
+
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
const fullPath = join(dir, entry);
|
|
275
|
+
|
|
276
|
+
// Skip excluded directories
|
|
277
|
+
if (exclude.includes(entry)) continue;
|
|
278
|
+
|
|
279
|
+
const stat = statSync(fullPath);
|
|
280
|
+
|
|
281
|
+
if (stat.isDirectory() && recursive) {
|
|
282
|
+
walkDir(fullPath);
|
|
283
|
+
} else if (stat.isFile()) {
|
|
284
|
+
const ext = extname(entry);
|
|
285
|
+
if (extensions.includes(ext)) {
|
|
286
|
+
results.push(processFile(fullPath, values, options));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
walkDir(dirPath);
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Generate a tech-stack.json from detected values
|
|
298
|
+
* @param {object} detected - Detected tech stack
|
|
299
|
+
* @param {object} userOverrides - User-provided overrides
|
|
300
|
+
* @returns {object} Merged tech stack
|
|
301
|
+
*/
|
|
302
|
+
export function generateTechStack(detected, userOverrides = {}) {
|
|
303
|
+
// Deep merge function
|
|
304
|
+
function deepMerge(target, source) {
|
|
305
|
+
const result = { ...target };
|
|
306
|
+
for (const key of Object.keys(source)) {
|
|
307
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
308
|
+
result[key] = deepMerge(result[key] || {}, source[key]);
|
|
309
|
+
} else if (source[key] !== undefined && source[key] !== null) {
|
|
310
|
+
result[key] = source[key];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return deepMerge(detected, userOverrides);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Flatten nested object to dot-notation keys
|
|
321
|
+
* @param {object} obj - Object to flatten
|
|
322
|
+
* @param {string} prefix - Current key prefix
|
|
323
|
+
* @returns {object} Flattened object
|
|
324
|
+
*/
|
|
325
|
+
export function flattenObject(obj, prefix = '') {
|
|
326
|
+
const result = {};
|
|
327
|
+
|
|
328
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
329
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
330
|
+
|
|
331
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
332
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
333
|
+
} else {
|
|
334
|
+
result[newKey] = value;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get all unique placeholders from content
|
|
343
|
+
* @param {string} content - Content to scan
|
|
344
|
+
* @returns {string[]} Array of placeholder paths
|
|
345
|
+
*/
|
|
346
|
+
export function extractPlaceholders(content) {
|
|
347
|
+
const matches = content.match(/\{\{([^}]+)\}\}/g) || [];
|
|
348
|
+
const placeholders = matches.map((m) => m.replace(/\{\{|\}\}/g, '').trim());
|
|
349
|
+
return [...new Set(placeholders)];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Validate a tech-stack.json against required placeholders
|
|
354
|
+
* @param {object} techStack - Tech stack values
|
|
355
|
+
* @param {string[]} requiredPlaceholders - Required placeholder paths
|
|
356
|
+
* @returns {object} Validation result
|
|
357
|
+
*/
|
|
358
|
+
export function validateTechStack(techStack, requiredPlaceholders) {
|
|
359
|
+
const missing = [];
|
|
360
|
+
const present = [];
|
|
361
|
+
|
|
362
|
+
for (const placeholder of requiredPlaceholders) {
|
|
363
|
+
const value = getNestedValue(techStack, placeholder);
|
|
364
|
+
if (value === undefined || value === null || value === '') {
|
|
365
|
+
missing.push(placeholder);
|
|
366
|
+
} else {
|
|
367
|
+
present.push(placeholder);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
valid: missing.length === 0,
|
|
373
|
+
missing,
|
|
374
|
+
present,
|
|
375
|
+
coverage: present.length / requiredPlaceholders.length,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export {
|
|
380
|
+
evaluateCondition,
|
|
381
|
+
processConditionalBlocks,
|
|
382
|
+
processEachBlocks,
|
|
383
|
+
processPathVariables,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export default {
|
|
387
|
+
replacePlaceholders,
|
|
388
|
+
processFile,
|
|
389
|
+
processDirectory,
|
|
390
|
+
generateTechStack,
|
|
391
|
+
flattenObject,
|
|
392
|
+
extractPlaceholders,
|
|
393
|
+
validateTechStack,
|
|
394
|
+
evaluateCondition,
|
|
395
|
+
processConditionalBlocks,
|
|
396
|
+
processEachBlocks,
|
|
397
|
+
processPathVariables,
|
|
398
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Validation Scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans template files for hardcoded values that should be parameterized.
|
|
5
|
+
* Ensures 100% platform agnosticism by detecting project-specific values.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { join, extname } from 'path';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Forbidden patterns - hardcoded values that should never appear in templates
|
|
14
|
+
* These are examples of project-specific values that must use placeholders
|
|
15
|
+
*/
|
|
16
|
+
const FORBIDDEN_PATTERNS = [
|
|
17
|
+
// Railway IDs (example patterns)
|
|
18
|
+
{ pattern: /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g, name: 'UUID (potential service/project ID)' },
|
|
19
|
+
|
|
20
|
+
// Hardcoded ngrok usage without conditional
|
|
21
|
+
{ pattern: /ngrok\s+http\s+\d+(?!\s*\}\})/g, name: 'Hardcoded ngrok command' },
|
|
22
|
+
|
|
23
|
+
// Specific project names that look hardcoded
|
|
24
|
+
{ pattern: /--project-name=[a-z][a-z0-9-]+(?![\s]*\}\})/g, name: 'Hardcoded project name' },
|
|
25
|
+
|
|
26
|
+
// Hardcoded GitHub usernames/orgs (not in placeholders)
|
|
27
|
+
{ pattern: /--owner\s+[a-zA-Z][a-zA-Z0-9-]+(?!\s*\}\})/g, name: 'Hardcoded GitHub owner' },
|
|
28
|
+
|
|
29
|
+
// Hardcoded API keys (patterns)
|
|
30
|
+
{ pattern: /dev-key-[a-z0-9]+/gi, name: 'Hardcoded API key' },
|
|
31
|
+
{ pattern: /api[_-]?key["\s:=]+[a-zA-Z0-9_-]{20,}/gi, name: 'Potential API key' },
|
|
32
|
+
|
|
33
|
+
// Hardcoded ports that should be configurable
|
|
34
|
+
{ pattern: /localhost:\d{4}(?!\s*\}\})/g, name: 'Hardcoded localhost port' },
|
|
35
|
+
|
|
36
|
+
// Hardcoded URLs that look project-specific
|
|
37
|
+
{ pattern: /https?:\/\/[a-z0-9-]+\.(railway|vercel|netlify)\.app/gi, name: 'Hardcoded deployment URL' },
|
|
38
|
+
|
|
39
|
+
// Specific SSH hosts
|
|
40
|
+
{ pattern: /ssh\s+[a-z]+@\d+\.\d+\.\d+\.\d+/g, name: 'Hardcoded SSH host' },
|
|
41
|
+
|
|
42
|
+
// Hardcoded email addresses
|
|
43
|
+
{ pattern: /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}(?!\s*\}\})/gi, name: 'Hardcoded email address' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Allowed patterns - these are OK even if they match forbidden patterns
|
|
48
|
+
*/
|
|
49
|
+
const ALLOWED_PATTERNS = [
|
|
50
|
+
// Template placeholders
|
|
51
|
+
/\{\{[^}]+\}\}/g,
|
|
52
|
+
|
|
53
|
+
// Documentation examples marked as such
|
|
54
|
+
/<example>[\s\S]*?<\/example>/g,
|
|
55
|
+
|
|
56
|
+
// Code comments explaining placeholders
|
|
57
|
+
/\/\/.*placeholder/gi,
|
|
58
|
+
/\/\*[\s\S]*?placeholder[\s\S]*?\*\//gi,
|
|
59
|
+
|
|
60
|
+
// Generic localhost for documentation
|
|
61
|
+
/localhost:5173|localhost:8000|localhost:3000/g,
|
|
62
|
+
|
|
63
|
+
// Schema URLs
|
|
64
|
+
/https:\/\/json-schema\.org/g,
|
|
65
|
+
/https:\/\/github\.com\/[^/]+\/[^/]+\/tech-stack\.schema/g,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a match is within an allowed context
|
|
70
|
+
*/
|
|
71
|
+
function isInAllowedContext(content, matchIndex, matchLength) {
|
|
72
|
+
// Get surrounding context
|
|
73
|
+
const start = Math.max(0, matchIndex - 50);
|
|
74
|
+
const end = Math.min(content.length, matchIndex + matchLength + 50);
|
|
75
|
+
const context = content.substring(start, end);
|
|
76
|
+
|
|
77
|
+
// Check if it's inside a placeholder
|
|
78
|
+
const beforeMatch = content.substring(Math.max(0, matchIndex - 10), matchIndex);
|
|
79
|
+
const afterMatch = content.substring(matchIndex + matchLength, Math.min(content.length, matchIndex + matchLength + 10));
|
|
80
|
+
|
|
81
|
+
if (beforeMatch.includes('{{') && afterMatch.includes('}}')) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if it's in a conditional block with placeholders
|
|
86
|
+
if (context.includes('{{#if') || context.includes('{{deployment.') || context.includes('{{versionControl.')) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if it's marked as an example
|
|
91
|
+
if (context.toLowerCase().includes('example') || context.includes('<placeholder>')) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scan a single file for forbidden patterns
|
|
100
|
+
*/
|
|
101
|
+
export function scanFile(filePath) {
|
|
102
|
+
const content = readFileSync(filePath, 'utf8');
|
|
103
|
+
const violations = [];
|
|
104
|
+
|
|
105
|
+
for (const { pattern, name } of FORBIDDEN_PATTERNS) {
|
|
106
|
+
// Reset regex
|
|
107
|
+
pattern.lastIndex = 0;
|
|
108
|
+
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
111
|
+
// Check if this match is in an allowed context
|
|
112
|
+
if (!isInAllowedContext(content, match.index, match[0].length)) {
|
|
113
|
+
// Get line number
|
|
114
|
+
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
115
|
+
|
|
116
|
+
violations.push({
|
|
117
|
+
file: filePath,
|
|
118
|
+
line: lineNumber,
|
|
119
|
+
match: match[0],
|
|
120
|
+
pattern: name,
|
|
121
|
+
context: content.substring(
|
|
122
|
+
Math.max(0, match.index - 20),
|
|
123
|
+
Math.min(content.length, match.index + match[0].length + 20)
|
|
124
|
+
).replace(/\n/g, '\\n'),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return violations;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scan all template files in a directory
|
|
135
|
+
*/
|
|
136
|
+
export function scanDirectory(dirPath, options = {}) {
|
|
137
|
+
const {
|
|
138
|
+
extensions = ['.md', '.js', '.ts', '.json', '.yml', '.yaml'],
|
|
139
|
+
exclude = ['node_modules', '.git', 'dist', 'build'],
|
|
140
|
+
recursive = true,
|
|
141
|
+
} = options;
|
|
142
|
+
|
|
143
|
+
const violations = [];
|
|
144
|
+
|
|
145
|
+
function walkDir(dir) {
|
|
146
|
+
const entries = readdirSync(dir);
|
|
147
|
+
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (exclude.includes(entry)) continue;
|
|
150
|
+
|
|
151
|
+
const fullPath = join(dir, entry);
|
|
152
|
+
const stat = statSync(fullPath);
|
|
153
|
+
|
|
154
|
+
if (stat.isDirectory() && recursive) {
|
|
155
|
+
walkDir(fullPath);
|
|
156
|
+
} else if (stat.isFile()) {
|
|
157
|
+
const ext = extname(entry);
|
|
158
|
+
if (extensions.includes(ext)) {
|
|
159
|
+
violations.push(...scanFile(fullPath));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
walkDir(dirPath);
|
|
166
|
+
return violations;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format violations for display
|
|
171
|
+
*/
|
|
172
|
+
export function formatViolations(violations) {
|
|
173
|
+
if (violations.length === 0) {
|
|
174
|
+
return chalk.green('✓ No hardcoded values detected');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let output = chalk.red(`\n✗ Found ${violations.length} potential hardcoded value(s):\n\n`);
|
|
178
|
+
|
|
179
|
+
// Group by file
|
|
180
|
+
const byFile = {};
|
|
181
|
+
for (const v of violations) {
|
|
182
|
+
if (!byFile[v.file]) byFile[v.file] = [];
|
|
183
|
+
byFile[v.file].push(v);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [file, fileViolations] of Object.entries(byFile)) {
|
|
187
|
+
output += chalk.yellow(` ${file}\n`);
|
|
188
|
+
|
|
189
|
+
for (const v of fileViolations) {
|
|
190
|
+
output += chalk.dim(` Line ${v.line}: `) + chalk.red(v.pattern) + '\n';
|
|
191
|
+
output += chalk.dim(` Match: "${v.match}"\n`);
|
|
192
|
+
output += chalk.dim(` Context: ...${v.context}...\n`);
|
|
193
|
+
output += '\n';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
output += chalk.dim('\nSuggestion: Replace hardcoded values with {{placeholder}} syntax.\n');
|
|
198
|
+
output += chalk.dim('Example: --project-name={{deployment.frontend.projectName}}\n');
|
|
199
|
+
|
|
200
|
+
return output;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run validation as CLI tool
|
|
205
|
+
*/
|
|
206
|
+
export async function runValidation(targetPath) {
|
|
207
|
+
console.log(chalk.cyan('Template Validation Scanner'));
|
|
208
|
+
console.log(chalk.dim('Checking for hardcoded values...\n'));
|
|
209
|
+
|
|
210
|
+
const violations = scanDirectory(targetPath);
|
|
211
|
+
|
|
212
|
+
console.log(formatViolations(violations));
|
|
213
|
+
|
|
214
|
+
return violations.length === 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default {
|
|
218
|
+
scanFile,
|
|
219
|
+
scanDirectory,
|
|
220
|
+
formatViolations,
|
|
221
|
+
runValidation,
|
|
222
|
+
FORBIDDEN_PATTERNS,
|
|
223
|
+
};
|