pkg-scaffold 3.2.0 → 3.3.2
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 +211 -21
- package/NOTICE +13 -0
- package/bin/cli.js +52 -59
- package/package.json +8 -6
- package/src/EngineContext.js +24 -20
- package/src/api/HeadlessAPI.js +376 -0
- package/src/api/PluginSDK.js +299 -0
- package/src/ast/ASTAnalyzer.js +3 -2
- package/src/ast/OxcAnalyzer.js +17 -7
- package/src/index.js +119 -148
- package/src/performance/SecretDetector.js +378 -0
- package/src/plugins/KnipAdapter.js +106 -0
- package/src/plugins/PluginRegistry.js +22 -4
- package/src/plugins/ecosystems/BackendServices.js +59 -0
- package/src/plugins/ecosystems/ModernFrameworks.js +157 -0
- package/src/resolution/CircularDetector.js +122 -0
- package/src/resolution/PathMapper.js +12 -2
- package/src/resolution/WorkSpaceGraph.js +10 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Secret Detection Engine for pkg-scaffold v3.3.2 (AST + REGEX Fallback)
|
|
4
|
+
*
|
|
5
|
+
* Uses OXC parser for fast, accurate detection of hardcoded secrets.
|
|
6
|
+
* Falls back to REGEX patterns if AST parsing fails.
|
|
7
|
+
* ============================================================================
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
export class SecretDetector {
|
|
14
|
+
constructor(context) {
|
|
15
|
+
this.context = context;
|
|
16
|
+
this.secrets = [];
|
|
17
|
+
|
|
18
|
+
// REGEX patterns for detecting secrets (fallback)
|
|
19
|
+
this.regexPatterns = {
|
|
20
|
+
apiKey: /['\"]?api[_-]?key['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_]{20,})['\"]?/gi,
|
|
21
|
+
bearerToken: /bearer\s+([a-zA-Z0-9\-_\.]{20,})/gi,
|
|
22
|
+
jwtToken: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g,
|
|
23
|
+
awsAccessKey: /AKIA[0-9A-Z]{16}/g,
|
|
24
|
+
awsSecretKey: /aws_secret_access_key\s*[:=]\s*['\"]([a-zA-Z0-9\/+]{40})['\"]?/gi,
|
|
25
|
+
databaseUrl: /(postgres|mysql|mongodb|redis):\/\/([a-zA-Z0-9_-]+):([a-zA-Z0-9_\-@!$%^&*()+=]+)@/gi,
|
|
26
|
+
dbPassword: /password\s*[:=]\s*['\"]([^'\"]{6,})['\"]?/gi,
|
|
27
|
+
githubToken: /ghp_[a-zA-Z0-9]{36}/g,
|
|
28
|
+
gitlabToken: /glpat-[a-zA-Z0-9_-]{20,}/g,
|
|
29
|
+
privateKey: /-----BEGIN (RSA|DSA|EC|PGP|OPENSSH) PRIVATE KEY-----/g,
|
|
30
|
+
slackWebhook: /https:\/\/hooks\.slack\.com\/services\/[a-zA-Z0-9\/]+/g,
|
|
31
|
+
discordWebhook: /https:\/\/discord\.com\/api\/webhooks\/[a-zA-Z0-9\/]+/g,
|
|
32
|
+
secretKey: /['\"]?secret[_-]?key['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_]{20,})['\"]?/gi,
|
|
33
|
+
accessToken: /['\"]?access[_-]?token['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_\.]{20,})['\"]?/gi,
|
|
34
|
+
stripeKey: /sk_live_[a-zA-Z0-9]{24,}/g,
|
|
35
|
+
googleApiKey: /AIza[0-9A-Za-z\-_]{35}/g,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Secret pattern metadata
|
|
39
|
+
this.secretMetadata = {
|
|
40
|
+
apiKey: { severity: 'HIGH', keywords: ['api_key', 'apikey'] },
|
|
41
|
+
bearerToken: { severity: 'CRITICAL', keywords: ['bearer', 'token'] },
|
|
42
|
+
jwtToken: { severity: 'CRITICAL', keywords: ['jwt', 'token'] },
|
|
43
|
+
awsAccessKey: { severity: 'CRITICAL', keywords: ['aws', 'access'] },
|
|
44
|
+
awsSecretKey: { severity: 'CRITICAL', keywords: ['aws', 'secret'] },
|
|
45
|
+
databaseUrl: { severity: 'CRITICAL', keywords: ['database', 'db', 'postgres', 'mysql'] },
|
|
46
|
+
dbPassword: { severity: 'CRITICAL', keywords: ['password', 'passwd'] },
|
|
47
|
+
githubToken: { severity: 'CRITICAL', keywords: ['github', 'token'] },
|
|
48
|
+
gitlabToken: { severity: 'CRITICAL', keywords: ['gitlab', 'token'] },
|
|
49
|
+
privateKey: { severity: 'CRITICAL', keywords: ['private', 'key', 'pem'] },
|
|
50
|
+
slackWebhook: { severity: 'HIGH', keywords: ['slack', 'webhook'] },
|
|
51
|
+
discordWebhook: { severity: 'HIGH', keywords: ['discord', 'webhook'] },
|
|
52
|
+
secretKey: { severity: 'HIGH', keywords: ['secret', 'key'] },
|
|
53
|
+
accessToken: { severity: 'CRITICAL', keywords: ['access', 'token'] },
|
|
54
|
+
stripeKey: { severity: 'CRITICAL', keywords: ['stripe', 'key'] },
|
|
55
|
+
googleApiKey: { severity: 'HIGH', keywords: ['google', 'api'] },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scans a file for hardcoded secrets using REGEX
|
|
61
|
+
*/
|
|
62
|
+
scanFileForSecretsRegex(filePath, content) {
|
|
63
|
+
const detectedSecrets = [];
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
|
|
66
|
+
lines.forEach((line, lineIndex) => {
|
|
67
|
+
// Skip comments and empty lines
|
|
68
|
+
if (line.trim().startsWith('//') || line.trim().startsWith('#') || line.trim().startsWith('*') || !line.trim()) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check each pattern
|
|
73
|
+
for (const [patternName, pattern] of Object.entries(this.regexPatterns)) {
|
|
74
|
+
const matches = [...line.matchAll(pattern)];
|
|
75
|
+
|
|
76
|
+
for (const match of matches) {
|
|
77
|
+
const metadata = this.secretMetadata[patternName] || { severity: 'MEDIUM', keywords: [] };
|
|
78
|
+
|
|
79
|
+
detectedSecrets.push({
|
|
80
|
+
file: filePath,
|
|
81
|
+
line: lineIndex + 1,
|
|
82
|
+
column: match.index + 1,
|
|
83
|
+
type: patternName,
|
|
84
|
+
secret: match[0].substring(0, 20) + '***',
|
|
85
|
+
severity: metadata.severity,
|
|
86
|
+
variable: this.extractVariableName(line, match.index)
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return detectedSecrets;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extracts variable name from line
|
|
97
|
+
*/
|
|
98
|
+
extractVariableName(line, matchIndex) {
|
|
99
|
+
// Look backwards for variable assignment
|
|
100
|
+
const beforeMatch = line.substring(0, matchIndex);
|
|
101
|
+
const varMatch = beforeMatch.match(/(?:const|let|var|=)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[:=]?/);
|
|
102
|
+
if (varMatch) return varMatch[1];
|
|
103
|
+
|
|
104
|
+
// Look for object property
|
|
105
|
+
const propMatch = beforeMatch.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[:=]\s*$/);
|
|
106
|
+
if (propMatch) return propMatch[1];
|
|
107
|
+
|
|
108
|
+
return 'unknown';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scans entire codebase for secrets (reads from disk)
|
|
113
|
+
*/
|
|
114
|
+
async scanCodebaseForSecrets(context) {
|
|
115
|
+
this.secrets = [];
|
|
116
|
+
this.cwd = context?.cwd || this.context.cwd;
|
|
117
|
+
const cwd = this.cwd;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Recursively scan all source files
|
|
121
|
+
await this.scanDirectory(cwd);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error('Error scanning codebase for secrets:', e.message);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return this.secrets;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Recursively scans directory for source files
|
|
131
|
+
*/
|
|
132
|
+
async scanDirectory(dirPath) {
|
|
133
|
+
try {
|
|
134
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
135
|
+
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
138
|
+
|
|
139
|
+
// Skip node_modules, dist, build, .git
|
|
140
|
+
if (['node_modules', 'dist', 'build', '.git', '.scaffold-cache', '.next', 'out'].includes(entry.name)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (entry.isDirectory()) {
|
|
145
|
+
await this.scanDirectory(fullPath);
|
|
146
|
+
} else if (entry.isFile()) {
|
|
147
|
+
const ext = path.extname(entry.name);
|
|
148
|
+
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.env', '.env.local'].includes(ext)) {
|
|
149
|
+
await this.scanFile(fullPath);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// Silently skip directories that can't be read
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Scans a single file for secrets
|
|
160
|
+
*/
|
|
161
|
+
async scanFile(filePath) {
|
|
162
|
+
try {
|
|
163
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
164
|
+
|
|
165
|
+
// Try AST parsing first (if available)
|
|
166
|
+
let detectedSecrets = [];
|
|
167
|
+
try {
|
|
168
|
+
detectedSecrets = this.scanFileForSecretsAST(filePath, content);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// Fall back to REGEX if AST fails
|
|
171
|
+
detectedSecrets = this.scanFileForSecretsRegex(filePath, content);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// If AST returned nothing, try REGEX as additional pass
|
|
175
|
+
if (detectedSecrets.length === 0) {
|
|
176
|
+
detectedSecrets = this.scanFileForSecretsRegex(filePath, content);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.secrets.push(...detectedSecrets);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Skip files that can't be read
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scans file using AST (with OXC if available)
|
|
187
|
+
*/
|
|
188
|
+
scanFileForSecretsAST(filePath, content) {
|
|
189
|
+
const detectedSecrets = [];
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
// Try to use OXC parser if available
|
|
193
|
+
let ast;
|
|
194
|
+
try {
|
|
195
|
+
const { parseSync } = require('oxc-parser');
|
|
196
|
+
ast = parseSync(content, {
|
|
197
|
+
sourceType: 'module',
|
|
198
|
+
ecmaVersion: 'latest'
|
|
199
|
+
});
|
|
200
|
+
} catch (e) {
|
|
201
|
+
// OXC not available, fall back to REGEX
|
|
202
|
+
return this.scanFileForSecretsRegex(filePath, content);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Walk AST and find variable assignments with secret values
|
|
206
|
+
this.walkAST(ast, (node) => {
|
|
207
|
+
// Variable declarations: const API_KEY = "sk_..."
|
|
208
|
+
if (node.type === 'VariableDeclarator' && node.init) {
|
|
209
|
+
const varName = node.id?.name || '';
|
|
210
|
+
const secret = this.extractSecretValue(node.init);
|
|
211
|
+
|
|
212
|
+
if (secret) {
|
|
213
|
+
const detectedType = this.classifySecret(varName, secret.value);
|
|
214
|
+
if (detectedType) {
|
|
215
|
+
detectedSecrets.push({
|
|
216
|
+
file: filePath,
|
|
217
|
+
line: node.loc?.start?.line || 0,
|
|
218
|
+
column: node.loc?.start?.column || 0,
|
|
219
|
+
type: detectedType.type,
|
|
220
|
+
severity: detectedType.severity,
|
|
221
|
+
variable: varName,
|
|
222
|
+
secret: secret.value.substring(0, 20) + '***'
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Object properties: { password: "...", apiKey: "..." }
|
|
229
|
+
if (node.type === 'Property' && node.value) {
|
|
230
|
+
const propName = node.key?.name || node.key?.value || '';
|
|
231
|
+
const secret = this.extractSecretValue(node.value);
|
|
232
|
+
|
|
233
|
+
if (secret) {
|
|
234
|
+
const detectedType = this.classifySecret(propName, secret.value);
|
|
235
|
+
if (detectedType) {
|
|
236
|
+
detectedSecrets.push({
|
|
237
|
+
file: filePath,
|
|
238
|
+
line: node.loc?.start?.line || 0,
|
|
239
|
+
column: node.loc?.start?.column || 0,
|
|
240
|
+
type: detectedType.type,
|
|
241
|
+
severity: detectedType.severity,
|
|
242
|
+
variable: propName,
|
|
243
|
+
secret: secret.value.substring(0, 20) + '***'
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Assignment expressions: API_KEY = "..."
|
|
250
|
+
if (node.type === 'AssignmentExpression' && node.right) {
|
|
251
|
+
const varName = node.left?.name || '';
|
|
252
|
+
const secret = this.extractSecretValue(node.right);
|
|
253
|
+
|
|
254
|
+
if (secret) {
|
|
255
|
+
const detectedType = this.classifySecret(varName, secret.value);
|
|
256
|
+
if (detectedType) {
|
|
257
|
+
detectedSecrets.push({
|
|
258
|
+
file: filePath,
|
|
259
|
+
line: node.loc?.start?.line || 0,
|
|
260
|
+
column: node.loc?.start?.column || 0,
|
|
261
|
+
type: detectedType.type,
|
|
262
|
+
severity: detectedType.severity,
|
|
263
|
+
variable: varName,
|
|
264
|
+
secret: secret.value.substring(0, 20) + '***'
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
} catch (e) {
|
|
271
|
+
// Return empty on error, will fall back to REGEX
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return detectedSecrets;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Extracts string value from AST node
|
|
280
|
+
*/
|
|
281
|
+
extractSecretValue(node) {
|
|
282
|
+
if (node.type === 'StringLiteral' || node.type === 'Literal') {
|
|
283
|
+
return { value: node.value || '' };
|
|
284
|
+
}
|
|
285
|
+
if (node.type === 'TemplateLiteral') {
|
|
286
|
+
return { value: node.quasis?.[0]?.value?.raw || '' };
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Classifies a secret based on variable name and value
|
|
293
|
+
*/
|
|
294
|
+
classifySecret(variableName, value) {
|
|
295
|
+
const lowerName = variableName.toLowerCase();
|
|
296
|
+
|
|
297
|
+
for (const [type, metadata] of Object.entries(this.secretMetadata)) {
|
|
298
|
+
const pattern = this.regexPatterns[type];
|
|
299
|
+
if (!pattern) continue;
|
|
300
|
+
|
|
301
|
+
// Check if variable name matches keywords
|
|
302
|
+
const nameMatches = metadata.keywords.some(kw => lowerName.includes(kw));
|
|
303
|
+
|
|
304
|
+
// Check if value matches pattern
|
|
305
|
+
const valueMatches = pattern.test(value);
|
|
306
|
+
|
|
307
|
+
if ((nameMatches && value.length > 8) || valueMatches) {
|
|
308
|
+
return { type, severity: metadata.severity };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Simple AST walker
|
|
317
|
+
*/
|
|
318
|
+
walkAST(node, callback) {
|
|
319
|
+
if (!node || typeof node !== 'object') return;
|
|
320
|
+
|
|
321
|
+
callback(node);
|
|
322
|
+
|
|
323
|
+
for (const key in node) {
|
|
324
|
+
if (key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue;
|
|
325
|
+
|
|
326
|
+
const child = node[key];
|
|
327
|
+
if (Array.isArray(child)) {
|
|
328
|
+
child.forEach(item => this.walkAST(item, callback));
|
|
329
|
+
} else if (typeof child === 'object') {
|
|
330
|
+
this.walkAST(child, callback);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Formats secrets for reporting
|
|
337
|
+
*/
|
|
338
|
+
formatSecretsForReport() {
|
|
339
|
+
if (this.secrets.length === 0) return [];
|
|
340
|
+
|
|
341
|
+
return this.secrets.map(secret => ({
|
|
342
|
+
file: secret.file,
|
|
343
|
+
line: secret.line,
|
|
344
|
+
column: secret.column,
|
|
345
|
+
type: secret.type,
|
|
346
|
+
severity: secret.severity,
|
|
347
|
+
variable: secret.variable,
|
|
348
|
+
redacted: secret.secret
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Gets secrets by severity level
|
|
354
|
+
*/
|
|
355
|
+
getSecretsBySeverity(severity) {
|
|
356
|
+
return this.secrets.filter(s => s.severity === severity);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Gets critical secrets only
|
|
361
|
+
*/
|
|
362
|
+
getCriticalSecrets() {
|
|
363
|
+
return this.getSecretsBySeverity('CRITICAL');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Gets count of secrets by type
|
|
368
|
+
*/
|
|
369
|
+
getSecretStats() {
|
|
370
|
+
const stats = {};
|
|
371
|
+
this.secrets.forEach(secret => {
|
|
372
|
+
stats[secret.type] = (stats[secret.type] || 0) + 1;
|
|
373
|
+
});
|
|
374
|
+
return stats;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export default SecretDetector;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Knip Plugin Adapter for pkg-scaffold v4.0.0
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* This adapter allows pkg-scaffold to use existing Knip plugins without
|
|
6
|
+
* requiring knip as a dependency. It implements the Knip plugin interface
|
|
7
|
+
* internally to ensure full compatibility.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
|
|
13
|
+
export class KnipAdapter {
|
|
14
|
+
constructor(context) {
|
|
15
|
+
this.context = context;
|
|
16
|
+
this.knipPlugins = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Discovers and loads Knip plugins from the project's node_modules
|
|
21
|
+
* or a specified directory.
|
|
22
|
+
*/
|
|
23
|
+
async discoverPlugins(projectRoot) {
|
|
24
|
+
// Knip plugins are typically named 'knip-plugin-*' or are part of knip's core
|
|
25
|
+
// We look for common Knip plugin patterns in node_modules
|
|
26
|
+
const nodeModulesPath = path.join(projectRoot, 'node_modules');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const dirs = await fs.readdir(nodeModulesPath);
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
if (dir.startsWith('knip-plugin-') || dir === '@knip/plugin') {
|
|
32
|
+
await this.loadPlugin(path.join(nodeModulesPath, dir));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// node_modules not found or unreadable
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loads a specific Knip plugin and wraps it for pkg-scaffold
|
|
42
|
+
*/
|
|
43
|
+
async loadPlugin(pluginPath) {
|
|
44
|
+
try {
|
|
45
|
+
const pluginModule = await import(pluginPath);
|
|
46
|
+
const plugin = pluginModule.default || pluginModule;
|
|
47
|
+
|
|
48
|
+
if (plugin.name && (plugin.config || plugin.entry)) {
|
|
49
|
+
this.knipPlugins.set(plugin.name, this.wrapKnipPlugin(plugin));
|
|
50
|
+
if (this.context.verbose) {
|
|
51
|
+
console.log(`[KnipAdapter] Successfully integrated Knip plugin: ${plugin.name}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// Failed to load plugin
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wraps a Knip plugin to match the pkg-scaffold BasePlugin interface
|
|
61
|
+
*/
|
|
62
|
+
wrapKnipPlugin(knipPlugin) {
|
|
63
|
+
return {
|
|
64
|
+
name: `knip-${knipPlugin.name}`,
|
|
65
|
+
isKnipWrapped: true,
|
|
66
|
+
|
|
67
|
+
getConfigFiles: () => {
|
|
68
|
+
if (Array.isArray(knipPlugin.config)) return knipPlugin.config;
|
|
69
|
+
if (typeof knipPlugin.config === 'string') return [knipPlugin.config];
|
|
70
|
+
return [];
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
getRoutePatterns: () => {
|
|
74
|
+
if (Array.isArray(knipPlugin.entry)) return knipPlugin.entry.map(e => new RegExp(e.replace('*', '.*')));
|
|
75
|
+
if (typeof knipPlugin.entry === 'string') return [new RegExp(knipPlugin.entry.replace('*', '.*'))];
|
|
76
|
+
return [];
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
isActive: async (baseDir) => {
|
|
80
|
+
const configFiles = Array.isArray(knipPlugin.config) ? knipPlugin.config : [knipPlugin.config];
|
|
81
|
+
for (const file of configFiles) {
|
|
82
|
+
if (!file) continue;
|
|
83
|
+
try {
|
|
84
|
+
await fs.access(path.join(baseDir, file));
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Map other Knip plugin properties to pkg-scaffold
|
|
94
|
+
get: (key) => knipPlugin[key]
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns all integrated Knip plugins
|
|
100
|
+
*/
|
|
101
|
+
getPlugins() {
|
|
102
|
+
return Array.from(this.knipPlugins.values());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default KnipAdapter;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
|
+
import { KnipAdapter } from './KnipAdapter.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Advanced Plugin Registry supporting Builtin, Custom, and Knip-style plugins.
|
|
7
|
-
* Version
|
|
8
|
+
* Version 4.0.0: Enhanced with Modern Frameworks, Backend Services, and Standalone Knip Integration.
|
|
8
9
|
*/
|
|
9
10
|
export class PluginRegistry {
|
|
10
11
|
constructor(context) {
|
|
11
12
|
this.context = context;
|
|
12
13
|
this.plugins = new Map();
|
|
13
14
|
this.config = null;
|
|
15
|
+
this.knipAdapter = new KnipAdapter(context);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async init(projectRoot) {
|
|
@@ -35,14 +37,21 @@ export class PluginRegistry {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
if (this.config.supportKnipPlugins) {
|
|
38
|
-
await this.initKnipAdapter();
|
|
40
|
+
await this.initKnipAdapter(projectRoot);
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
async loadBuiltinPlugins() {
|
|
45
|
+
// Core Ecosystems
|
|
43
46
|
const { NextJsPlugin } = await import('./ecosystems/NextJsPlugin.js');
|
|
44
47
|
const { NuxtPlugin, RemixPlugin, SvelteKitPlugin, AstroPlugin } = await import('./ecosystems/GenericPlugins.js');
|
|
45
48
|
const { TypeScriptPlugin } = await import('./ecosystems/TypeScriptPlugin.js');
|
|
49
|
+
|
|
50
|
+
// Modern Frameworks (New in v4.0)
|
|
51
|
+
const { ReactPlugin, VuePlugin, SveltePlugin, AngularPlugin } = await import('./ecosystems/ModernFrameworks.js');
|
|
52
|
+
|
|
53
|
+
// Backend Services (New in v4.0)
|
|
54
|
+
const { GraphQLPlugin, DatabasePlugin } = await import('./ecosystems/BackendServices.js');
|
|
46
55
|
|
|
47
56
|
const builtins = [
|
|
48
57
|
new NextJsPlugin(this.context),
|
|
@@ -50,7 +59,13 @@ export class PluginRegistry {
|
|
|
50
59
|
new RemixPlugin(this.context),
|
|
51
60
|
new SvelteKitPlugin(this.context),
|
|
52
61
|
new AstroPlugin(this.context),
|
|
53
|
-
new TypeScriptPlugin(this.context)
|
|
62
|
+
new TypeScriptPlugin(this.context),
|
|
63
|
+
new ReactPlugin(this.context),
|
|
64
|
+
new VuePlugin(this.context),
|
|
65
|
+
new SveltePlugin(this.context),
|
|
66
|
+
new AngularPlugin(this.context),
|
|
67
|
+
new GraphQLPlugin(this.context),
|
|
68
|
+
new DatabasePlugin(this.context)
|
|
54
69
|
];
|
|
55
70
|
|
|
56
71
|
builtins.forEach(p => {
|
|
@@ -82,8 +97,11 @@ export class PluginRegistry {
|
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
async initKnipAdapter() {
|
|
100
|
+
async initKnipAdapter(projectRoot) {
|
|
86
101
|
this.context.knipCompatible = true;
|
|
102
|
+
await this.knipAdapter.discoverPlugins(projectRoot);
|
|
103
|
+
const knipPlugins = this.knipAdapter.getPlugins();
|
|
104
|
+
knipPlugins.forEach(p => this.register(p));
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
register(plugin) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Backend Services Plugins for pkg-scaffold v4.0.0
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* Built-in support for GraphQL, REST APIs, and Databases.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BasePlugin } from '../BasePlugin.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GraphQL Ecosystem Plugin
|
|
12
|
+
*/
|
|
13
|
+
export class GraphQLPlugin extends BasePlugin {
|
|
14
|
+
get name() {
|
|
15
|
+
return 'graphql';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getConfigFiles() {
|
|
19
|
+
return ['package.json', 'graphql.config.js', '.graphqlconfig'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async isActive(baseDir) {
|
|
23
|
+
try {
|
|
24
|
+
const pkgJson = JSON.parse(await require('fs').promises.readFile(require('path').join(baseDir, 'package.json'), 'utf8'));
|
|
25
|
+
return !!(pkgJson.dependencies?.graphql || pkgJson.devDependencies?.graphql || pkgJson.dependencies?.['@apollo/client']);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async analyze(node, filePath) {
|
|
32
|
+
// Detect GraphQL tagged templates
|
|
33
|
+
const gqlPattern = /gql\s*`([\s\S]*?)`/g;
|
|
34
|
+
const matches = node.rawCode?.match(gqlPattern) || [];
|
|
35
|
+
if (matches.length > 0) {
|
|
36
|
+
node.graphqlQueries = matches;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Database Ecosystem Plugin (Prisma, Drizzle, TypeORM)
|
|
43
|
+
*/
|
|
44
|
+
export class DatabasePlugin extends BasePlugin {
|
|
45
|
+
get name() {
|
|
46
|
+
return 'database';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getConfigFiles() {
|
|
50
|
+
return ['package.json', 'prisma/schema.prisma', 'drizzle.config.ts', 'ormconfig.json'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async analyze(node, filePath) {
|
|
54
|
+
// Detect DB usage
|
|
55
|
+
if (node.explicitImports.has('@prisma/client') || node.explicitImports.has('drizzle-orm')) {
|
|
56
|
+
node.usesDatabase = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|