tlc-claude-code 1.4.5 → 1.4.7
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/docker-compose.dev.yml +4 -4
- package/package.json +4 -1
- package/server/dashboard/index.html +79 -5
- package/server/dashboard/login.html +262 -0
- package/server/index.js +239 -0
- package/server/lib/auth-system.js +9 -5
- package/server/lib/debug.test.js +2 -2
- package/server/lib/plan-parser.js +59 -16
package/docker-compose.dev.yml
CHANGED
|
@@ -101,18 +101,18 @@ services:
|
|
|
101
101
|
working_dir: /project
|
|
102
102
|
command: >
|
|
103
103
|
sh -c "
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
cd /project && node /
|
|
104
|
+
npm install -g tlc-claude-code &&
|
|
105
|
+
TLC_DIR=$$(npm root -g)/tlc-claude-code &&
|
|
106
|
+
cd /project && node $$TLC_DIR/server/index.js --proxy-only
|
|
107
107
|
"
|
|
108
108
|
environment:
|
|
109
109
|
- TLC_PORT=3147
|
|
110
110
|
- TLC_PROXY_ONLY=true
|
|
111
111
|
- TLC_APP_PORT=5001
|
|
112
|
+
- TLC_AUTH=false
|
|
112
113
|
ports:
|
|
113
114
|
- "${DASHBOARD_PORT:-3147}:3147"
|
|
114
115
|
volumes:
|
|
115
|
-
- ./server:/tlc/server
|
|
116
116
|
- ..:/project
|
|
117
117
|
depends_on:
|
|
118
118
|
- app
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tlc-claude-code",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"description": "TLC - Test Led Coding for Claude Code",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tlc": "./bin/tlc.js",
|
|
@@ -55,5 +55,8 @@
|
|
|
55
55
|
"@playwright/test": "^1.58.1",
|
|
56
56
|
"playwright": "^1.58.1",
|
|
57
57
|
"text-to-image": "^8.0.1"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"cookie-parser": "^1.4.7"
|
|
58
61
|
}
|
|
59
62
|
}
|
|
@@ -951,7 +951,9 @@
|
|
|
951
951
|
<span id="status-text">Connecting...</span>
|
|
952
952
|
</div>
|
|
953
953
|
<span style="color: #30363d">|</span>
|
|
954
|
-
<span class="version">v1.4.
|
|
954
|
+
<span class="version">v1.4.5</span>
|
|
955
|
+
<span style="color: #30363d">|</span>
|
|
956
|
+
<button id="logout-btn" onclick="logout()" style="background: transparent; border: 1px solid #30363d; color: #8b949e; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; display: none;">Logout</button>
|
|
955
957
|
</div>
|
|
956
958
|
</header>
|
|
957
959
|
|
|
@@ -1342,6 +1344,33 @@
|
|
|
1342
1344
|
let appPort = 5001;
|
|
1343
1345
|
|
|
1344
1346
|
const views = ['projects', 'tasks', 'chat', 'agents', 'preview', 'logs', 'github', 'health', 'router', 'settings'];
|
|
1347
|
+
|
|
1348
|
+
// Auth functions
|
|
1349
|
+
async function checkAuth() {
|
|
1350
|
+
try {
|
|
1351
|
+
const res = await fetch('/api/auth/status');
|
|
1352
|
+
const data = await res.json();
|
|
1353
|
+
if (data.authEnabled) {
|
|
1354
|
+
document.getElementById('logout-btn').style.display = 'inline-block';
|
|
1355
|
+
}
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
console.log('Auth check failed:', e);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function logout() {
|
|
1362
|
+
try {
|
|
1363
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
1364
|
+
window.location.href = '/login.html';
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
console.error('Logout failed:', e);
|
|
1367
|
+
window.location.href = '/login.html';
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Check auth on load
|
|
1372
|
+
checkAuth();
|
|
1373
|
+
|
|
1345
1374
|
const viewTitles = {
|
|
1346
1375
|
projects: '📁 Projects',
|
|
1347
1376
|
tasks: '📋 Tasks',
|
|
@@ -1822,19 +1851,63 @@
|
|
|
1822
1851
|
if (data.coverage) {
|
|
1823
1852
|
document.getElementById('stat-coverage').textContent = data.coverage + '%';
|
|
1824
1853
|
}
|
|
1854
|
+
// Update app port for preview
|
|
1855
|
+
if (data.appPort) {
|
|
1856
|
+
updateAppPort(data.appPort);
|
|
1857
|
+
}
|
|
1825
1858
|
} catch (e) {
|
|
1826
1859
|
console.error('Failed to load stats:', e);
|
|
1827
1860
|
}
|
|
1828
1861
|
}
|
|
1829
1862
|
|
|
1863
|
+
async function refreshProject() {
|
|
1864
|
+
try {
|
|
1865
|
+
const res = await fetch('/api/project');
|
|
1866
|
+
const data = await res.json();
|
|
1867
|
+
|
|
1868
|
+
document.getElementById('project-name').textContent = data.name || 'Unknown Project';
|
|
1869
|
+
document.getElementById('project-desc').textContent = data.description || data.projectDir || '';
|
|
1870
|
+
|
|
1871
|
+
if (data.phase && data.phaseName) {
|
|
1872
|
+
document.getElementById('phase-badge').textContent = `Phase ${data.phase}: ${data.phaseName}`;
|
|
1873
|
+
} else if (data.branch) {
|
|
1874
|
+
document.getElementById('phase-badge').textContent = `Branch: ${data.branch}`;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// Update progress bar
|
|
1878
|
+
const progress = data.tasks?.progress || 0;
|
|
1879
|
+
document.getElementById('progress-fill').style.width = `${progress}%`;
|
|
1880
|
+
|
|
1881
|
+
// Update stats if available
|
|
1882
|
+
if (data.tasks) {
|
|
1883
|
+
document.getElementById('stat-passing').textContent = data.tasks.done || 0;
|
|
1884
|
+
document.getElementById('stat-failing').textContent = (data.tasks.total - data.tasks.done) || 0;
|
|
1885
|
+
}
|
|
1886
|
+
} catch (e) {
|
|
1887
|
+
console.error('Failed to load project:', e);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1830
1891
|
async function refreshTasks() {
|
|
1831
1892
|
try {
|
|
1832
1893
|
const res = await fetch('/api/tasks');
|
|
1833
|
-
const
|
|
1894
|
+
const json = await res.json();
|
|
1895
|
+
// Handle both { items: [...] } and direct array
|
|
1896
|
+
const data = json.items || json || [];
|
|
1897
|
+
|
|
1898
|
+
// Normalize status values: done -> completed, working -> in_progress
|
|
1899
|
+
const normalizeStatus = (s) => {
|
|
1900
|
+
if (s === 'done' || s === 'complete') return 'completed';
|
|
1901
|
+
if (s === 'working') return 'in_progress';
|
|
1902
|
+
if (s === 'available') return 'pending';
|
|
1903
|
+
return s || 'pending';
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
const tasks = data.map(t => ({ ...t, status: normalizeStatus(t.status) }));
|
|
1834
1907
|
|
|
1835
|
-
const pending =
|
|
1836
|
-
const inProgress =
|
|
1837
|
-
const completed =
|
|
1908
|
+
const pending = tasks.filter(t => t.status === 'pending');
|
|
1909
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1910
|
+
const completed = tasks.filter(t => t.status === 'completed');
|
|
1838
1911
|
|
|
1839
1912
|
document.getElementById('tasks-pending').innerHTML = pending.map(t => `
|
|
1840
1913
|
<div class="task-item">
|
|
@@ -1959,6 +2032,7 @@
|
|
|
1959
2032
|
|
|
1960
2033
|
// Refresh All
|
|
1961
2034
|
function refreshAll() {
|
|
2035
|
+
refreshProject();
|
|
1962
2036
|
refreshStats();
|
|
1963
2037
|
refreshTasks();
|
|
1964
2038
|
refreshGitHub();
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>TLC Dashboard - Login</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
color: #e4e4e7;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.login-container {
|
|
25
|
+
background: rgba(30, 30, 46, 0.95);
|
|
26
|
+
border: 1px solid #3f3f5a;
|
|
27
|
+
border-radius: 12px;
|
|
28
|
+
padding: 40px;
|
|
29
|
+
width: 100%;
|
|
30
|
+
max-width: 400px;
|
|
31
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.logo {
|
|
35
|
+
text-align: center;
|
|
36
|
+
margin-bottom: 30px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.logo pre {
|
|
40
|
+
font-size: 10px;
|
|
41
|
+
line-height: 1.1;
|
|
42
|
+
color: #60a5fa;
|
|
43
|
+
font-family: monospace;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.logo h1 {
|
|
47
|
+
font-size: 18px;
|
|
48
|
+
color: #a5b4fc;
|
|
49
|
+
margin-top: 10px;
|
|
50
|
+
font-weight: 500;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.form-group {
|
|
54
|
+
margin-bottom: 20px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
label {
|
|
58
|
+
display: block;
|
|
59
|
+
margin-bottom: 8px;
|
|
60
|
+
font-size: 14px;
|
|
61
|
+
color: #9ca3af;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
input {
|
|
65
|
+
width: 100%;
|
|
66
|
+
padding: 12px 16px;
|
|
67
|
+
background: #27273a;
|
|
68
|
+
border: 1px solid #3f3f5a;
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
color: #e4e4e7;
|
|
71
|
+
font-size: 16px;
|
|
72
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
input:focus {
|
|
76
|
+
outline: none;
|
|
77
|
+
border-color: #60a5fa;
|
|
78
|
+
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
input::placeholder {
|
|
82
|
+
color: #6b7280;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
button {
|
|
86
|
+
width: 100%;
|
|
87
|
+
padding: 14px;
|
|
88
|
+
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
89
|
+
border: none;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
color: white;
|
|
92
|
+
font-size: 16px;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
button:hover {
|
|
99
|
+
transform: translateY(-1px);
|
|
100
|
+
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
button:active {
|
|
104
|
+
transform: translateY(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
button:disabled {
|
|
108
|
+
opacity: 0.6;
|
|
109
|
+
cursor: not-allowed;
|
|
110
|
+
transform: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.error {
|
|
114
|
+
background: rgba(239, 68, 68, 0.15);
|
|
115
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
116
|
+
color: #f87171;
|
|
117
|
+
padding: 12px;
|
|
118
|
+
border-radius: 8px;
|
|
119
|
+
margin-bottom: 20px;
|
|
120
|
+
font-size: 14px;
|
|
121
|
+
display: none;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.error.visible {
|
|
125
|
+
display: block;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.footer {
|
|
129
|
+
text-align: center;
|
|
130
|
+
margin-top: 24px;
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
color: #6b7280;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.footer a {
|
|
136
|
+
color: #60a5fa;
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.setup-hint {
|
|
141
|
+
background: rgba(96, 165, 250, 0.1);
|
|
142
|
+
border: 1px solid rgba(96, 165, 250, 0.2);
|
|
143
|
+
border-radius: 8px;
|
|
144
|
+
padding: 16px;
|
|
145
|
+
margin-bottom: 24px;
|
|
146
|
+
font-size: 13px;
|
|
147
|
+
line-height: 1.5;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.setup-hint code {
|
|
151
|
+
background: rgba(0, 0, 0, 0.3);
|
|
152
|
+
padding: 2px 6px;
|
|
153
|
+
border-radius: 4px;
|
|
154
|
+
font-family: monospace;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
158
|
+
</head>
|
|
159
|
+
<body>
|
|
160
|
+
<div class="login-container">
|
|
161
|
+
<div class="logo">
|
|
162
|
+
<pre>
|
|
163
|
+
████████╗██╗ ██████╗
|
|
164
|
+
╚══██╔══╝██║ ██╔════╝
|
|
165
|
+
██║ ██║ ██║
|
|
166
|
+
██║ ██║ ██║
|
|
167
|
+
██║ ███████╗╚██████╗
|
|
168
|
+
╚═╝ ╚══════╝ ╚═════╝</pre>
|
|
169
|
+
<h1>Dashboard Login</h1>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div id="setup-hint" class="setup-hint" style="display: none;">
|
|
173
|
+
<strong>Setup Required</strong><br>
|
|
174
|
+
Set credentials in <code>.tlc.json</code>:
|
|
175
|
+
<pre style="margin-top: 8px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
|
176
|
+
{
|
|
177
|
+
"auth": {
|
|
178
|
+
"adminEmail": "you@example.com",
|
|
179
|
+
"adminPassword": "your-password"
|
|
180
|
+
}
|
|
181
|
+
}</pre>
|
|
182
|
+
Or use environment variables:<br>
|
|
183
|
+
<code>TLC_ADMIN_EMAIL</code> / <code>TLC_ADMIN_PASSWORD</code>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div id="error" class="error"></div>
|
|
187
|
+
|
|
188
|
+
<form id="login-form">
|
|
189
|
+
<div class="form-group">
|
|
190
|
+
<label for="email">Email</label>
|
|
191
|
+
<input type="email" id="email" name="email" placeholder="admin@localhost" required>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="form-group">
|
|
195
|
+
<label for="password">Password</label>
|
|
196
|
+
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<button type="submit" id="submit-btn">Sign In</button>
|
|
200
|
+
</form>
|
|
201
|
+
|
|
202
|
+
<div class="footer">
|
|
203
|
+
TLC Dev Server · <a href="https://github.com/anthropics/tlc">Documentation</a>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<script>
|
|
208
|
+
const form = document.getElementById('login-form');
|
|
209
|
+
const errorEl = document.getElementById('error');
|
|
210
|
+
const submitBtn = document.getElementById('submit-btn');
|
|
211
|
+
const setupHint = document.getElementById('setup-hint');
|
|
212
|
+
|
|
213
|
+
// Check if this is first visit with failed login
|
|
214
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
215
|
+
if (urlParams.get('setup') === 'true') {
|
|
216
|
+
setupHint.style.display = 'block';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
form.addEventListener('submit', async (e) => {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
|
|
222
|
+
const email = document.getElementById('email').value;
|
|
223
|
+
const password = document.getElementById('password').value;
|
|
224
|
+
|
|
225
|
+
errorEl.classList.remove('visible');
|
|
226
|
+
submitBtn.disabled = true;
|
|
227
|
+
submitBtn.textContent = 'Signing in...';
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const res = await fetch('/api/auth/login', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ email, password }),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const data = await res.json();
|
|
237
|
+
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
throw new Error(data.error || 'Login failed');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Redirect to dashboard
|
|
243
|
+
window.location.href = '/';
|
|
244
|
+
} catch (err) {
|
|
245
|
+
errorEl.textContent = err.message;
|
|
246
|
+
errorEl.classList.add('visible');
|
|
247
|
+
|
|
248
|
+
// Show setup hint on first failed login
|
|
249
|
+
if (err.message === 'Invalid credentials') {
|
|
250
|
+
setupHint.style.display = 'block';
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
submitBtn.disabled = false;
|
|
254
|
+
submitBtn.textContent = 'Sign In';
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Auto-focus email field
|
|
259
|
+
document.getElementById('email').focus();
|
|
260
|
+
</script>
|
|
261
|
+
</body>
|
|
262
|
+
</html>
|
package/server/index.js
CHANGED
|
@@ -12,6 +12,14 @@ const chokidar = require('chokidar');
|
|
|
12
12
|
const { detectProject } = require('./lib/project-detector');
|
|
13
13
|
const { parsePlan, parseBugs } = require('./lib/plan-parser');
|
|
14
14
|
const { autoProvision, stopDatabase } = require('./lib/auto-database');
|
|
15
|
+
const {
|
|
16
|
+
createUserStore,
|
|
17
|
+
createAuthMiddleware,
|
|
18
|
+
generateJWT,
|
|
19
|
+
verifyJWT,
|
|
20
|
+
hashPassword,
|
|
21
|
+
verifyPassword,
|
|
22
|
+
} = require('./lib/auth-system');
|
|
15
23
|
|
|
16
24
|
// Handle PGlite WASM crashes gracefully
|
|
17
25
|
process.on('uncaughtException', (err) => {
|
|
@@ -48,6 +56,149 @@ const wss = new WebSocketServer({ server });
|
|
|
48
56
|
|
|
49
57
|
// Middleware
|
|
50
58
|
app.use(express.json());
|
|
59
|
+
const cookieParser = require('cookie-parser');
|
|
60
|
+
app.use(cookieParser());
|
|
61
|
+
|
|
62
|
+
// ============================================
|
|
63
|
+
// Authentication Setup
|
|
64
|
+
// ============================================
|
|
65
|
+
const userStore = createUserStore();
|
|
66
|
+
const JWT_SECRET = process.env.TLC_JWT_SECRET || 'tlc-dashboard-secret-change-in-production';
|
|
67
|
+
const AUTH_ENABLED = process.env.TLC_AUTH !== 'false';
|
|
68
|
+
|
|
69
|
+
// Initialize admin user from config or environment
|
|
70
|
+
async function initializeAuth() {
|
|
71
|
+
const tlcConfigPath = path.join(PROJECT_DIR, '.tlc.json');
|
|
72
|
+
let adminEmail = process.env.TLC_ADMIN_EMAIL || 'admin@localhost';
|
|
73
|
+
let adminPassword = process.env.TLC_ADMIN_PASSWORD;
|
|
74
|
+
|
|
75
|
+
// Try to read from .tlc.json
|
|
76
|
+
if (fs.existsSync(tlcConfigPath)) {
|
|
77
|
+
try {
|
|
78
|
+
const config = JSON.parse(fs.readFileSync(tlcConfigPath, 'utf-8'));
|
|
79
|
+
if (config.auth?.adminEmail) adminEmail = config.auth.adminEmail;
|
|
80
|
+
if (config.auth?.adminPassword) adminPassword = config.auth.adminPassword;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Ignore parse errors
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create admin user if password is set
|
|
87
|
+
if (adminPassword) {
|
|
88
|
+
try {
|
|
89
|
+
await userStore.createUser({
|
|
90
|
+
email: adminEmail,
|
|
91
|
+
password: adminPassword,
|
|
92
|
+
name: 'Admin',
|
|
93
|
+
role: 'admin',
|
|
94
|
+
}, { skipValidation: true }); // Dev tool - allow simple passwords
|
|
95
|
+
console.log(`[TLC] Admin user initialized: ${adminEmail}`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (!e.message.includes('already registered')) {
|
|
98
|
+
console.error('[TLC] Failed to create admin user:', e.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Auth middleware for protected routes
|
|
105
|
+
const authMiddleware = createAuthMiddleware({
|
|
106
|
+
userStore,
|
|
107
|
+
jwtSecret: JWT_SECRET,
|
|
108
|
+
requireAuth: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Public paths that don't require auth
|
|
112
|
+
const publicPaths = ['/api/auth/login', '/api/auth/status', '/login.html', '/login'];
|
|
113
|
+
|
|
114
|
+
// Apply auth to API routes (except public paths)
|
|
115
|
+
app.use((req, res, next) => {
|
|
116
|
+
// Skip auth if disabled
|
|
117
|
+
if (!AUTH_ENABLED) return next();
|
|
118
|
+
|
|
119
|
+
// Allow public paths
|
|
120
|
+
if (publicPaths.some(p => req.path === p || req.path.startsWith(p))) {
|
|
121
|
+
return next();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Allow static assets
|
|
125
|
+
if (req.path.match(/\.(js|css|png|jpg|ico|svg|woff|woff2)$/)) {
|
|
126
|
+
return next();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for auth cookie or header
|
|
130
|
+
const token = req.cookies?.tlc_token || req.headers.authorization?.replace('Bearer ', '');
|
|
131
|
+
|
|
132
|
+
if (!token) {
|
|
133
|
+
// Redirect browser requests to login, return 401 for API
|
|
134
|
+
if (req.path.startsWith('/api/')) {
|
|
135
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
136
|
+
}
|
|
137
|
+
return res.redirect('/login.html');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Verify token
|
|
141
|
+
const payload = verifyJWT(token, JWT_SECRET);
|
|
142
|
+
if (!payload) {
|
|
143
|
+
if (req.path.startsWith('/api/')) {
|
|
144
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
145
|
+
}
|
|
146
|
+
return res.redirect('/login.html');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Attach user to request
|
|
150
|
+
req.user = payload;
|
|
151
|
+
next();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Auth routes
|
|
155
|
+
app.get('/api/auth/status', (req, res) => {
|
|
156
|
+
res.json({ authEnabled: AUTH_ENABLED });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.post('/api/auth/login', async (req, res) => {
|
|
160
|
+
const { email, password } = req.body;
|
|
161
|
+
|
|
162
|
+
if (!email || !password) {
|
|
163
|
+
return res.status(400).json({ error: 'Email and password required' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const user = await userStore.authenticate(email, password);
|
|
167
|
+
if (!user) {
|
|
168
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Generate JWT
|
|
172
|
+
const token = generateJWT(
|
|
173
|
+
{ sub: user.id, email: user.email, role: user.role, name: user.name },
|
|
174
|
+
JWT_SECRET,
|
|
175
|
+
{ expiresIn: 86400 * 7 } // 7 days
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Set cookie
|
|
179
|
+
res.cookie('tlc_token', token, {
|
|
180
|
+
httpOnly: true,
|
|
181
|
+
secure: process.env.NODE_ENV === 'production',
|
|
182
|
+
sameSite: 'lax',
|
|
183
|
+
maxAge: 86400 * 7 * 1000, // 7 days
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
res.json({ success: true, user: { email: user.email, name: user.name, role: user.role } });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.post('/api/auth/logout', (req, res) => {
|
|
190
|
+
res.clearCookie('tlc_token');
|
|
191
|
+
res.json({ success: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
app.get('/api/auth/me', (req, res) => {
|
|
195
|
+
if (!req.user) {
|
|
196
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
197
|
+
}
|
|
198
|
+
res.json({ user: req.user });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Serve static files (after auth middleware)
|
|
51
202
|
app.use(express.static(path.join(__dirname, 'dashboard')));
|
|
52
203
|
|
|
53
204
|
// Broadcast to all WebSocket clients
|
|
@@ -203,6 +354,86 @@ function runTests() {
|
|
|
203
354
|
}
|
|
204
355
|
|
|
205
356
|
// API Routes
|
|
357
|
+
|
|
358
|
+
// Project info endpoint - returns real project data
|
|
359
|
+
app.get('/api/project', (req, res) => {
|
|
360
|
+
try {
|
|
361
|
+
const { execSync } = require('child_process');
|
|
362
|
+
|
|
363
|
+
// Get project name and description from package.json or .tlc.json
|
|
364
|
+
let projectName = 'Unknown Project';
|
|
365
|
+
let projectDesc = '';
|
|
366
|
+
let version = '';
|
|
367
|
+
|
|
368
|
+
const pkgPath = path.join(PROJECT_DIR, 'package.json');
|
|
369
|
+
if (fs.existsSync(pkgPath)) {
|
|
370
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
371
|
+
projectName = pkg.name || projectName;
|
|
372
|
+
projectDesc = pkg.description || '';
|
|
373
|
+
version = pkg.version || '';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const tlcPath = path.join(PROJECT_DIR, '.tlc.json');
|
|
377
|
+
if (fs.existsSync(tlcPath)) {
|
|
378
|
+
const tlc = JSON.parse(fs.readFileSync(tlcPath, 'utf-8'));
|
|
379
|
+
if (tlc.project) projectName = tlc.project;
|
|
380
|
+
if (tlc.description) projectDesc = tlc.description;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Get git info
|
|
384
|
+
let branch = 'unknown';
|
|
385
|
+
let lastCommit = null;
|
|
386
|
+
try {
|
|
387
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: PROJECT_DIR, encoding: 'utf-8' }).trim();
|
|
388
|
+
const commitInfo = execSync('git log -1 --pretty=format:"%h|%s|%ar"', { cwd: PROJECT_DIR, encoding: 'utf-8' }).trim();
|
|
389
|
+
const [hash, message, time] = commitInfo.split('|');
|
|
390
|
+
lastCommit = { hash, message, time };
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// Not a git repo
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Get phase info
|
|
396
|
+
const plan = parsePlan(PROJECT_DIR);
|
|
397
|
+
|
|
398
|
+
// Count phases from roadmap
|
|
399
|
+
let totalPhases = 0;
|
|
400
|
+
let completedPhases = 0;
|
|
401
|
+
const roadmapPath = path.join(PROJECT_DIR, '.planning', 'ROADMAP.md');
|
|
402
|
+
if (fs.existsSync(roadmapPath)) {
|
|
403
|
+
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
404
|
+
const phases = content.match(/##\s+Phase\s+\d+/g) || [];
|
|
405
|
+
totalPhases = phases.length;
|
|
406
|
+
const completed = content.match(/##\s+Phase\s+\d+[^[]*\[x\]/gi) || [];
|
|
407
|
+
completedPhases = completed.length;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Calculate progress
|
|
411
|
+
const tasksDone = plan.tasks?.filter(t => t.status === 'done' || t.status === 'complete').length || 0;
|
|
412
|
+
const tasksTotal = plan.tasks?.length || 0;
|
|
413
|
+
const progress = tasksTotal > 0 ? Math.round((tasksDone / tasksTotal) * 100) : 0;
|
|
414
|
+
|
|
415
|
+
res.json({
|
|
416
|
+
name: projectName,
|
|
417
|
+
description: projectDesc,
|
|
418
|
+
version,
|
|
419
|
+
branch,
|
|
420
|
+
lastCommit,
|
|
421
|
+
phase: plan.currentPhase,
|
|
422
|
+
phaseName: plan.currentPhaseName,
|
|
423
|
+
totalPhases,
|
|
424
|
+
completedPhases,
|
|
425
|
+
tasks: {
|
|
426
|
+
total: tasksTotal,
|
|
427
|
+
done: tasksDone,
|
|
428
|
+
progress
|
|
429
|
+
},
|
|
430
|
+
projectDir: PROJECT_DIR
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
res.status(500).json({ error: err.message });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
206
437
|
app.get('/api/status', (req, res) => {
|
|
207
438
|
const bugs = parseBugs(PROJECT_DIR);
|
|
208
439
|
const plan = parsePlan(PROJECT_DIR);
|
|
@@ -834,9 +1065,17 @@ async function main() {
|
|
|
834
1065
|
TLC Dev Server
|
|
835
1066
|
`);
|
|
836
1067
|
|
|
1068
|
+
// Initialize authentication
|
|
1069
|
+
await initializeAuth();
|
|
1070
|
+
|
|
837
1071
|
server.listen(TLC_PORT, () => {
|
|
838
1072
|
console.log(` Dashboard: http://localhost:${TLC_PORT}`);
|
|
839
1073
|
console.log(` Share: http://${getLocalIP()}:${TLC_PORT}`);
|
|
1074
|
+
if (AUTH_ENABLED) {
|
|
1075
|
+
console.log(` Auth: ENABLED (set TLC_AUTH=false to disable)`);
|
|
1076
|
+
} else {
|
|
1077
|
+
console.log(` Auth: DISABLED`);
|
|
1078
|
+
}
|
|
840
1079
|
console.log('');
|
|
841
1080
|
});
|
|
842
1081
|
|
|
@@ -318,8 +318,9 @@ function createUserStore() {
|
|
|
318
318
|
|
|
319
319
|
return {
|
|
320
320
|
// User methods
|
|
321
|
-
async createUser(data) {
|
|
322
|
-
|
|
321
|
+
async createUser(data, options = {}) {
|
|
322
|
+
// Skip email validation for dev setup (allows plain usernames)
|
|
323
|
+
if (!options.skipValidation && !validateEmail(data.email) && data.email.includes('@')) {
|
|
323
324
|
throw new Error('Invalid email format');
|
|
324
325
|
}
|
|
325
326
|
|
|
@@ -331,9 +332,12 @@ function createUserStore() {
|
|
|
331
332
|
throw new Error('Email already registered');
|
|
332
333
|
}
|
|
333
334
|
|
|
334
|
-
|
|
335
|
-
if (!
|
|
336
|
-
|
|
335
|
+
// Skip password validation for dev/config-based setup
|
|
336
|
+
if (!options.skipValidation) {
|
|
337
|
+
const passwordValidation = validatePassword(data.password);
|
|
338
|
+
if (!passwordValidation.valid) {
|
|
339
|
+
throw new Error(passwordValidation.errors.join(', '));
|
|
340
|
+
}
|
|
337
341
|
}
|
|
338
342
|
|
|
339
343
|
const user = createUser(data);
|
package/server/lib/debug.test.js
CHANGED
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
findOrphanedAgents,
|
|
4
4
|
resetCleanup,
|
|
5
5
|
} from './agent-cleanup.js';
|
|
6
|
-
import { getAgentRegistry, resetRegistry } from './
|
|
7
|
-
import { STATES } from './
|
|
6
|
+
import { getAgentRegistry, resetRegistry } from './agent-registry.js';
|
|
7
|
+
import { STATES } from './agent-state.js';
|
|
8
8
|
|
|
9
9
|
describe('debug', () => {
|
|
10
10
|
const BASE_TIME = new Date('2025-01-01T12:00:00Z').getTime();
|
|
@@ -53,41 +53,84 @@ function parsePlan(projectDir) {
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Parse task entries from PLAN.md content
|
|
56
|
-
* Supports formats:
|
|
56
|
+
* Supports multiple formats:
|
|
57
57
|
* ### Task 1: Title [ ]
|
|
58
58
|
* ### Task 1: Title [>@user]
|
|
59
59
|
* ### Task 1: Title [x@user]
|
|
60
|
+
* - [ ] Task description
|
|
61
|
+
* - [x] Completed task
|
|
62
|
+
* - [>] In progress task
|
|
63
|
+
* ## Task 1: Title
|
|
60
64
|
*/
|
|
61
65
|
function parseTasksFromPlan(content) {
|
|
62
66
|
const tasks = [];
|
|
63
|
-
const taskRegex = /###\s+Task\s+(\d+)[:\s]+(.+?)\s*\[([^\]]*)\]/g;
|
|
64
67
|
|
|
68
|
+
// Format 1: ### Task N: Title [status]
|
|
69
|
+
const taskRegex1 = /###\s+Task\s+(\d+)[:\s]+(.+?)\s*\[([^\]]*)\]/g;
|
|
65
70
|
let match;
|
|
66
|
-
while ((match =
|
|
71
|
+
while ((match = taskRegex1.exec(content)) !== null) {
|
|
67
72
|
const [, num, title, statusMarker] = match;
|
|
73
|
+
tasks.push(parseTaskEntry(num, title, statusMarker));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Format 2: Checkbox format - [ ] Task or - [x] Task
|
|
77
|
+
if (tasks.length === 0) {
|
|
78
|
+
const checkboxRegex = /^[-*]\s*\[([ x>])\]\s*(.+)$/gm;
|
|
79
|
+
let num = 1;
|
|
80
|
+
while ((match = checkboxRegex.exec(content)) !== null) {
|
|
81
|
+
const [, marker, title] = match;
|
|
82
|
+
// Skip if title looks like a sub-item or criterion
|
|
83
|
+
if (title.match(/^(Has|Should|Must|Can|Is|Are|The)\s/i)) continue;
|
|
84
|
+
const statusMarker = marker === 'x' ? 'x' : marker === '>' ? '>' : ' ';
|
|
85
|
+
tasks.push(parseTaskEntry(num++, title, statusMarker));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Format 3: ## Task N: Title (without status marker)
|
|
90
|
+
if (tasks.length === 0) {
|
|
91
|
+
const taskRegex3 = /##\s+Task\s+(\d+)[:\s]+(.+?)$/gm;
|
|
92
|
+
while ((match = taskRegex3.exec(content)) !== null) {
|
|
93
|
+
const [, num, title] = match;
|
|
94
|
+
tasks.push(parseTaskEntry(num, title, ' '));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Format 4: Numbered list - 1. Task title
|
|
99
|
+
if (tasks.length === 0) {
|
|
100
|
+
const numberedRegex = /^(\d+)\.\s+(.+)$/gm;
|
|
101
|
+
while ((match = numberedRegex.exec(content)) !== null) {
|
|
102
|
+
const [, num, title] = match;
|
|
103
|
+
// Skip if looks like a sub-point
|
|
104
|
+
if (title.length < 10) continue;
|
|
105
|
+
tasks.push(parseTaskEntry(num, title, ' '));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
68
108
|
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
return tasks;
|
|
110
|
+
}
|
|
71
111
|
|
|
72
|
-
|
|
112
|
+
function parseTaskEntry(num, title, statusMarker) {
|
|
113
|
+
let status = 'pending';
|
|
114
|
+
let owner = null;
|
|
115
|
+
|
|
116
|
+
if (typeof statusMarker === 'string') {
|
|
117
|
+
if (statusMarker.startsWith('x') || statusMarker === 'x') {
|
|
73
118
|
status = 'done';
|
|
74
119
|
const ownerMatch = statusMarker.match(/@(\w+)/);
|
|
75
120
|
if (ownerMatch) owner = ownerMatch[1];
|
|
76
|
-
} else if (statusMarker.startsWith('>')) {
|
|
77
|
-
status = '
|
|
121
|
+
} else if (statusMarker.startsWith('>') || statusMarker === '>') {
|
|
122
|
+
status = 'in_progress';
|
|
78
123
|
const ownerMatch = statusMarker.match(/@(\w+)/);
|
|
79
124
|
if (ownerMatch) owner = ownerMatch[1];
|
|
80
125
|
}
|
|
81
|
-
|
|
82
|
-
tasks.push({
|
|
83
|
-
num: parseInt(num),
|
|
84
|
-
title: title.trim(),
|
|
85
|
-
status,
|
|
86
|
-
owner
|
|
87
|
-
});
|
|
88
126
|
}
|
|
89
127
|
|
|
90
|
-
return
|
|
128
|
+
return {
|
|
129
|
+
num: parseInt(num),
|
|
130
|
+
title: title.trim(),
|
|
131
|
+
status,
|
|
132
|
+
owner
|
|
133
|
+
};
|
|
91
134
|
}
|
|
92
135
|
|
|
93
136
|
/**
|