@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.
@@ -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
+ }