project-compass 1.0.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ export const CONFIG_DIR = path.join(os.homedir(), '.project-compass');
6
+ export const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
7
+ export const PLUGIN_FILE = path.join(CONFIG_DIR, 'plugins.json');
8
+
9
+ export function ensureConfigDir() {
10
+ if (!fs.existsSync(CONFIG_DIR)) {
11
+ fs.mkdirSync(CONFIG_DIR, {recursive: true});
12
+ }
13
+ }
@@ -0,0 +1,627 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import fastGlob from 'fast-glob';
4
+ import {ensureConfigDir, PLUGIN_FILE} from './configPaths.js';
5
+
6
+ const IGNORE_PATTERNS = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/target/**'];
7
+
8
+ function gatherNodeDependencies(pkg) {
9
+ const deps = new Set();
10
+ ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((key) => {
11
+ if (pkg[key]) {
12
+ Object.keys(pkg[key]).forEach((name) => deps.add(name));
13
+ }
14
+ });
15
+ return Array.from(deps);
16
+ }
17
+
18
+ function gatherPythonDependencies(projectPath) {
19
+ const set = new Set();
20
+ const addFromFile = (filePath) => {
21
+ if (!fs.existsSync(filePath)) {
22
+ return;
23
+ }
24
+ const raw = fs.readFileSync(filePath, 'utf-8');
25
+ raw.split(/\r?\n/).forEach((line) => {
26
+ const clean = line.trim().split('#')[0].trim();
27
+ if (clean) {
28
+ const token = clean.split(/[>=<=~!]/)[0].trim().toLowerCase();
29
+ if (token) {
30
+ set.add(token);
31
+ }
32
+ }
33
+ });
34
+ };
35
+ addFromFile(path.join(projectPath, 'requirements.txt'));
36
+ addFromFile(path.join(projectPath, 'Pipfile'));
37
+ const pyproject = path.join(projectPath, 'pyproject.toml');
38
+ if (fs.existsSync(pyproject)) {
39
+ const content = fs.readFileSync(pyproject, 'utf-8').toLowerCase();
40
+ const matches = content.match(/\b[a-z0-9-_/.]+\b/g);
41
+ (matches || []).forEach((match) => {
42
+ if (match) {
43
+ set.add(match);
44
+ }
45
+ });
46
+ }
47
+ return Array.from(set);
48
+ }
49
+
50
+ function dependencyMatches(project, needle) {
51
+ const dependencies = (project.metadata?.dependencies || []).map((dep) => dep.toLowerCase());
52
+ const target = needle.toLowerCase();
53
+ return dependencies.some((value) => value === target || value.startsWith(`${target}@`) || value.includes(`/${target}`));
54
+ }
55
+
56
+ function parseCommandTokens(value) {
57
+ if (Array.isArray(value)) {
58
+ return value;
59
+ }
60
+ if (value && typeof value === 'object') {
61
+ if (Array.isArray(value.command)) {
62
+ return value.command;
63
+ }
64
+ if (typeof value.command === 'string') {
65
+ return value.command.trim().split(/\s+/).filter(Boolean);
66
+ }
67
+ }
68
+ if (typeof value === 'string') {
69
+ return value.trim().split(/\s+/).filter(Boolean);
70
+ }
71
+ return [];
72
+ }
73
+
74
+ class SchemaRegistry {
75
+ constructor() {
76
+ this.cache = null;
77
+ }
78
+
79
+ getSchemas() {
80
+ if (this.cache) {
81
+ return this.cache;
82
+ }
83
+ const schemas = this.buildSchemas();
84
+ this.cache = schemas;
85
+ return schemas;
86
+ }
87
+
88
+ buildSchemas() {
89
+ const schemas = [
90
+ {
91
+ type: 'node',
92
+ label: 'Node.js',
93
+ icon: '🟢',
94
+ priority: 100,
95
+ files: ['package.json'],
96
+ async build(projectPath, manifest) {
97
+ const pkgPath = path.join(projectPath, 'package.json');
98
+ if (!fs.existsSync(pkgPath)) {
99
+ return null;
100
+ }
101
+ const content = await fs.promises.readFile(pkgPath, 'utf-8');
102
+ const pkg = JSON.parse(content);
103
+ const scripts = pkg.scripts || {};
104
+ const commands = {};
105
+ const preferScript = (targetKey, names, label) => {
106
+ for (const name of names) {
107
+ if (Object.prototype.hasOwnProperty.call(scripts, name)) {
108
+ commands[targetKey] = {label, command: ['npm', 'run', name]};
109
+ break;
110
+ }
111
+ }
112
+ };
113
+ preferScript('build', ['build', 'compile', 'dist'], 'Build');
114
+ preferScript('test', ['test', 'check', 'spec'], 'Test');
115
+ preferScript('run', ['start', 'dev', 'serve', 'run'], 'Start');
116
+ if (Object.prototype.hasOwnProperty.call(scripts, 'lint')) {
117
+ commands.lint = {label: 'Lint', command: ['npm', 'run', 'lint']};
118
+ }
119
+
120
+ const metadata = {
121
+ dependencies: gatherNodeDependencies(pkg),
122
+ scripts,
123
+ packageJson: pkg
124
+ };
125
+
126
+ const setupHints = [];
127
+ if (metadata.dependencies.length) {
128
+ setupHints.push('Run npm install to fetch dependencies.');
129
+ if (fs.existsSync(path.join(projectPath, 'yarn.lock'))) {
130
+ setupHints.push('Or run yarn install if you prefer Yarn.');
131
+ }
132
+ }
133
+
134
+ return {
135
+ id: `${projectPath}::node`,
136
+ path: projectPath,
137
+ name: pkg.name || path.basename(projectPath),
138
+ type: 'Node.js',
139
+ icon: '🟢',
140
+ priority: this.priority,
141
+ commands,
142
+ metadata,
143
+ manifest: path.basename(manifest),
144
+ description: pkg.description || '',
145
+ extra: {
146
+ scripts: Object.keys(scripts),
147
+ setupHints
148
+ }
149
+ };
150
+ }
151
+ },
152
+ {
153
+ type: 'python',
154
+ label: 'Python',
155
+ icon: '🐍',
156
+ priority: 95,
157
+ files: ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile'],
158
+ async build(projectPath, manifest) {
159
+ const commands = {};
160
+ if (fs.existsSync(path.join(projectPath, 'pyproject.toml'))) {
161
+ commands.test = {label: 'Pytest', command: ['pytest']};
162
+ } else {
163
+ commands.test = {label: 'Unittest', command: ['python', '-m', 'unittest', 'discover']};
164
+ }
165
+
166
+ const entry = this.findPythonEntry(projectPath);
167
+ if (entry) {
168
+ commands.run = {label: 'Run', command: ['python', entry]};
169
+ }
170
+
171
+ const metadata = {
172
+ dependencies: gatherPythonDependencies(projectPath)
173
+ };
174
+
175
+ const setupHints = [];
176
+ const reqPath = path.join(projectPath, 'requirements.txt');
177
+ if (fs.existsSync(reqPath)) {
178
+ setupHints.push('pip install -r requirements.txt');
179
+ }
180
+ if (fs.existsSync(path.join(projectPath, 'Pipfile'))) {
181
+ setupHints.push('pipenv install --dev or poetry install');
182
+ }
183
+
184
+ return {
185
+ id: `${projectPath}::python`,
186
+ path: projectPath,
187
+ name: path.basename(projectPath),
188
+ type: 'Python',
189
+ icon: '🐍',
190
+ priority: this.priority,
191
+ commands,
192
+ metadata,
193
+ manifest: path.basename(manifest),
194
+ description: '',
195
+ extra: {
196
+ entry,
197
+ setupHints
198
+ }
199
+ };
200
+ },
201
+ findPythonEntry(projectPath) {
202
+ const candidates = ['main.py', 'app.py', 'src/main.py', 'src/app.py'];
203
+ for (const candidate of candidates) {
204
+ const candidatePath = path.join(projectPath, candidate);
205
+ if (fs.existsSync(candidatePath)) {
206
+ return candidate;
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+ },
212
+ {
213
+ type: 'rust',
214
+ label: 'Rust',
215
+ icon: '🦀',
216
+ priority: 90,
217
+ files: ['Cargo.toml'],
218
+ async build(projectPath, manifest) {
219
+ return {
220
+ id: `${projectPath}::rust`,
221
+ path: projectPath,
222
+ name: path.basename(projectPath),
223
+ type: 'Rust',
224
+ icon: '🦀',
225
+ priority: this.priority,
226
+ commands: {
227
+ build: {label: 'Cargo build', command: ['cargo', 'build']},
228
+ test: {label: 'Cargo test', command: ['cargo', 'test']},
229
+ run: {label: 'Cargo run', command: ['cargo', 'run']}
230
+ },
231
+ metadata: {},
232
+ manifest: path.basename(manifest),
233
+ description: '',
234
+ extra: {
235
+ setupHints: ['cargo fetch', 'Run cargo build before releasing']
236
+ }
237
+ };
238
+ }
239
+ },
240
+ {
241
+ type: 'go',
242
+ label: 'Go',
243
+ icon: '🐹',
244
+ priority: 85,
245
+ files: ['go.mod'],
246
+ async build(projectPath, manifest) {
247
+ return {
248
+ id: `${projectPath}::go`,
249
+ path: projectPath,
250
+ name: path.basename(projectPath),
251
+ type: 'Go',
252
+ icon: '🐹',
253
+ priority: this.priority,
254
+ commands: {
255
+ build: {label: 'Go build', command: ['go', 'build', './...']},
256
+ test: {label: 'Go test', command: ['go', 'test', './...']},
257
+ run: {label: 'Go run', command: ['go', 'run', '.']}
258
+ },
259
+ metadata: {},
260
+ manifest: path.basename(manifest),
261
+ description: '',
262
+ extra: {
263
+ setupHints: ['go mod tidy', 'Ensure Go toolchain is installed']
264
+ }
265
+ };
266
+ }
267
+ },
268
+ {
269
+ type: 'java',
270
+ label: 'Java',
271
+ icon: '☕️',
272
+ priority: 80,
273
+ files: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
274
+ async build(projectPath, manifest) {
275
+ const hasMvnw = fs.existsSync(path.join(projectPath, 'mvnw'));
276
+ const hasGradlew = fs.existsSync(path.join(projectPath, 'gradlew'));
277
+ const commands = {};
278
+ if (hasGradlew) {
279
+ commands.build = {label: 'Gradle build', command: ['./gradlew', 'build']};
280
+ commands.test = {label: 'Gradle test', command: ['./gradlew', 'test']};
281
+ } else if (hasMvnw) {
282
+ commands.build = {label: 'Maven package', command: ['./mvnw', 'package']};
283
+ commands.test = {label: 'Maven test', command: ['./mvnw', 'test']};
284
+ } else {
285
+ commands.build = {label: 'Maven package', command: ['mvn', 'package']};
286
+ commands.test = {label: 'Maven test', command: ['mvn', 'test']};
287
+ }
288
+ return {
289
+ id: `${projectPath}::java`,
290
+ path: projectPath,
291
+ name: path.basename(projectPath),
292
+ type: 'Java',
293
+ icon: '☕️',
294
+ priority: this.priority,
295
+ commands,
296
+ metadata: {},
297
+ manifest: path.basename(manifest),
298
+ description: '',
299
+ extra: {
300
+ setupHints: ['Install JDK 17+ and run ./mvnw install / ./gradlew build']
301
+ }
302
+ };
303
+ }
304
+ },
305
+ {
306
+ type: 'scala',
307
+ label: 'Scala',
308
+ icon: '🔵',
309
+ priority: 70,
310
+ files: ['build.sbt'],
311
+ async build(projectPath, manifest) {
312
+ return {
313
+ id: `${projectPath}::scala`,
314
+ path: projectPath,
315
+ name: path.basename(projectPath),
316
+ type: 'Scala',
317
+ icon: '🔵',
318
+ priority: this.priority,
319
+ commands: {
320
+ build: {label: 'sbt compile', command: ['sbt', 'compile']},
321
+ test: {label: 'sbt test', command: ['sbt', 'test']},
322
+ run: {label: 'sbt run', command: ['sbt', 'run']}
323
+ },
324
+ metadata: {},
325
+ manifest: path.basename(manifest),
326
+ description: '',
327
+ extra: {
328
+ setupHints: ['Ensure sbt is installed', 'Run sbt compile before running your app']
329
+ }
330
+ };
331
+ }
332
+ },
333
+ {
334
+ type: 'php',
335
+ label: 'PHP',
336
+ icon: '🐘',
337
+ priority: 65,
338
+ files: ['composer.json'],
339
+ async build(projectPath, manifest) {
340
+ return {
341
+ id: `${projectPath}::php`,
342
+ path: projectPath,
343
+ name: path.basename(projectPath),
344
+ type: 'PHP',
345
+ icon: '🐘',
346
+ priority: this.priority,
347
+ commands: {
348
+ test: {label: 'PHPStorm', command: ['php', '-v']}
349
+ },
350
+ metadata: {},
351
+ manifest: path.basename(manifest),
352
+ description: '',
353
+ extra: {
354
+ setupHints: ['composer install to install dependencies']
355
+ }
356
+ };
357
+ }
358
+ },
359
+ {
360
+ type: 'ruby',
361
+ label: 'Ruby',
362
+ icon: '💎',
363
+ priority: 65,
364
+ files: ['Gemfile'],
365
+ async build(projectPath, manifest) {
366
+ return {
367
+ id: `${projectPath}::ruby`,
368
+ path: projectPath,
369
+ name: path.basename(projectPath),
370
+ type: 'Ruby',
371
+ icon: '💎',
372
+ priority: this.priority,
373
+ commands: {
374
+ run: {label: 'Ruby console', command: ['ruby', 'app.rb']},
375
+ test: {label: 'Ruby test', command: ['bundle', 'exec', 'rspec']}
376
+ },
377
+ metadata: {},
378
+ manifest: path.basename(manifest),
379
+ description: '',
380
+ extra: {
381
+ setupHints: ['bundle install to ensure gems are present']
382
+ }
383
+ };
384
+ }
385
+ },
386
+ {
387
+ type: 'dotnet',
388
+ label: '.NET',
389
+ icon: '🔷',
390
+ priority: 65,
391
+ files: ['*.csproj'],
392
+ async build(projectPath, manifest) {
393
+ return {
394
+ id: `${projectPath}::dotnet`,
395
+ path: projectPath,
396
+ name: path.basename(projectPath),
397
+ type: '.NET',
398
+ icon: '🔷',
399
+ priority: this.priority,
400
+ commands: {
401
+ build: {label: 'dotnet build', command: ['dotnet', 'build']},
402
+ test: {label: 'dotnet test', command: ['dotnet', 'test']},
403
+ run: {label: 'dotnet run', command: ['dotnet', 'run']}
404
+ },
405
+ metadata: {},
406
+ manifest: path.basename(manifest),
407
+ description: '',
408
+ extra: {
409
+ setupHints: ['Install .NET SDK 8+', 'dotnet restore before running']
410
+ }
411
+ };
412
+ }
413
+ },
414
+ {
415
+ type: 'shell',
416
+ label: 'Shell / Makefile',
417
+ icon: '🐚',
418
+ priority: 50,
419
+ files: ['Makefile', 'build.sh'],
420
+ async build(projectPath, manifest) {
421
+ return {
422
+ id: `${projectPath}::shell`,
423
+ path: projectPath,
424
+ name: path.basename(projectPath),
425
+ type: 'Shell / Makefile',
426
+ icon: '🐚',
427
+ priority: this.priority,
428
+ commands: {
429
+ build: {label: 'make build', command: ['make', 'build']},
430
+ test: {label: 'make test', command: ['make', 'test']}
431
+ },
432
+ metadata: {},
433
+ manifest: path.basename(manifest),
434
+ description: '',
435
+ extra: {
436
+ setupHints: ['Run make install if available', 'Ensure shell scripts are executable']
437
+ }
438
+ };
439
+ }
440
+ },
441
+ {
442
+ type: 'generic',
443
+ label: 'Custom project',
444
+ icon: '🧰',
445
+ priority: 10,
446
+ files: ['README.md'],
447
+ async build(projectPath, manifest) {
448
+ return {
449
+ id: `${projectPath}::generic`,
450
+ path: projectPath,
451
+ name: path.basename(projectPath),
452
+ type: 'Custom',
453
+ icon: '🧰',
454
+ priority: this.priority,
455
+ commands: {},
456
+ metadata: {},
457
+ manifest: path.basename(manifest),
458
+ description: 'Detected via README or Makefile layout.',
459
+ extra: {
460
+ setupHints: ['Read the README for custom build instructions']
461
+ }
462
+ };
463
+ }
464
+ }
465
+ ];
466
+ return schemas;
467
+ }
468
+ }
469
+
470
+ const schemaRegistry = new SchemaRegistry();
471
+
472
+ const builtInFrameworks = [];
473
+
474
+
475
+ function loadUserFrameworks() {
476
+ ensureConfigDir();
477
+ if (!fs.existsSync(PLUGIN_FILE)) {
478
+ return [];
479
+ }
480
+ try {
481
+ const payload = JSON.parse(fs.readFileSync(PLUGIN_FILE, 'utf-8') || '{}');
482
+ const plugins = payload.plugins || [];
483
+ return plugins.map((entry) => {
484
+ const normalizedId = entry.id || (entry.name ? entry.name.toLowerCase().replace(/\s+/g, '-') : `plugin-${Math.random().toString(36).slice(2, 8)}`);
485
+ const commands = {};
486
+ Object.entries(entry.commands || {}).forEach(([key, value]) => {
487
+ const tokens = parseCommandTokens(value);
488
+ if (!tokens.length) {
489
+ return;
490
+ }
491
+ commands[key] = {
492
+ label: typeof value === 'object' ? value.label || key : key,
493
+ command: tokens,
494
+ source: 'plugin'
495
+ };
496
+ });
497
+ return {
498
+ id: normalizedId,
499
+ name: entry.name || normalizedId,
500
+ icon: entry.icon || '🧩',
501
+ description: entry.description || '',
502
+ languages: entry.languages || [],
503
+ files: entry.files || [],
504
+ dependencies: entry.dependencies || [],
505
+ scripts: entry.scripts || [],
506
+ priority: Number.isFinite(entry.priority) ? entry.priority : 70,
507
+ commands,
508
+ match: entry.match
509
+ };
510
+ })
511
+ .filter((plugin) => plugin.name && plugin.commands && Object.keys(plugin.commands).length);
512
+ } catch (error) {
513
+ console.error(`Failed to parse plugins.json: ${error.message}`);
514
+ return [];
515
+ }
516
+ }
517
+
518
+ let cachedFrameworkPlugins = null;
519
+
520
+ function getFrameworkPlugins() {
521
+ if (cachedFrameworkPlugins) {
522
+ return cachedFrameworkPlugins;
523
+ }
524
+ cachedFrameworkPlugins = [...builtInFrameworks, ...loadUserFrameworks()];
525
+ return cachedFrameworkPlugins;
526
+ }
527
+
528
+ function matchesPlugin(project, plugin) {
529
+ if (plugin.languages && plugin.languages.length > 0 && !plugin.languages.includes(project.type)) {
530
+ return false;
531
+ }
532
+ if (plugin.files && plugin.files.length > 0) {
533
+ const hit = plugin.files.some((file) => fs.existsSync(path.join(project.path, file)));
534
+ if (!hit) {
535
+ return false;
536
+ }
537
+ }
538
+ if (plugin.dependencies && plugin.dependencies.length > 0) {
539
+ const hit = plugin.dependencies.some((dep) => dependencyMatches(project, dep));
540
+ if (!hit) {
541
+ return false;
542
+ }
543
+ }
544
+ if (plugin.scripts && plugin.scripts.length > 0) {
545
+ const scripts = project.metadata?.scripts || {};
546
+ const hit = plugin.scripts.some((name) => Object.prototype.hasOwnProperty.call(scripts, name));
547
+ if (!hit) {
548
+ return false;
549
+ }
550
+ }
551
+ if (typeof plugin.match === 'function') {
552
+ if (!plugin.match(project)) {
553
+ return false;
554
+ }
555
+ }
556
+ return true;
557
+ }
558
+
559
+ function applyFrameworkPlugins(project) {
560
+ const plugins = getFrameworkPlugins();
561
+ let commands = {...project.commands};
562
+ const frameworks = [];
563
+ let maxPriority = project.priority || 0;
564
+ for (const plugin of plugins) {
565
+ if (!matchesPlugin(project, plugin)) {
566
+ continue;
567
+ }
568
+ frameworks.push({id: plugin.id, name: plugin.name, icon: plugin.icon, description: plugin.description});
569
+ if (plugin.priority && plugin.priority > maxPriority) {
570
+ maxPriority = plugin.priority;
571
+ }
572
+ const pluginCommands = typeof plugin.commands === 'function' ? plugin.commands(project) : plugin.commands;
573
+ if (pluginCommands) {
574
+ Object.entries(pluginCommands).forEach(([key, command]) => {
575
+ if (!Array.isArray(command.command) || command.command.length === 0) {
576
+ return;
577
+ }
578
+ commands = {
579
+ ...commands,
580
+ [key]: {
581
+ ...command,
582
+ source: command.source || 'framework'
583
+ }
584
+ };
585
+ });
586
+ }
587
+ }
588
+ return {...project, commands, frameworks, priority: maxPriority};
589
+ }
590
+
591
+ async function discoverProjects(root) {
592
+ const projectMap = new Map();
593
+ const schemas = schemaRegistry.getSchemas();
594
+ for (const schema of schemas) {
595
+ const patterns = schema.files.map((file) => `**/${file}`);
596
+ const matches = await fastGlob(patterns, {
597
+ cwd: root,
598
+ ignore: IGNORE_PATTERNS,
599
+ onlyFiles: true,
600
+ deep: 5
601
+ });
602
+
603
+ for (const match of matches) {
604
+ const projectDir = path.resolve(root, path.dirname(match));
605
+ const existing = projectMap.get(projectDir);
606
+ if (existing && existing.priority >= schema.priority) {
607
+ continue;
608
+ }
609
+ const entry = await schema.build(projectDir, match);
610
+ if (!entry) {
611
+ continue;
612
+ }
613
+ const withFrameworks = applyFrameworkPlugins(entry);
614
+ projectMap.set(projectDir, withFrameworks);
615
+ }
616
+ }
617
+ return Array.from(projectMap.values()).sort((a, b) => b.priority - a.priority);
618
+ }
619
+
620
+ const SCHEMA_GUIDE = schemaRegistry.getSchemas().map((schema) => ({
621
+ type: schema.type,
622
+ label: schema.label || schema.type,
623
+ icon: schema.icon || '⚙',
624
+ files: schema.files
625
+ }));
626
+
627
+ export {discoverProjects, SCHEMA_GUIDE};