@shawnstack/quickforge 1.3.7 → 1.3.9
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 +10 -10
- package/dist/assets/{anthropic-67kzbrvt.js → anthropic-DEWK7KRC.js} +1 -1
- package/dist/assets/{azure-openai-responses-D2XquyCv.js → azure-openai-responses-DPabzClN.js} +1 -1
- package/dist/assets/{google-gemini-cli-CNT4rz9y.js → google-gemini-cli-yHADA_B-.js} +1 -1
- package/dist/assets/{google-CPViRPcU.js → google-sImdbEw6.js} +1 -1
- package/dist/assets/{google-vertex-DOnrgFCc.js → google-vertex-BvABOE3j.js} +1 -1
- package/dist/assets/{icons-B8AoRjZB.js → icons-DBuN_6a3.js} +1 -1
- package/dist/assets/index-CVgEhBX2.css +3 -0
- package/dist/assets/{index-D3njc0Th.js → index-C_380Bws.js} +544 -499
- package/dist/assets/{mistral-Cz-xIri6.js → mistral-C5okAJhj.js} +1 -1
- package/dist/assets/{openai-codex-responses-CyA4Z3WI.js → openai-codex-responses-B7ztXQ9j.js} +1 -1
- package/dist/assets/{openai-completions-DSDeg9P2.js → openai-completions-8QDwhUFZ.js} +1 -1
- package/dist/assets/{openai-responses-jFa5zzTQ.js → openai-responses-Bt2misJJ.js} +1 -1
- package/dist/assets/{openai-responses-shared-BDx4vPct.js → openai-responses-shared-CptIMUoB.js} +1 -1
- package/dist/assets/{react-vendor-DP6iK2-u.js → react-vendor-BA_xfwfk.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +6 -3
- package/server/index.mjs +47 -0
- package/server/routes/terminal.mjs +110 -0
- package/server/routes/workspace.mjs +296 -0
- package/server/terminal/terminal-manager.mjs +256 -0
- package/dist/assets/index-B0wkRg7T.css +0 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { sendJson } from '../utils/response.mjs'
|
|
5
|
+
import { projectContextFromId } from '../project-config.mjs'
|
|
6
|
+
import {
|
|
7
|
+
assertSafeWorkspacePath,
|
|
8
|
+
isSensitiveWorkspacePath,
|
|
9
|
+
resolveWorkspacePath,
|
|
10
|
+
shouldSearchFile,
|
|
11
|
+
toWorkspaceRelative,
|
|
12
|
+
} from '../utils/workspace.mjs'
|
|
13
|
+
|
|
14
|
+
const MAX_PREVIEW_BYTES = 1024 * 1024
|
|
15
|
+
const MAX_TREE_NODES = 5000
|
|
16
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-ssr', 'package-dist', 'package-offline', '.vite', 'coverage'])
|
|
17
|
+
|
|
18
|
+
const extensionLanguageMap = new Map([
|
|
19
|
+
['ts', 'typescript'], ['tsx', 'typescript'], ['js', 'javascript'], ['jsx', 'javascript'],
|
|
20
|
+
['mjs', 'javascript'], ['cjs', 'javascript'], ['json', 'json'], ['jsonc', 'json'],
|
|
21
|
+
['css', 'css'], ['scss', 'scss'], ['less', 'less'], ['html', 'html'], ['htm', 'html'],
|
|
22
|
+
['md', 'markdown'], ['markdown', 'markdown'], ['py', 'python'], ['rb', 'ruby'], ['go', 'go'],
|
|
23
|
+
['rs', 'rust'], ['java', 'java'], ['c', 'c'], ['h', 'c'], ['cpp', 'cpp'], ['cc', 'cpp'],
|
|
24
|
+
['cxx', 'cpp'], ['hpp', 'cpp'], ['cs', 'csharp'], ['php', 'php'], ['swift', 'swift'],
|
|
25
|
+
['kt', 'kotlin'], ['kts', 'kotlin'], ['sh', 'shell'], ['bash', 'shell'], ['zsh', 'shell'],
|
|
26
|
+
['ps1', 'powershell'], ['yml', 'yaml'], ['yaml', 'yaml'], ['xml', 'xml'], ['sql', 'sql'],
|
|
27
|
+
['toml', 'toml'], ['ini', 'ini'], ['env', 'ini'],
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
function languageFromPath(filePath) {
|
|
31
|
+
const fileName = path.basename(filePath).toLowerCase()
|
|
32
|
+
if (fileName === 'dockerfile' || fileName.endsWith('.dockerfile')) return 'dockerfile'
|
|
33
|
+
const extension = fileName.includes('.') ? fileName.split('.').pop() : fileName
|
|
34
|
+
return extensionLanguageMap.get(extension) || 'plaintext'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isBinaryBuffer(buffer) {
|
|
38
|
+
const length = Math.min(buffer.length, 8000)
|
|
39
|
+
for (let index = 0; index < length; index += 1) {
|
|
40
|
+
if (buffer[index] === 0) return true
|
|
41
|
+
}
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function projectContextFromUrl(url) {
|
|
46
|
+
const projectId = url.searchParams.get('projectId')
|
|
47
|
+
if (!projectId) {
|
|
48
|
+
const error = new Error('projectId is required')
|
|
49
|
+
error.statusCode = 400
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
return projectContextFromId(projectId)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function git(args, cwd, options = {}) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawn('git', args, {
|
|
58
|
+
cwd,
|
|
59
|
+
shell: false,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
62
|
+
})
|
|
63
|
+
const stdout = []
|
|
64
|
+
const stderr = []
|
|
65
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
|
66
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
|
67
|
+
child.once('error', reject)
|
|
68
|
+
child.once('close', (code) => {
|
|
69
|
+
const out = Buffer.concat(stdout)
|
|
70
|
+
const err = Buffer.concat(stderr).toString('utf8').trim()
|
|
71
|
+
if (code === 0 || options.allowFailure) {
|
|
72
|
+
resolve({ code, stdout: out, stderr: err })
|
|
73
|
+
} else {
|
|
74
|
+
const error = new Error(err || `git ${args.join(' ')} failed`)
|
|
75
|
+
error.statusCode = 400
|
|
76
|
+
reject(error)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function isGitRepository(workspaceRoot) {
|
|
83
|
+
const result = await git(['rev-parse', '--is-inside-work-tree'], workspaceRoot, { allowFailure: true })
|
|
84
|
+
return result.code === 0 && result.stdout.toString('utf8').trim() === 'true'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function classifyStatus(x, y) {
|
|
88
|
+
if (x === '?' && y === '?') return 'untracked'
|
|
89
|
+
if (x === 'R' || y === 'R') return 'renamed'
|
|
90
|
+
if (x === 'A' || y === 'A') return 'added'
|
|
91
|
+
if (x === 'D' || y === 'D') return 'deleted'
|
|
92
|
+
return 'modified'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseGitStatus(buffer) {
|
|
96
|
+
const entries = buffer.toString('utf8').split('\0').filter(Boolean)
|
|
97
|
+
const files = []
|
|
98
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
99
|
+
const entry = entries[index]
|
|
100
|
+
const x = entry[0] || ' '
|
|
101
|
+
const y = entry[1] || ' '
|
|
102
|
+
const status = classifyStatus(x, y)
|
|
103
|
+
const file = {
|
|
104
|
+
path: entry.slice(3).replace(/\\/g, '/'),
|
|
105
|
+
status,
|
|
106
|
+
staged: x !== ' ' && x !== '?',
|
|
107
|
+
unstaged: y !== ' ' && y !== '?',
|
|
108
|
+
}
|
|
109
|
+
if (status === 'renamed') {
|
|
110
|
+
const oldPath = entries[index + 1]
|
|
111
|
+
if (oldPath) {
|
|
112
|
+
file.oldPath = oldPath.replace(/\\/g, '/')
|
|
113
|
+
index += 1
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
files.push(file)
|
|
117
|
+
}
|
|
118
|
+
return files.sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: 'base' }))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function listGitStatus(context) {
|
|
122
|
+
if (!(await isGitRepository(context.workspaceRoot))) return { isGitRepository: false, files: [] }
|
|
123
|
+
const result = await git(['status', '--porcelain=v1', '-z'], context.workspaceRoot)
|
|
124
|
+
return { isGitRepository: true, files: parseGitStatus(result.stdout) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readGitFile(workspaceRoot, ref, relativePath) {
|
|
128
|
+
const result = await git(['show', `${ref}:${relativePath}`], workspaceRoot, { allowFailure: true })
|
|
129
|
+
return result.code === 0 ? result.stdout.toString('utf8') : ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readWorkspaceTextFile(context, relativePath) {
|
|
133
|
+
const file = resolveWorkspacePath(relativePath, context)
|
|
134
|
+
await assertSafeWorkspacePath(file, context)
|
|
135
|
+
const stat = await fs.stat(file)
|
|
136
|
+
if (!stat.isFile()) {
|
|
137
|
+
const error = new Error('Path is not a file')
|
|
138
|
+
error.statusCode = 400
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
if (stat.size > MAX_PREVIEW_BYTES) {
|
|
142
|
+
const error = new Error('File is too large to preview')
|
|
143
|
+
error.statusCode = 413
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
const buffer = await fs.readFile(file)
|
|
147
|
+
if (isBinaryBuffer(buffer)) {
|
|
148
|
+
const error = new Error('Binary file cannot be previewed')
|
|
149
|
+
error.statusCode = 415
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
return { content: buffer.toString('utf8'), size: stat.size, path: toWorkspaceRelative(file, context) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function buildTreeForDirectory(dir, context, counter) {
|
|
156
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
157
|
+
const nodes = []
|
|
158
|
+
const sortedEntries = entries.sort((left, right) => {
|
|
159
|
+
if (left.isDirectory() !== right.isDirectory()) return left.isDirectory() ? -1 : 1
|
|
160
|
+
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
for (const entry of sortedEntries) {
|
|
164
|
+
if (counter.count >= MAX_TREE_NODES) break
|
|
165
|
+
const fullPath = path.join(dir, entry.name)
|
|
166
|
+
const relativePath = toWorkspaceRelative(fullPath, context)
|
|
167
|
+
if (entry.isDirectory()) {
|
|
168
|
+
if (SKIP_DIRS.has(entry.name) || isSensitiveWorkspacePath(fullPath, context)) continue
|
|
169
|
+
try {
|
|
170
|
+
await assertSafeWorkspacePath(fullPath, context)
|
|
171
|
+
counter.count += 1
|
|
172
|
+
nodes.push({
|
|
173
|
+
name: entry.name,
|
|
174
|
+
path: relativePath,
|
|
175
|
+
type: 'directory',
|
|
176
|
+
children: await buildTreeForDirectory(fullPath, context, counter),
|
|
177
|
+
})
|
|
178
|
+
} catch {
|
|
179
|
+
// Skip directories that cannot be safely resolved.
|
|
180
|
+
}
|
|
181
|
+
} else if (entry.isFile()) {
|
|
182
|
+
if (!shouldSearchFile(entry.name) || isSensitiveWorkspacePath(fullPath, context)) continue
|
|
183
|
+
try {
|
|
184
|
+
await assertSafeWorkspacePath(fullPath, context)
|
|
185
|
+
counter.count += 1
|
|
186
|
+
nodes.push({ name: entry.name, path: relativePath, type: 'file' })
|
|
187
|
+
} catch {
|
|
188
|
+
// Skip files that cannot be safely resolved.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return nodes
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function handleWorkspaceTree(req, res, url) {
|
|
196
|
+
const context = await projectContextFromUrl(url)
|
|
197
|
+
const tree = await buildTreeForDirectory(context.workspaceRoot, context, { count: 0 })
|
|
198
|
+
sendJson(res, 200, { root: context.project.name, tree })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function handleWorkspaceFile(req, res, url) {
|
|
202
|
+
const context = await projectContextFromUrl(url)
|
|
203
|
+
const relativePath = url.searchParams.get('path') || ''
|
|
204
|
+
if (!relativePath) {
|
|
205
|
+
const error = new Error('path is required')
|
|
206
|
+
error.statusCode = 400
|
|
207
|
+
throw error
|
|
208
|
+
}
|
|
209
|
+
const file = await readWorkspaceTextFile(context, relativePath)
|
|
210
|
+
sendJson(res, 200, {
|
|
211
|
+
...file,
|
|
212
|
+
language: languageFromPath(file.path),
|
|
213
|
+
readonly: true,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleGitStatus(req, res, url) {
|
|
218
|
+
const context = await projectContextFromUrl(url)
|
|
219
|
+
sendJson(res, 200, await listGitStatus(context))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleGitFileDiff(req, res, url) {
|
|
223
|
+
const context = await projectContextFromUrl(url)
|
|
224
|
+
const relativePath = url.searchParams.get('path') || ''
|
|
225
|
+
if (!relativePath) {
|
|
226
|
+
const error = new Error('path is required')
|
|
227
|
+
error.statusCode = 400
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const statusPayload = await listGitStatus(context)
|
|
232
|
+
if (!statusPayload.isGitRepository) {
|
|
233
|
+
const error = new Error('This project is not a Git repository')
|
|
234
|
+
error.statusCode = 400
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
const changedFile = statusPayload.files.find((file) => file.path === relativePath)
|
|
238
|
+
if (!changedFile) {
|
|
239
|
+
const error = new Error('File has no working tree changes')
|
|
240
|
+
error.statusCode = 404
|
|
241
|
+
throw error
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const newRelativePath = changedFile.path
|
|
245
|
+
const oldRelativePath = changedFile.oldPath || changedFile.path
|
|
246
|
+
let oldContent = ''
|
|
247
|
+
let newContent = ''
|
|
248
|
+
|
|
249
|
+
if (changedFile.status !== 'added' && changedFile.status !== 'untracked') {
|
|
250
|
+
const oldFile = resolveWorkspacePath(oldRelativePath, context)
|
|
251
|
+
await assertSafeWorkspacePath(oldFile, context, { ignoreMissing: true })
|
|
252
|
+
oldContent = await readGitFile(context.workspaceRoot, 'HEAD', oldRelativePath)
|
|
253
|
+
}
|
|
254
|
+
if (changedFile.status !== 'deleted') {
|
|
255
|
+
newContent = (await readWorkspaceTextFile(context, newRelativePath)).content
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
sendJson(res, 200, {
|
|
259
|
+
path: newRelativePath,
|
|
260
|
+
oldPath: changedFile.oldPath,
|
|
261
|
+
status: changedFile.status,
|
|
262
|
+
oldContent,
|
|
263
|
+
newContent,
|
|
264
|
+
language: languageFromPath(newRelativePath),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function handleWorkspaceApi(req, res, url) {
|
|
269
|
+
if (req.method === 'GET' && url.pathname === '/api/workspace/tree') {
|
|
270
|
+
await handleWorkspaceTree(req, res, url)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (req.method === 'GET' && url.pathname === '/api/workspace/file') {
|
|
274
|
+
await handleWorkspaceFile(req, res, url)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const error = new Error('Not found')
|
|
279
|
+
error.statusCode = 404
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function handleGitApi(req, res, url) {
|
|
284
|
+
if (req.method === 'GET' && url.pathname === '/api/git/status') {
|
|
285
|
+
await handleGitStatus(req, res, url)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (req.method === 'GET' && url.pathname === '/api/git/file-diff') {
|
|
289
|
+
await handleGitFileDiff(req, res, url)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const error = new Error('Not found')
|
|
294
|
+
error.statusCode = 404
|
|
295
|
+
throw error
|
|
296
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { logger } from '../utils/logger.mjs'
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
const MAX_SESSIONS = Math.max(1, Number(process.env.QUICKFORGE_MAX_TERMINALS || 6))
|
|
9
|
+
const TERMINAL_DISABLED = process.env.QUICKFORGE_TERMINAL === '0'
|
|
10
|
+
const RECONNECT_GRACE_MS = Number(process.env.QUICKFORGE_TERMINAL_RECONNECT_MS || 5 * 60 * 1000)
|
|
11
|
+
const IDLE_TIMEOUT_MS = Number(process.env.QUICKFORGE_TERMINAL_IDLE_MS || 30 * 60 * 1000)
|
|
12
|
+
|
|
13
|
+
const sessions = new Map()
|
|
14
|
+
let cleanupTimer = null
|
|
15
|
+
let pty = null
|
|
16
|
+
let ptyLoadError = null
|
|
17
|
+
|
|
18
|
+
function loadPty() {
|
|
19
|
+
if (pty) return pty
|
|
20
|
+
if (ptyLoadError) throw ptyLoadError
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
pty = require('node-pty')
|
|
24
|
+
return pty
|
|
25
|
+
} catch (error) {
|
|
26
|
+
ptyLoadError = createError('Terminal requires node-pty. Install optional terminal dependencies to enable it.', 503)
|
|
27
|
+
ptyLoadError.cause = error
|
|
28
|
+
throw ptyLoadError
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWindows() {
|
|
33
|
+
return process.platform === 'win32'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commandExists(command) {
|
|
37
|
+
const probe = isWindows() ? 'where' : 'command'
|
|
38
|
+
const args = isWindows() ? [command] : ['-v', command]
|
|
39
|
+
const result = spawnSync(probe, args, { shell: !isWindows(), stdio: 'ignore', windowsHide: true })
|
|
40
|
+
return result.status === 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectShell() {
|
|
44
|
+
if (process.env.QUICKFORGE_TERMINAL_SHELL) return process.env.QUICKFORGE_TERMINAL_SHELL
|
|
45
|
+
if (isWindows()) {
|
|
46
|
+
if (commandExists('pwsh.exe')) return 'pwsh.exe'
|
|
47
|
+
if (commandExists('powershell.exe')) return 'powershell.exe'
|
|
48
|
+
return 'cmd.exe'
|
|
49
|
+
}
|
|
50
|
+
return process.env.SHELL || (commandExists('/bin/bash') ? '/bin/bash' : '/bin/sh')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createError(message, statusCode = 500) {
|
|
54
|
+
const error = new Error(message)
|
|
55
|
+
error.statusCode = statusCode
|
|
56
|
+
return error
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serializeSession(session) {
|
|
60
|
+
return {
|
|
61
|
+
id: session.id,
|
|
62
|
+
name: session.name,
|
|
63
|
+
projectId: session.projectId,
|
|
64
|
+
cwd: session.cwd,
|
|
65
|
+
shell: session.shell,
|
|
66
|
+
createdAt: session.createdAt,
|
|
67
|
+
updatedAt: session.updatedAt,
|
|
68
|
+
exited: session.exited,
|
|
69
|
+
exitCode: session.exitCode,
|
|
70
|
+
signal: session.signal,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function send(client, message) {
|
|
75
|
+
if (client.readyState === client.OPEN) {
|
|
76
|
+
client.send(JSON.stringify(message))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function broadcast(session, message) {
|
|
81
|
+
for (const client of session.clients) send(client, message)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scheduleCleanup() {
|
|
85
|
+
if (cleanupTimer) return
|
|
86
|
+
cleanupTimer = setInterval(() => {
|
|
87
|
+
const now = Date.now()
|
|
88
|
+
for (const session of sessions.values()) {
|
|
89
|
+
const disconnectedTooLong = session.clients.size === 0 && now - session.disconnectedAt > RECONNECT_GRACE_MS
|
|
90
|
+
const idleTooLong = now - session.touchedAt > IDLE_TIMEOUT_MS
|
|
91
|
+
if (session.exited || disconnectedTooLong || idleTooLong) {
|
|
92
|
+
destroyTerminalSession(session.id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (sessions.size === 0) {
|
|
96
|
+
clearInterval(cleanupTimer)
|
|
97
|
+
cleanupTimer = null
|
|
98
|
+
}
|
|
99
|
+
}, 60 * 1000)
|
|
100
|
+
cleanupTimer.unref?.()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function terminalCapabilities() {
|
|
104
|
+
const shell = detectShell()
|
|
105
|
+
const terminalAvailable = !TERMINAL_DISABLED && (() => {
|
|
106
|
+
try {
|
|
107
|
+
loadPty()
|
|
108
|
+
return true
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
})()
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
enabled: terminalAvailable,
|
|
116
|
+
localOnly: true,
|
|
117
|
+
maxSessions: MAX_SESSIONS,
|
|
118
|
+
shell: terminalAvailable ? shell : null,
|
|
119
|
+
reason: TERMINAL_DISABLED
|
|
120
|
+
? 'Terminal is disabled by QUICKFORGE_TERMINAL=0.'
|
|
121
|
+
: (terminalAvailable ? null : 'Terminal requires node-pty. Install optional terminal dependencies to enable it.'),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function listTerminalSessions(projectId) {
|
|
126
|
+
return [...sessions.values()]
|
|
127
|
+
.filter((session) => !projectId || session.projectId === projectId)
|
|
128
|
+
.map(serializeSession)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createTerminalSession({ cwd, projectId = null, name, cols = 120, rows = 30 }) {
|
|
132
|
+
if (TERMINAL_DISABLED) throw createError('Terminal is disabled', 403)
|
|
133
|
+
if (sessions.size >= MAX_SESSIONS) throw createError(`Maximum terminal sessions reached (${MAX_SESSIONS})`, 429)
|
|
134
|
+
|
|
135
|
+
const shell = detectShell()
|
|
136
|
+
const ptyModule = loadPty()
|
|
137
|
+
const id = randomUUID()
|
|
138
|
+
const now = new Date().toISOString()
|
|
139
|
+
const ptyProcess = ptyModule.spawn(shell, [], {
|
|
140
|
+
name: 'xterm-256color',
|
|
141
|
+
cols: Math.max(20, Number(cols) || 120),
|
|
142
|
+
rows: Math.max(8, Number(rows) || 30),
|
|
143
|
+
cwd,
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
TERM: 'xterm-256color',
|
|
147
|
+
COLORTERM: 'truecolor',
|
|
148
|
+
QUICKFORGE_TERMINAL: '1',
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const session = {
|
|
153
|
+
id,
|
|
154
|
+
name: String(name || `Terminal ${sessions.size + 1}`),
|
|
155
|
+
projectId,
|
|
156
|
+
cwd,
|
|
157
|
+
shell,
|
|
158
|
+
pty: ptyProcess,
|
|
159
|
+
clients: new Set(),
|
|
160
|
+
cols: Math.max(20, Number(cols) || 120),
|
|
161
|
+
rows: Math.max(8, Number(rows) || 30),
|
|
162
|
+
createdAt: now,
|
|
163
|
+
updatedAt: now,
|
|
164
|
+
touchedAt: Date.now(),
|
|
165
|
+
disconnectedAt: Date.now(),
|
|
166
|
+
exited: false,
|
|
167
|
+
exitCode: null,
|
|
168
|
+
signal: null,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ptyProcess.onData((data) => {
|
|
172
|
+
session.touchedAt = Date.now()
|
|
173
|
+
session.updatedAt = new Date().toISOString()
|
|
174
|
+
broadcast(session, { type: 'output', data })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
178
|
+
session.exited = true
|
|
179
|
+
session.exitCode = exitCode
|
|
180
|
+
session.signal = signal
|
|
181
|
+
session.updatedAt = new Date().toISOString()
|
|
182
|
+
broadcast(session, { type: 'exit', exitCode, signal })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
sessions.set(id, session)
|
|
186
|
+
scheduleCleanup()
|
|
187
|
+
return serializeSession(session)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function attachTerminalClient(sessionId, client) {
|
|
191
|
+
const session = sessions.get(sessionId)
|
|
192
|
+
if (!session) throw createError('Terminal session not found', 404)
|
|
193
|
+
|
|
194
|
+
session.clients.add(client)
|
|
195
|
+
session.touchedAt = Date.now()
|
|
196
|
+
session.updatedAt = new Date().toISOString()
|
|
197
|
+
send(client, { type: 'ready', session: serializeSession(session) })
|
|
198
|
+
|
|
199
|
+
client.on('message', (raw) => {
|
|
200
|
+
try {
|
|
201
|
+
const message = JSON.parse(raw.toString('utf8'))
|
|
202
|
+
session.touchedAt = Date.now()
|
|
203
|
+
session.updatedAt = new Date().toISOString()
|
|
204
|
+
|
|
205
|
+
if (message.type === 'input' && typeof message.data === 'string' && !session.exited) {
|
|
206
|
+
session.pty.write(message.data)
|
|
207
|
+
} else if (message.type === 'resize' && !session.exited) {
|
|
208
|
+
const cols = Math.max(20, Number(message.cols) || session.cols)
|
|
209
|
+
const rows = Math.max(8, Number(message.rows) || session.rows)
|
|
210
|
+
session.cols = cols
|
|
211
|
+
session.rows = rows
|
|
212
|
+
session.pty.resize(cols, rows)
|
|
213
|
+
} else if (message.type === 'ping') {
|
|
214
|
+
send(client, { type: 'pong' })
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
send(client, { type: 'error', message: error instanceof Error ? error.message : 'Invalid terminal message' })
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
client.on('close', () => {
|
|
222
|
+
session.clients.delete(client)
|
|
223
|
+
session.disconnectedAt = Date.now()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (session.exited) {
|
|
227
|
+
send(client, { type: 'exit', exitCode: session.exitCode, signal: session.signal })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function destroyTerminalSession(sessionId) {
|
|
232
|
+
const session = sessions.get(sessionId)
|
|
233
|
+
if (!session) return false
|
|
234
|
+
sessions.delete(sessionId)
|
|
235
|
+
for (const client of session.clients) {
|
|
236
|
+
try { client.close() } catch { /* ignore */ }
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
if (!session.exited) session.pty.kill()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
logger.warn('Failed to kill terminal session', { sessionId, error: error?.message })
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function shutdownTerminalSessions() {
|
|
247
|
+
if (cleanupTimer) {
|
|
248
|
+
clearInterval(cleanupTimer)
|
|
249
|
+
cleanupTimer = null
|
|
250
|
+
}
|
|
251
|
+
for (const sessionId of [...sessions.keys()]) destroyTerminalSession(sessionId)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function platformInfo() {
|
|
255
|
+
return { platform: os.platform(), shell: detectShell() }
|
|
256
|
+
}
|