@shawnstack/quickforge 1.0.0 → 1.2.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.
- package/README.md +22 -16
- package/bin/quickforge.mjs +83 -8
- package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +2 -1
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +326 -34
- package/server/project-config.mjs +85 -55
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +49 -19
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +66 -12
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +578 -133
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +31 -1
- package/server/utils/response.mjs +9 -2
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-BQJ8qi1U.css +0 -3
- package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
- package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
package/server/index.mjs
CHANGED
|
@@ -2,47 +2,183 @@
|
|
|
2
2
|
import { createServer } from 'node:http'
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
5
6
|
import { fileURLToPath } from 'node:url'
|
|
6
7
|
import { sendJson, sendError } from './utils/response.mjs'
|
|
7
8
|
import { openBrowser } from './utils/platform.mjs'
|
|
8
|
-
import {
|
|
9
|
+
import { ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from './storage.mjs'
|
|
9
10
|
import { setDefaultWorkspaceRoot, initializeActiveProject, readProjectConfig, getActiveProject } from './project-config.mjs'
|
|
10
11
|
import { getWorkspaceRoot } from './utils/workspace.mjs'
|
|
11
12
|
import { handleStorageApi } from './routes/storage.mjs'
|
|
12
13
|
import { handleProjectApi } from './routes/project.mjs'
|
|
13
|
-
import { handleFilesystemApi } from './routes/filesystem.mjs'
|
|
14
|
-
import { handleToolApi } from './routes/tools.mjs'
|
|
14
|
+
import { handleFilesystemApi, setActiveWorkspaceRootForFilesystem } from './routes/filesystem.mjs'
|
|
15
|
+
import { handleToolApi, handleGetTools } from './routes/tools.mjs'
|
|
15
16
|
import { handleInstructionsApi } from './routes/instructions.mjs'
|
|
17
|
+
import { handleSkillsApi } from './routes/skills.mjs'
|
|
18
|
+
import { handleAgentApi } from './routes/agent.mjs'
|
|
19
|
+
import { handleScheduledTasksApi, startScheduledTaskRunner, stopScheduledTaskRunner } from './routes/scheduled-tasks.mjs'
|
|
20
|
+
import { handleBackupApi } from './routes/backup.mjs'
|
|
21
|
+
import { handleSystemApi } from './routes/system.mjs'
|
|
22
|
+
import { handleSharesApi } from './routes/shares.mjs'
|
|
23
|
+
import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
|
|
16
24
|
import { serveStatic } from './routes/static.mjs'
|
|
17
|
-
import {
|
|
25
|
+
import { logger } from './utils/logger.mjs'
|
|
26
|
+
import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
|
|
27
|
+
import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
|
|
18
28
|
|
|
19
29
|
const __filename = fileURLToPath(import.meta.url)
|
|
20
30
|
const __dirname = path.dirname(__filename)
|
|
21
31
|
const projectRoot = path.resolve(__dirname, '..')
|
|
32
|
+
const serverScript = path.join(__dirname, 'index.mjs')
|
|
33
|
+
const restartSupervisorScript = path.join(__dirname, 'restart-supervisor.mjs')
|
|
34
|
+
const bootId = randomUUID()
|
|
35
|
+
const startedAt = new Date().toISOString()
|
|
22
36
|
|
|
23
37
|
const isDev = process.argv.includes('--dev')
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
38
|
+
const shareLanEnabled = process.env.QUICKFORGE_SHARE_LAN !== '0'
|
|
39
|
+
const host = process.env.QUICKFORGE_HOST || '0.0.0.0'
|
|
40
|
+
if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_REMOTE !== '1' && !shareLanEnabled) {
|
|
41
|
+
throw new Error('Remote binding is disabled by default. Set QUICKFORGE_ALLOW_REMOTE=1 or keep QUICKFORGE_SHARE_LAN enabled to allow it.')
|
|
42
|
+
}
|
|
43
|
+
const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
|
|
44
|
+
const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
|
|
45
|
+
let restartInProgress = false
|
|
46
|
+
|
|
47
|
+
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || projectRoot)
|
|
48
|
+
|
|
49
|
+
function getRestartSupport() {
|
|
50
|
+
return { supported: true, reason: null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function getSystemStatus() {
|
|
54
|
+
const config = await readProjectConfig()
|
|
55
|
+
const restartSupport = getRestartSupport()
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
mode: isDev ? 'development' : 'production',
|
|
59
|
+
pid: process.pid,
|
|
60
|
+
bootId,
|
|
61
|
+
startedAt,
|
|
62
|
+
restartSupported: restartSupport.supported,
|
|
63
|
+
restartUnsupportedReason: restartSupport.reason,
|
|
64
|
+
dataDir,
|
|
65
|
+
configDir,
|
|
66
|
+
storageDir,
|
|
67
|
+
cacheDir,
|
|
68
|
+
logsDir,
|
|
69
|
+
workspaceRoot: getWorkspaceRoot(),
|
|
70
|
+
host,
|
|
71
|
+
port,
|
|
72
|
+
shareLanEnabled,
|
|
73
|
+
lanUrls: getLanUrls(port),
|
|
74
|
+
project: getActiveProject(config),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function spawnRestartSupervisor() {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const child = spawn(process.execPath, [
|
|
81
|
+
restartSupervisorScript,
|
|
82
|
+
String(process.pid),
|
|
83
|
+
serverScript,
|
|
84
|
+
projectRoot,
|
|
85
|
+
...process.argv.slice(2),
|
|
86
|
+
], {
|
|
87
|
+
cwd: projectRoot,
|
|
88
|
+
detached: true,
|
|
89
|
+
stdio: 'ignore',
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
shell: false,
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
QUICKFORGE_NO_OPEN: '1',
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
child.once('error', reject)
|
|
99
|
+
child.once('spawn', () => {
|
|
100
|
+
child.unref()
|
|
101
|
+
resolve(child.pid)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function closeHttpServer() {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const forceTimer = setTimeout(() => {
|
|
109
|
+
server.closeAllConnections?.()
|
|
110
|
+
resolve()
|
|
111
|
+
}, 1500)
|
|
112
|
+
|
|
113
|
+
server.close(() => {
|
|
114
|
+
clearTimeout(forceTimer)
|
|
115
|
+
resolve()
|
|
116
|
+
})
|
|
117
|
+
server.closeIdleConnections?.()
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function performRestart() {
|
|
122
|
+
logger.info('Restart requested from settings UI.')
|
|
123
|
+
const supervisorPid = await spawnRestartSupervisor()
|
|
124
|
+
logger.info(`Restart supervisor started (PID ${supervisorPid}).`)
|
|
125
|
+
|
|
126
|
+
stopScheduledTaskRunner()
|
|
127
|
+
stopVite()
|
|
128
|
+
await shutdownAgentManager()
|
|
129
|
+
await closeHttpServer()
|
|
130
|
+
process.exit(0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function requestRestart() {
|
|
134
|
+
if (restartInProgress) {
|
|
135
|
+
const error = new Error('Restart already in progress')
|
|
136
|
+
error.statusCode = 423
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const restartSupport = getRestartSupport()
|
|
141
|
+
if (!restartSupport.supported) {
|
|
142
|
+
const error = new Error(restartSupport.reason || 'Restart is not supported')
|
|
143
|
+
error.statusCode = 409
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
27
146
|
|
|
28
|
-
|
|
147
|
+
restartInProgress = true
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
void performRestart().catch((error) => {
|
|
150
|
+
logger.error('Failed to restart QuickForge:', error)
|
|
151
|
+
restartInProgress = false
|
|
152
|
+
})
|
|
153
|
+
}, 100)
|
|
154
|
+
|
|
155
|
+
return { ok: true, restarting: true, bootId }
|
|
156
|
+
}
|
|
29
157
|
|
|
30
158
|
// --- Route dispatching ---
|
|
31
159
|
async function handleApi(req, res, url) {
|
|
32
160
|
const pathname = url.pathname
|
|
33
161
|
const parts = pathname.split('/').filter(Boolean)
|
|
34
162
|
|
|
163
|
+
// Conversation share routes (management + public LAN access)
|
|
164
|
+
if (pathname === '/api/shares' || pathname.startsWith('/api/shares/')) {
|
|
165
|
+
await handleSharesApi(req, res, url, { port })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (pathname.startsWith('/api/shared/')) {
|
|
170
|
+
await handleSharedConversationApi(req, res, url)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
35
174
|
// Health check
|
|
36
175
|
if (req.method === 'GET' && pathname === '/api/health') {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
workspaceRoot: getWorkspaceRoot(),
|
|
44
|
-
project: getActiveProject(config),
|
|
45
|
-
})
|
|
176
|
+
sendJson(res, 200, await getSystemStatus())
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (req.method === 'GET' && pathname === '/api/project/commands') {
|
|
181
|
+
await handleProjectApi(req, res, url)
|
|
46
182
|
return
|
|
47
183
|
}
|
|
48
184
|
|
|
@@ -52,6 +188,12 @@ async function handleApi(req, res, url) {
|
|
|
52
188
|
return
|
|
53
189
|
}
|
|
54
190
|
|
|
191
|
+
// Skills
|
|
192
|
+
if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
|
|
193
|
+
await handleSkillsApi(req, res, url)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
55
197
|
// Project routes
|
|
56
198
|
if (pathname === '/api/project' || pathname.startsWith('/api/project/')) {
|
|
57
199
|
await handleProjectApi(req, res, url)
|
|
@@ -64,12 +206,48 @@ async function handleApi(req, res, url) {
|
|
|
64
206
|
return
|
|
65
207
|
}
|
|
66
208
|
|
|
209
|
+
// Tool definitions (canonical)
|
|
210
|
+
if (req.method === 'GET' && pathname === '/api/tools') {
|
|
211
|
+
await handleGetTools(req, res)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
67
215
|
// Tool routes
|
|
68
216
|
if (pathname.startsWith('/api/tools/') || (parts[0] === 'api' && parts[1] === 'projects' && parts[3] === 'tools')) {
|
|
69
217
|
await handleToolApi(req, res, url)
|
|
70
218
|
return
|
|
71
219
|
}
|
|
72
220
|
|
|
221
|
+
// Agent routes
|
|
222
|
+
if (parts[0] === 'api' && parts[1] === 'agents') {
|
|
223
|
+
await handleAgentApi(req, res, url)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Scheduled task routes
|
|
228
|
+
if (pathname === '/api/scheduled-tasks' || pathname.startsWith('/api/scheduled-tasks/')) {
|
|
229
|
+
await handleScheduledTasksApi(req, res, url)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Backup / import-export routes
|
|
234
|
+
if (pathname === '/api/backup/export' || pathname === '/api/backup/import') {
|
|
235
|
+
await handleBackupApi(req, res, url)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// System routes
|
|
240
|
+
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network') {
|
|
241
|
+
await handleSystemApi(req, res, url, {
|
|
242
|
+
getSystemStatus,
|
|
243
|
+
requestRestart,
|
|
244
|
+
host,
|
|
245
|
+
port,
|
|
246
|
+
remoteEnabled: host !== '127.0.0.1' && host !== 'localhost',
|
|
247
|
+
})
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
73
251
|
// Storage routes (catch-all)
|
|
74
252
|
if (parts[0] === 'api' && parts[1] === 'storage') {
|
|
75
253
|
await handleStorageApi(req, res, url)
|
|
@@ -82,64 +260,178 @@ async function handleApi(req, res, url) {
|
|
|
82
260
|
}
|
|
83
261
|
|
|
84
262
|
// --- Vite dev server ---
|
|
263
|
+
let viteChild = null
|
|
264
|
+
|
|
85
265
|
function startVite() {
|
|
86
266
|
const viteCli = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js')
|
|
87
|
-
|
|
267
|
+
viteChild = spawn(process.execPath, [viteCli, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
|
|
88
268
|
cwd: projectRoot,
|
|
89
269
|
stdio: 'inherit',
|
|
90
270
|
shell: false,
|
|
91
271
|
env: { ...process.env, QUICKFORGE_SERVER_PORT: String(port) },
|
|
92
272
|
})
|
|
93
|
-
|
|
273
|
+
viteChild.on('exit', (code) => {
|
|
94
274
|
if (code && code !== 0) process.exitCode = code
|
|
95
275
|
})
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stopVite() {
|
|
279
|
+
if (viteChild) {
|
|
280
|
+
viteChild.kill('SIGTERM')
|
|
281
|
+
viteChild = null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isAllowedCorsOrigin(origin) {
|
|
286
|
+
try {
|
|
287
|
+
const parsed = new URL(origin)
|
|
288
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false
|
|
289
|
+
if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) return false
|
|
290
|
+
const originPort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
|
|
291
|
+
return originPort === String(port) || originPort === String(vitePort)
|
|
292
|
+
} catch {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseHostHeader(value) {
|
|
298
|
+
if (!value) return null
|
|
299
|
+
try {
|
|
300
|
+
const parsed = new URL(`http://${value}`)
|
|
301
|
+
return { hostname: parsed.hostname, port: parsed.port }
|
|
302
|
+
} catch {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isAllowedHostHeader(value) {
|
|
308
|
+
const parsed = parseHostHeader(value)
|
|
309
|
+
if (!parsed) return false
|
|
310
|
+
const allowedHosts = new Set(['localhost', '127.0.0.1', host])
|
|
311
|
+
if (process.env.QUICKFORGE_ALLOW_REMOTE === '1' || shareLanEnabled) {
|
|
312
|
+
allowedHosts.add('0.0.0.0')
|
|
313
|
+
allowedHosts.add(parsed.hostname)
|
|
314
|
+
}
|
|
315
|
+
const expectedPort = String(port)
|
|
316
|
+
const hostPort = parsed.port || '80'
|
|
317
|
+
return allowedHosts.has(parsed.hostname) && hostPort === expectedPort
|
|
105
318
|
}
|
|
106
319
|
|
|
107
320
|
// --- Bootstrap ---
|
|
108
321
|
const server = createServer(async (req, res) => {
|
|
322
|
+
const startedAt = Date.now()
|
|
323
|
+
res.on('finish', () => {
|
|
324
|
+
const durationMs = Date.now() - startedAt
|
|
325
|
+
logger.info(`${req.method} ${req.url} ${res.statusCode} ${durationMs}ms`)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
if (!isAllowedHostHeader(req.headers.host)) {
|
|
329
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
330
|
+
res.end(JSON.stringify({ error: 'Forbidden host' }))
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Allow direct browser connections to the API server (e.g. SSE from dev mode
|
|
335
|
+
// where the Vite proxy on :5176 would otherwise consume HTTP/1.1 connections).
|
|
336
|
+
const origin = req.headers.origin
|
|
337
|
+
if (origin && isAllowedCorsOrigin(origin)) {
|
|
338
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
339
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
|
340
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type, x-quickforge-action')
|
|
341
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
342
|
+
}
|
|
343
|
+
if (req.method === 'OPTIONS') {
|
|
344
|
+
res.writeHead(origin && !isAllowedCorsOrigin(origin) ? 403 : 204)
|
|
345
|
+
res.end()
|
|
346
|
+
return
|
|
347
|
+
}
|
|
109
348
|
try {
|
|
110
349
|
const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`)
|
|
350
|
+
const remoteAddress = req.socket.remoteAddress
|
|
351
|
+
if (
|
|
352
|
+
url.pathname.startsWith('/api/') &&
|
|
353
|
+
!isLoopbackAddress(remoteAddress) &&
|
|
354
|
+
shareLanEnabled &&
|
|
355
|
+
!(url.pathname.startsWith('/api/shared/') || url.pathname === '/api/health')
|
|
356
|
+
) {
|
|
357
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
358
|
+
res.end(JSON.stringify({ error: 'Remote API access is limited to shared conversation endpoints.' }))
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
111
362
|
if (url.pathname.startsWith('/api/')) {
|
|
112
363
|
await handleApi(req, res, url)
|
|
113
364
|
return
|
|
114
365
|
}
|
|
115
366
|
|
|
367
|
+
if (url.pathname.startsWith('/share/')) {
|
|
368
|
+
await serveStatic(req, res, url)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
url.pathname.startsWith('/assets/') ||
|
|
374
|
+
url.pathname === '/favicon.svg' ||
|
|
375
|
+
url.pathname === '/vite.svg'
|
|
376
|
+
) {
|
|
377
|
+
await serveStatic(req, res, url)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!isLoopbackAddress(remoteAddress) && shareLanEnabled) {
|
|
382
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
383
|
+
res.end(JSON.stringify({ error: 'Remote access is limited to shared conversation links.' }))
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
116
387
|
if (isDev) {
|
|
117
388
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
118
|
-
res.end(
|
|
389
|
+
res.end(`QuickForge local API server is running. Open the Vite app at http://127.0.0.1:${vitePort}`)
|
|
119
390
|
return
|
|
120
391
|
}
|
|
121
392
|
|
|
122
393
|
await serveStatic(req, res, url)
|
|
123
394
|
} catch (error) {
|
|
124
|
-
|
|
395
|
+
logger.error(error)
|
|
125
396
|
sendError(res, error)
|
|
126
397
|
}
|
|
127
398
|
})
|
|
128
399
|
|
|
129
|
-
await migrateLegacyDataDirs()
|
|
130
400
|
await ensureStorage()
|
|
401
|
+
await resetStaleTaskStatuses()
|
|
131
402
|
await initializeActiveProject()
|
|
132
403
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
404
|
+
startScheduledTaskRunner()
|
|
133
405
|
|
|
134
406
|
server.listen(port, host, () => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
407
|
+
logger.info(`QuickForge local API: http://${host}:${port}`)
|
|
408
|
+
if (shareLanEnabled) {
|
|
409
|
+
const lanUrls = getLanUrls(port)
|
|
410
|
+
logger.info(`QuickForge LAN sharing is enabled. Share pages are available at: ${lanUrls.length ? lanUrls.join(', ') : `http://<your-lan-ip>:${port}`}`)
|
|
411
|
+
logger.info('Remote non-share API routes are restricted while QUICKFORGE_SHARE_LAN=1.')
|
|
412
|
+
}
|
|
413
|
+
logger.info(`QuickForge data dir: ${dataDir}`)
|
|
414
|
+
logger.info(`QuickForge project: ${getWorkspaceRoot()}`)
|
|
138
415
|
|
|
139
416
|
if (isDev) {
|
|
140
417
|
startVite()
|
|
141
418
|
setTimeout(() => openBrowser(`http://localhost:${vitePort}`), 1000)
|
|
419
|
+
} else if (shareLanEnabled) {
|
|
420
|
+
const lanUrls = getLanUrls(port)
|
|
421
|
+
openBrowser(lanUrls[0] || `http://localhost:${port}`)
|
|
142
422
|
} else {
|
|
143
423
|
openBrowser(`http://localhost:${port}`)
|
|
144
424
|
}
|
|
145
425
|
})
|
|
426
|
+
|
|
427
|
+
// Graceful shutdown
|
|
428
|
+
async function gracefulShutdown(signal) {
|
|
429
|
+
logger.info(`Received ${signal}, shutting down gracefully...`)
|
|
430
|
+
stopScheduledTaskRunner()
|
|
431
|
+
stopVite()
|
|
432
|
+
await shutdownAgentManager()
|
|
433
|
+
process.exit(0)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
process.on('SIGINT', (signal) => gracefulShutdown(signal))
|
|
437
|
+
process.on('SIGTERM', (signal) => gracefulShutdown(signal))
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import {
|
|
3
|
+
import { ensureProjectCache, readProjectConfigData, atomicProjectConfigUpdate, dataDir } from './storage.mjs'
|
|
4
4
|
import { promises as fs } from 'node:fs'
|
|
5
5
|
import { setWorkspaceRoot, getWorkspaceRoot, assertDirectory } from './utils/workspace.mjs'
|
|
6
|
+
import { loadSelectedGlobalSkills, loadSelectedProjectSkills, mergeSkills } from './skills.mjs'
|
|
6
7
|
|
|
7
8
|
let defaultWorkspaceRoot = ''
|
|
8
9
|
|
|
@@ -15,41 +16,24 @@ function projectNameFromPath(dir) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function defaultProjectConfig() {
|
|
18
|
-
const now = new Date().toISOString()
|
|
19
|
-
const id = 'default'
|
|
20
19
|
return {
|
|
21
|
-
activeProjectId:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
id,
|
|
25
|
-
name: projectNameFromPath(defaultWorkspaceRoot),
|
|
26
|
-
path: defaultWorkspaceRoot,
|
|
27
|
-
lastOpenedAt: now,
|
|
28
|
-
},
|
|
29
|
-
],
|
|
20
|
+
activeProjectId: null,
|
|
21
|
+
globalSkills: [],
|
|
22
|
+
projects: [],
|
|
30
23
|
}
|
|
31
24
|
}
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!Array.isArray(parsed.projects) || parsed.projects.length === 0) return defaultProjectConfig()
|
|
40
|
-
return parsed
|
|
41
|
-
} catch (error) {
|
|
42
|
-
if (error?.code === 'ENOENT') return defaultProjectConfig()
|
|
43
|
-
throw error
|
|
26
|
+
function normalizeProjectConfig(config) {
|
|
27
|
+
if (!config || typeof config !== 'object') return defaultProjectConfig()
|
|
28
|
+
return {
|
|
29
|
+
activeProjectId: typeof config.activeProjectId === 'string' ? config.activeProjectId : null,
|
|
30
|
+
globalSkills: Array.isArray(config.globalSkills) ? config.globalSkills : [],
|
|
31
|
+
projects: Array.isArray(config.projects) ? config.projects : [],
|
|
44
32
|
}
|
|
45
33
|
}
|
|
46
34
|
|
|
47
|
-
export async function
|
|
48
|
-
await
|
|
49
|
-
const file = projectConfigFile()
|
|
50
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
51
|
-
await fs.writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
52
|
-
await fs.rename(tmp, file)
|
|
35
|
+
export async function readProjectConfig() {
|
|
36
|
+
return normalizeProjectConfig(await readProjectConfigData())
|
|
53
37
|
}
|
|
54
38
|
|
|
55
39
|
export function getActiveProject(config) {
|
|
@@ -60,28 +44,34 @@ export async function setActiveProjectPath(inputPath) {
|
|
|
60
44
|
const resolved = path.resolve(String(inputPath || ''))
|
|
61
45
|
await assertDirectory(resolved)
|
|
62
46
|
|
|
63
|
-
const config = await readProjectConfig()
|
|
64
47
|
const now = new Date().toISOString()
|
|
65
|
-
let project
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
48
|
+
let project
|
|
49
|
+
|
|
50
|
+
const updated = await atomicProjectConfigUpdate((config) => {
|
|
51
|
+
project = config.projects.find((item) => path.resolve(item.path) === resolved)
|
|
52
|
+
if (!project) {
|
|
53
|
+
project = {
|
|
54
|
+
id: randomUUID(),
|
|
55
|
+
name: projectNameFromPath(resolved),
|
|
56
|
+
path: resolved,
|
|
57
|
+
lastOpenedAt: now,
|
|
58
|
+
skills: [],
|
|
59
|
+
}
|
|
60
|
+
config.projects.unshift(project)
|
|
61
|
+
} else {
|
|
62
|
+
project.name = projectNameFromPath(resolved)
|
|
63
|
+
project.path = resolved
|
|
64
|
+
project.lastOpenedAt = now
|
|
72
65
|
}
|
|
73
|
-
config.projects.unshift(project)
|
|
74
|
-
} else {
|
|
75
|
-
project.name = projectNameFromPath(resolved)
|
|
76
|
-
project.path = resolved
|
|
77
|
-
project.lastOpenedAt = now
|
|
78
|
-
}
|
|
79
66
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
67
|
+
config.activeProjectId = project.id
|
|
68
|
+
config.projects = [project, ...config.projects.filter((item) => item.id !== project.id)].slice(0, 20)
|
|
69
|
+
return config
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await ensureProjectCache(project.id)
|
|
83
73
|
setWorkspaceRoot(resolved)
|
|
84
|
-
return { project, projects:
|
|
74
|
+
return { project, projects: updated.projects }
|
|
85
75
|
}
|
|
86
76
|
|
|
87
77
|
export async function initializeActiveProject() {
|
|
@@ -90,6 +80,7 @@ export async function initializeActiveProject() {
|
|
|
90
80
|
if (activeProject?.path) {
|
|
91
81
|
try {
|
|
92
82
|
await assertDirectory(activeProject.path)
|
|
83
|
+
await ensureProjectCache(activeProject.id)
|
|
93
84
|
setWorkspaceRoot(path.resolve(activeProject.path))
|
|
94
85
|
return
|
|
95
86
|
} catch {
|
|
@@ -97,8 +88,7 @@ export async function initializeActiveProject() {
|
|
|
97
88
|
}
|
|
98
89
|
}
|
|
99
90
|
|
|
100
|
-
|
|
101
|
-
setWorkspaceRoot(path.resolve(fallback.project.path))
|
|
91
|
+
// No project configured — leave workspace unset, user will be prompted to add one.
|
|
102
92
|
}
|
|
103
93
|
|
|
104
94
|
export async function projectContextFromId(projectId) {
|
|
@@ -111,15 +101,55 @@ export async function projectContextFromId(projectId) {
|
|
|
111
101
|
}
|
|
112
102
|
|
|
113
103
|
await assertDirectory(project.path)
|
|
104
|
+
await ensureProjectCache(project.id)
|
|
114
105
|
return { project, workspaceRoot: path.resolve(project.path) }
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
export async function readInstructionsFile(filePath) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
109
|
+
const candidates = filePath.endsWith('AGENTS.md')
|
|
110
|
+
? [filePath, path.join(path.dirname(filePath), 'agents.md')]
|
|
111
|
+
: [filePath]
|
|
112
|
+
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
try {
|
|
115
|
+
const content = await fs.readFile(candidate, 'utf8')
|
|
116
|
+
const trimmed = content.trim()
|
|
117
|
+
if (trimmed) return trimmed
|
|
118
|
+
} catch {
|
|
119
|
+
// try next candidate
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function buildInstructionsPayload(projectId) {
|
|
126
|
+
const config = await readProjectConfig()
|
|
127
|
+
let projectInstructions = null
|
|
128
|
+
let project = projectId ? config.projects.find((item) => item.id === projectId) ?? null : null
|
|
129
|
+
|
|
130
|
+
if (projectId) {
|
|
131
|
+
try {
|
|
132
|
+
const context = await projectContextFromId(projectId)
|
|
133
|
+
project = context.project
|
|
134
|
+
projectInstructions = await readInstructionsFile(path.join(context.workspaceRoot, 'AGENTS.md'))
|
|
135
|
+
} catch {
|
|
136
|
+
// project not found or inaccessible — leave projectInstructions null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
|
|
141
|
+
const globalSkills = await loadSelectedGlobalSkills(config.globalSkills)
|
|
142
|
+
const projectSkills = project?.skills && project?.path
|
|
143
|
+
? await loadSelectedProjectSkills(project.skills, project.path)
|
|
144
|
+
: []
|
|
145
|
+
const activeSkills = mergeSkills(globalSkills, projectSkills)
|
|
146
|
+
const stripRuntimeFields = ({ rootDir: _rootDir, instructions: _instructions, location: _location, ...skill }) => skill
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
global: globalInstructions,
|
|
150
|
+
project: projectInstructions,
|
|
151
|
+
globalSkills: globalSkills.map(stripRuntimeFields),
|
|
152
|
+
projectSkills: projectSkills.map(stripRuntimeFields),
|
|
153
|
+
skills: activeSkills.map(stripRuntimeFields),
|
|
124
154
|
}
|
|
125
155
|
}
|