@gettrace/cli 1.2.6 → 1.2.8
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/dist/index.js +876 -866
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ class TerminalBuffer {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
getLast(n = 100) {
|
|
37
|
-
return this.lines.slice(-n)
|
|
37
|
+
return this.lines.slice(-n);
|
|
38
38
|
}
|
|
39
39
|
clear() {
|
|
40
40
|
this.lines = [];
|
|
@@ -86,7 +86,7 @@ function detectDevCommand(projectPath, override) {
|
|
|
86
86
|
};
|
|
87
87
|
return { command: runCmd[pm] ?? `npm run ${script}`, pm, script };
|
|
88
88
|
}
|
|
89
|
-
const VERSION = '1.2.
|
|
89
|
+
const VERSION = '1.2.8';
|
|
90
90
|
/** Levenshtein distance for fuzzy block matching */
|
|
91
91
|
function levenshtein(a, b) {
|
|
92
92
|
if (a === '' || b === '')
|
|
@@ -540,9 +540,11 @@ program
|
|
|
540
540
|
clientCount++;
|
|
541
541
|
connectedClients.add(ws);
|
|
542
542
|
console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|
|
543
|
-
//
|
|
543
|
+
// Wire up the full IDE bridge protocol (file read/write, detect project, etc.)
|
|
544
|
+
attachMessageHandler(ws, projectPath);
|
|
545
|
+
// Send the last 100 lines immediately so the extension has terminal context
|
|
544
546
|
const catchup = globalTerminalBuffer.getLast(100);
|
|
545
|
-
if (catchup) {
|
|
547
|
+
if (catchup.length > 0) {
|
|
546
548
|
ws.send(JSON.stringify({ type: 'TERMINAL_CATCHUP', lines: catchup }));
|
|
547
549
|
}
|
|
548
550
|
ws.on('close', () => {
|
|
@@ -577,965 +579,973 @@ program
|
|
|
577
579
|
process.on('SIGTERM', shutdown);
|
|
578
580
|
});
|
|
579
581
|
// ============================================
|
|
580
|
-
//
|
|
582
|
+
// SHARED MESSAGE HANDLER
|
|
583
|
+
// Called by both `trace dev` and `trace connect`.
|
|
584
|
+
// Attaches the full IDE bridge protocol to a WS.
|
|
581
585
|
// ============================================
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
.option('-p, --port <port>', 'WebSocket port', '8765')
|
|
587
|
-
.action(async (options) => {
|
|
588
|
-
const port = parseInt(options.port);
|
|
589
|
-
const projectPath = process.cwd();
|
|
590
|
-
console.log();
|
|
591
|
-
console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
|
|
592
|
-
console.log(chalk.gray('─'.repeat(55)));
|
|
593
|
-
console.log();
|
|
594
|
-
console.log(`📁 Project: ${chalk.green(projectPath)}`);
|
|
595
|
-
console.log(`🌐 Port: ${chalk.cyan(port)}`);
|
|
596
|
-
console.log();
|
|
597
|
-
// Read package.json for project info
|
|
586
|
+
function attachMessageHandler(ws, projectPath) {
|
|
587
|
+
// Per-connection undo stack
|
|
588
|
+
const undoStack = [];
|
|
589
|
+
// Build project info for GET_PROJECT_INFO responses
|
|
598
590
|
let projectInfo = { projectPath };
|
|
599
591
|
try {
|
|
600
592
|
const pkgPath = path.join(projectPath, 'package.json');
|
|
601
593
|
if (fs.existsSync(pkgPath)) {
|
|
602
594
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
603
|
-
projectInfo = {
|
|
604
|
-
...projectInfo,
|
|
605
|
-
name: pkg.name,
|
|
606
|
-
version: pkg.version,
|
|
607
|
-
description: pkg.description
|
|
608
|
-
};
|
|
609
|
-
console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
|
|
595
|
+
projectInfo = { ...projectInfo, name: pkg.name, version: pkg.version, description: pkg.description };
|
|
610
596
|
}
|
|
611
597
|
}
|
|
612
|
-
catch (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
case 'READ_FILE':
|
|
640
|
-
try {
|
|
641
|
-
const filePath = path.resolve(projectPath, message.filePath);
|
|
642
|
-
if (!filePath.startsWith(projectPath)) {
|
|
643
|
-
response.error = 'Access denied';
|
|
644
|
-
}
|
|
645
|
-
else if (fs.existsSync(filePath)) {
|
|
646
|
-
const stat = fs.statSync(filePath);
|
|
647
|
-
// Binary file detection (check first 4KB for null bytes)
|
|
648
|
-
if (stat.size > 0) {
|
|
649
|
-
const fd = fs.openSync(filePath, 'r');
|
|
650
|
-
const sample = Buffer.alloc(Math.min(4096, stat.size));
|
|
651
|
-
fs.readSync(fd, sample, 0, sample.length, 0);
|
|
652
|
-
fs.closeSync(fd);
|
|
653
|
-
if (sample.includes(0)) {
|
|
654
|
-
response.error = `Cannot read binary file: ${message.filePath}`;
|
|
655
|
-
break;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
659
|
-
const allLines = rawContent.split('\n');
|
|
660
|
-
const offset = message.offset || 1;
|
|
661
|
-
const limit = message.limit || 2000;
|
|
662
|
-
const startIdx = Math.max(0, offset - 1);
|
|
663
|
-
const endIdx = Math.min(allLines.length, startIdx + limit);
|
|
664
|
-
const sliced = allLines.slice(startIdx, endIdx);
|
|
665
|
-
// Line-number prefixed output (like OpenCode)
|
|
666
|
-
const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
|
|
667
|
-
const truncated = endIdx < allLines.length;
|
|
668
|
-
response.data = {
|
|
669
|
-
content: numbered,
|
|
670
|
-
rawContent: rawContent,
|
|
671
|
-
exists: true,
|
|
672
|
-
path: filePath,
|
|
673
|
-
totalLines: allLines.length,
|
|
674
|
-
showing: { from: offset, to: endIdx, truncated },
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
else {
|
|
678
|
-
// Suggest similar files if not found
|
|
679
|
-
const dir = path.dirname(filePath);
|
|
680
|
-
const base = path.basename(filePath);
|
|
681
|
-
let suggestions = [];
|
|
682
|
-
try {
|
|
683
|
-
suggestions = fs.readdirSync(dir)
|
|
684
|
-
.filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
|
|
685
|
-
.slice(0, 3)
|
|
686
|
-
.map(e => path.relative(projectPath, path.join(dir, e)));
|
|
598
|
+
catch (_) { }
|
|
599
|
+
ws.on('message', async (data) => {
|
|
600
|
+
try {
|
|
601
|
+
const message = JSON.parse(data.toString());
|
|
602
|
+
const { id, type } = message;
|
|
603
|
+
let response = { id };
|
|
604
|
+
switch (type) {
|
|
605
|
+
case 'GET_PROJECT_INFO':
|
|
606
|
+
response.data = projectInfo;
|
|
607
|
+
break;
|
|
608
|
+
case 'READ_FILE':
|
|
609
|
+
try {
|
|
610
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
611
|
+
if (!filePath.startsWith(projectPath)) {
|
|
612
|
+
response.error = 'Access denied';
|
|
613
|
+
}
|
|
614
|
+
else if (fs.existsSync(filePath)) {
|
|
615
|
+
const stat = fs.statSync(filePath);
|
|
616
|
+
// Binary file detection (check first 4KB for null bytes)
|
|
617
|
+
if (stat.size > 0) {
|
|
618
|
+
const fd = fs.openSync(filePath, 'r');
|
|
619
|
+
const sample = Buffer.alloc(Math.min(4096, stat.size));
|
|
620
|
+
fs.readSync(fd, sample, 0, sample.length, 0);
|
|
621
|
+
fs.closeSync(fd);
|
|
622
|
+
if (sample.includes(0)) {
|
|
623
|
+
response.error = `Cannot read binary file: ${message.filePath}`;
|
|
624
|
+
break;
|
|
687
625
|
}
|
|
688
|
-
catch (_) { }
|
|
689
|
-
response.data = { exists: false, suggestions };
|
|
690
626
|
}
|
|
627
|
+
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
628
|
+
const allLines = rawContent.split('\n');
|
|
629
|
+
const offset = message.offset || 1;
|
|
630
|
+
const limit = message.limit || 2000;
|
|
631
|
+
const startIdx = Math.max(0, offset - 1);
|
|
632
|
+
const endIdx = Math.min(allLines.length, startIdx + limit);
|
|
633
|
+
const sliced = allLines.slice(startIdx, endIdx);
|
|
634
|
+
// Line-number prefixed output (like OpenCode)
|
|
635
|
+
const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
|
|
636
|
+
const truncated = endIdx < allLines.length;
|
|
637
|
+
response.data = {
|
|
638
|
+
content: numbered,
|
|
639
|
+
rawContent: rawContent,
|
|
640
|
+
exists: true,
|
|
641
|
+
path: filePath,
|
|
642
|
+
totalLines: allLines.length,
|
|
643
|
+
showing: { from: offset, to: endIdx, truncated },
|
|
644
|
+
};
|
|
691
645
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
else if (fs.existsSync(filePath)) {
|
|
703
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
704
|
-
const lines = content.split('\n');
|
|
705
|
-
const start = Math.max(0, (message.lineStart || 1) - 1);
|
|
706
|
-
const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
|
|
707
|
-
response.data = {
|
|
708
|
-
lines: lines.slice(start, end),
|
|
709
|
-
startLine: start + 1,
|
|
710
|
-
endLine: end,
|
|
711
|
-
totalLines: lines.length
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
response.error = 'File not found';
|
|
646
|
+
else {
|
|
647
|
+
// Suggest similar files if not found
|
|
648
|
+
const dir = path.dirname(filePath);
|
|
649
|
+
const base = path.basename(filePath);
|
|
650
|
+
let suggestions = [];
|
|
651
|
+
try {
|
|
652
|
+
suggestions = fs.readdirSync(dir)
|
|
653
|
+
.filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
|
|
654
|
+
.slice(0, 3)
|
|
655
|
+
.map(e => path.relative(projectPath, path.join(dir, e)));
|
|
716
656
|
}
|
|
657
|
+
catch (_) { }
|
|
658
|
+
response.data = { exists: false, suggestions };
|
|
717
659
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
else if (fs.existsSync(filePath)) {
|
|
729
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
730
|
-
const lines = content.split('\n');
|
|
731
|
-
const targetLine = message.line || 1;
|
|
732
|
-
const contextLines = message.contextLines || 5;
|
|
733
|
-
const start = Math.max(0, targetLine - contextLines - 1);
|
|
734
|
-
const end = Math.min(lines.length, targetLine + contextLines);
|
|
735
|
-
response.data = {
|
|
736
|
-
lines: lines.slice(start, end).map((line, i) => ({
|
|
737
|
-
number: start + i + 1,
|
|
738
|
-
content: line,
|
|
739
|
-
isError: start + i + 1 === targetLine
|
|
740
|
-
})),
|
|
741
|
-
errorLine: targetLine,
|
|
742
|
-
filePath: message.filePath
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
else {
|
|
746
|
-
response.error = 'File not found';
|
|
747
|
-
}
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
response.error = e.message;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
case 'GET_SOURCE':
|
|
666
|
+
try {
|
|
667
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
668
|
+
if (!filePath.startsWith(projectPath)) {
|
|
669
|
+
response.error = 'Access denied';
|
|
748
670
|
}
|
|
749
|
-
|
|
750
|
-
|
|
671
|
+
else if (fs.existsSync(filePath)) {
|
|
672
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
673
|
+
const lines = content.split('\n');
|
|
674
|
+
const start = Math.max(0, (message.lineStart || 1) - 1);
|
|
675
|
+
const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
|
|
676
|
+
response.data = {
|
|
677
|
+
lines: lines.slice(start, end),
|
|
678
|
+
startLine: start + 1,
|
|
679
|
+
endLine: end,
|
|
680
|
+
totalLines: lines.length
|
|
681
|
+
};
|
|
751
682
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
try {
|
|
755
|
-
const depth = message.depth || 3;
|
|
756
|
-
const tree = getFileTree(projectPath, depth);
|
|
757
|
-
response.data = { tree };
|
|
683
|
+
else {
|
|
684
|
+
response.error = 'File not found';
|
|
758
685
|
}
|
|
759
|
-
|
|
760
|
-
|
|
686
|
+
}
|
|
687
|
+
catch (e) {
|
|
688
|
+
response.error = e.message;
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
case 'GET_ERROR_CONTEXT':
|
|
692
|
+
try {
|
|
693
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
694
|
+
if (!filePath.startsWith(projectPath)) {
|
|
695
|
+
response.error = 'Access denied';
|
|
761
696
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
const
|
|
767
|
-
|
|
697
|
+
else if (fs.existsSync(filePath)) {
|
|
698
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
699
|
+
const lines = content.split('\n');
|
|
700
|
+
const targetLine = message.line || 1;
|
|
701
|
+
const contextLines = message.contextLines || 5;
|
|
702
|
+
const start = Math.max(0, targetLine - contextLines - 1);
|
|
703
|
+
const end = Math.min(lines.length, targetLine + contextLines);
|
|
704
|
+
response.data = {
|
|
705
|
+
lines: lines.slice(start, end).map((line, i) => ({
|
|
706
|
+
number: start + i + 1,
|
|
707
|
+
content: line,
|
|
708
|
+
isError: start + i + 1 === targetLine
|
|
709
|
+
})),
|
|
710
|
+
errorLine: targetLine,
|
|
711
|
+
filePath: message.filePath
|
|
712
|
+
};
|
|
768
713
|
}
|
|
769
|
-
|
|
770
|
-
response.error =
|
|
714
|
+
else {
|
|
715
|
+
response.error = 'File not found';
|
|
771
716
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
response.error = e.message;
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
case 'GET_FILE_TREE':
|
|
723
|
+
try {
|
|
724
|
+
const depth = message.depth || 3;
|
|
725
|
+
const tree = getFileTree(projectPath, depth);
|
|
726
|
+
response.data = { tree };
|
|
727
|
+
}
|
|
728
|
+
catch (e) {
|
|
729
|
+
response.error = e.message;
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
case 'SEARCH_CODE':
|
|
733
|
+
try {
|
|
734
|
+
const query = message.query;
|
|
735
|
+
const matches = searchInFiles(projectPath, query, 20);
|
|
736
|
+
response.data = { matches };
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
response.error = e.message;
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
case 'FIND_FILES':
|
|
743
|
+
try {
|
|
744
|
+
const pattern = message.pattern || '';
|
|
745
|
+
// Extract the filename from a glob like **/Services.css
|
|
746
|
+
const fileName = pattern.split('/').pop().replace(/\*\*/g, '').replace(/\*/g, '');
|
|
747
|
+
const maxResults = message.maxResults || 20;
|
|
748
|
+
const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
|
|
749
|
+
const found = [];
|
|
750
|
+
function findFiles(dir) {
|
|
751
|
+
if (found.length >= maxResults)
|
|
752
|
+
return;
|
|
753
|
+
try {
|
|
754
|
+
const items = fs.readdirSync(dir);
|
|
755
|
+
for (const item of items) {
|
|
756
|
+
if (found.length >= maxResults)
|
|
757
|
+
break;
|
|
758
|
+
if (ignorePatterns.includes(item) || item.startsWith('.'))
|
|
759
|
+
continue;
|
|
760
|
+
const fullPath = path.join(dir, item);
|
|
761
|
+
try {
|
|
762
|
+
const stat = fs.statSync(fullPath);
|
|
763
|
+
if (stat.isDirectory()) {
|
|
764
|
+
findFiles(fullPath);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
// Match by filename (with optional wildcard)
|
|
768
|
+
const matchByName = !fileName || item === fileName ||
|
|
769
|
+
(pattern.includes('*') && item.endsWith(fileName));
|
|
770
|
+
if (matchByName) {
|
|
771
|
+
found.push(path.relative(projectPath, fullPath));
|
|
804
772
|
}
|
|
805
773
|
}
|
|
806
|
-
catch (e) { /* skip */ }
|
|
807
774
|
}
|
|
775
|
+
catch (e) { /* skip */ }
|
|
808
776
|
}
|
|
809
|
-
catch (e) { /* skip */ }
|
|
810
777
|
}
|
|
811
|
-
|
|
812
|
-
response.data = found;
|
|
778
|
+
catch (e) { /* skip */ }
|
|
813
779
|
}
|
|
814
|
-
|
|
815
|
-
|
|
780
|
+
findFiles(projectPath);
|
|
781
|
+
response.data = found;
|
|
782
|
+
}
|
|
783
|
+
catch (e) {
|
|
784
|
+
response.error = e.message;
|
|
785
|
+
}
|
|
786
|
+
// ========== PROJECT DETECTION (Code Export Pipeline) ==========
|
|
787
|
+
case 'DETECT_PROJECT':
|
|
788
|
+
try {
|
|
789
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
790
|
+
let deps = {};
|
|
791
|
+
let devDeps = {};
|
|
792
|
+
let pkgName = '';
|
|
793
|
+
if (fs.existsSync(pkgPath)) {
|
|
794
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
795
|
+
deps = pkg.dependencies || {};
|
|
796
|
+
devDeps = pkg.devDependencies || {};
|
|
797
|
+
pkgName = pkg.name || '';
|
|
816
798
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
799
|
+
const allDeps = { ...deps, ...devDeps };
|
|
800
|
+
// --- Framework detection ---
|
|
801
|
+
let framework = 'html';
|
|
802
|
+
if (allDeps['next'])
|
|
803
|
+
framework = 'next.js';
|
|
804
|
+
else if (allDeps['nuxt'] || allDeps['nuxt3'])
|
|
805
|
+
framework = 'nuxt';
|
|
806
|
+
else if (allDeps['gatsby'])
|
|
807
|
+
framework = 'gatsby';
|
|
808
|
+
else if (allDeps['@remix-run/react'])
|
|
809
|
+
framework = 'remix';
|
|
810
|
+
else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
|
|
811
|
+
framework = 'svelte';
|
|
812
|
+
else if (allDeps['vue'])
|
|
813
|
+
framework = 'vue';
|
|
814
|
+
else if (allDeps['@angular/core'])
|
|
815
|
+
framework = 'angular';
|
|
816
|
+
else if (allDeps['react'])
|
|
817
|
+
framework = 'react';
|
|
818
|
+
// --- TypeScript detection ---
|
|
819
|
+
const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
|
|
820
|
+
const ext = typescript ? '.tsx' : '.jsx';
|
|
821
|
+
// --- Styling detection ---
|
|
822
|
+
let styling = 'plain-css';
|
|
823
|
+
if (allDeps['tailwindcss'])
|
|
824
|
+
styling = 'tailwind';
|
|
825
|
+
else if (allDeps['styled-components'])
|
|
826
|
+
styling = 'styled-components';
|
|
827
|
+
else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
|
|
828
|
+
styling = 'emotion';
|
|
829
|
+
else if (allDeps['sass'] || allDeps['node-sass'])
|
|
830
|
+
styling = 'sass';
|
|
831
|
+
// CSS Modules: detected by file presence later
|
|
832
|
+
// --- Router detection (Next.js specific) ---
|
|
833
|
+
let router = null;
|
|
834
|
+
if (framework === 'next.js') {
|
|
835
|
+
if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
|
|
836
|
+
router = 'app';
|
|
829
837
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
let framework = 'html';
|
|
833
|
-
if (allDeps['next'])
|
|
834
|
-
framework = 'next.js';
|
|
835
|
-
else if (allDeps['nuxt'] || allDeps['nuxt3'])
|
|
836
|
-
framework = 'nuxt';
|
|
837
|
-
else if (allDeps['gatsby'])
|
|
838
|
-
framework = 'gatsby';
|
|
839
|
-
else if (allDeps['@remix-run/react'])
|
|
840
|
-
framework = 'remix';
|
|
841
|
-
else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
|
|
842
|
-
framework = 'svelte';
|
|
843
|
-
else if (allDeps['vue'])
|
|
844
|
-
framework = 'vue';
|
|
845
|
-
else if (allDeps['@angular/core'])
|
|
846
|
-
framework = 'angular';
|
|
847
|
-
else if (allDeps['react'])
|
|
848
|
-
framework = 'react';
|
|
849
|
-
// --- TypeScript detection ---
|
|
850
|
-
const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
|
|
851
|
-
const ext = typescript ? '.tsx' : '.jsx';
|
|
852
|
-
// --- Styling detection ---
|
|
853
|
-
let styling = 'plain-css';
|
|
854
|
-
if (allDeps['tailwindcss'])
|
|
855
|
-
styling = 'tailwind';
|
|
856
|
-
else if (allDeps['styled-components'])
|
|
857
|
-
styling = 'styled-components';
|
|
858
|
-
else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
|
|
859
|
-
styling = 'emotion';
|
|
860
|
-
else if (allDeps['sass'] || allDeps['node-sass'])
|
|
861
|
-
styling = 'sass';
|
|
862
|
-
// CSS Modules: detected by file presence later
|
|
863
|
-
// --- Router detection (Next.js specific) ---
|
|
864
|
-
let router = null;
|
|
865
|
-
if (framework === 'next.js') {
|
|
866
|
-
if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
|
|
867
|
-
router = 'app';
|
|
868
|
-
}
|
|
869
|
-
else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
|
|
870
|
-
router = 'pages';
|
|
871
|
-
}
|
|
838
|
+
else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
|
|
839
|
+
router = 'pages';
|
|
872
840
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
return null;
|
|
880
|
-
};
|
|
881
|
-
const layoutFile = findFirst([
|
|
882
|
-
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
|
|
883
|
-
'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
|
|
884
|
-
'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
|
|
885
|
-
'src/App.tsx', 'src/App.jsx', 'src/App.js',
|
|
886
|
-
'src/main.tsx', 'src/main.jsx', 'src/main.js',
|
|
887
|
-
'index.html'
|
|
888
|
-
]);
|
|
889
|
-
const globalStyleFile = findFirst([
|
|
890
|
-
'src/app/globals.css', 'src/app/global.css',
|
|
891
|
-
'app/globals.css', 'app/global.css',
|
|
892
|
-
'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
|
|
893
|
-
'src/App.css', 'src/styles.css',
|
|
894
|
-
'styles/globals.css', 'styles/global.css',
|
|
895
|
-
'css/style.css', 'css/main.css',
|
|
896
|
-
'style.css', 'styles.css'
|
|
897
|
-
]);
|
|
898
|
-
// Initial guess via disk — overridden after tree scan with real component count
|
|
899
|
-
let componentsDir = findFirst([
|
|
900
|
-
'src/components', 'src/app/components', 'components',
|
|
901
|
-
'src/ui', 'src/app/ui'
|
|
902
|
-
]);
|
|
903
|
-
// --- Tailwind config ---
|
|
904
|
-
let tailwindConfig = null;
|
|
905
|
-
let tailwindVersion = null;
|
|
906
|
-
if (styling === 'tailwind') {
|
|
907
|
-
tailwindConfig = findFirst([
|
|
908
|
-
'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
|
|
909
|
-
]);
|
|
910
|
-
tailwindVersion = allDeps['tailwindcss'] || null;
|
|
841
|
+
}
|
|
842
|
+
// --- Key file location finder ---
|
|
843
|
+
const findFirst = (candidates) => {
|
|
844
|
+
for (const c of candidates) {
|
|
845
|
+
if (fs.existsSync(path.join(projectPath, c)))
|
|
846
|
+
return c;
|
|
911
847
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
848
|
+
return null;
|
|
849
|
+
};
|
|
850
|
+
const layoutFile = findFirst([
|
|
851
|
+
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
|
|
852
|
+
'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
|
|
853
|
+
'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
|
|
854
|
+
'src/App.tsx', 'src/App.jsx', 'src/App.js',
|
|
855
|
+
'src/main.tsx', 'src/main.jsx', 'src/main.js',
|
|
856
|
+
'index.html'
|
|
857
|
+
]);
|
|
858
|
+
const globalStyleFile = findFirst([
|
|
859
|
+
'src/app/globals.css', 'src/app/global.css',
|
|
860
|
+
'app/globals.css', 'app/global.css',
|
|
861
|
+
'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
|
|
862
|
+
'src/App.css', 'src/styles.css',
|
|
863
|
+
'styles/globals.css', 'styles/global.css',
|
|
864
|
+
'css/style.css', 'css/main.css',
|
|
865
|
+
'style.css', 'styles.css'
|
|
866
|
+
]);
|
|
867
|
+
// Initial guess via disk — overridden after tree scan with real component count
|
|
868
|
+
let componentsDir = findFirst([
|
|
869
|
+
'src/components', 'src/app/components', 'components',
|
|
870
|
+
'src/ui', 'src/app/ui'
|
|
871
|
+
]);
|
|
872
|
+
// --- Tailwind config ---
|
|
873
|
+
let tailwindConfig = null;
|
|
874
|
+
let tailwindVersion = null;
|
|
875
|
+
if (styling === 'tailwind') {
|
|
876
|
+
tailwindConfig = findFirst([
|
|
877
|
+
'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
|
|
878
|
+
]);
|
|
879
|
+
tailwindVersion = allDeps['tailwindcss'] || null;
|
|
880
|
+
}
|
|
881
|
+
// --- Detect code conventions from existing components ---
|
|
882
|
+
let componentPattern = 'arrow-function';
|
|
883
|
+
let exportPattern = 'default';
|
|
884
|
+
let sampleComponentFile = null;
|
|
885
|
+
let sampleComponentCode = null;
|
|
886
|
+
if (componentsDir) {
|
|
887
|
+
const compDir = path.join(projectPath, componentsDir);
|
|
888
|
+
try {
|
|
889
|
+
const files = fs.readdirSync(compDir).filter(f => f.endsWith('.tsx') || f.endsWith('.jsx') || f.endsWith('.js') || f.endsWith('.vue') || f.endsWith('.svelte'));
|
|
890
|
+
if (files.length > 0) {
|
|
891
|
+
sampleComponentFile = path.join(componentsDir, files[0]);
|
|
892
|
+
const code = fs.readFileSync(path.join(compDir, files[0]), 'utf-8');
|
|
893
|
+
// Only include first 80 lines as a pattern reference
|
|
894
|
+
sampleComponentCode = code.split('\n').slice(0, 80).join('\n');
|
|
895
|
+
// Detect patterns
|
|
896
|
+
if (/^export default function /m.test(code)) {
|
|
897
|
+
componentPattern = 'function-declaration';
|
|
898
|
+
exportPattern = 'default';
|
|
946
899
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
if (/
|
|
900
|
+
else if (/^export function /m.test(code)) {
|
|
901
|
+
componentPattern = 'function-declaration';
|
|
902
|
+
exportPattern = 'named';
|
|
903
|
+
}
|
|
904
|
+
else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
|
|
905
|
+
componentPattern = 'arrow-function';
|
|
906
|
+
}
|
|
907
|
+
if (/^export default /m.test(code))
|
|
908
|
+
exportPattern = 'default';
|
|
909
|
+
else if (/^export \{/m.test(code) || /^export const/m.test(code))
|
|
910
|
+
exportPattern = 'named';
|
|
911
|
+
// CSS Modules detection from component imports
|
|
912
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
|
|
955
913
|
styling = 'css-modules';
|
|
956
914
|
}
|
|
957
915
|
}
|
|
958
|
-
catch (e) { /* skip */ }
|
|
959
916
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
917
|
+
catch (e) { /* skip */ }
|
|
918
|
+
}
|
|
919
|
+
// --- Also check layout file for CSS Modules if not yet detected ---
|
|
920
|
+
if (styling === 'plain-css' && layoutFile) {
|
|
921
|
+
try {
|
|
922
|
+
const layoutCode = fs.readFileSync(path.join(projectPath, layoutFile), 'utf-8');
|
|
923
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
|
|
924
|
+
styling = 'css-modules';
|
|
966
925
|
}
|
|
967
|
-
catch (e) { /* skip */ }
|
|
968
926
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
'.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
|
|
978
|
-
'.html', '.astro'
|
|
979
|
-
]);
|
|
980
|
-
const treeLines = [];
|
|
981
|
-
const MAX_TREE_LINES = 120;
|
|
982
|
-
// Track dir → count of Pascal-case component files for smart detection
|
|
983
|
-
const dirComponentCount = new Map();
|
|
984
|
-
const buildTree = (dir, prefix, depth) => {
|
|
985
|
-
if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
|
|
986
|
-
return;
|
|
987
|
-
try {
|
|
988
|
-
const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
|
|
989
|
-
// Sort: directories first, then files
|
|
990
|
-
const sorted = entries
|
|
991
|
-
.filter(e => !e.name.startsWith('.') || e.name === '.env')
|
|
992
|
-
.sort((a, b) => {
|
|
993
|
-
if (a.isDirectory() && !b.isDirectory())
|
|
994
|
-
return -1;
|
|
995
|
-
if (!a.isDirectory() && b.isDirectory())
|
|
996
|
-
return 1;
|
|
997
|
-
return a.name.localeCompare(b.name);
|
|
998
|
-
});
|
|
999
|
-
for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
|
|
1000
|
-
const entry = sorted[i];
|
|
1001
|
-
const isLast = i === sorted.length - 1;
|
|
1002
|
-
const connector = isLast ? '└── ' : '├── ';
|
|
1003
|
-
const childPrefix = isLast ? ' ' : '│ ';
|
|
1004
|
-
const childPath = dir ? `${dir}/${entry.name}` : entry.name;
|
|
1005
|
-
if (entry.isDirectory()) {
|
|
1006
|
-
if (SKIP_DIRS.has(entry.name))
|
|
1007
|
-
continue;
|
|
1008
|
-
treeLines.push(`${prefix}${connector}${entry.name}/`);
|
|
1009
|
-
buildTree(childPath, prefix + childPrefix, depth + 1);
|
|
1010
|
-
}
|
|
1011
|
-
else {
|
|
1012
|
-
const ext = path.extname(entry.name).toLowerCase();
|
|
1013
|
-
// Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
|
|
1014
|
-
const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
1015
|
-
if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
|
|
1016
|
-
dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
|
|
1017
|
-
}
|
|
1018
|
-
// Include UI-relevant files + config files at root
|
|
1019
|
-
if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
1020
|
-
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
1021
|
-
entry.name.startsWith('vite.config')))) {
|
|
1022
|
-
treeLines.push(`${prefix}${connector}${entry.name}`);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
catch (e) { /* permission error, skip */ }
|
|
1028
|
-
};
|
|
1029
|
-
// Only scan UI-relevant root dirs (not the entire project)
|
|
1030
|
-
const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
|
|
1031
|
-
const existingRoots = [];
|
|
1032
|
-
for (const root of scanRoots) {
|
|
1033
|
-
if (fs.existsSync(path.join(projectPath, root))) {
|
|
1034
|
-
existingRoots.push(root);
|
|
1035
|
-
}
|
|
927
|
+
catch (e) { /* skip */ }
|
|
928
|
+
}
|
|
929
|
+
// --- Read a snippet of global CSS for context ---
|
|
930
|
+
let globalStylePreview = null;
|
|
931
|
+
if (globalStyleFile) {
|
|
932
|
+
try {
|
|
933
|
+
const css = fs.readFileSync(path.join(projectPath, globalStyleFile), 'utf-8');
|
|
934
|
+
globalStylePreview = css.split('\n').slice(0, 40).join('\n');
|
|
1036
935
|
}
|
|
1037
|
-
|
|
936
|
+
catch (e) { /* skip */ }
|
|
937
|
+
}
|
|
938
|
+
// --- Build filtered scaffolding tree (UI-relevant files only) ---
|
|
939
|
+
const SKIP_DIRS = new Set([
|
|
940
|
+
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
|
|
941
|
+
'dist', 'build', '.cache', '.turbo', 'coverage',
|
|
942
|
+
'__pycache__', '.vercel', '.output', '.parcel-cache'
|
|
943
|
+
]);
|
|
944
|
+
const UI_EXTENSIONS = new Set([
|
|
945
|
+
'.tsx', '.jsx', '.js', '.ts', '.vue', '.svelte',
|
|
946
|
+
'.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
|
|
947
|
+
'.html', '.astro'
|
|
948
|
+
]);
|
|
949
|
+
const treeLines = [];
|
|
950
|
+
const MAX_TREE_LINES = 120;
|
|
951
|
+
// Track dir → count of Pascal-case component files for smart detection
|
|
952
|
+
const dirComponentCount = new Map();
|
|
953
|
+
const buildTree = (dir, prefix, depth) => {
|
|
954
|
+
if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
|
|
955
|
+
return;
|
|
1038
956
|
try {
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
957
|
+
const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
|
|
958
|
+
// Sort: directories first, then files
|
|
959
|
+
const sorted = entries
|
|
960
|
+
.filter(e => !e.name.startsWith('.') || e.name === '.env')
|
|
961
|
+
.sort((a, b) => {
|
|
962
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
963
|
+
return -1;
|
|
964
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
965
|
+
return 1;
|
|
966
|
+
return a.name.localeCompare(b.name);
|
|
967
|
+
});
|
|
968
|
+
for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
|
|
969
|
+
const entry = sorted[i];
|
|
970
|
+
const isLast = i === sorted.length - 1;
|
|
971
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
972
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
973
|
+
const childPath = dir ? `${dir}/${entry.name}` : entry.name;
|
|
974
|
+
if (entry.isDirectory()) {
|
|
975
|
+
if (SKIP_DIRS.has(entry.name))
|
|
976
|
+
continue;
|
|
977
|
+
treeLines.push(`${prefix}${connector}${entry.name}/`);
|
|
978
|
+
buildTree(childPath, prefix + childPrefix, depth + 1);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
982
|
+
// Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
|
|
983
|
+
const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
984
|
+
if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
|
|
985
|
+
dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
|
|
986
|
+
}
|
|
987
|
+
// Include UI-relevant files + config files at root
|
|
988
|
+
if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
989
|
+
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
990
|
+
entry.name.startsWith('vite.config')))) {
|
|
991
|
+
treeLines.push(`${prefix}${connector}${entry.name}`);
|
|
992
|
+
}
|
|
1045
993
|
}
|
|
1046
994
|
}
|
|
1047
995
|
}
|
|
1048
|
-
catch (e) { /* skip */ }
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
996
|
+
catch (e) { /* permission error, skip */ }
|
|
997
|
+
};
|
|
998
|
+
// Only scan UI-relevant root dirs (not the entire project)
|
|
999
|
+
const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
|
|
1000
|
+
const existingRoots = [];
|
|
1001
|
+
for (const root of scanRoots) {
|
|
1002
|
+
if (fs.existsSync(path.join(projectPath, root))) {
|
|
1003
|
+
existingRoots.push(root);
|
|
1053
1004
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (count > bestCount) {
|
|
1064
|
-
bestCount = count;
|
|
1065
|
-
bestDir = dir;
|
|
1066
|
-
}
|
|
1005
|
+
}
|
|
1006
|
+
// Also add root-level config files
|
|
1007
|
+
try {
|
|
1008
|
+
const rootEntries = fs.readdirSync(projectPath, { withFileTypes: true });
|
|
1009
|
+
for (const entry of rootEntries) {
|
|
1010
|
+
if (!entry.isDirectory() && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
1011
|
+
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
1012
|
+
entry.name.startsWith('vite.config') || entry.name === 'index.html')) {
|
|
1013
|
+
treeLines.push(entry.name);
|
|
1067
1014
|
}
|
|
1068
|
-
if (bestCount > 0)
|
|
1069
|
-
componentsDir = bestDir;
|
|
1070
1015
|
}
|
|
1071
|
-
response.data = {
|
|
1072
|
-
framework,
|
|
1073
|
-
typescript,
|
|
1074
|
-
styling,
|
|
1075
|
-
router,
|
|
1076
|
-
ext,
|
|
1077
|
-
pkgName,
|
|
1078
|
-
// Key file locations
|
|
1079
|
-
layoutFile,
|
|
1080
|
-
globalStyleFile,
|
|
1081
|
-
componentsDir,
|
|
1082
|
-
tailwindConfig,
|
|
1083
|
-
tailwindVersion,
|
|
1084
|
-
// Code conventions
|
|
1085
|
-
componentPattern,
|
|
1086
|
-
exportPattern,
|
|
1087
|
-
sampleComponentFile,
|
|
1088
|
-
sampleComponentCode,
|
|
1089
|
-
// CSS context
|
|
1090
|
-
globalStylePreview,
|
|
1091
|
-
// Scaffolding tree
|
|
1092
|
-
projectTree,
|
|
1093
|
-
};
|
|
1094
|
-
console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
|
|
1095
1016
|
}
|
|
1096
|
-
catch (e) {
|
|
1097
|
-
|
|
1017
|
+
catch (e) { /* skip */ }
|
|
1018
|
+
// Build tree for each UI root
|
|
1019
|
+
for (const root of existingRoots) {
|
|
1020
|
+
treeLines.push(`${root}/`);
|
|
1021
|
+
buildTree(root, '', 1);
|
|
1098
1022
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
else {
|
|
1112
|
-
// Snapshot before write
|
|
1113
|
-
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
1114
|
-
undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1115
|
-
if (undoStack.length > 20)
|
|
1116
|
-
undoStack.shift();
|
|
1117
|
-
// --- CSS safety: validate balanced braces ---
|
|
1118
|
-
if (filePath.endsWith('.css')) {
|
|
1119
|
-
const writeContent = message.content || '';
|
|
1120
|
-
const opens = (writeContent.match(/\{/g) || []).length;
|
|
1121
|
-
const closes = (writeContent.match(/\}/g) || []).length;
|
|
1122
|
-
if (opens !== closes) {
|
|
1123
|
-
// Remove snapshot since we're rejecting the write
|
|
1124
|
-
undoStack.pop();
|
|
1125
|
-
response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
|
|
1126
|
-
break;
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
const dirPath = path.dirname(filePath);
|
|
1130
|
-
if (!fs.existsSync(dirPath)) {
|
|
1131
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
1023
|
+
const projectTree = treeLines.length > 0 ? treeLines.join('\n') : null;
|
|
1024
|
+
// --- Override componentsDir with real scan result ---
|
|
1025
|
+
// Pick the dir with the most Pascal-case component files.
|
|
1026
|
+
// This beats any hardcoded list: works for src/features, src/modules,
|
|
1027
|
+
// src/views, src/app/components — whatever the project uses.
|
|
1028
|
+
if (dirComponentCount.size > 0) {
|
|
1029
|
+
let bestDir = '';
|
|
1030
|
+
let bestCount = 0;
|
|
1031
|
+
for (const [dir, count] of dirComponentCount.entries()) {
|
|
1032
|
+
if (count > bestCount) {
|
|
1033
|
+
bestCount = count;
|
|
1034
|
+
bestDir = dir;
|
|
1132
1035
|
}
|
|
1133
|
-
fs.writeFileSync(filePath, message.content, 'utf-8');
|
|
1134
|
-
// Auto-format after write
|
|
1135
|
-
const formatter = await autoFormat(filePath, projectPath);
|
|
1136
|
-
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1137
|
-
console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
1138
1036
|
}
|
|
1037
|
+
if (bestCount > 0)
|
|
1038
|
+
componentsDir = bestDir;
|
|
1139
1039
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1040
|
+
response.data = {
|
|
1041
|
+
framework,
|
|
1042
|
+
typescript,
|
|
1043
|
+
styling,
|
|
1044
|
+
router,
|
|
1045
|
+
ext,
|
|
1046
|
+
pkgName,
|
|
1047
|
+
// Key file locations
|
|
1048
|
+
layoutFile,
|
|
1049
|
+
globalStyleFile,
|
|
1050
|
+
componentsDir,
|
|
1051
|
+
tailwindConfig,
|
|
1052
|
+
tailwindVersion,
|
|
1053
|
+
// Code conventions
|
|
1054
|
+
componentPattern,
|
|
1055
|
+
exportPattern,
|
|
1056
|
+
sampleComponentFile,
|
|
1057
|
+
sampleComponentCode,
|
|
1058
|
+
// CSS context
|
|
1059
|
+
globalStylePreview,
|
|
1060
|
+
// Scaffolding tree
|
|
1061
|
+
projectTree,
|
|
1062
|
+
};
|
|
1063
|
+
console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
|
|
1064
|
+
}
|
|
1065
|
+
catch (e) {
|
|
1066
|
+
response.error = e.message;
|
|
1067
|
+
}
|
|
1068
|
+
break;
|
|
1069
|
+
// ========== FILE WRITING (UI Design Export) ==========
|
|
1070
|
+
// --- Undo Stack ---
|
|
1071
|
+
// Snapshots file contents before every write/append/edit.
|
|
1072
|
+
// UNDO_LAST pops the most recent snapshot and restores it.
|
|
1073
|
+
// Max 20 entries to avoid unbounded memory growth.
|
|
1074
|
+
case 'WRITE_FILE':
|
|
1075
|
+
try {
|
|
1076
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1077
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1078
|
+
response.error = 'Access denied: Path outside project';
|
|
1142
1079
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
1080
|
+
else {
|
|
1081
|
+
// Snapshot before write
|
|
1082
|
+
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
1083
|
+
undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1084
|
+
if (undoStack.length > 20)
|
|
1085
|
+
undoStack.shift();
|
|
1086
|
+
// --- CSS safety: validate balanced braces ---
|
|
1087
|
+
if (filePath.endsWith('.css')) {
|
|
1088
|
+
const writeContent = message.content || '';
|
|
1089
|
+
const opens = (writeContent.match(/\{/g) || []).length;
|
|
1090
|
+
const closes = (writeContent.match(/\}/g) || []).length;
|
|
1091
|
+
if (opens !== closes) {
|
|
1092
|
+
// Remove snapshot since we're rejecting the write
|
|
1093
|
+
undoStack.pop();
|
|
1094
|
+
response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1149
1097
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1098
|
+
const dirPath = path.dirname(filePath);
|
|
1099
|
+
if (!fs.existsSync(dirPath)) {
|
|
1100
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
1101
|
+
}
|
|
1102
|
+
fs.writeFileSync(filePath, message.content, 'utf-8');
|
|
1103
|
+
// Auto-format after write
|
|
1104
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1105
|
+
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1106
|
+
console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
catch (e) {
|
|
1110
|
+
response.error = e.message;
|
|
1111
|
+
}
|
|
1112
|
+
break;
|
|
1113
|
+
case 'APPEND_FILE':
|
|
1114
|
+
try {
|
|
1115
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1116
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1117
|
+
response.error = 'Access denied: Path outside project';
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
const isCssFile = filePath.endsWith('.css');
|
|
1121
|
+
const newContent = message.content || '';
|
|
1122
|
+
// --- CSS safety: validate balanced braces ---
|
|
1123
|
+
if (isCssFile) {
|
|
1124
|
+
const opens = (newContent.match(/\{/g) || []).length;
|
|
1125
|
+
const closes = (newContent.match(/\}/g) || []).length;
|
|
1126
|
+
if (opens !== closes) {
|
|
1127
|
+
response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
|
|
1128
|
+
break;
|
|
1161
1129
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1130
|
+
}
|
|
1131
|
+
// Snapshot before append
|
|
1132
|
+
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
1133
|
+
undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1134
|
+
if (undoStack.length > 20)
|
|
1135
|
+
undoStack.shift();
|
|
1136
|
+
// --- CSS safety: hoist @import/@charset to file top ---
|
|
1137
|
+
if (isCssFile && prevContent !== null) {
|
|
1138
|
+
// Extract @import and @charset from the NEW content
|
|
1139
|
+
const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
|
|
1140
|
+
const newImports = [];
|
|
1141
|
+
const bodyContent = newContent.replace(importRegex, (match) => {
|
|
1142
|
+
newImports.push(match.trim());
|
|
1143
|
+
return '';
|
|
1144
|
+
}).trim();
|
|
1145
|
+
if (newImports.length > 0) {
|
|
1146
|
+
// Find where existing imports end in the original file
|
|
1147
|
+
const existingLines = prevContent.split('\n');
|
|
1148
|
+
let lastImportLineIdx = -1;
|
|
1149
|
+
for (let i = 0; i < existingLines.length; i++) {
|
|
1150
|
+
const trimmed = existingLines[i].trim();
|
|
1151
|
+
if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
|
|
1152
|
+
lastImportLineIdx = i;
|
|
1153
|
+
}
|
|
1154
|
+
// Stop scanning after first non-import, non-comment, non-empty line
|
|
1155
|
+
if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
|
|
1156
|
+
break;
|
|
1189
1157
|
}
|
|
1190
|
-
// Deduplicate: only add imports that don't already exist
|
|
1191
|
-
const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
|
|
1192
|
-
// Build new file: existing imports + new imports + rest of existing + new body
|
|
1193
|
-
const insertIdx = lastImportLineIdx + 1;
|
|
1194
|
-
const topLines = existingLines.slice(0, insertIdx);
|
|
1195
|
-
const restLines = existingLines.slice(insertIdx);
|
|
1196
|
-
const merged = [
|
|
1197
|
-
...topLines,
|
|
1198
|
-
...dedupedImports,
|
|
1199
|
-
...restLines,
|
|
1200
|
-
'', // separator
|
|
1201
|
-
bodyContent
|
|
1202
|
-
].join('\n');
|
|
1203
|
-
fs.writeFileSync(filePath, merged, 'utf-8');
|
|
1204
|
-
console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
|
|
1205
|
-
}
|
|
1206
|
-
else {
|
|
1207
|
-
// No imports in new content — simple append
|
|
1208
|
-
fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
|
|
1209
1158
|
}
|
|
1159
|
+
// Deduplicate: only add imports that don't already exist
|
|
1160
|
+
const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
|
|
1161
|
+
// Build new file: existing imports + new imports + rest of existing + new body
|
|
1162
|
+
const insertIdx = lastImportLineIdx + 1;
|
|
1163
|
+
const topLines = existingLines.slice(0, insertIdx);
|
|
1164
|
+
const restLines = existingLines.slice(insertIdx);
|
|
1165
|
+
const merged = [
|
|
1166
|
+
...topLines,
|
|
1167
|
+
...dedupedImports,
|
|
1168
|
+
...restLines,
|
|
1169
|
+
'', // separator
|
|
1170
|
+
bodyContent
|
|
1171
|
+
].join('\n');
|
|
1172
|
+
fs.writeFileSync(filePath, merged, 'utf-8');
|
|
1173
|
+
console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
|
|
1210
1174
|
}
|
|
1211
1175
|
else {
|
|
1212
|
-
//
|
|
1213
|
-
fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
|
|
1176
|
+
// No imports in new content — simple append
|
|
1177
|
+
fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
|
|
1214
1178
|
}
|
|
1215
|
-
// Auto-format after append
|
|
1216
|
-
const formatter = await autoFormat(filePath, projectPath);
|
|
1217
|
-
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1218
|
-
console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
1219
1179
|
}
|
|
1180
|
+
else {
|
|
1181
|
+
// Non-CSS file or new file — simple append
|
|
1182
|
+
fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
|
|
1183
|
+
}
|
|
1184
|
+
// Auto-format after append
|
|
1185
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1186
|
+
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1187
|
+
console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
1220
1188
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1189
|
+
}
|
|
1190
|
+
catch (e) {
|
|
1191
|
+
response.error = e.message;
|
|
1192
|
+
}
|
|
1193
|
+
break;
|
|
1194
|
+
case 'EDIT_FILE':
|
|
1195
|
+
try {
|
|
1196
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1197
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1198
|
+
response.error = 'Access denied: Path outside project';
|
|
1223
1199
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const files = fs.readdirSync(absDir, { recursive: true });
|
|
1244
|
-
for (const f of files.slice(0, 100)) {
|
|
1245
|
-
const fBase = path.basename(f);
|
|
1246
|
-
// Same stem, any extension (page.tsx → page.js, page.jsx)
|
|
1247
|
-
if (fBase.startsWith(requestedStem + '.') ||
|
|
1248
|
-
fBase === requestedBase) {
|
|
1249
|
-
suggestions.push(path.join(dir, f).replace(/\\/g, '/'));
|
|
1250
|
-
}
|
|
1200
|
+
else if (!fs.existsSync(filePath)) {
|
|
1201
|
+
// Find similarly-named files to help the LLM self-correct
|
|
1202
|
+
const requestedBase = path.basename(message.filePath);
|
|
1203
|
+
const requestedExt = path.extname(requestedBase);
|
|
1204
|
+
const requestedStem = requestedBase.replace(requestedExt, '');
|
|
1205
|
+
// Walk shallow project tree to find candidates
|
|
1206
|
+
const suggestions = [];
|
|
1207
|
+
const dirsToSearch = ['src/app', 'app', 'src/pages', 'pages', 'src/components', 'components', 'src'];
|
|
1208
|
+
for (const dir of dirsToSearch) {
|
|
1209
|
+
const absDir = path.join(projectPath, dir);
|
|
1210
|
+
if (fs.existsSync(absDir)) {
|
|
1211
|
+
try {
|
|
1212
|
+
const files = fs.readdirSync(absDir, { recursive: true });
|
|
1213
|
+
for (const f of files.slice(0, 100)) {
|
|
1214
|
+
const fBase = path.basename(f);
|
|
1215
|
+
// Same stem, any extension (page.tsx → page.js, page.jsx)
|
|
1216
|
+
if (fBase.startsWith(requestedStem + '.') ||
|
|
1217
|
+
fBase === requestedBase) {
|
|
1218
|
+
suggestions.push(path.join(dir, f).replace(/\\/g, '/'));
|
|
1251
1219
|
}
|
|
1252
1220
|
}
|
|
1253
|
-
catch (e) { /* skip unreadable dirs */ }
|
|
1254
1221
|
}
|
|
1222
|
+
catch (e) { /* skip unreadable dirs */ }
|
|
1255
1223
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1224
|
+
}
|
|
1225
|
+
const hint = suggestions.length > 0
|
|
1226
|
+
? ' Did you mean: ' + suggestions.slice(0, 3).join(' or ') + '?'
|
|
1227
|
+
: ' Double-check the path against detect_project() output (layoutFile, globalStyleFile) or projectTree.';
|
|
1228
|
+
response.error = 'File not found: "' + message.filePath + '".' + hint;
|
|
1229
|
+
}
|
|
1230
|
+
else {
|
|
1231
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1232
|
+
// Snapshot before edit
|
|
1233
|
+
undoStack.push({ filePath, prevContent: content, operation: 'EDIT_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1234
|
+
if (undoStack.length > 20)
|
|
1235
|
+
undoStack.shift();
|
|
1236
|
+
// Use the 9-layer fuzzy replacer cascade (ported from OpenCode)
|
|
1237
|
+
const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
|
|
1238
|
+
if ('error' in result) {
|
|
1239
|
+
// Edit failed — remove the snapshot we just pushed
|
|
1240
|
+
undoStack.pop();
|
|
1241
|
+
response.error = result.error;
|
|
1260
1242
|
}
|
|
1261
1243
|
else {
|
|
1262
|
-
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
fs.writeFileSync(filePath, result.result, 'utf-8');
|
|
1276
|
-
// Auto-format after edit
|
|
1277
|
-
const formatter = await autoFormat(filePath, projectPath);
|
|
1278
|
-
response.data = {
|
|
1279
|
-
success: true,
|
|
1280
|
-
path: filePath,
|
|
1281
|
-
strategy: result.strategy,
|
|
1282
|
-
formatted: formatter,
|
|
1283
|
-
undoAvailable: true,
|
|
1284
|
-
};
|
|
1285
|
-
const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
|
|
1286
|
-
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1287
|
-
console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1288
|
-
}
|
|
1244
|
+
fs.writeFileSync(filePath, result.result, 'utf-8');
|
|
1245
|
+
// Auto-format after edit
|
|
1246
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1247
|
+
response.data = {
|
|
1248
|
+
success: true,
|
|
1249
|
+
path: filePath,
|
|
1250
|
+
strategy: result.strategy,
|
|
1251
|
+
formatted: formatter,
|
|
1252
|
+
undoAvailable: true,
|
|
1253
|
+
};
|
|
1254
|
+
const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
|
|
1255
|
+
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1256
|
+
console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1289
1257
|
}
|
|
1290
1258
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1259
|
+
}
|
|
1260
|
+
catch (e) {
|
|
1261
|
+
response.error = e.message;
|
|
1262
|
+
}
|
|
1263
|
+
break;
|
|
1264
|
+
case 'REPLACE_LINES':
|
|
1265
|
+
try {
|
|
1266
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1267
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1268
|
+
response.error = 'Access denied: Path outside project';
|
|
1269
|
+
}
|
|
1270
|
+
else if (!fs.existsSync(filePath)) {
|
|
1271
|
+
response.error = 'File not found: ' + message.filePath;
|
|
1293
1272
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
1273
|
+
else {
|
|
1274
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1275
|
+
const lines = content.split('\n');
|
|
1276
|
+
const startLine = Math.max(1, message.startLine || 1);
|
|
1277
|
+
const endLine = Math.min(lines.length, message.endLine || startLine);
|
|
1278
|
+
if (startLine > lines.length) {
|
|
1279
|
+
response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
|
|
1300
1280
|
}
|
|
1301
|
-
else if (
|
|
1302
|
-
response.error =
|
|
1281
|
+
else if (startLine > endLine) {
|
|
1282
|
+
response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
|
|
1303
1283
|
}
|
|
1304
1284
|
else {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
newLineCount: newLines.length,
|
|
1341
|
-
lineDelta: lineDelta,
|
|
1342
|
-
totalLines: lines.length,
|
|
1343
|
-
formatted: formatter,
|
|
1344
|
-
undoAvailable: true,
|
|
1345
|
-
hint: lineDelta !== 0
|
|
1346
|
-
? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
|
|
1347
|
-
: null
|
|
1348
|
-
};
|
|
1349
|
-
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1350
|
-
console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1351
|
-
}
|
|
1285
|
+
// Snapshot for undo
|
|
1286
|
+
undoStack.push({
|
|
1287
|
+
filePath,
|
|
1288
|
+
prevContent: content,
|
|
1289
|
+
operation: 'REPLACE_LINES',
|
|
1290
|
+
timestamp: Date.now(),
|
|
1291
|
+
relativePath: message.filePath
|
|
1292
|
+
});
|
|
1293
|
+
if (undoStack.length > 20)
|
|
1294
|
+
undoStack.shift();
|
|
1295
|
+
const oldLineCount = endLine - startLine + 1;
|
|
1296
|
+
const newLines = (message.newContent || '').split('\n');
|
|
1297
|
+
// splice: remove old range, insert new lines
|
|
1298
|
+
lines.splice(startLine - 1, oldLineCount, ...newLines);
|
|
1299
|
+
const newContent = lines.join('\n');
|
|
1300
|
+
fs.writeFileSync(filePath, newContent, 'utf-8');
|
|
1301
|
+
// Auto-format
|
|
1302
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1303
|
+
const lineDelta = newLines.length - oldLineCount;
|
|
1304
|
+
response.data = {
|
|
1305
|
+
success: true,
|
|
1306
|
+
path: filePath,
|
|
1307
|
+
linesReplaced: `${startLine}-${endLine}`,
|
|
1308
|
+
oldLineCount,
|
|
1309
|
+
newLineCount: newLines.length,
|
|
1310
|
+
lineDelta: lineDelta,
|
|
1311
|
+
totalLines: lines.length,
|
|
1312
|
+
formatted: formatter,
|
|
1313
|
+
undoAvailable: true,
|
|
1314
|
+
hint: lineDelta !== 0
|
|
1315
|
+
? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
|
|
1316
|
+
: null
|
|
1317
|
+
};
|
|
1318
|
+
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1319
|
+
console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1352
1320
|
}
|
|
1353
1321
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1322
|
+
}
|
|
1323
|
+
catch (e) {
|
|
1324
|
+
response.error = e.message;
|
|
1325
|
+
}
|
|
1326
|
+
break;
|
|
1327
|
+
case 'UNDO_LAST':
|
|
1328
|
+
try {
|
|
1329
|
+
if (undoStack.length === 0) {
|
|
1330
|
+
response.error = 'Nothing to undo — no file changes recorded in this session.';
|
|
1356
1331
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
undone: snapshot.operation,
|
|
1372
|
-
file: snapshot.relativePath,
|
|
1373
|
-
action: 'deleted (was new file)',
|
|
1374
|
-
remaining: undoStack.length,
|
|
1375
|
-
};
|
|
1376
|
-
console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
|
|
1377
|
-
}
|
|
1378
|
-
else {
|
|
1379
|
-
response.data = {
|
|
1380
|
-
success: true,
|
|
1381
|
-
undone: snapshot.operation,
|
|
1382
|
-
file: snapshot.relativePath,
|
|
1383
|
-
action: 'already gone',
|
|
1384
|
-
remaining: undoStack.length,
|
|
1385
|
-
};
|
|
1386
|
-
}
|
|
1332
|
+
else {
|
|
1333
|
+
const snapshot = undoStack.pop();
|
|
1334
|
+
if (snapshot.prevContent === null) {
|
|
1335
|
+
// File was created from scratch — delete it
|
|
1336
|
+
if (fs.existsSync(snapshot.filePath)) {
|
|
1337
|
+
fs.unlinkSync(snapshot.filePath);
|
|
1338
|
+
response.data = {
|
|
1339
|
+
success: true,
|
|
1340
|
+
undone: snapshot.operation,
|
|
1341
|
+
file: snapshot.relativePath,
|
|
1342
|
+
action: 'deleted (was new file)',
|
|
1343
|
+
remaining: undoStack.length,
|
|
1344
|
+
};
|
|
1345
|
+
console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
|
|
1387
1346
|
}
|
|
1388
1347
|
else {
|
|
1389
|
-
// Restore previous content
|
|
1390
|
-
fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
|
|
1391
1348
|
response.data = {
|
|
1392
1349
|
success: true,
|
|
1393
1350
|
undone: snapshot.operation,
|
|
1394
1351
|
file: snapshot.relativePath,
|
|
1395
|
-
action: '
|
|
1352
|
+
action: 'already gone',
|
|
1396
1353
|
remaining: undoStack.length,
|
|
1397
1354
|
};
|
|
1398
|
-
console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
|
|
1399
1355
|
}
|
|
1400
1356
|
}
|
|
1401
|
-
}
|
|
1402
|
-
catch (e) {
|
|
1403
|
-
response.error = e.message;
|
|
1404
|
-
}
|
|
1405
|
-
break;
|
|
1406
|
-
// ========== GIT & ENVIRONMENT TOOLS ==========
|
|
1407
|
-
case 'GIT_BLAME':
|
|
1408
|
-
try {
|
|
1409
|
-
const filePath = message.filePath;
|
|
1410
|
-
const line = message.line;
|
|
1411
|
-
const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
|
|
1412
|
-
const lines = stdout.split('\n');
|
|
1413
|
-
const commitHash = lines[0].split(' ')[0];
|
|
1414
|
-
const author = lines.find((l) => l.startsWith('author '))?.substring(7);
|
|
1415
|
-
const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
|
|
1416
|
-
const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
|
|
1417
|
-
const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
|
|
1418
|
-
response.data = {
|
|
1419
|
-
commit: commitHash,
|
|
1420
|
-
author,
|
|
1421
|
-
email,
|
|
1422
|
-
date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
|
|
1423
|
-
message: summary
|
|
1424
|
-
};
|
|
1425
|
-
}
|
|
1426
|
-
catch (e) {
|
|
1427
|
-
response.error = `Git blame failed: ${e.message}`;
|
|
1428
|
-
}
|
|
1429
|
-
break;
|
|
1430
|
-
case 'GIT_RECENT_CHANGES':
|
|
1431
|
-
try {
|
|
1432
|
-
const filePath = message.filePath;
|
|
1433
|
-
const days = message.days || 7;
|
|
1434
|
-
const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
|
|
1435
|
-
response.data = {
|
|
1436
|
-
history: stdout.split('\n').filter(Boolean).map((line) => {
|
|
1437
|
-
const [hash, author, date, message] = line.split('|');
|
|
1438
|
-
return { hash, author, date, message };
|
|
1439
|
-
})
|
|
1440
|
-
};
|
|
1441
|
-
}
|
|
1442
|
-
catch (e) {
|
|
1443
|
-
response.error = `Git log failed: ${e.message}`;
|
|
1444
|
-
}
|
|
1445
|
-
break;
|
|
1446
|
-
case 'GET_IMPORTS':
|
|
1447
|
-
try {
|
|
1448
|
-
const filePath = path.resolve(projectPath, message.filePath);
|
|
1449
|
-
if (fs.existsSync(filePath)) {
|
|
1450
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1451
|
-
const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
|
|
1452
|
-
const imports = [];
|
|
1453
|
-
let match;
|
|
1454
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
1455
|
-
imports.push(match[1]);
|
|
1456
|
-
}
|
|
1457
|
-
response.data = { imports };
|
|
1458
|
-
}
|
|
1459
1357
|
else {
|
|
1460
|
-
|
|
1358
|
+
// Restore previous content
|
|
1359
|
+
fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
|
|
1360
|
+
response.data = {
|
|
1361
|
+
success: true,
|
|
1362
|
+
undone: snapshot.operation,
|
|
1363
|
+
file: snapshot.relativePath,
|
|
1364
|
+
action: 'restored',
|
|
1365
|
+
remaining: undoStack.length,
|
|
1366
|
+
};
|
|
1367
|
+
console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
|
|
1461
1368
|
}
|
|
1462
1369
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1370
|
+
}
|
|
1371
|
+
catch (e) {
|
|
1372
|
+
response.error = e.message;
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
// ========== GIT & ENVIRONMENT TOOLS ==========
|
|
1376
|
+
case 'GIT_BLAME':
|
|
1377
|
+
try {
|
|
1378
|
+
const filePath = message.filePath;
|
|
1379
|
+
const line = message.line;
|
|
1380
|
+
const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
|
|
1381
|
+
const lines = stdout.split('\n');
|
|
1382
|
+
const commitHash = lines[0].split(' ')[0];
|
|
1383
|
+
const author = lines.find((l) => l.startsWith('author '))?.substring(7);
|
|
1384
|
+
const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
|
|
1385
|
+
const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
|
|
1386
|
+
const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
|
|
1387
|
+
response.data = {
|
|
1388
|
+
commit: commitHash,
|
|
1389
|
+
author,
|
|
1390
|
+
email,
|
|
1391
|
+
date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
|
|
1392
|
+
message: summary
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
catch (e) {
|
|
1396
|
+
response.error = `Git blame failed: ${e.message}`;
|
|
1397
|
+
}
|
|
1398
|
+
break;
|
|
1399
|
+
case 'GIT_RECENT_CHANGES':
|
|
1400
|
+
try {
|
|
1401
|
+
const filePath = message.filePath;
|
|
1402
|
+
const days = message.days || 7;
|
|
1403
|
+
const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
|
|
1404
|
+
response.data = {
|
|
1405
|
+
history: stdout.split('\n').filter(Boolean).map((line) => {
|
|
1406
|
+
const [hash, author, date, message] = line.split('|');
|
|
1407
|
+
return { hash, author, date, message };
|
|
1408
|
+
})
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
catch (e) {
|
|
1412
|
+
response.error = `Git log failed: ${e.message}`;
|
|
1413
|
+
}
|
|
1414
|
+
break;
|
|
1415
|
+
case 'GET_IMPORTS':
|
|
1416
|
+
try {
|
|
1417
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1418
|
+
if (fs.existsSync(filePath)) {
|
|
1419
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1420
|
+
const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
|
|
1421
|
+
const imports = [];
|
|
1422
|
+
let match;
|
|
1423
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
1424
|
+
imports.push(match[1]);
|
|
1504
1425
|
}
|
|
1426
|
+
response.data = { imports };
|
|
1505
1427
|
}
|
|
1506
|
-
|
|
1507
|
-
response.error =
|
|
1428
|
+
else {
|
|
1429
|
+
response.error = 'File not found';
|
|
1508
1430
|
}
|
|
1509
|
-
break;
|
|
1510
|
-
// ========== TERMINAL BUFFER ACCESS ==========
|
|
1511
|
-
case 'GET_TERMINAL_BUFFER': {
|
|
1512
|
-
// Returns the last N lines of the dev server terminal output.
|
|
1513
|
-
// Agents use this to get compiler errors without running a build.
|
|
1514
|
-
const lines = parseInt(message.lines) || 100;
|
|
1515
|
-
const buffer = globalTerminalBuffer.getLast(lines);
|
|
1516
|
-
response.data = {
|
|
1517
|
-
lines: buffer,
|
|
1518
|
-
totalLines: globalTerminalBuffer.length,
|
|
1519
|
-
hasDevProcess: devProcess !== null && !devProcess.killed,
|
|
1520
|
-
};
|
|
1521
|
-
break;
|
|
1522
1431
|
}
|
|
1523
|
-
|
|
1432
|
+
catch (e) {
|
|
1433
|
+
response.error = e.message;
|
|
1434
|
+
}
|
|
1435
|
+
break;
|
|
1436
|
+
case 'FIND_USAGES':
|
|
1437
|
+
try {
|
|
1438
|
+
const query = message.query;
|
|
1439
|
+
const { stdout } = await execAsync(`git grep -n "${query}"`, { cwd: projectPath });
|
|
1524
1440
|
response.data = {
|
|
1525
|
-
|
|
1526
|
-
|
|
1441
|
+
usages: stdout.split('\n').filter(Boolean).slice(0, 20).map((line) => {
|
|
1442
|
+
const parts = line.split(':');
|
|
1443
|
+
return {
|
|
1444
|
+
file: parts[0],
|
|
1445
|
+
line: parseInt(parts[1]),
|
|
1446
|
+
content: parts.slice(2).join(':').trim()
|
|
1447
|
+
};
|
|
1448
|
+
})
|
|
1527
1449
|
};
|
|
1528
|
-
break;
|
|
1529
1450
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1451
|
+
catch (e) {
|
|
1452
|
+
if (e.code === 1)
|
|
1453
|
+
response.data = { usages: [] };
|
|
1454
|
+
else
|
|
1455
|
+
response.error = `Grep failed: ${e.message}`;
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
case 'GET_ENV_VARS':
|
|
1459
|
+
try {
|
|
1460
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1461
|
+
if (fs.existsSync(filePath)) {
|
|
1462
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1463
|
+
const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
|
|
1464
|
+
const vars = new Set();
|
|
1465
|
+
let match;
|
|
1466
|
+
while ((match = envRegex.exec(content)) !== null) {
|
|
1467
|
+
vars.add(match[1]);
|
|
1468
|
+
}
|
|
1469
|
+
response.data = { envVars: Array.from(vars) };
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
response.error = 'File not found';
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
catch (e) {
|
|
1476
|
+
response.error = e.message;
|
|
1477
|
+
}
|
|
1478
|
+
break;
|
|
1479
|
+
// ========== TERMINAL BUFFER ACCESS ==========
|
|
1480
|
+
case 'GET_TERMINAL_BUFFER': {
|
|
1481
|
+
// Returns the last N lines of the dev server terminal output.
|
|
1482
|
+
// Agents use this to get compiler errors without running a build.
|
|
1483
|
+
const lines = parseInt(message.lines) || 100;
|
|
1484
|
+
const buffer = globalTerminalBuffer.getLast(lines);
|
|
1485
|
+
response.data = {
|
|
1486
|
+
lines: buffer,
|
|
1487
|
+
totalLines: globalTerminalBuffer.length,
|
|
1488
|
+
hasDevProcess: devProcess !== null && !devProcess.killed,
|
|
1489
|
+
};
|
|
1490
|
+
break;
|
|
1532
1491
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1492
|
+
case 'GET_DEV_STATUS': {
|
|
1493
|
+
response.data = {
|
|
1494
|
+
running: devProcess !== null && !devProcess.killed,
|
|
1495
|
+
pid: devProcess?.pid ?? null,
|
|
1496
|
+
};
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
default:
|
|
1500
|
+
response.error = `Unknown message type: ${type}`;
|
|
1537
1501
|
}
|
|
1538
|
-
|
|
1502
|
+
ws.send(JSON.stringify(response));
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
console.error(chalk.red('Parse error:'), e.message);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
// ============================================
|
|
1510
|
+
// COMMAND: trace connect (legacy / IDE-only)
|
|
1511
|
+
// ============================================
|
|
1512
|
+
program
|
|
1513
|
+
.command('connect', { isDefault: true })
|
|
1514
|
+
.alias('c')
|
|
1515
|
+
.description('Start IDE bridge only (use "trace dev" to also start your dev server)')
|
|
1516
|
+
.option('-p, --port <port>', 'WebSocket port', '8765')
|
|
1517
|
+
.action(async (options) => {
|
|
1518
|
+
const port = parseInt(options.port);
|
|
1519
|
+
const projectPath = process.cwd();
|
|
1520
|
+
console.log();
|
|
1521
|
+
console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
|
|
1522
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
1523
|
+
console.log();
|
|
1524
|
+
console.log(`Project: ${chalk.green(projectPath)}`);
|
|
1525
|
+
console.log(`Port: ${chalk.cyan(port)}`);
|
|
1526
|
+
console.log();
|
|
1527
|
+
try {
|
|
1528
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
1529
|
+
if (fs.existsSync(pkgPath)) {
|
|
1530
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
1531
|
+
console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
catch (_) { }
|
|
1535
|
+
const wss = new WebSocketServer({ port });
|
|
1536
|
+
let clientCount = 0;
|
|
1537
|
+
console.log();
|
|
1538
|
+
console.log(chalk.green('✓') + ' WebSocket server started');
|
|
1539
|
+
console.log(chalk.dim('Waiting for extension to connect...'));
|
|
1540
|
+
console.log();
|
|
1541
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
1542
|
+
console.log(chalk.dim('Press Ctrl+C to stop'));
|
|
1543
|
+
console.log();
|
|
1544
|
+
wss.on('connection', (ws) => {
|
|
1545
|
+
clientCount++;
|
|
1546
|
+
console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|
|
1547
|
+
// Wire up the full IDE bridge protocol
|
|
1548
|
+
attachMessageHandler(ws, projectPath);
|
|
1539
1549
|
ws.on('close', () => {
|
|
1540
1550
|
clientCount--;
|
|
1541
1551
|
console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|