@bamptee/aia-code 2.0.1 → 2.0.3
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/README.md +55 -4
- package/package.json +7 -1
- package/src/commands/init.js +11 -1
- package/src/models.js +19 -2
- package/src/prompt-builder.js +33 -1
- package/src/providers/anthropic.js +1 -1
- package/src/providers/cli-runner.js +2 -1
- package/src/services/config.js +53 -2
- package/src/services/flow-analyzer.js +32 -0
- package/src/services/runner.js +2 -2
- package/src/services/status.js +14 -0
- package/src/services/suggestions.js +166 -0
- package/src/services/worktrunk.js +197 -0
- package/src/ui/api/config.js +55 -2
- package/src/ui/api/features.js +301 -6
- package/src/ui/api/index.js +2 -0
- package/src/ui/api/worktrunk.js +153 -0
- package/src/ui/public/components/config-view.js +196 -2
- package/src/ui/public/components/dashboard.js +64 -4
- package/src/ui/public/components/feature-detail.js +584 -94
- package/src/ui/public/components/terminal.js +197 -0
- package/src/ui/public/components/worktrunk-panel.js +205 -0
- package/src/ui/public/main.js +23 -1
- package/src/ui/router.js +1 -1
- package/src/ui/server.js +85 -0
- package/src/utils/prompt.js +38 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// Terminal component using xterm.js loaded from CDN
|
|
4
|
+
export function Terminal({ wsUrl, onClose }) {
|
|
5
|
+
const containerRef = React.useRef(null);
|
|
6
|
+
const termRef = React.useRef(null);
|
|
7
|
+
const [status, setStatus] = React.useState('connecting');
|
|
8
|
+
const [error, setError] = React.useState(null);
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
let term = null;
|
|
12
|
+
let ws = null;
|
|
13
|
+
let fitAddon = null;
|
|
14
|
+
|
|
15
|
+
const initTerminal = async () => {
|
|
16
|
+
// Check if xterm is loaded
|
|
17
|
+
if (!window.Terminal) {
|
|
18
|
+
setError('xterm.js not loaded');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
term = new window.Terminal({
|
|
24
|
+
cursorBlink: true,
|
|
25
|
+
fontSize: 13,
|
|
26
|
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
|
27
|
+
theme: {
|
|
28
|
+
background: '#0f172a',
|
|
29
|
+
foreground: '#e2e8f0',
|
|
30
|
+
cursor: '#38bdf8',
|
|
31
|
+
cursorAccent: '#0f172a',
|
|
32
|
+
selection: '#38bdf833',
|
|
33
|
+
black: '#1e293b',
|
|
34
|
+
red: '#f87171',
|
|
35
|
+
green: '#4ade80',
|
|
36
|
+
yellow: '#fbbf24',
|
|
37
|
+
blue: '#60a5fa',
|
|
38
|
+
magenta: '#c084fc',
|
|
39
|
+
cyan: '#22d3ee',
|
|
40
|
+
white: '#f1f5f9',
|
|
41
|
+
brightBlack: '#475569',
|
|
42
|
+
brightRed: '#fca5a5',
|
|
43
|
+
brightGreen: '#86efac',
|
|
44
|
+
brightYellow: '#fcd34d',
|
|
45
|
+
brightBlue: '#93c5fd',
|
|
46
|
+
brightMagenta: '#d8b4fe',
|
|
47
|
+
brightCyan: '#67e8f9',
|
|
48
|
+
brightWhite: '#f8fafc',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (window.FitAddon) {
|
|
53
|
+
fitAddon = new window.FitAddon.FitAddon();
|
|
54
|
+
term.loadAddon(fitAddon);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
term.open(containerRef.current);
|
|
58
|
+
|
|
59
|
+
if (fitAddon) {
|
|
60
|
+
fitAddon.fit();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Connect WebSocket
|
|
64
|
+
ws = new WebSocket(wsUrl);
|
|
65
|
+
|
|
66
|
+
ws.onopen = () => {
|
|
67
|
+
setStatus('connected');
|
|
68
|
+
// Send initial size
|
|
69
|
+
if (fitAddon) {
|
|
70
|
+
const dims = fitAddon.proposeDimensions();
|
|
71
|
+
if (dims) {
|
|
72
|
+
ws.send(`\x1b[8;${dims.rows};${dims.cols}t`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
ws.onmessage = (event) => {
|
|
78
|
+
term.write(event.data);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
ws.onclose = () => {
|
|
82
|
+
setStatus('disconnected');
|
|
83
|
+
term.write('\r\n\x1b[33m[Connection closed]\x1b[0m\r\n');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
ws.onerror = () => {
|
|
87
|
+
setStatus('error');
|
|
88
|
+
setError('WebSocket connection failed');
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Send input to server
|
|
92
|
+
term.onData((data) => {
|
|
93
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
94
|
+
ws.send(data);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Handle resize
|
|
99
|
+
const handleResize = () => {
|
|
100
|
+
if (fitAddon) {
|
|
101
|
+
fitAddon.fit();
|
|
102
|
+
const dims = fitAddon.proposeDimensions();
|
|
103
|
+
if (dims && ws && ws.readyState === WebSocket.OPEN) {
|
|
104
|
+
ws.send(`\x1b[8;${dims.rows};${dims.cols}t`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
window.addEventListener('resize', handleResize);
|
|
110
|
+
termRef.current = { term, ws, fitAddon, handleResize };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setError(err.message);
|
|
113
|
+
setStatus('error');
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
initTerminal();
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
if (termRef.current) {
|
|
121
|
+
if (termRef.current.handleResize) {
|
|
122
|
+
window.removeEventListener('resize', termRef.current.handleResize);
|
|
123
|
+
}
|
|
124
|
+
if (termRef.current.ws) {
|
|
125
|
+
termRef.current.ws.close();
|
|
126
|
+
}
|
|
127
|
+
if (termRef.current.term) {
|
|
128
|
+
termRef.current.term.dispose();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}, [wsUrl]);
|
|
133
|
+
|
|
134
|
+
return React.createElement('div', { className: 'relative' },
|
|
135
|
+
// Header bar
|
|
136
|
+
React.createElement('div', { className: 'flex items-center justify-between bg-slate-800 px-3 py-1.5 rounded-t border border-slate-700 border-b-0' },
|
|
137
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
138
|
+
React.createElement('span', { className: 'text-xs text-slate-400' }, 'Terminal'),
|
|
139
|
+
React.createElement('span', {
|
|
140
|
+
className: `w-2 h-2 rounded-full ${status === 'connected' ? 'bg-emerald-400' : status === 'connecting' ? 'bg-amber-400 animate-pulse' : 'bg-red-400'}`,
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
onClose && React.createElement('button', {
|
|
144
|
+
onClick: onClose,
|
|
145
|
+
className: 'text-slate-400 hover:text-slate-200 text-xs',
|
|
146
|
+
title: 'Close terminal',
|
|
147
|
+
}, 'Close'),
|
|
148
|
+
),
|
|
149
|
+
|
|
150
|
+
// Terminal container
|
|
151
|
+
React.createElement('div', {
|
|
152
|
+
ref: containerRef,
|
|
153
|
+
className: 'h-64 bg-aia-bg rounded-b border border-slate-700 overflow-hidden',
|
|
154
|
+
}),
|
|
155
|
+
|
|
156
|
+
// Error message
|
|
157
|
+
error && React.createElement('div', { className: 'absolute inset-0 flex items-center justify-center bg-black/80 rounded' },
|
|
158
|
+
React.createElement('div', { className: 'text-center' },
|
|
159
|
+
React.createElement('p', { className: 'text-red-400 text-sm mb-2' }, error),
|
|
160
|
+
React.createElement('p', { className: 'text-slate-500 text-xs' }, 'Make sure node-pty is installed'),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Script loader for xterm.js from CDN
|
|
167
|
+
export function loadXtermScripts() {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
// Check if already loaded
|
|
170
|
+
if (window.Terminal && window.FitAddon) {
|
|
171
|
+
resolve();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Load CSS
|
|
176
|
+
if (!document.querySelector('link[href*="xterm.css"]')) {
|
|
177
|
+
const link = document.createElement('link');
|
|
178
|
+
link.rel = 'stylesheet';
|
|
179
|
+
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
|
|
180
|
+
document.head.appendChild(link);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Load xterm.js
|
|
184
|
+
const xtermScript = document.createElement('script');
|
|
185
|
+
xtermScript.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js';
|
|
186
|
+
xtermScript.onload = () => {
|
|
187
|
+
// Load fit addon
|
|
188
|
+
const fitScript = document.createElement('script');
|
|
189
|
+
fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js';
|
|
190
|
+
fitScript.onload = resolve;
|
|
191
|
+
fitScript.onerror = reject;
|
|
192
|
+
document.head.appendChild(fitScript);
|
|
193
|
+
};
|
|
194
|
+
xtermScript.onerror = reject;
|
|
195
|
+
document.head.appendChild(xtermScript);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { api } from '/main.js';
|
|
3
|
+
import { Terminal, loadXtermScripts } from '/components/terminal.js';
|
|
4
|
+
|
|
5
|
+
export function WorktrunkPanel({ featureName }) {
|
|
6
|
+
const [wtStatus, setWtStatus] = React.useState(null);
|
|
7
|
+
const [loading, setLoading] = React.useState(true);
|
|
8
|
+
const [actionLoading, setActionLoading] = React.useState(false);
|
|
9
|
+
const [error, setError] = React.useState(null);
|
|
10
|
+
const [showTerminal, setShowTerminal] = React.useState(false);
|
|
11
|
+
const [terminalReady, setTerminalReady] = React.useState(false);
|
|
12
|
+
const [servicesLoading, setServicesLoading] = React.useState(false);
|
|
13
|
+
const [servicesMessage, setServicesMessage] = React.useState(null);
|
|
14
|
+
|
|
15
|
+
const loadStatus = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const status = await api.get(`/features/${featureName}/wt`);
|
|
18
|
+
setWtStatus(status);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
setError(err.message);
|
|
21
|
+
}
|
|
22
|
+
setLoading(false);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
loadStatus();
|
|
27
|
+
}, [featureName]);
|
|
28
|
+
|
|
29
|
+
const handleCreateWorktree = async () => {
|
|
30
|
+
setActionLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
await api.post(`/features/${featureName}/wt/create`);
|
|
34
|
+
await loadStatus();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(err.message);
|
|
37
|
+
}
|
|
38
|
+
setActionLoading(false);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleRemoveWorktree = async () => {
|
|
42
|
+
if (!confirm(`Remove worktree for "${featureName}"? This will delete the worktree directory.`)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setActionLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
await api.delete(`/features/${featureName}/wt`);
|
|
49
|
+
setShowTerminal(false);
|
|
50
|
+
await loadStatus();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err.message);
|
|
53
|
+
}
|
|
54
|
+
setActionLoading(false);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleOpenTerminal = async () => {
|
|
58
|
+
if (!terminalReady) {
|
|
59
|
+
try {
|
|
60
|
+
await loadXtermScripts();
|
|
61
|
+
setTerminalReady(true);
|
|
62
|
+
} catch {
|
|
63
|
+
setError('Failed to load terminal scripts');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
setShowTerminal(true);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleStartServices = async () => {
|
|
71
|
+
setServicesLoading(true);
|
|
72
|
+
setError(null);
|
|
73
|
+
setServicesMessage(null);
|
|
74
|
+
try {
|
|
75
|
+
await api.post(`/features/${featureName}/wt/services/start`);
|
|
76
|
+
setServicesMessage('Services started');
|
|
77
|
+
setTimeout(() => setServicesMessage(null), 3000);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError(err.message);
|
|
80
|
+
}
|
|
81
|
+
setServicesLoading(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleStopServices = async () => {
|
|
85
|
+
setServicesLoading(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
setServicesMessage(null);
|
|
88
|
+
try {
|
|
89
|
+
await api.post(`/features/${featureName}/wt/services/stop`);
|
|
90
|
+
setServicesMessage('Services stopped');
|
|
91
|
+
setTimeout(() => setServicesMessage(null), 3000);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
setError(err.message);
|
|
94
|
+
}
|
|
95
|
+
setServicesLoading(false);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (loading) {
|
|
99
|
+
return React.createElement('div', { className: 'bg-aia-card border border-aia-border rounded p-4' },
|
|
100
|
+
React.createElement('p', { className: 'text-slate-500 text-sm' }, 'Loading worktrunk status...')
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Not installed state
|
|
105
|
+
if (!wtStatus?.installed) {
|
|
106
|
+
return React.createElement('div', { className: 'bg-slate-900 border border-slate-700 rounded p-4' },
|
|
107
|
+
React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
|
|
108
|
+
React.createElement('span', { className: 'text-orange-400' }, 'Worktrunk'),
|
|
109
|
+
React.createElement('span', { className: 'bg-slate-700 text-slate-400 text-xs px-2 py-0.5 rounded' }, 'not installed'),
|
|
110
|
+
),
|
|
111
|
+
React.createElement('p', { className: 'text-slate-500 text-xs mb-3' },
|
|
112
|
+
'Worktrunk (wt) CLI is not installed. It enables isolated development environments using git worktrees.'
|
|
113
|
+
),
|
|
114
|
+
React.createElement('div', { className: 'bg-black/30 rounded p-2' },
|
|
115
|
+
React.createElement('code', { className: 'text-xs text-slate-400' },
|
|
116
|
+
'cargo install worktrunk'
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No worktree state
|
|
123
|
+
if (!wtStatus?.hasWorktree) {
|
|
124
|
+
return React.createElement('div', { className: 'bg-slate-900 border border-orange-500/30 rounded p-4' },
|
|
125
|
+
React.createElement('div', { className: 'flex items-center justify-between mb-3' },
|
|
126
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
127
|
+
React.createElement('span', { className: 'text-orange-400 font-medium' }, 'Worktrunk'),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
React.createElement('p', { className: 'text-slate-400 text-sm mb-3' },
|
|
131
|
+
'Create an isolated worktree for this feature to work without affecting your main branch.'
|
|
132
|
+
),
|
|
133
|
+
React.createElement('button', {
|
|
134
|
+
onClick: handleCreateWorktree,
|
|
135
|
+
disabled: actionLoading,
|
|
136
|
+
className: 'bg-orange-500/20 text-orange-400 border border-orange-500/30 rounded px-4 py-2 text-sm hover:bg-orange-500/30 disabled:opacity-40',
|
|
137
|
+
}, actionLoading ? 'Creating...' : 'Create Worktree'),
|
|
138
|
+
error && React.createElement('p', { className: 'text-red-400 text-xs mt-2' }, error),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Active worktree state
|
|
143
|
+
// F8: Use wss:// for HTTPS, ws:// for HTTP
|
|
144
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
145
|
+
const wsUrl = `${wsProtocol}//${window.location.host}/api/terminal?cwd=${encodeURIComponent(wtStatus.path)}`;
|
|
146
|
+
|
|
147
|
+
return React.createElement('div', { className: 'bg-slate-900 border border-orange-500/30 rounded p-4 space-y-4' },
|
|
148
|
+
// Header
|
|
149
|
+
React.createElement('div', { className: 'flex items-center justify-between' },
|
|
150
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
151
|
+
React.createElement('span', { className: 'text-orange-400 font-medium' }, 'Worktrunk'),
|
|
152
|
+
React.createElement('span', { className: 'bg-emerald-500/20 text-emerald-400 text-xs px-2 py-0.5 rounded' }, 'active'),
|
|
153
|
+
),
|
|
154
|
+
React.createElement('button', {
|
|
155
|
+
onClick: handleRemoveWorktree,
|
|
156
|
+
disabled: actionLoading,
|
|
157
|
+
className: 'text-red-400 hover:text-red-300 text-xs disabled:opacity-40',
|
|
158
|
+
}, actionLoading ? 'Removing...' : 'Remove'),
|
|
159
|
+
),
|
|
160
|
+
|
|
161
|
+
// Worktree info
|
|
162
|
+
React.createElement('div', { className: 'space-y-1' },
|
|
163
|
+
React.createElement('p', { className: 'text-xs text-slate-500' }, 'Branch'),
|
|
164
|
+
React.createElement('p', { className: 'text-sm text-slate-300 font-mono' }, wtStatus.branch),
|
|
165
|
+
React.createElement('p', { className: 'text-xs text-slate-500 mt-2' }, 'Path'),
|
|
166
|
+
React.createElement('p', { className: 'text-sm text-slate-400 font-mono truncate', title: wtStatus.path }, wtStatus.path),
|
|
167
|
+
),
|
|
168
|
+
|
|
169
|
+
// Actions
|
|
170
|
+
React.createElement('div', { className: 'flex flex-wrap gap-2' },
|
|
171
|
+
React.createElement('button', {
|
|
172
|
+
onClick: showTerminal ? () => setShowTerminal(false) : handleOpenTerminal,
|
|
173
|
+
className: 'bg-slate-700 text-slate-300 border border-slate-600 rounded px-3 py-1.5 text-xs hover:bg-slate-600',
|
|
174
|
+
}, showTerminal ? 'Hide Terminal' : 'Open Terminal'),
|
|
175
|
+
|
|
176
|
+
// Docker services buttons (only if services exist)
|
|
177
|
+
wtStatus.hasServices && React.createElement(React.Fragment, null,
|
|
178
|
+
React.createElement('button', {
|
|
179
|
+
onClick: handleStartServices,
|
|
180
|
+
disabled: servicesLoading,
|
|
181
|
+
className: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded px-3 py-1.5 text-xs hover:bg-emerald-500/30 disabled:opacity-40',
|
|
182
|
+
}, servicesLoading ? '...' : 'Start Services'),
|
|
183
|
+
React.createElement('button', {
|
|
184
|
+
onClick: handleStopServices,
|
|
185
|
+
disabled: servicesLoading,
|
|
186
|
+
className: 'bg-red-500/20 text-red-400 border border-red-500/30 rounded px-3 py-1.5 text-xs hover:bg-red-500/30 disabled:opacity-40',
|
|
187
|
+
}, servicesLoading ? '...' : 'Stop Services'),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
|
|
191
|
+
// Terminal
|
|
192
|
+
showTerminal && terminalReady && React.createElement('div', { className: 'mt-4' },
|
|
193
|
+
React.createElement(Terminal, {
|
|
194
|
+
wsUrl,
|
|
195
|
+
onClose: () => setShowTerminal(false),
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
|
|
199
|
+
// F9: Services feedback message
|
|
200
|
+
servicesMessage && React.createElement('p', { className: 'text-emerald-400 text-xs' }, servicesMessage),
|
|
201
|
+
|
|
202
|
+
// Error
|
|
203
|
+
error && React.createElement('p', { className: 'text-red-400 text-xs' }, error),
|
|
204
|
+
);
|
|
205
|
+
}
|
package/src/ui/public/main.js
CHANGED
|
@@ -29,6 +29,28 @@ export const api = {
|
|
|
29
29
|
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
|
30
30
|
return res.json();
|
|
31
31
|
},
|
|
32
|
+
async patch(path, body = {}) {
|
|
33
|
+
const res = await fetch(`/api${path}`, {
|
|
34
|
+
method: 'PATCH',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
|
39
|
+
return res.json();
|
|
40
|
+
},
|
|
41
|
+
async delete(path) {
|
|
42
|
+
const res = await fetch(`/api${path}`, { method: 'DELETE' });
|
|
43
|
+
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
|
44
|
+
return res.json();
|
|
45
|
+
},
|
|
46
|
+
async upload(path, formData) {
|
|
47
|
+
const res = await fetch(`/api${path}`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body: formData,
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
|
52
|
+
return res.json();
|
|
53
|
+
},
|
|
32
54
|
};
|
|
33
55
|
|
|
34
56
|
// --- SSE stream helper ---
|
|
@@ -56,7 +78,7 @@ export async function streamPost(path, body, { onLog, onStatus }) {
|
|
|
56
78
|
let eventType = null;
|
|
57
79
|
for (const line of lines) {
|
|
58
80
|
if (line.startsWith('event: ')) {
|
|
59
|
-
eventType = line.slice(7);
|
|
81
|
+
eventType = line.slice(7).trim();
|
|
60
82
|
} else if (line.startsWith('data: ') && eventType) {
|
|
61
83
|
try {
|
|
62
84
|
const data = JSON.parse(line.slice(6));
|
package/src/ui/router.js
CHANGED
|
@@ -22,7 +22,7 @@ export function createRouter() {
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
return { add, match, get: (p, h) => add('GET', p, h), post: (p, h) => add('POST', p, h), put: (p, h) => add('PUT', p, h), delete: (p, h) => add('DELETE', p, h) };
|
|
25
|
+
return { add, match, get: (p, h) => add('GET', p, h), post: (p, h) => add('POST', p, h), put: (p, h) => add('PUT', p, h), patch: (p, h) => add('PATCH', p, h), delete: (p, h) => add('DELETE', p, h) };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export async function parseBody(req) {
|
package/src/ui/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createConnection } from 'node:net';
|
|
|
3
3
|
import { createReadStream, existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
6
7
|
import { createRouter, parseBody, json, error } from './router.js';
|
|
7
8
|
import { registerApiRoutes } from './api/index.js';
|
|
8
9
|
|
|
@@ -95,6 +96,90 @@ export async function startServer(preferredPort, root = process.cwd()) {
|
|
|
95
96
|
serveStatic(res, filePath);
|
|
96
97
|
});
|
|
97
98
|
|
|
99
|
+
// Setup WebSocket server for terminal
|
|
100
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
101
|
+
|
|
102
|
+
server.on('upgrade', (req, socket, head) => {
|
|
103
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
104
|
+
|
|
105
|
+
if (url.pathname === '/api/terminal') {
|
|
106
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
107
|
+
const cwd = url.searchParams.get('cwd') || root;
|
|
108
|
+
|
|
109
|
+
// Dynamically import node-pty (may not be installed)
|
|
110
|
+
import('node-pty').then(({ spawn: ptySpawn }) => {
|
|
111
|
+
// F6: Use user's preferred shell from environment
|
|
112
|
+
const shell = process.platform === 'win32'
|
|
113
|
+
? 'powershell.exe'
|
|
114
|
+
: process.env.SHELL || '/bin/bash';
|
|
115
|
+
const ptyProcess = ptySpawn(shell, [], {
|
|
116
|
+
name: 'xterm-256color',
|
|
117
|
+
cols: 80,
|
|
118
|
+
rows: 24,
|
|
119
|
+
cwd,
|
|
120
|
+
env: process.env,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// F7: Idle timeout - close terminal after 30 minutes of inactivity
|
|
124
|
+
const IDLE_TIMEOUT = 30 * 60 * 1000;
|
|
125
|
+
let idleTimer = setTimeout(() => {
|
|
126
|
+
ws.send('\r\n\x1b[33m[Session closed due to inactivity]\x1b[0m\r\n');
|
|
127
|
+
ws.close();
|
|
128
|
+
}, IDLE_TIMEOUT);
|
|
129
|
+
|
|
130
|
+
const resetIdleTimer = () => {
|
|
131
|
+
clearTimeout(idleTimer);
|
|
132
|
+
idleTimer = setTimeout(() => {
|
|
133
|
+
ws.send('\r\n\x1b[33m[Session closed due to inactivity]\x1b[0m\r\n');
|
|
134
|
+
ws.close();
|
|
135
|
+
}, IDLE_TIMEOUT);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
ws.on('message', (data) => {
|
|
139
|
+
resetIdleTimer();
|
|
140
|
+
const msg = data.toString();
|
|
141
|
+
// F5: Limit message size to 64KB
|
|
142
|
+
if (msg.length > 65536) return;
|
|
143
|
+
// Handle resize messages
|
|
144
|
+
if (msg.startsWith('\x1b[8;')) {
|
|
145
|
+
const match = msg.match(/\x1b\[8;(\d+);(\d+)t/);
|
|
146
|
+
if (match) {
|
|
147
|
+
ptyProcess.resize(parseInt(match[2], 10), parseInt(match[1], 10));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
ptyProcess.write(msg);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
ptyProcess.onData((data) => {
|
|
155
|
+
resetIdleTimer();
|
|
156
|
+
try {
|
|
157
|
+
ws.send(data);
|
|
158
|
+
} catch {}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
ws.on('close', () => {
|
|
162
|
+
clearTimeout(idleTimer);
|
|
163
|
+
ptyProcess.kill();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
ws.on('error', () => {
|
|
167
|
+
clearTimeout(idleTimer);
|
|
168
|
+
ptyProcess.kill();
|
|
169
|
+
});
|
|
170
|
+
}).catch((err) => {
|
|
171
|
+
ws.send(`\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`);
|
|
172
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
173
|
+
ws.send(`\x1b[33mRun: npm install node-pty\x1b[0m\r\n`);
|
|
174
|
+
}
|
|
175
|
+
ws.close();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
socket.destroy();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
98
183
|
return new Promise((resolve, reject) => {
|
|
99
184
|
server.on('error', reject);
|
|
100
185
|
server.listen(port, '127.0.0.1', () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
export function isInteractive() {
|
|
4
|
+
return process.stdin.isTTY === true;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ask(question, defaultValue = '') {
|
|
8
|
+
if (!isInteractive()) {
|
|
9
|
+
return Promise.resolve(defaultValue);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const rl = readline.createInterface({
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout,
|
|
15
|
+
});
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
|
|
18
|
+
rl.question(prompt, (answer) => {
|
|
19
|
+
rl.close();
|
|
20
|
+
resolve(answer.trim() || defaultValue);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function askRequired(question) {
|
|
26
|
+
if (!isInteractive()) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let answer = '';
|
|
31
|
+
while (!answer) {
|
|
32
|
+
answer = await ask(question);
|
|
33
|
+
if (!answer) {
|
|
34
|
+
console.log('This field is required.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return answer;
|
|
38
|
+
}
|