glad-web 1.0.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.
@@ -0,0 +1,454 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const { WebSocketServer } = require('ws');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const zlib = require('zlib');
8
+ const chalk = require('chalk');
9
+ const { getAllTools } = require('../ai-tools/registry');
10
+ const { GitService } = require('../git/service');
11
+ const WorkspaceService = require('../workspace/service');
12
+ const SessionManager = require('../session/session-manager');
13
+
14
+ function sendCompressedJson(req, res, payload) {
15
+ const body = Buffer.from(JSON.stringify(payload), 'utf8');
16
+ const acceptEncoding = req.headers['accept-encoding'] || '';
17
+
18
+ if (/\bgzip\b/.test(acceptEncoding)) {
19
+ zlib.gzip(body, { level: 6 }, (error, compressed) => {
20
+ if (error) {
21
+ res.type('application/json').send(body);
22
+ return;
23
+ }
24
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
25
+ res.setHeader('Content-Encoding', 'gzip');
26
+ res.setHeader('Vary', 'Accept-Encoding');
27
+ res.setHeader('Content-Length', compressed.length);
28
+ res.send(compressed);
29
+ });
30
+ return;
31
+ }
32
+
33
+ res.type('application/json').send(body);
34
+ }
35
+
36
+ const { detectInstalledTools } = require('../ai-tools/detector');
37
+ const logger = require('../utils/logger');
38
+ const { JobStore } = require('../schedule/job-store');
39
+ const JobRunner = require('../schedule/job-runner');
40
+ const SchedulerService = require('../schedule/scheduler-service');
41
+
42
+ async function webCommand(options) {
43
+ const port = parseInt(options.port) || 3000;
44
+ const debugHistoryEnabled = process.env.DEBUG_SESSION_HISTORY === '1';
45
+ const defaultRenderedTools = getAllTools().map(tool => tool.key).join(',');
46
+ const renderHistoryTools = new Set(
47
+ String(process.env.HISTORY_RENDER_TOOLS || defaultRenderedTools)
48
+ .split(',')
49
+ .map(value => value.trim().toLowerCase())
50
+ .filter(Boolean)
51
+ );
52
+ const app = express();
53
+ app.use((req, res, next) => {
54
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
55
+ res.setHeader('Pragma', 'no-cache');
56
+ res.setHeader('Expires', '0');
57
+ next();
58
+ });
59
+ app.use(express.json());
60
+ const server = http.createServer(app);
61
+ const wss = new WebSocketServer({
62
+ server,
63
+ perMessageDeflate: {
64
+ threshold: 1024,
65
+ zlibDeflateOptions: { level: 3 },
66
+ zlibInflateOptions: {},
67
+ clientNoContextTakeover: true,
68
+ serverNoContextTakeover: true
69
+ }
70
+ });
71
+
72
+ // Use directory from options if provided, otherwise default to current working directory
73
+ const baseDir = options.directory ? path.resolve(process.cwd(), options.directory) : process.cwd();
74
+
75
+ const jobStore = new JobStore();
76
+ const gitService = new GitService();
77
+ const workspaceService = new WorkspaceService({ gitService });
78
+ const sessionManager = new SessionManager({
79
+ baseDir,
80
+ renderHistoryTools,
81
+ debugHistoryEnabled,
82
+ logger,
83
+ hasConnectedSessionClient
84
+ });
85
+ sessionManager.on('output', ({ sessionId, data }) => {
86
+ broadcastToSession(sessionId, { type: 'output', data });
87
+ });
88
+ sessionManager.on('exit', ({ sessionId }) => {
89
+ broadcastToSession(sessionId, { type: 'exit' });
90
+ });
91
+
92
+ const jobRunner = new JobRunner({
93
+ createSession: input => sessionManager.create(input),
94
+ getJob: id => jobStore.get(id),
95
+ updateJob: (id, patch) => jobStore.patchRuntime(id, patch),
96
+ logger
97
+ });
98
+ const schedulerService = new SchedulerService({ jobStore, jobRunner, logger });
99
+ schedulerService.start();
100
+
101
+ // API: Get all supported and installed tools
102
+ app.get('/api/tools', async (req, res) => {
103
+ try {
104
+ const tools = await detectInstalledTools();
105
+ res.json(tools);
106
+ } catch (e) {
107
+ res.status(500).json({ error: 'Failed to detect tools' });
108
+ }
109
+ });
110
+
111
+ // API: Get web UI runtime configuration
112
+ app.get('/api/config', (req, res) => {
113
+ res.json({ defaultWorkingDirectory: baseDir });
114
+ });
115
+
116
+ // API: Scheduled tasks
117
+ app.get('/api/schedules', (req, res) => {
118
+ res.json(jobStore.list());
119
+ });
120
+
121
+ app.post('/api/schedules', (req, res) => {
122
+ try {
123
+ const job = jobStore.create(req.body || {});
124
+ res.json(job);
125
+ } catch (e) {
126
+ res.status(400).json({ error: e.message });
127
+ }
128
+ });
129
+
130
+ app.get('/api/schedules/:id', (req, res) => {
131
+ const job = jobStore.get(req.params.id);
132
+ if (!job) return res.status(404).json({ error: 'Scheduled task not found' });
133
+ res.json(job);
134
+ });
135
+
136
+ app.patch('/api/schedules/:id', (req, res) => {
137
+ const job = jobStore.update(req.params.id, req.body || {});
138
+ if (!job) return res.status(404).json({ error: 'Scheduled task not found' });
139
+ res.json(job);
140
+ });
141
+
142
+ app.patch('/api/schedules/:id/enabled', (req, res) => {
143
+ const job = jobStore.patchRuntime(req.params.id, { enabled: Boolean(req.body && req.body.enabled) });
144
+ if (!job) return res.status(404).json({ error: 'Scheduled task not found' });
145
+ res.json(job);
146
+ });
147
+
148
+ app.delete('/api/schedules/:id', (req, res) => {
149
+ res.json({ success: jobStore.delete(req.params.id) });
150
+ });
151
+
152
+ app.post('/api/schedules/:id/duplicate', (req, res) => {
153
+ const job = jobStore.duplicate(req.params.id);
154
+ if (!job) return res.status(404).json({ error: 'Scheduled task not found' });
155
+ res.json(job);
156
+ });
157
+
158
+ app.post('/api/schedules/:id/run', async (req, res) => {
159
+ try {
160
+ const result = await jobRunner.run(req.params.id, { manual: false });
161
+ res.json(result);
162
+ } catch (e) {
163
+ res.status(400).json({ error: e.message });
164
+ }
165
+ });
166
+
167
+ app.post('/api/schedules/:id/simulate', async (req, res) => {
168
+ try {
169
+ const result = await jobRunner.run(req.params.id, { manual: true, background: true });
170
+ res.json(result);
171
+ } catch (e) {
172
+ res.status(400).json({ error: e.message });
173
+ }
174
+ });
175
+
176
+ // API: List all active sessions
177
+ app.get('/api/sessions', (req, res) => {
178
+ logger.debug('API: GET /api/sessions');
179
+ res.json(sessionManager.list());
180
+ });
181
+
182
+ // API: Create a new PTY session
183
+ app.post('/api/sessions', async (req, res) => {
184
+ logger.debug(`API: POST /api/sessions - ${JSON.stringify(req.body)}`);
185
+ try {
186
+ const { toolKey, workingDirectory } = req.body;
187
+ const session = sessionManager.create({ toolKey, workingDirectory });
188
+ res.json({ id: session.id });
189
+ } catch (e) {
190
+ logger.error(`API: POST /api/sessions failed: ${e.message}`);
191
+ res.status(e.statusCode || 500).json({ error: e.message });
192
+ }
193
+ });
194
+
195
+ // API: Plain text terminal history for mobile-friendly reading
196
+ app.get('/api/sessions/:id/history', (req, res) => {
197
+ const history = sessionManager.getHistory(req.params.id);
198
+ if (!history) return res.status(404).json({ error: 'Session not found' });
199
+ sessionManager.logHistoryRequest(req.params.id, req);
200
+ sendCompressedJson(req, res, history);
201
+ });
202
+
203
+ // API: Rename session
204
+ app.patch('/api/sessions/:id', (req, res) => {
205
+ const session = sessionManager.rename(req.params.id, req.body.name);
206
+ if (session) {
207
+ res.json({ success: true, name: session.name });
208
+ } else {
209
+ res.status(404).json({ error: 'Session not found' });
210
+ }
211
+ });
212
+
213
+ // API: Mark a session completion indicator as read
214
+ app.post('/api/sessions/:id/completion/read', (req, res) => {
215
+ const session = sessionManager.markCompletionRead(req.params.id);
216
+ if (!session) return res.status(404).json({ error: 'Session not found' });
217
+ res.json({ success: true });
218
+ });
219
+
220
+ // API: Delete/Kill session
221
+ app.delete('/api/sessions/:id', (req, res) => {
222
+ sessionManager.kill(req.params.id);
223
+ res.json({ success: true });
224
+ });
225
+
226
+ app.get('/api/sessions/:id/debug', (req, res) => {
227
+ const diagnostics = sessionManager.getDiagnostics(req.params.id);
228
+ if (!diagnostics) return res.status(404).json({ error: 'Session not found' });
229
+ res.json({ success: true, diagnostics });
230
+ });
231
+
232
+ app.post('/api/debug/client-log', (req, res) => {
233
+ const { sessionId, event, payload } = req.body || {};
234
+ sessionManager.logClientDebug(sessionId, event, payload);
235
+ res.json({ success: true });
236
+ });
237
+
238
+ // API: Git Show
239
+ app.get('/api/sessions/:id/git-show/:hash', async (req, res) => {
240
+ const session = sessionManager.get(req.params.id);
241
+ if (!session) return res.status(404).json({ error: 'Session not found' });
242
+ const hash = req.params.hash;
243
+ const result = await gitService.show(session.ptyManager.workingDir, hash);
244
+ res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
245
+ });
246
+
247
+ // API: Git Log
248
+ app.get('/api/sessions/:id/git-log', async (req, res) => {
249
+ const session = sessionManager.get(req.params.id);
250
+ if (!session) return res.status(404).json({ error: 'Session not found' });
251
+ const result = await gitService.log(session.ptyManager.workingDir, req.query.maxCount);
252
+ if (!result.success) {
253
+ return res.status(500).json({ error: result.error, stderr: result.stderr });
254
+ }
255
+ res.json({ success: true, commits: result.commits });
256
+ });
257
+
258
+ // API: Git Status
259
+ app.get('/api/sessions/:id/git-status', async (req, res) => {
260
+ const session = sessionManager.get(req.params.id);
261
+ if (!session) return res.status(404).json({ error: 'Session not found' });
262
+ const result = await gitService.status(session.ptyManager.workingDir);
263
+ if (!result.success) {
264
+ return res.status(500).json({ error: result.error, stderr: result.stderr });
265
+ }
266
+ res.json({ success: true, files: result.files });
267
+ });
268
+
269
+ // API: Git Diff Numstat (unstaged and staged)
270
+ app.get('/api/sessions/:id/git-diff-numstat', async (req, res) => {
271
+ const session = sessionManager.get(req.params.id);
272
+ if (!session) return res.status(404).json({ error: 'Session not found' });
273
+ const isStaged = req.query.staged === 'true';
274
+ const result = await gitService.diffNumstat(session.ptyManager.workingDir, isStaged);
275
+ res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
276
+ });
277
+
278
+ // API: Git Diff File
279
+ app.get('/api/sessions/:id/git-diff-file', async (req, res) => {
280
+ const session = sessionManager.get(req.params.id);
281
+ if (!session) return res.status(404).json({ error: 'Session not found' });
282
+ const isStaged = req.query.staged === 'true';
283
+ const filePath = req.query.path;
284
+ if (!filePath) return res.status(400).json({ error: 'Missing file path' });
285
+ const result = await gitService.diffFile(session.ptyManager.workingDir, filePath, isStaged);
286
+ res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
287
+ });
288
+
289
+ // API: Get File Content
290
+ app.get('/api/sessions/:id/file', async (req, res) => {
291
+ const session = sessionManager.get(req.params.id);
292
+ if (!session) return res.status(404).json({ error: 'Session not found' });
293
+ const filePath = req.query.path || '';
294
+ if (!filePath) return res.status(400).json({ error: 'Missing file path' });
295
+ const cwd = session.ptyManager.workingDir || '';
296
+ try {
297
+ const content = workspaceService.readFile(cwd, filePath);
298
+ res.json({ success: true, content });
299
+ } catch (e) {
300
+ res.status(e.statusCode || 200).json({ success: false, error: e.message });
301
+ }
302
+ });
303
+
304
+ // API: Get Directory Contents
305
+ app.get('/api/sessions/:id/fs/dir', async (req, res) => {
306
+ const session = sessionManager.get(req.params.id);
307
+ if (!session) return res.status(404).json({ error: 'Session not found' });
308
+ const dirPath = req.query.path || '';
309
+ const cwd = session.ptyManager.workingDir || '';
310
+ try {
311
+ const files = await workspaceService.listDirectory(cwd, dirPath);
312
+ res.json({ success: true, files });
313
+ } catch (e) {
314
+ res.status(e.statusCode || 200).json({ success: false, error: e.message });
315
+ }
316
+ });
317
+
318
+
319
+ function broadcastToSession(sessionId, message) {
320
+ const msgStr = JSON.stringify(message);
321
+ wss.clients.forEach(client => {
322
+ if (client.readyState === 1 && client.sessionId === sessionId) {
323
+ client.send(msgStr);
324
+ }
325
+ });
326
+ }
327
+
328
+ function hasConnectedSessionClient(sessionId) {
329
+ for (const client of wss.clients) {
330
+ if (client.readyState === 1 && client.sessionId === sessionId) return true;
331
+ }
332
+ return false;
333
+ }
334
+
335
+ // WebSocket: Terminal I/O
336
+ wss.on('connection', (ws, req) => {
337
+ const url = new URL(req.url, 'http://' + req.headers.host);
338
+ const sessionId = url.searchParams.get('sessionId');
339
+
340
+ if (!sessionId || !sessionManager.has(sessionId)) {
341
+ ws.close(4001, 'Invalid Session ID');
342
+ return;
343
+ }
344
+
345
+ ws.sessionId = sessionId;
346
+ const session = sessionManager.get(sessionId);
347
+ if (!session.resizeOwner) {
348
+ session.resizeOwner = ws;
349
+ }
350
+ sessionManager.logWsConnected(sessionId, req);
351
+
352
+ // Send catchup buffer
353
+ const history = session.buffer.getAfter(0);
354
+ if (history.length > 0) {
355
+ sessionManager.logWsCatchup(sessionId, history);
356
+ ws.send(JSON.stringify({ type: 'output', data: history.map(m => m.data).join('') }));
357
+ }
358
+
359
+ ws.on('message', (message) => {
360
+ try {
361
+ const payload = JSON.parse(message);
362
+ if (payload.type === 'input') {
363
+ session.write(payload.data);
364
+ }
365
+ if (payload.type === 'resize' && session.resizeOwner === ws) {
366
+ sessionManager.logWsResize(sessionId, payload.cols, payload.rows);
367
+ sessionManager.resize(sessionId, payload.cols, payload.rows);
368
+ }
369
+ } catch (e) {
370
+ logger.error('WS Message Error: ' + e.message);
371
+ }
372
+ });
373
+
374
+ ws.on('close', () => {
375
+ sessionManager.logWsClosed(sessionId);
376
+ if (session.resizeOwner !== ws) return;
377
+ session.resizeOwner = null;
378
+ for (const client of wss.clients) {
379
+ if (client.readyState === 1 && client.sessionId === sessionId) {
380
+ session.resizeOwner = client;
381
+ break;
382
+ }
383
+ }
384
+ });
385
+ });
386
+
387
+ // Frontend routes
388
+ app.get('/', (req, res) => {
389
+ try {
390
+ const htmlPath = path.join(__dirname, '../web/index.html');
391
+ const html = fs.readFileSync(htmlPath, 'utf8');
392
+ res.send(html);
393
+ } catch (e) {
394
+ res.status(500).send('UI not found');
395
+ }
396
+ });
397
+
398
+ app.get('/gitgraph.js', (req, res) => {
399
+ try {
400
+ res.sendFile(path.join(__dirname, '../web/gitgraph.js'));
401
+ } catch (e) {
402
+ res.status(404).send('Not found');
403
+ }
404
+ });
405
+
406
+ app.get('/logo.svg', (req, res) => {
407
+ try {
408
+ res.sendFile(path.resolve(__dirname, '../../assets/logo.svg'));
409
+ } catch (e) {
410
+ res.status(404).send('Not found');
411
+ }
412
+ });
413
+
414
+ app.get('/manifest.json', (req, res) => res.json({
415
+ name: "Glad Web",
416
+ short_name: "Glad",
417
+ start_url: "/",
418
+ display: "standalone",
419
+ background_color: "#000000",
420
+ theme_color: "#007aff",
421
+ icons: [
422
+ {
423
+ src: "/logo.svg",
424
+ sizes: "any",
425
+ type: "image/svg+xml"
426
+ }
427
+ ]
428
+ }));
429
+
430
+ server.listen(port, '0.0.0.0', () => {
431
+ const interfaces = os.networkInterfaces();
432
+ let networkInfo = '';
433
+ for (const name of Object.keys(interfaces)) {
434
+ for (const iface of interfaces[name]) {
435
+ if (iface.family === 'IPv4' && !iface.internal) {
436
+ networkInfo += `\n ➜ Network: http://${iface.address}:${port}`;
437
+ }
438
+ }
439
+ }
440
+ console.log(chalk.green(`\n🚀 Glad Web Server is running!`));
441
+ console.log(chalk.cyan(` ➜ Local: http://localhost:${port}${networkInfo}\n`));
442
+ console.log(chalk.gray(` ➜ Project: ${baseDir}\n`));
443
+ console.log(chalk.gray(` ➜ History Render Tools: ${Array.from(renderHistoryTools).join(', ') || '(none)'}\n`));
444
+ console.log(chalk.gray(`Tips: Access from your phone via the Network URL above.\n`));
445
+ });
446
+
447
+ process.on('SIGINT', () => {
448
+ schedulerService.stop();
449
+ sessionManager.killAll();
450
+ process.exit(0);
451
+ });
452
+ }
453
+
454
+ module.exports = webCommand;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Application constants
3
+ *
4
+ * All magic numbers and configuration values should be defined here.
5
+ */
6
+
7
+ module.exports = {
8
+ // Network & WebSocket
9
+ HEARTBEAT_TIMEOUT: 13000, // 13s - detect network loss (server pings every ~5s)
10
+ CLI_IDLE_THRESHOLD: 15000, // 15s - no PTY output = CLI is idle
11
+
12
+ // Buffer
13
+ DEFAULT_BUFFER_SIZE: 100000, // 100KB - circular buffer max size
14
+
15
+ // Reconnection
16
+ MAX_RECONNECT_ATTEMPTS: 10, // Max WebSocket reconnection attempts
17
+ };
@@ -0,0 +1,71 @@
1
+ const Conf = require('conf');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Config schema - only user preferences, no environment config
6
+ const schema = {
7
+ defaultAI: {
8
+ type: 'string',
9
+ default: ''
10
+ },
11
+ version: {
12
+ type: 'string',
13
+ default: '1.0.0'
14
+ },
15
+ lastUpdated: {
16
+ type: 'string',
17
+ default: ''
18
+ }
19
+ };
20
+
21
+ // Create config instance
22
+ const config = new Conf({
23
+ projectName: 'glad',
24
+ cwd: path.join(os.homedir(), '.glad'),
25
+ configName: 'config',
26
+ schema
27
+ });
28
+
29
+ // Get config value
30
+ function getConfig(key) {
31
+ if (key) {
32
+ return config.get(key);
33
+ }
34
+ return config.store;
35
+ }
36
+
37
+ // Set config value
38
+ function setConfig(key, value) {
39
+ config.set(key, value);
40
+ config.set('lastUpdated', new Date().toISOString());
41
+ }
42
+
43
+ // Get default AI tool
44
+ function getDefaultAI() {
45
+ const value = config.get('defaultAI');
46
+ return value || null;
47
+ }
48
+
49
+ // Set default AI tool
50
+ function setDefaultAI(tool) {
51
+ setConfig('defaultAI', tool);
52
+ }
53
+
54
+ // Get config file path
55
+ function getConfigPath() {
56
+ return config.path;
57
+ }
58
+
59
+ // Reset config to defaults
60
+ function resetConfig() {
61
+ config.clear();
62
+ }
63
+
64
+ module.exports = {
65
+ getConfig,
66
+ setConfig,
67
+ getDefaultAI,
68
+ setDefaultAI,
69
+ getConfigPath,
70
+ resetConfig
71
+ };
@@ -0,0 +1,79 @@
1
+ const { execFile } = require('child_process');
2
+
3
+ function execFilePromise(file, args, cwd) {
4
+ return new Promise((resolve) => {
5
+ execFile(file, args, { cwd, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
6
+ resolve({ success: !error, error: error?.message, stdout, stderr });
7
+ });
8
+ });
9
+ }
10
+
11
+ function parseGitStatusZ(stdout) {
12
+ if (!stdout) return [];
13
+
14
+ const entries = [];
15
+ const records = stdout.split('\0').filter(Boolean);
16
+
17
+ for (let i = 0; i < records.length; i++) {
18
+ const record = records[i];
19
+ if (record.length < 3) continue;
20
+
21
+ const status = record.substring(0, 2);
22
+ const path = record.substring(3);
23
+ const entry = { path, status };
24
+
25
+ if ((status.includes('R') || status.includes('C')) && i + 1 < records.length) {
26
+ entry.originalPath = records[++i];
27
+ }
28
+
29
+ entries.push(entry);
30
+ }
31
+
32
+ return entries;
33
+ }
34
+
35
+ class GitService {
36
+ async show(cwd, hash) {
37
+ return execFilePromise('git', ['show', '--format=fuller', '--stat', '-p', hash], cwd);
38
+ }
39
+
40
+ async log(cwd, maxCount = 100) {
41
+ const count = Number.parseInt(maxCount, 10) || 100;
42
+ const result = await execFilePromise(
43
+ 'git',
44
+ ['log', '--all', '--date-order', `--max-count=${count}`, '--pretty=format:%h|%p|%d|%s|%an|%ar'],
45
+ cwd
46
+ );
47
+ if (!result.success) return result;
48
+
49
+ const commits = result.stdout.split('\n').filter(Boolean).map(line => {
50
+ const [hash, parents, refs, subject, author, time] = line.split('|');
51
+ return { hash, parents: parents ? parents.split(' ') : [], refs: refs ? refs.trim() : '', subject, author, time };
52
+ });
53
+ return { ...result, commits };
54
+ }
55
+
56
+ async status(cwd) {
57
+ const result = await execFilePromise('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], cwd);
58
+ if (!result.success) return result;
59
+ return { ...result, files: parseGitStatusZ(result.stdout) };
60
+ }
61
+
62
+ diffNumstat(cwd, isStaged = false) {
63
+ const args = isStaged ? ['diff', '--cached', '--numstat'] : ['diff', '--numstat'];
64
+ return execFilePromise('git', args, cwd);
65
+ }
66
+
67
+ diffFile(cwd, filePath, isStaged = false) {
68
+ const args = isStaged
69
+ ? ['diff', '--cached', '--no-ext-diff', '--', filePath]
70
+ : ['diff', '--no-ext-diff', '--', filePath];
71
+ return execFilePromise('git', args, cwd);
72
+ }
73
+ }
74
+
75
+ module.exports = {
76
+ GitService,
77
+ execFilePromise,
78
+ parseGitStatusZ
79
+ };