@shawnstack/quickforge 1.0.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/quickforge.mjs +172 -0
  4. package/dist/assets/anthropic-u1nbNXhV.js +39 -0
  5. package/dist/assets/azure-openai-responses-DQ6xSOmb.js +1 -0
  6. package/dist/assets/chunk-62oNxeRG.js +1 -0
  7. package/dist/assets/confirm-dialog-DSmrqQ60.js +1 -0
  8. package/dist/assets/github-copilot-headers-C0toI16e.js +1 -0
  9. package/dist/assets/google-OeyKMN12.js +1 -0
  10. package/dist/assets/google-gemini-cli-SnPixyBu.js +2 -0
  11. package/dist/assets/google-shared-CXUHW-9O.js +11 -0
  12. package/dist/assets/google-vertex-y0o2eCZV.js +1 -0
  13. package/dist/assets/hash-fDQBJsbb.js +1 -0
  14. package/dist/assets/headers-Drkm68SQ.js +1 -0
  15. package/dist/assets/index-BQJ8qi1U.css +3 -0
  16. package/dist/assets/index-CK_34smc.js +3048 -0
  17. package/dist/assets/mistral-DzE_jn-B.js +44 -0
  18. package/dist/assets/openai-CuiHR4mv.js +16 -0
  19. package/dist/assets/openai-codex-responses-MtFRvp_b.js +7 -0
  20. package/dist/assets/openai-completions-C2dhwzO8.js +5 -0
  21. package/dist/assets/openai-responses-C4n0VhzY.js +1 -0
  22. package/dist/assets/openai-responses-shared-D2RkRvTj.js +10 -0
  23. package/dist/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
  24. package/dist/assets/prompt-dialog-B4BD09Oc.js +1 -0
  25. package/dist/assets/transform-messages-BFwlToJ0.js +1 -0
  26. package/dist/favicon.svg +1 -0
  27. package/dist/index.html +15 -0
  28. package/package.json +80 -0
  29. package/server/index.mjs +145 -0
  30. package/server/project-config.mjs +125 -0
  31. package/server/routes/filesystem.mjs +87 -0
  32. package/server/routes/instructions.mjs +31 -0
  33. package/server/routes/project.mjs +76 -0
  34. package/server/routes/static.mjs +57 -0
  35. package/server/routes/storage.mjs +97 -0
  36. package/server/routes/tools.mjs +31 -0
  37. package/server/storage.mjs +217 -0
  38. package/server/tools/index.mjs +236 -0
  39. package/server/utils/platform.mjs +131 -0
  40. package/server/utils/response.mjs +35 -0
  41. package/server/utils/workspace.mjs +135 -0
@@ -0,0 +1,131 @@
1
+ import { spawn } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ export function spawnCollect(command, args, options = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(command, args, {
8
+ stdio: ['ignore', 'pipe', 'pipe'],
9
+ windowsHide: options.windowsHide ?? true,
10
+ shell: false,
11
+ })
12
+ let stdout = ''
13
+ let stderr = ''
14
+ let timedOut = false
15
+ const timer = options.timeoutMs
16
+ ? setTimeout(() => {
17
+ timedOut = true
18
+ child.kill('SIGTERM')
19
+ }, options.timeoutMs)
20
+ : undefined
21
+ child.stdout.on('data', (chunk) => {
22
+ stdout += chunk.toString()
23
+ })
24
+ child.stderr.on('data', (chunk) => {
25
+ stderr += chunk.toString()
26
+ })
27
+ child.on('error', (error) => {
28
+ if (timer) clearTimeout(timer)
29
+ reject(error)
30
+ })
31
+ child.on('close', (code) => {
32
+ if (timer) clearTimeout(timer)
33
+ resolve({ code, stdout, stderr, timedOut })
34
+ })
35
+ })
36
+ }
37
+
38
+ export async function selectDirectoryDialog() {
39
+ if (process.platform === 'win32') {
40
+ const script = `
41
+ $ErrorActionPreference = 'Stop'
42
+ Add-Type -AssemblyName System.Windows.Forms
43
+ Add-Type -AssemblyName System.Drawing
44
+
45
+ $form = New-Object System.Windows.Forms.Form
46
+ $form.Text = 'QuickForge'
47
+ $form.StartPosition = 'CenterScreen'
48
+ $form.Size = New-Object System.Drawing.Size(1, 1)
49
+ $form.ShowInTaskbar = $false
50
+ $form.TopMost = $true
51
+ $form.Opacity = 0.01
52
+
53
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
54
+ $dialog.Description = 'Select QuickForge project folder'
55
+ $dialog.ShowNewFolderButton = $true
56
+
57
+ try {
58
+ [void]$form.Show()
59
+ [void]$form.Activate()
60
+ $result = $dialog.ShowDialog($form)
61
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
62
+ [Console]::Out.Write($dialog.SelectedPath)
63
+ }
64
+ exit 0
65
+ } finally {
66
+ if ($dialog) { $dialog.Dispose() }
67
+ if ($form) { $form.Dispose() }
68
+ }
69
+ `
70
+ let result = await spawnCollect(
71
+ 'powershell.exe',
72
+ ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', script],
73
+ { windowsHide: false, timeoutMs: 10 * 60 * 1000 },
74
+ ).catch(() => null)
75
+ if (!result) {
76
+ result = await spawnCollect(
77
+ 'pwsh.exe',
78
+ ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', script],
79
+ { windowsHide: false, timeoutMs: 10 * 60 * 1000 },
80
+ ).catch(() => null)
81
+ }
82
+ if (!result) {
83
+ const error = new Error('PowerShell was not found. Please install PowerShell or enable Windows PowerShell.')
84
+ error.statusCode = 500
85
+ throw error
86
+ }
87
+ if (result.timedOut) {
88
+ const error = new Error('Folder picker timed out. It may have been blocked by Windows or opened on another desktop.')
89
+ error.statusCode = 504
90
+ throw error
91
+ }
92
+ if (result.code === 0) return result.stdout.trim()
93
+ const error = new Error(result.stderr.trim() || 'Failed to open folder picker')
94
+ error.statusCode = 500
95
+ throw error
96
+ }
97
+
98
+ if (process.platform === 'darwin') {
99
+ const result = await spawnCollect('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select QuickForge project folder")'])
100
+ if (result.code === 0) return result.stdout.trim()
101
+ if (/User canceled/i.test(result.stderr)) return ''
102
+ const error = new Error(result.stderr.trim() || 'Failed to open folder picker')
103
+ error.statusCode = 500
104
+ throw error
105
+ }
106
+
107
+ const zenity = await spawnCollect('zenity', ['--file-selection', '--directory', '--title=Select QuickForge project folder']).catch(() => null)
108
+ if (zenity) {
109
+ if (zenity.code === 0) return zenity.stdout.trim()
110
+ if (zenity.code === 1) return ''
111
+ }
112
+
113
+ const kdialog = await spawnCollect('kdialog', ['--getexistingdirectory', os.homedir(), 'Select QuickForge project folder']).catch(() => null)
114
+ if (kdialog) {
115
+ if (kdialog.code === 0) return kdialog.stdout.trim()
116
+ if (kdialog.code === 1) return ''
117
+ }
118
+
119
+ const error = new Error('No supported folder picker found. Install zenity or kdialog on Linux.')
120
+ error.statusCode = 501
121
+ throw error
122
+ }
123
+
124
+ export function openBrowser(url) {
125
+ if (process.env.QUICKFORGE_NO_OPEN === '1' || process.env.FASTCODE_NO_OPEN === '1') return
126
+
127
+ const command = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'
128
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]
129
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false })
130
+ child.unref()
131
+ }
@@ -0,0 +1,35 @@
1
+ const DEFAULT_MAX_BODY_BYTES = Number(process.env.QUICKFORGE_MAX_BODY_BYTES || process.env.FASTCODE_MAX_BODY_BYTES || 50 * 1024 * 1024)
2
+
3
+ export function sendJson(res, status, value) {
4
+ const body = JSON.stringify(value)
5
+ res.writeHead(status, {
6
+ 'content-type': 'application/json; charset=utf-8',
7
+ 'cache-control': 'no-store',
8
+ })
9
+ res.end(body)
10
+ }
11
+
12
+ export function sendError(res, error) {
13
+ const status = error?.statusCode || 500
14
+ sendJson(res, status, { error: error?.message || 'Internal server error' })
15
+ }
16
+
17
+ export async function readJsonBody(req, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
18
+ const chunks = []
19
+ let size = 0
20
+ for await (const chunk of req) {
21
+ size += chunk.length
22
+ if (size > maxBodyBytes) {
23
+ const error = new Error('Request body is too large')
24
+ error.statusCode = 413
25
+ throw error
26
+ }
27
+ chunks.push(chunk)
28
+ }
29
+ const text = Buffer.concat(chunks).toString('utf8')
30
+ return text ? JSON.parse(text) : null
31
+ }
32
+
33
+ export function decodeSegment(value) {
34
+ return decodeURIComponent(value || '')
35
+ }
@@ -0,0 +1,135 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ let _workspaceRoot = ''
5
+
6
+ export function setWorkspaceRoot(root) {
7
+ _workspaceRoot = path.resolve(root)
8
+ }
9
+
10
+ export function getWorkspaceRoot() {
11
+ return _workspaceRoot
12
+ }
13
+
14
+ export function getToolWorkspaceRoot(context) {
15
+ return context?.workspaceRoot || getWorkspaceRoot()
16
+ }
17
+
18
+ export function isInside(parent, child) {
19
+ const relative = path.relative(parent, child)
20
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
21
+ }
22
+
23
+ export function resolveWorkspacePath(input = '.', context) {
24
+ const workspaceRoot = getToolWorkspaceRoot(context)
25
+ const candidate = path.isAbsolute(input)
26
+ ? path.resolve(input)
27
+ : path.resolve(workspaceRoot, input)
28
+
29
+ if (!isInside(workspaceRoot, candidate)) {
30
+ const error = new Error(`Path is outside the selected project: ${input}`)
31
+ error.statusCode = 403
32
+ throw error
33
+ }
34
+
35
+ return candidate
36
+ }
37
+
38
+ export function toWorkspaceRelative(fullPath, context) {
39
+ return path.relative(getToolWorkspaceRoot(context), fullPath).replace(/\\/g, '/') || '.'
40
+ }
41
+
42
+ export function isSensitiveWorkspacePath(fullPath, context) {
43
+ const relative = toWorkspaceRelative(fullPath, context)
44
+ const parts = relative.split('/')
45
+ const name = parts.at(-1) || ''
46
+ return (
47
+ parts.includes('.git') ||
48
+ name === '.env' ||
49
+ name.startsWith('.env.') ||
50
+ name.endsWith('.pem') ||
51
+ name.endsWith('.key') ||
52
+ name.endsWith('.p12') ||
53
+ name.endsWith('.pfx') ||
54
+ name.endsWith('.crt') ||
55
+ name.endsWith('.cer') ||
56
+ name.endsWith('.token') ||
57
+ name === 'credentials.json' ||
58
+ name === 'secrets.json' ||
59
+ name === 'id_rsa' ||
60
+ name === 'id_ed25519'
61
+ )
62
+ }
63
+
64
+ export function assertSafeWorkspacePath(fullPath, context) {
65
+ if (isSensitiveWorkspacePath(fullPath, context)) {
66
+ const error = new Error(`Access to sensitive path is blocked: ${toWorkspaceRelative(fullPath, context)}`)
67
+ error.statusCode = 403
68
+ throw error
69
+ }
70
+ }
71
+
72
+ export function truncateText(text, maxChars = 50000) {
73
+ if (text.length <= maxChars) return text
74
+ return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`
75
+ }
76
+
77
+ export function splitLines(text) {
78
+ return text.split(/\r?\n/)
79
+ }
80
+
81
+ export async function pathExists(dir) {
82
+ try {
83
+ await fs.access(dir)
84
+ return true
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ export async function assertDirectory(dir) {
91
+ const stat = await fs.stat(dir).catch(() => null)
92
+ if (!stat || !stat.isDirectory()) {
93
+ const error = new Error(`Project directory does not exist: ${dir}`)
94
+ error.statusCode = 400
95
+ throw error
96
+ }
97
+ }
98
+
99
+ export async function directorySize(dir) {
100
+ try {
101
+ const entries = await fs.readdir(dir, { withFileTypes: true })
102
+ const sizes = await Promise.all(entries.map(async (entry) => {
103
+ const full = path.join(dir, entry.name)
104
+ if (entry.isDirectory()) return directorySize(full)
105
+ const stat = await fs.stat(full)
106
+ return stat.size
107
+ }))
108
+ return sizes.reduce((sum, value) => sum + value, 0)
109
+ } catch {
110
+ return 0
111
+ }
112
+ }
113
+
114
+ export function shouldSkipSearchDir(name) {
115
+ return ['.git', 'node_modules', 'dist', 'dist-ssr', '.vite'].includes(name)
116
+ }
117
+
118
+ export function shouldSearchFile(name) {
119
+ const lower = name.toLowerCase()
120
+ const blocked = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.7z', '.exe', '.dll', '.woff', '.woff2', '.ttf']
121
+ return !blocked.some((extension) => lower.endsWith(extension))
122
+ }
123
+
124
+ export async function walkFiles(root, files = [], context) {
125
+ const entries = await fs.readdir(root, { withFileTypes: true })
126
+ for (const entry of entries) {
127
+ const fullPath = path.join(root, entry.name)
128
+ if (entry.isDirectory()) {
129
+ if (!shouldSkipSearchDir(entry.name)) await walkFiles(fullPath, files, context)
130
+ } else if (entry.isFile() && shouldSearchFile(entry.name) && !isSensitiveWorkspacePath(fullPath, context)) {
131
+ files.push(fullPath)
132
+ }
133
+ }
134
+ return files
135
+ }