devglide 0.1.2 → 0.1.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/package.json +5 -1
- package/src/project-context.ts +36 -0
- package/src/public/app.js +701 -0
- package/src/public/favicon.svg +7 -0
- package/src/public/index.html +78 -0
- package/src/public/state.js +84 -0
- package/src/public/style.css +1213 -0
- package/src/routers/coder.ts +157 -0
- package/src/routers/dashboard.ts +158 -0
- package/src/routers/kanban.ts +38 -0
- package/src/routers/log.ts +42 -0
- package/src/routers/prompts.ts +134 -0
- package/src/routers/shell/index.ts +47 -0
- package/src/routers/shell/pty-manager.ts +107 -0
- package/src/routers/shell/shell-config.ts +38 -0
- package/src/routers/shell/shell-routes.ts +108 -0
- package/src/routers/shell/shell-socket.ts +321 -0
- package/src/routers/shell/shell-state.ts +59 -0
- package/src/routers/test.ts +254 -0
- package/src/routers/vocabulary.ts +149 -0
- package/src/routers/voice.ts +10 -0
- package/src/routers/workflow.ts +243 -0
- package/src/server.ts +325 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import type { Dirent } from 'fs';
|
|
4
|
+
import { open, readFile, writeFile, readdir, stat } from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { getActiveProject } from '../project-context.js';
|
|
9
|
+
|
|
10
|
+
// ── Zod schema for HTTP input validation ─────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const writeFileSchema = z.object({
|
|
13
|
+
root: z.string().optional(),
|
|
14
|
+
path: z.string().min(1, 'path is required'),
|
|
15
|
+
content: z.string().default(''),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const router: Router = Router();
|
|
19
|
+
|
|
20
|
+
const SKIP = new Set([
|
|
21
|
+
'node_modules', '.git', 'dist', '.next', '.turbo',
|
|
22
|
+
'__pycache__', '.pnpm-store', 'pnpm-store', '.cache',
|
|
23
|
+
'build', 'coverage', '.nyc_output', '.venv', 'venv',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const MAX_TREE_ENTRIES = 5000;
|
|
27
|
+
|
|
28
|
+
const MONOREPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
29
|
+
|
|
30
|
+
function safeRoot(reqRoot: string | undefined): string {
|
|
31
|
+
if (!reqRoot) return getActiveProject()?.path || MONOREPO_ROOT;
|
|
32
|
+
const resolved = path.resolve(reqRoot);
|
|
33
|
+
const allowed = getActiveProject()?.path || MONOREPO_ROOT;
|
|
34
|
+
if (resolved !== allowed && !resolved.startsWith(allowed + path.sep)) {
|
|
35
|
+
throw new Error('Root path outside allowed directory');
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safePath(reqPath: string | undefined, root: string): string {
|
|
41
|
+
const abs = path.resolve(root, (reqPath || '').replace(/^\/+/, ''));
|
|
42
|
+
if (!abs.startsWith(root + path.sep) && abs !== root) throw new Error('Path traversal denied');
|
|
43
|
+
return abs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TreeEntry {
|
|
47
|
+
name: string;
|
|
48
|
+
path: string;
|
|
49
|
+
type: 'dir' | 'file';
|
|
50
|
+
children?: TreeEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildTree(
|
|
54
|
+
dir: string,
|
|
55
|
+
depth: number = 0,
|
|
56
|
+
root: string,
|
|
57
|
+
counter: { count: number } = { count: 0 },
|
|
58
|
+
): Promise<TreeEntry[]> {
|
|
59
|
+
if (depth > 8 || counter.count >= MAX_TREE_ENTRIES) return [];
|
|
60
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
61
|
+
const dirs: Dirent[] = [];
|
|
62
|
+
const files: Dirent[] = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (SKIP.has(entry.name)) continue;
|
|
65
|
+
if (entry.isDirectory()) dirs.push(entry);
|
|
66
|
+
else files.push(entry);
|
|
67
|
+
}
|
|
68
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
70
|
+
|
|
71
|
+
const result: TreeEntry[] = [];
|
|
72
|
+
|
|
73
|
+
// Process directories in parallel
|
|
74
|
+
const dirResults = await Promise.all(dirs.map(async (entry) => {
|
|
75
|
+
if (counter.count >= MAX_TREE_ENTRIES) return null;
|
|
76
|
+
const abs = path.join(dir, entry.name);
|
|
77
|
+
counter.count++;
|
|
78
|
+
return {
|
|
79
|
+
name: entry.name,
|
|
80
|
+
path: path.relative(root, abs),
|
|
81
|
+
type: 'dir' as const,
|
|
82
|
+
children: await buildTree(abs, depth + 1, root, counter),
|
|
83
|
+
};
|
|
84
|
+
}));
|
|
85
|
+
for (const d of dirResults) {
|
|
86
|
+
if (d) result.push(d);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Process files
|
|
90
|
+
for (const entry of files) {
|
|
91
|
+
if (counter.count >= MAX_TREE_ENTRIES) break;
|
|
92
|
+
counter.count++;
|
|
93
|
+
result.push({ name: entry.name, path: path.relative(root, path.join(dir, entry.name)), type: 'file' });
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function isBinary(filePath: string): Promise<boolean> {
|
|
99
|
+
let fh;
|
|
100
|
+
try {
|
|
101
|
+
fh = await open(filePath, 'r');
|
|
102
|
+
const buf = Buffer.alloc(8192);
|
|
103
|
+
const { bytesRead } = await fh.read(buf, 0, 8192, 0);
|
|
104
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
105
|
+
if (buf[i] === 0) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
} finally {
|
|
109
|
+
await fh?.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
router.get('/tree', async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const root = safeRoot(req.query.root as string | undefined);
|
|
116
|
+
if (!fs.existsSync(root)) return res.status(400).json({ error: 'Root path does not exist' });
|
|
117
|
+
res.json(await buildTree(root, 0, root));
|
|
118
|
+
} catch (err: unknown) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
const status = message.includes('outside allowed') || message === 'Path traversal denied' ? 403 : 500;
|
|
121
|
+
res.status(status).json({ error: message });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.get('/file', async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const root = safeRoot(req.query.root as string | undefined);
|
|
128
|
+
const abs = safePath(req.query.path as string | undefined, root);
|
|
129
|
+
const s = await stat(abs);
|
|
130
|
+
if (s.size > 2 * 1024 * 1024) return res.status(413).json({ error: 'File too large (>2MB)' });
|
|
131
|
+
if (await isBinary(abs)) return res.status(422).json({ error: 'Binary file cannot be displayed' });
|
|
132
|
+
const content = await readFile(abs, 'utf8');
|
|
133
|
+
res.json({ content });
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
const status = message === 'Path traversal denied' ? 403 : 500;
|
|
137
|
+
res.status(status).json({ error: message });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
router.put('/file', async (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = writeFileSchema.safeParse(req.body);
|
|
144
|
+
if (!parsed.success) {
|
|
145
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const root = safeRoot(parsed.data.root);
|
|
149
|
+
const abs = safePath(parsed.data.path, root);
|
|
150
|
+
await writeFile(abs, parsed.data.content, 'utf8');
|
|
151
|
+
res.json({ ok: true });
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
+
const status = message === 'Path traversal denied' ? 403 : 500;
|
|
155
|
+
res.status(status).json({ error: message });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Namespace } from 'socket.io';
|
|
3
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
listProjects,
|
|
8
|
+
addProject,
|
|
9
|
+
removeProject,
|
|
10
|
+
updateProject,
|
|
11
|
+
activateProject,
|
|
12
|
+
getActiveProject,
|
|
13
|
+
} from '../packages/project-store.js';
|
|
14
|
+
import { setActiveProject } from '../project-context.js';
|
|
15
|
+
|
|
16
|
+
export const router: Router = Router();
|
|
17
|
+
|
|
18
|
+
let dashboardNsp: Namespace | null = null;
|
|
19
|
+
|
|
20
|
+
// ── REST API: Project context ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
router.get('/projects', (_req, res) => {
|
|
23
|
+
res.json(listProjects());
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
router.post('/projects', (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const project = addProject(req.body?.name, req.body?.path);
|
|
29
|
+
dashboardNsp?.emit('project:list', listProjects());
|
|
30
|
+
res.status(201).json(project);
|
|
31
|
+
} catch (err: unknown) {
|
|
32
|
+
res.status(400).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
router.delete('/projects/:id', (req, res) => {
|
|
37
|
+
const removed = removeProject(req.params.id);
|
|
38
|
+
if (!removed) return res.status(404).json({ error: 'Project not found' });
|
|
39
|
+
const store = listProjects();
|
|
40
|
+
dashboardNsp?.emit('project:list', store);
|
|
41
|
+
dashboardNsp?.emit('project:active', store.activeProjectId
|
|
42
|
+
? store.projects.find((p) => p.id === store.activeProjectId) ?? null
|
|
43
|
+
: null);
|
|
44
|
+
const active = getActiveProject();
|
|
45
|
+
setActiveProject(active ? { id: active.id, name: active.name, path: active.path } : null);
|
|
46
|
+
res.json({ ok: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
router.put('/projects/:id', (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const project = updateProject(req.params.id, { name: req.body?.name, path: req.body?.path });
|
|
52
|
+
const store = listProjects();
|
|
53
|
+
dashboardNsp?.emit('project:list', store);
|
|
54
|
+
const active = getActiveProject();
|
|
55
|
+
if (active && active.id === req.params.id) {
|
|
56
|
+
dashboardNsp?.emit('project:active', project);
|
|
57
|
+
setActiveProject({ id: project.id, name: project.name, path: project.path });
|
|
58
|
+
}
|
|
59
|
+
res.json(project);
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
const status = (err instanceof Error ? err.message : String(err)) === 'Project not found' ? 404 : 400;
|
|
62
|
+
res.status(status).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
router.put('/projects/:id/activate', (req, res) => {
|
|
67
|
+
const project = activateProject(req.params.id);
|
|
68
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
69
|
+
dashboardNsp?.emit('project:active', project);
|
|
70
|
+
setActiveProject({ id: project.id, name: project.name, path: project.path });
|
|
71
|
+
res.json(project);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── REST API: Directory browsing ─────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
router.get('/browse', (req, res) => {
|
|
77
|
+
const raw = typeof req.query.path === 'string' ? req.query.path : '';
|
|
78
|
+
const target = resolve(raw || homedir());
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const stat = statSync(target);
|
|
82
|
+
if (!stat.isDirectory()) {
|
|
83
|
+
return res.status(400).json({ error: 'Not a directory' });
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return res.status(400).json({ error: 'Path does not exist' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const entries = readdirSync(target, { withFileTypes: true });
|
|
91
|
+
const dirs = entries
|
|
92
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
93
|
+
.map(e => e.name)
|
|
94
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
95
|
+
|
|
96
|
+
res.json({ path: target, dirs });
|
|
97
|
+
} catch {
|
|
98
|
+
res.status(403).json({ error: 'Cannot read directory' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── Socket.io namespace initializer ────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function initDashboard(nsp: Namespace): void {
|
|
105
|
+
dashboardNsp = nsp;
|
|
106
|
+
|
|
107
|
+
nsp.on('connection', (socket) => {
|
|
108
|
+
socket.emit('project:active', getActiveProject());
|
|
109
|
+
socket.emit('project:list', listProjects());
|
|
110
|
+
|
|
111
|
+
socket.on('project:activate', ({ id }) => {
|
|
112
|
+
const project = activateProject(id);
|
|
113
|
+
if (project) {
|
|
114
|
+
nsp.emit('project:active', project);
|
|
115
|
+
setActiveProject({ id: project.id, name: project.name, path: project.path });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.on('project:add', ({ name, path: projectPath }, ack) => {
|
|
120
|
+
try {
|
|
121
|
+
const project = addProject(name, projectPath);
|
|
122
|
+
nsp.emit('project:list', listProjects());
|
|
123
|
+
if (typeof ack === 'function') ack({ ok: true, project });
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
if (typeof ack === 'function') ack({ ok: false, error: (err instanceof Error ? err.message : String(err)) });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
socket.on('project:remove', ({ id }, ack) => {
|
|
130
|
+
const removed = removeProject(id);
|
|
131
|
+
if (removed) {
|
|
132
|
+
const store = listProjects();
|
|
133
|
+
nsp.emit('project:list', store);
|
|
134
|
+
nsp.emit('project:active', store.activeProjectId
|
|
135
|
+
? store.projects.find((p) => p.id === store.activeProjectId) ?? null
|
|
136
|
+
: null);
|
|
137
|
+
const active = getActiveProject();
|
|
138
|
+
setActiveProject(active ? { id: active.id, name: active.name, path: active.path } : null);
|
|
139
|
+
}
|
|
140
|
+
if (typeof ack === 'function') ack({ ok: removed });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
socket.on('project:update', ({ id, name, path: projectPath }, ack) => {
|
|
144
|
+
try {
|
|
145
|
+
const project = updateProject(id, { name, path: projectPath });
|
|
146
|
+
nsp.emit('project:list', listProjects());
|
|
147
|
+
const active = getActiveProject();
|
|
148
|
+
if (active && active.id === id) {
|
|
149
|
+
nsp.emit('project:active', project);
|
|
150
|
+
setActiveProject({ id: project.id, name: project.name, path: project.path });
|
|
151
|
+
}
|
|
152
|
+
if (typeof ack === 'function') ack({ ok: true, project });
|
|
153
|
+
} catch (err: unknown) {
|
|
154
|
+
if (typeof ack === 'function') ack({ ok: false, error: (err instanceof Error ? err.message : String(err)) });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { featuresRouter } from '../apps/kanban/src/routes/features.js';
|
|
3
|
+
import { issuesRouter } from '../apps/kanban/src/routes/issues.js';
|
|
4
|
+
import { attachmentsRouter } from '../apps/kanban/src/routes/attachments.js';
|
|
5
|
+
|
|
6
|
+
export { createKanbanMcpServer } from '../apps/kanban/src/mcp.js';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
namespace Express {
|
|
10
|
+
interface Request {
|
|
11
|
+
projectId?: string;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseCookie(cookieHeader?: string): Record<string, string> {
|
|
17
|
+
if (!cookieHeader) return {};
|
|
18
|
+
return Object.fromEntries(cookieHeader.split(';').map(c => {
|
|
19
|
+
const [k, ...v] = c.trim().split('=');
|
|
20
|
+
return [k, decodeURIComponent(v.join('='))];
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const router: Router = Router();
|
|
25
|
+
|
|
26
|
+
// Project context middleware
|
|
27
|
+
router.use((req, _res, next) => {
|
|
28
|
+
const fromHeader = req.headers['x-project-id'];
|
|
29
|
+
const cookies = parseCookie(req.headers.cookie);
|
|
30
|
+
const fromCookie = cookies['devglide-project-id'];
|
|
31
|
+
req.projectId = (typeof fromHeader === 'string' ? fromHeader : fromCookie) || undefined;
|
|
32
|
+
next();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Mount sub-routers
|
|
36
|
+
router.use('/features', featuresRouter);
|
|
37
|
+
router.use('/issues', issuesRouter);
|
|
38
|
+
router.use('/attachments', attachmentsRouter);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { logRouter } from '../apps/log/src/routes/log.js';
|
|
3
|
+
import { statusRouter } from '../apps/log/src/routes/status.js';
|
|
4
|
+
import { FileTailer } from '../apps/log/src/services/file-tailer.js';
|
|
5
|
+
import { onProjectChange } from '../project-context.js';
|
|
6
|
+
|
|
7
|
+
export { createLogMcpServer } from '../apps/log/src/mcp.js';
|
|
8
|
+
|
|
9
|
+
export const router: Router = Router();
|
|
10
|
+
|
|
11
|
+
// Mount sub-routers
|
|
12
|
+
router.use('/', logRouter);
|
|
13
|
+
router.use('/status', statusRouter);
|
|
14
|
+
|
|
15
|
+
// ── File tailer lifecycle ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
let fileTailer: FileTailer | null = null;
|
|
18
|
+
let unsubscribe: (() => void) | null = null;
|
|
19
|
+
|
|
20
|
+
export function initLog(): void {
|
|
21
|
+
fileTailer = new FileTailer();
|
|
22
|
+
|
|
23
|
+
unsubscribe = onProjectChange((project) => {
|
|
24
|
+
// Start/stop tailing based on active project
|
|
25
|
+
if (project?.path) {
|
|
26
|
+
fileTailer!.start(project.path);
|
|
27
|
+
} else {
|
|
28
|
+
fileTailer!.stop();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shutdownLog(): void {
|
|
34
|
+
if (fileTailer) {
|
|
35
|
+
fileTailer.stop();
|
|
36
|
+
fileTailer = null;
|
|
37
|
+
}
|
|
38
|
+
if (unsubscribe) {
|
|
39
|
+
unsubscribe();
|
|
40
|
+
unsubscribe = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { PromptStore } from '../apps/prompts/services/prompt-store.js';
|
|
5
|
+
|
|
6
|
+
// ── Zod schemas ───────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const createPromptSchema = z.object({
|
|
9
|
+
title: z.string().min(1, 'title is required'),
|
|
10
|
+
content: z.string().min(1, 'content is required'),
|
|
11
|
+
description: z.string().optional(),
|
|
12
|
+
category: z.string().optional(),
|
|
13
|
+
tags: z.array(z.string()).optional(),
|
|
14
|
+
model: z.string().optional(),
|
|
15
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
16
|
+
rating: z.number().int().min(1).max(5).optional(),
|
|
17
|
+
notes: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const updatePromptSchema = z.object({
|
|
21
|
+
title: z.string().optional(),
|
|
22
|
+
content: z.string().optional(),
|
|
23
|
+
description: z.string().nullable().optional(),
|
|
24
|
+
category: z.string().nullable().optional(),
|
|
25
|
+
tags: z.array(z.string()).nullable().optional(),
|
|
26
|
+
model: z.string().nullable().optional(),
|
|
27
|
+
temperature: z.number().min(0).max(2).nullable().optional(),
|
|
28
|
+
rating: z.number().int().min(1).max(5).nullable().optional(),
|
|
29
|
+
notes: z.string().nullable().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const renderSchema = z.object({
|
|
33
|
+
vars: z.record(z.string()).default({}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export { createPromptsMcpServer } from '../apps/prompts/mcp.js';
|
|
37
|
+
|
|
38
|
+
export const router: Router = Router();
|
|
39
|
+
|
|
40
|
+
const store = PromptStore.getInstance();
|
|
41
|
+
|
|
42
|
+
// GET /context — compiled markdown for LLM injection
|
|
43
|
+
router.get('/context', async (_req: Request, res: Response) => {
|
|
44
|
+
try {
|
|
45
|
+
const markdown = await store.getCompiledContext();
|
|
46
|
+
res.type('text/markdown').send(markdown || 'No prompts defined.');
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// GET /entries — list prompts
|
|
53
|
+
router.get('/entries', async (req: Request, res: Response) => {
|
|
54
|
+
try {
|
|
55
|
+
const category = req.query.category as string | undefined;
|
|
56
|
+
const tagsParam = req.query.tags as string | undefined;
|
|
57
|
+
const tags = tagsParam ? tagsParam.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
|
|
58
|
+
const search = req.query.search as string | undefined;
|
|
59
|
+
const entries = await store.list({ category, tags, search });
|
|
60
|
+
res.json(entries);
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// GET /entries/:id — get by ID
|
|
67
|
+
router.get('/entries/:id', async (req: Request, res: Response) => {
|
|
68
|
+
try {
|
|
69
|
+
const entry = await store.get(req.params.id);
|
|
70
|
+
if (!entry) { res.status(404).json({ error: 'Prompt not found' }); return; }
|
|
71
|
+
res.json(entry);
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// POST /entries — create
|
|
78
|
+
router.post('/entries', async (req: Request, res: Response) => {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = createPromptSchema.safeParse(req.body);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const entry = await store.save(parsed.data);
|
|
86
|
+
res.status(201).json(entry);
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// PUT /entries/:id — update
|
|
93
|
+
router.put('/entries/:id', async (req: Request, res: Response) => {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = updatePromptSchema.safeParse(req.body);
|
|
96
|
+
if (!parsed.success) {
|
|
97
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const entry = await store.update(req.params.id, parsed.data);
|
|
102
|
+
if (!entry) { res.status(404).json({ error: 'Prompt not found' }); return; }
|
|
103
|
+
res.json(entry);
|
|
104
|
+
} catch (err: unknown) {
|
|
105
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// DELETE /entries/:id — delete
|
|
110
|
+
router.delete('/entries/:id', async (req: Request, res: Response) => {
|
|
111
|
+
try {
|
|
112
|
+
const deleted = await store.delete(req.params.id);
|
|
113
|
+
if (deleted) { res.json({ ok: true }); return; }
|
|
114
|
+
res.status(404).json({ error: 'Prompt not found' });
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// POST /entries/:id/render — render with variable substitution
|
|
121
|
+
router.post('/entries/:id/render', async (req: Request, res: Response) => {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = renderSchema.safeParse(req.body);
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const rendered = await store.render(req.params.id, parsed.data.vars);
|
|
129
|
+
if (rendered === null) { res.status(404).json({ error: 'Prompt not found' }); return; }
|
|
130
|
+
res.json({ rendered });
|
|
131
|
+
} catch (err: unknown) {
|
|
132
|
+
res.status(500).json({ error: (err instanceof Error ? err.message : String(err)) });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createShellMcpServer } from '../../apps/shell/src/mcp.js';
|
|
2
|
+
import { mountMcpHttp } from '../../packages/mcp-utils/src/index.js';
|
|
3
|
+
import type { McpState } from '../../apps/shell/src/shell-types.js';
|
|
4
|
+
import {
|
|
5
|
+
globalPtys,
|
|
6
|
+
dashboardState,
|
|
7
|
+
getShellNsp,
|
|
8
|
+
MAX_PANES,
|
|
9
|
+
nextPaneId,
|
|
10
|
+
paneActiveSocket,
|
|
11
|
+
socketDimensions,
|
|
12
|
+
} from './shell-state.js';
|
|
13
|
+
import { SHELL_CONFIGS } from './shell-config.js';
|
|
14
|
+
import { killPty, spawnGlobalPty } from './pty-manager.js';
|
|
15
|
+
|
|
16
|
+
export type { PtyEntry, PaneInfo, DashboardState, ShellConfig, McpState } from '../../apps/shell/src/shell-types.js';
|
|
17
|
+
export { router } from './shell-routes.js';
|
|
18
|
+
export { initShell } from './shell-socket.js';
|
|
19
|
+
|
|
20
|
+
// ── MCP integration ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function getShellMcpState(): McpState {
|
|
23
|
+
return {
|
|
24
|
+
globalPtys,
|
|
25
|
+
dashboardState,
|
|
26
|
+
io: getShellNsp() as any,
|
|
27
|
+
spawnGlobalPty,
|
|
28
|
+
SHELL_CONFIGS,
|
|
29
|
+
MAX_PANES,
|
|
30
|
+
nextPaneId,
|
|
31
|
+
paneActiveSocket,
|
|
32
|
+
socketDimensions,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mountShellMcp(app: any, prefix: string): void {
|
|
37
|
+
mountMcpHttp(app, () => createShellMcpServer(getShellMcpState()), prefix);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Shutdown ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function shutdownShell(): void {
|
|
43
|
+
for (const { ptyProcess } of globalPtys.values()) {
|
|
44
|
+
killPty(ptyProcess);
|
|
45
|
+
}
|
|
46
|
+
globalPtys.clear();
|
|
47
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import pty, { type IPty } from 'node-pty';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import type { PtyEntry, PaneInfo } from '../../apps/shell/src/shell-types.js';
|
|
4
|
+
import {
|
|
5
|
+
globalPtys,
|
|
6
|
+
dashboardState,
|
|
7
|
+
getShellNsp,
|
|
8
|
+
SCROLLBACK_LIMIT,
|
|
9
|
+
} from './shell-state.js';
|
|
10
|
+
|
|
11
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Send SIGHUP, then SIGKILL after 2 s if still alive. */
|
|
14
|
+
export function killPty(p: IPty): void {
|
|
15
|
+
try {
|
|
16
|
+
p.kill();
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const { pid } = p;
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
process.kill(pid, 'SIGKILL');
|
|
25
|
+
} catch { /* already exited */ }
|
|
26
|
+
}, 2000).unref();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readCwd(pid: number): Promise<string | null> {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
fs.readlink(`/proc/${pid}/cwd`, (err: NodeJS.ErrnoException | null, linkPath: string) => resolve(err ? null : linkPath));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updatePaneCwd(id: string, cwd: string): void {
|
|
36
|
+
const pane = dashboardState.panes.find((p: PaneInfo) => p.id === id);
|
|
37
|
+
if (pane) pane.cwd = cwd;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── PTY lifecycle ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function spawnGlobalPty(
|
|
43
|
+
id: string,
|
|
44
|
+
command: string,
|
|
45
|
+
args: string[],
|
|
46
|
+
env: Record<string, string>,
|
|
47
|
+
cols: number,
|
|
48
|
+
rows: number,
|
|
49
|
+
trackCwd: boolean,
|
|
50
|
+
oscOnly: boolean,
|
|
51
|
+
startCwd: string | null
|
|
52
|
+
): IPty {
|
|
53
|
+
cols = Number.isInteger(cols) && cols >= 1 ? Math.min(cols, 500) : 80;
|
|
54
|
+
rows = Number.isInteger(rows) && rows >= 1 ? Math.min(rows, 500) : 24;
|
|
55
|
+
|
|
56
|
+
const ptyProcess: IPty = pty.spawn(command, args, {
|
|
57
|
+
name: 'xterm-256color',
|
|
58
|
+
cols,
|
|
59
|
+
rows,
|
|
60
|
+
cwd: startCwd || process.env.HOME || '/',
|
|
61
|
+
env
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const entry: PtyEntry = { ptyProcess, chunks: [], totalLen: 0 };
|
|
65
|
+
globalPtys.set(id, entry);
|
|
66
|
+
|
|
67
|
+
let cwdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
|
|
69
|
+
ptyProcess.onData((data: string) => {
|
|
70
|
+
entry.chunks.push(data);
|
|
71
|
+
entry.totalLen += data.length;
|
|
72
|
+
if (entry.totalLen > SCROLLBACK_LIMIT * 1.5) {
|
|
73
|
+
const joined = entry.chunks.join('').slice(-SCROLLBACK_LIMIT);
|
|
74
|
+
entry.chunks = [joined];
|
|
75
|
+
entry.totalLen = joined.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getShellNsp()!.to(`pane:${id}`).emit('terminal:data', { id, data });
|
|
79
|
+
|
|
80
|
+
if (trackCwd) {
|
|
81
|
+
const oscMatch = data.match(/\x1b\]7;([^\x07\x1b]+)\x07/);
|
|
82
|
+
if (oscMatch) {
|
|
83
|
+
const cwd = oscMatch[1];
|
|
84
|
+
updatePaneCwd(id, cwd);
|
|
85
|
+
getShellNsp()!.emit('terminal:cwd', { id, cwd });
|
|
86
|
+
} else if (!oscOnly) {
|
|
87
|
+
if (cwdTimer) clearTimeout(cwdTimer);
|
|
88
|
+
cwdTimer = setTimeout(async () => {
|
|
89
|
+
if (!globalPtys.has(id)) return;
|
|
90
|
+
const cwd = await readCwd(ptyProcess.pid);
|
|
91
|
+
if (cwd) {
|
|
92
|
+
updatePaneCwd(id, cwd);
|
|
93
|
+
getShellNsp()!.emit('terminal:cwd', { id, cwd });
|
|
94
|
+
}
|
|
95
|
+
}, 300);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
|
101
|
+
if (cwdTimer) clearTimeout(cwdTimer);
|
|
102
|
+
if (!globalPtys.delete(id)) return;
|
|
103
|
+
getShellNsp()!.emit('terminal:exit', { id, code: exitCode });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return ptyProcess;
|
|
107
|
+
}
|