@shawnstack/quickforge 1.5.2 → 1.5.4
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 +108 -12
- package/dist/assets/{AgentProfilesPage-DKyIh3dE.js → AgentProfilesPage-CGm7ZzRM.js} +1 -1
- package/dist/assets/ChatPanelHost-nzOC6Tbg.js +242 -0
- package/dist/assets/{PluginsPage-CN-SFQ_s.js → PluginsPage-K3o4AB2E.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-C0htXZk2.js → ScheduledTasksPage-BVjejep8.js} +1 -1
- package/dist/assets/{SharedConversationPage-CxbAx1fN.js → SharedConversationPage-BwmUajLr.js} +1 -1
- package/dist/assets/{TerminalDock-_voUf7d-.js → TerminalDock-D-GWlS7P.js} +1 -1
- package/dist/assets/{WorkspaceInspector-Ci4FuaZH.js → WorkspaceInspector-CEU6nnM-.js} +2 -2
- package/dist/assets/{WorkspaceReaderDialog-D75__GFg.js → WorkspaceReaderDialog-D9Qy6LUm.js} +1 -1
- package/dist/assets/{diff-line-counts-DHyWKEXk.js → diff-line-counts-DCot_pZu.js} +1 -1
- package/dist/assets/index-CkQWeO9c.css +3 -0
- package/dist/assets/index-DTiIspXQ.js +1482 -0
- package/dist/favicon.svg +16 -16
- package/dist/index.html +2 -2
- package/dist/pwa-icon-192.png +0 -0
- package/dist/pwa-icon-512.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/package.json +2 -1
- package/server/index.mjs +76 -6
- package/server/mcp/config.mjs +20 -20
- package/server/public-api.mjs +196 -0
- package/server/routes/backup.mjs +84 -20
- package/server/routes/system.mjs +2 -1
- package/server/storage.mjs +182 -48
- package/server/update-supervisor.mjs +121 -0
- package/dist/assets/ChatPanelHost-BUZ6scv9.js +0 -242
- package/dist/assets/index-BI7xZuj-.css +0 -3
- package/dist/assets/index-BzOV50wA.js +0 -1442
package/dist/favicon.svg
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="stroke" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
-
<stop offset="0" stop-color="#
|
|
5
|
-
<stop offset="1" stop-color="#
|
|
6
|
-
</linearGradient>
|
|
7
|
-
<linearGradient id="bolt" x1="0" y1="0" x2="1" y2="1">
|
|
8
|
-
<stop offset="0" stop-color="#
|
|
9
|
-
<stop offset="1" stop-color="#
|
|
10
|
-
</linearGradient>
|
|
11
|
-
</defs>
|
|
12
|
-
<polygon points="32,6 52.78,18 52.78,42 32,54 11.22,42 11.22,18"
|
|
13
|
-
fill="none" stroke="url(#stroke)" stroke-width="4.5" stroke-linejoin="round" />
|
|
14
|
-
<path d="M37.2 13 L22 34 L30.6 34 L26.8 50 L42.8 26 L33.8 26 Z" fill="url(#bolt)" />
|
|
15
|
-
<path d="M37.2 13 L22 34 L30.6 34 L33.8 26 Z" fill="#
|
|
16
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="stroke" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#9ca3af" />
|
|
5
|
+
<stop offset="1" stop-color="#4b5563" />
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="bolt" x1="0" y1="0" x2="1" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#374151" />
|
|
9
|
+
<stop offset="1" stop-color="#0f172a" />
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<polygon points="32,6 52.78,18 52.78,42 32,54 11.22,42 11.22,18"
|
|
13
|
+
fill="none" stroke="url(#stroke)" stroke-width="4.5" stroke-linejoin="round" />
|
|
14
|
+
<path d="M37.2 13 L22 34 L30.6 34 L26.8 50 L42.8 26 L33.8 26 Z" fill="url(#bolt)" />
|
|
15
|
+
<path d="M37.2 13 L22 34 L30.6 34 L33.8 26 Z" fill="#e5e7eb" opacity="0.4" />
|
|
16
|
+
</svg>
|
package/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="QuickForge" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<title>速构 QuickForge</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DTiIspXQ.js"></script>
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/pi-ai-Cx633yhb.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="/assets/lit-vendor-Dr3cpBGF.js">
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<link rel="modulepreload" crossorigin href="/assets/icons-DzxBk7tb.js">
|
|
21
21
|
<link rel="modulepreload" crossorigin href="/assets/react-vendor-DsAeMFcm.js">
|
|
22
22
|
<link rel="modulepreload" crossorigin href="/assets/logger-B65Akg8A.js">
|
|
23
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
23
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CkQWeO9c.css">
|
|
24
24
|
</head>
|
|
25
25
|
<body>
|
|
26
26
|
<div id="root"></div>
|
package/dist/pwa-icon-192.png
CHANGED
|
Binary file
|
package/dist/pwa-icon-512.png
CHANGED
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shawnstack/quickforge",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"description": "AI chat application with Agent access modes for local workspace tools. React + Vite + Tailwind CSS frontend, local Node.js storage server.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/shawnstack/quickforge#readme",
|
|
30
30
|
"type": "module",
|
|
31
|
+
"main": "./server/public-api.mjs",
|
|
31
32
|
"bin": {
|
|
32
33
|
"quickforge": "bin/quickforge.mjs",
|
|
33
34
|
"qf": "bin/quickforge.mjs"
|
package/server/index.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { handleChannelsApi } from './routes/channels.mjs'
|
|
|
31
31
|
import { handleModelsApi } from './routes/models.mjs'
|
|
32
32
|
import { serveStatic } from './routes/static.mjs'
|
|
33
33
|
import { logger, flushLogger } from './utils/logger.mjs'
|
|
34
|
-
import { getPackageInfo, checkForUpdates
|
|
34
|
+
import { getPackageInfo, checkForUpdates } from './utils/package-update.mjs'
|
|
35
35
|
import { installAiHttpLogger } from './ai-http-logger.mjs'
|
|
36
36
|
import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
|
|
37
37
|
import { parseCookies } from './share-store.mjs'
|
|
@@ -46,6 +46,7 @@ const __dirname = path.dirname(__filename)
|
|
|
46
46
|
const projectRoot = path.resolve(__dirname, '..')
|
|
47
47
|
const serverScript = path.join(__dirname, 'index.mjs')
|
|
48
48
|
const restartSupervisorScript = path.join(__dirname, 'restart-supervisor.mjs')
|
|
49
|
+
const updateSupervisorScript = path.join(__dirname, 'update-supervisor.mjs')
|
|
49
50
|
const bootId = randomUUID()
|
|
50
51
|
const startedAt = new Date().toISOString()
|
|
51
52
|
|
|
@@ -175,6 +176,56 @@ async function requestRestart() {
|
|
|
175
176
|
return { ok: true, restarting: true, bootId }
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
function updateLogFile() {
|
|
180
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '')
|
|
181
|
+
return path.join(logsDir, `update-${stamp}.log`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function spawnUpdateSupervisor(update) {
|
|
185
|
+
const logFile = updateLogFile()
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const child = spawn(process.execPath, [
|
|
188
|
+
updateSupervisorScript,
|
|
189
|
+
String(process.pid),
|
|
190
|
+
update.name,
|
|
191
|
+
update.latestVersion,
|
|
192
|
+
serverScript,
|
|
193
|
+
projectRoot,
|
|
194
|
+
logFile,
|
|
195
|
+
...process.argv.slice(2),
|
|
196
|
+
], {
|
|
197
|
+
cwd: dataDir,
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: 'ignore',
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
shell: false,
|
|
202
|
+
env: {
|
|
203
|
+
...process.env,
|
|
204
|
+
QUICKFORGE_NO_OPEN: '1',
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
child.once('error', reject)
|
|
209
|
+
child.once('spawn', () => {
|
|
210
|
+
child.unref()
|
|
211
|
+
resolve({ pid: child.pid, logFile })
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function shutdownForUpdate() {
|
|
217
|
+
logger.info('Shutting down QuickForge for external updater.')
|
|
218
|
+
stopScheduledTaskRunner()
|
|
219
|
+
stopVite()
|
|
220
|
+
await shutdownAgentManager()
|
|
221
|
+
await shutdownMcpConnections()
|
|
222
|
+
await shutdownChannels()
|
|
223
|
+
shutdownTerminalSessions()
|
|
224
|
+
await closeHttpServer()
|
|
225
|
+
flushLogger()
|
|
226
|
+
process.exit(0)
|
|
227
|
+
}
|
|
228
|
+
|
|
178
229
|
async function updateQuickForge() {
|
|
179
230
|
if (updateInProgress) {
|
|
180
231
|
const error = new Error('Update already in progress')
|
|
@@ -186,15 +237,34 @@ async function updateQuickForge() {
|
|
|
186
237
|
try {
|
|
187
238
|
const update = await checkForUpdates(projectRoot)
|
|
188
239
|
if (!update.updateAvailable) {
|
|
240
|
+
updateInProgress = false
|
|
189
241
|
return { ...update, ok: true, updated: false }
|
|
190
242
|
}
|
|
191
243
|
|
|
192
|
-
logger.info(`
|
|
193
|
-
await
|
|
194
|
-
logger.info(
|
|
195
|
-
|
|
196
|
-
|
|
244
|
+
logger.info(`Starting external QuickForge updater from ${update.currentVersion} to ${update.latestVersion}.`)
|
|
245
|
+
const supervisor = await spawnUpdateSupervisor(update)
|
|
246
|
+
logger.info(`Update supervisor started (PID ${supervisor.pid}). Log: ${supervisor.logFile}`)
|
|
247
|
+
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
void shutdownForUpdate().catch((error) => {
|
|
250
|
+
logger.error('Failed to shut down for QuickForge update:', error)
|
|
251
|
+
updateInProgress = false
|
|
252
|
+
})
|
|
253
|
+
}, 100)
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...update,
|
|
257
|
+
ok: true,
|
|
258
|
+
updated: false,
|
|
259
|
+
updateStarted: true,
|
|
260
|
+
restarting: true,
|
|
261
|
+
updaterPid: supervisor.pid,
|
|
262
|
+
logFile: supervisor.logFile,
|
|
263
|
+
bootId,
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
197
266
|
updateInProgress = false
|
|
267
|
+
throw error
|
|
198
268
|
}
|
|
199
269
|
}
|
|
200
270
|
|
package/server/mcp/config.mjs
CHANGED
|
@@ -163,43 +163,43 @@ export function normalizeMcpServers(value) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
export async function readMcpServers() {
|
|
166
|
-
const
|
|
167
|
-
return normalizeMcpServers(
|
|
166
|
+
const mcp = await readStore('mcp')
|
|
167
|
+
return normalizeMcpServers(mcp?.[MCP_CONFIG_KEY])
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
export async function writeMcpServers(servers) {
|
|
171
171
|
const normalized = normalizeMcpServers(servers)
|
|
172
|
-
return atomicUpdate('
|
|
173
|
-
|
|
174
|
-
return
|
|
175
|
-
}).then((
|
|
172
|
+
return atomicUpdate('mcp', (mcp) => {
|
|
173
|
+
mcp[MCP_CONFIG_KEY] = normalized.map((server) => ({ ...server, updatedAt: new Date().toISOString() }))
|
|
174
|
+
return mcp
|
|
175
|
+
}).then((mcp) => normalizeMcpServers(mcp?.[MCP_CONFIG_KEY]))
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
export async function upsertMcpServer(server) {
|
|
179
179
|
const normalized = normalizeMcpServerConfig(server)
|
|
180
|
-
return atomicUpdate('
|
|
181
|
-
const servers = normalizeMcpServers(
|
|
180
|
+
return atomicUpdate('mcp', (mcp) => {
|
|
181
|
+
const servers = normalizeMcpServers(mcp?.[MCP_CONFIG_KEY])
|
|
182
182
|
const index = servers.findIndex((item) => item.name === normalized.name)
|
|
183
183
|
const next = { ...normalized, updatedAt: new Date().toISOString() }
|
|
184
184
|
if (index >= 0) servers[index] = next
|
|
185
185
|
else servers.push(next)
|
|
186
|
-
|
|
187
|
-
return
|
|
188
|
-
}).then((
|
|
186
|
+
mcp[MCP_CONFIG_KEY] = servers
|
|
187
|
+
return mcp
|
|
188
|
+
}).then((mcp) => normalizeMcpServers(mcp?.[MCP_CONFIG_KEY]))
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
export async function deleteMcpServer(name) {
|
|
192
192
|
const normalizedName = normalizeName(name)
|
|
193
|
-
return atomicUpdate('
|
|
194
|
-
|
|
195
|
-
return
|
|
196
|
-
}).then((
|
|
193
|
+
return atomicUpdate('mcp', (mcp) => {
|
|
194
|
+
mcp[MCP_CONFIG_KEY] = normalizeMcpServers(mcp?.[MCP_CONFIG_KEY]).filter((server) => server.name !== normalizedName)
|
|
195
|
+
return mcp
|
|
196
|
+
}).then((mcp) => normalizeMcpServers(mcp?.[MCP_CONFIG_KEY]))
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
export async function setMcpServerEnabled(name, enabled) {
|
|
200
200
|
const normalizedName = normalizeName(name)
|
|
201
|
-
return atomicUpdate('
|
|
202
|
-
const servers = normalizeMcpServers(
|
|
201
|
+
return atomicUpdate('mcp', (mcp) => {
|
|
202
|
+
const servers = normalizeMcpServers(mcp?.[MCP_CONFIG_KEY])
|
|
203
203
|
const index = servers.findIndex((server) => server.name === normalizedName)
|
|
204
204
|
if (index < 0) {
|
|
205
205
|
const error = new Error(`MCP server not found: ${normalizedName}`)
|
|
@@ -211,7 +211,7 @@ export async function setMcpServerEnabled(name, enabled) {
|
|
|
211
211
|
enabled: Boolean(enabled),
|
|
212
212
|
updatedAt: new Date().toISOString(),
|
|
213
213
|
}
|
|
214
|
-
|
|
215
|
-
return
|
|
216
|
-
}).then((
|
|
214
|
+
mcp[MCP_CONFIG_KEY] = servers
|
|
215
|
+
return mcp
|
|
216
|
+
}).then((mcp) => normalizeMcpServers(mcp?.[MCP_CONFIG_KEY]))
|
|
217
217
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
6
|
+
const __dirname = path.dirname(__filename)
|
|
7
|
+
const projectRoot = path.resolve(__dirname, '..')
|
|
8
|
+
const serverScript = path.join(__dirname, 'index.mjs')
|
|
9
|
+
const serverScriptUrl = pathToFileURL(serverScript).href
|
|
10
|
+
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeHost(host) {
|
|
16
|
+
return host || '127.0.0.1'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getProbeHost(host) {
|
|
20
|
+
if (host === '0.0.0.0' || host === '::') return '127.0.0.1'
|
|
21
|
+
return host || '127.0.0.1'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDisplayHost(host) {
|
|
25
|
+
if (host === '0.0.0.0') return '<LAN-IP>'
|
|
26
|
+
if (host === '::') return '<LAN-IP>'
|
|
27
|
+
return host || '127.0.0.1'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getPort(port) {
|
|
31
|
+
return Number(port || 5176)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildEnv(options = {}) {
|
|
35
|
+
const host = normalizeHost(options.host)
|
|
36
|
+
const port = getPort(options.port)
|
|
37
|
+
const shareLan = options.shareLan === true
|
|
38
|
+
const env = {
|
|
39
|
+
...process.env,
|
|
40
|
+
QUICKFORGE_HOST: host,
|
|
41
|
+
QUICKFORGE_PORT: String(port),
|
|
42
|
+
QUICKFORGE_SHARE_LAN: shareLan ? '1' : '0',
|
|
43
|
+
QUICKFORGE_NO_OPEN: options.openBrowser ? '0' : '1',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options.dataDir) env.QUICKFORGE_DATA_DIR = path.resolve(options.dataDir)
|
|
47
|
+
if (options.workspaceDir) env.QUICKFORGE_WORKSPACE_DIR = path.resolve(options.workspaceDir)
|
|
48
|
+
if (options.vitePort) env.QUICKFORGE_VITE_PORT = String(options.vitePort)
|
|
49
|
+
if (options.terminal === false) env.QUICKFORGE_TERMINAL = '0'
|
|
50
|
+
if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = '1'
|
|
51
|
+
if (options.allowRemote || shareLan) env.QUICKFORGE_ALLOW_REMOTE = '1'
|
|
52
|
+
|
|
53
|
+
return env
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getQuickForgeUrl(options = {}) {
|
|
57
|
+
const host = getDisplayHost(normalizeHost(options.host))
|
|
58
|
+
const port = getPort(options.port)
|
|
59
|
+
return `http://${host}:${port}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getQuickForgeHealthUrl(options = {}) {
|
|
63
|
+
const host = getProbeHost(normalizeHost(options.host))
|
|
64
|
+
const port = getPort(options.port)
|
|
65
|
+
return `http://${host}:${port}/api/health`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function checkQuickForgeHealth(options = {}) {
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
const timeout = setTimeout(() => controller.abort(), options.requestTimeoutMs || 800)
|
|
71
|
+
timeout.unref?.()
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetch(getQuickForgeHealthUrl(options), {
|
|
75
|
+
headers: { accept: 'application/json' },
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
})
|
|
78
|
+
if (!response.ok) return null
|
|
79
|
+
const payload = await response.json()
|
|
80
|
+
if (!payload || payload.ok !== true || !payload.pid) return null
|
|
81
|
+
return payload
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timeout)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function waitForQuickForge(options = {}) {
|
|
90
|
+
const timeoutMs = options.timeoutMs || 15000
|
|
91
|
+
const deadline = Date.now() + timeoutMs
|
|
92
|
+
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
const health = await checkQuickForgeHealth(options)
|
|
95
|
+
if (health) return health
|
|
96
|
+
await sleep(options.pollIntervalMs || 300)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function startQuickForge(options = {}) {
|
|
103
|
+
const existingHealth = options.reuseExisting === false ? null : await checkQuickForgeHealth(options)
|
|
104
|
+
const url = getQuickForgeUrl(options)
|
|
105
|
+
const healthUrl = getQuickForgeHealthUrl(options)
|
|
106
|
+
|
|
107
|
+
if (existingHealth) {
|
|
108
|
+
return {
|
|
109
|
+
url,
|
|
110
|
+
healthUrl,
|
|
111
|
+
health: existingHealth,
|
|
112
|
+
pid: existingHealth.pid,
|
|
113
|
+
child: null,
|
|
114
|
+
reused: true,
|
|
115
|
+
async stop() {
|
|
116
|
+
return false
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (options.inline === true) {
|
|
122
|
+
await import(serverScriptUrl)
|
|
123
|
+
const health = await waitForQuickForge(options)
|
|
124
|
+
if (!health) throw new Error('QuickForge failed to start: health check timed out')
|
|
125
|
+
return {
|
|
126
|
+
url,
|
|
127
|
+
healthUrl,
|
|
128
|
+
health,
|
|
129
|
+
pid: health.pid || process.pid,
|
|
130
|
+
child: null,
|
|
131
|
+
reused: false,
|
|
132
|
+
inline: true,
|
|
133
|
+
async stop() {
|
|
134
|
+
return false
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const child = spawn(process.execPath, [serverScript], {
|
|
140
|
+
cwd: options.cwd ? path.resolve(options.cwd) : projectRoot,
|
|
141
|
+
detached: options.detached === true,
|
|
142
|
+
stdio: options.stdio || 'ignore',
|
|
143
|
+
windowsHide: true,
|
|
144
|
+
shell: false,
|
|
145
|
+
env: buildEnv(options),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
let exitInfo = null
|
|
149
|
+
child.once('exit', (code, signal) => {
|
|
150
|
+
exitInfo = { code, signal }
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
child.once('spawn', resolve)
|
|
155
|
+
child.once('error', reject)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const health = await waitForQuickForge(options)
|
|
159
|
+
if (!health) {
|
|
160
|
+
if (!child.killed && exitInfo === null) {
|
|
161
|
+
try {
|
|
162
|
+
child.kill('SIGTERM')
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore best-effort cleanup errors
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const reason = exitInfo
|
|
169
|
+
? `process exited early (code ${exitInfo.code ?? 'null'}, signal ${exitInfo.signal ?? 'null'})`
|
|
170
|
+
: 'health check timed out'
|
|
171
|
+
throw new Error(`QuickForge failed to start: ${reason}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (options.detached === true) child.unref()
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
url,
|
|
178
|
+
healthUrl,
|
|
179
|
+
health,
|
|
180
|
+
pid: health.pid || child.pid,
|
|
181
|
+
child,
|
|
182
|
+
reused: false,
|
|
183
|
+
async stop() {
|
|
184
|
+
if (child.killed) return false
|
|
185
|
+
child.kill('SIGTERM')
|
|
186
|
+
return true
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function stopQuickForge(instance) {
|
|
192
|
+
if (!instance?.child || instance.reused) return false
|
|
193
|
+
if (instance.child.killed) return false
|
|
194
|
+
instance.child.kill('SIGTERM')
|
|
195
|
+
return true
|
|
196
|
+
}
|
package/server/routes/backup.mjs
CHANGED
|
@@ -16,7 +16,13 @@ import { getWorkspaceRoot } from '../utils/workspace.mjs'
|
|
|
16
16
|
const BACKUP_VERSION = 1
|
|
17
17
|
const BACKUP_APP = 'quickforge'
|
|
18
18
|
const backupScopes = new Set(['all', 'config', 'sessions'])
|
|
19
|
-
const restoreSectionIds = new Set(['settings', 'providerKeys', 'customProviders', 'projects', 'scheduledTasks', 'conversations'])
|
|
19
|
+
const restoreSectionIds = new Set(['settings', 'mcp', 'providerKeys', 'customProviders', 'projects', 'scheduledTasks', 'conversations'])
|
|
20
|
+
const restoreModes = new Set(['replace', 'merge'])
|
|
21
|
+
|
|
22
|
+
function normalizeMode(value) {
|
|
23
|
+
const mode = String(value || 'replace')
|
|
24
|
+
return restoreModes.has(mode) ? mode : 'replace'
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
function normalizeScope(value) {
|
|
22
28
|
const scope = String(value || 'all')
|
|
@@ -109,8 +115,9 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
109
115
|
const data = {}
|
|
110
116
|
|
|
111
117
|
if (includeConfig) {
|
|
112
|
-
const [settings, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
|
|
118
|
+
const [settings, mcp, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
|
|
113
119
|
readStore('settings'),
|
|
120
|
+
readStore('mcp'),
|
|
114
121
|
includeSecrets ? readStore('provider-keys') : Promise.resolve(undefined),
|
|
115
122
|
readStore('custom-providers'),
|
|
116
123
|
readProjectConfigData(),
|
|
@@ -118,6 +125,7 @@ async function buildBackup(scope = 'all', options = {}) {
|
|
|
118
125
|
])
|
|
119
126
|
Object.assign(data, {
|
|
120
127
|
settings,
|
|
128
|
+
mcp,
|
|
121
129
|
customProviders,
|
|
122
130
|
projects,
|
|
123
131
|
scheduledTasks,
|
|
@@ -160,6 +168,7 @@ function normalizeBackupPayload(payload) {
|
|
|
160
168
|
|
|
161
169
|
const sections = {
|
|
162
170
|
settings: section(data, 'settings'),
|
|
171
|
+
mcp: section(data, 'mcp'),
|
|
163
172
|
providerKeys: section(data, 'providerKeys', 'provider-keys'),
|
|
164
173
|
customProviders: section(data, 'customProviders', 'custom-providers'),
|
|
165
174
|
projects: section(data, 'projects'),
|
|
@@ -168,6 +177,17 @@ function normalizeBackupPayload(payload) {
|
|
|
168
177
|
sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
// Backward compat: older backups stored MCP servers inside settings.mcpServers.
|
|
181
|
+
if (
|
|
182
|
+
sections.mcp === undefined &&
|
|
183
|
+
sections.settings && typeof sections.settings === 'object' && !Array.isArray(sections.settings) &&
|
|
184
|
+
Object.prototype.hasOwnProperty.call(sections.settings, 'mcpServers')
|
|
185
|
+
) {
|
|
186
|
+
const { mcpServers, ...restSettings } = sections.settings
|
|
187
|
+
sections.settings = restSettings
|
|
188
|
+
sections.mcp = { mcpServers: Array.isArray(mcpServers) ? mcpServers : [] }
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
if (Object.values(sections).every((value) => value === undefined)) {
|
|
172
192
|
const error = new Error('Backup does not contain any restorable sections')
|
|
173
193
|
error.statusCode = 400
|
|
@@ -197,6 +217,7 @@ function validateBackupPayload(payload) {
|
|
|
197
217
|
...backup,
|
|
198
218
|
sections: {
|
|
199
219
|
settings: assertObjectSection(sections.settings, 'settings'),
|
|
220
|
+
mcp: assertObjectSection(sections.mcp, 'mcp'),
|
|
200
221
|
providerKeys: assertObjectSection(sections.providerKeys, 'providerKeys'),
|
|
201
222
|
customProviders: assertObjectSection(sections.customProviders, 'customProviders'),
|
|
202
223
|
projects: assertProjectConfig(sections.projects),
|
|
@@ -249,6 +270,7 @@ function filterRestoreSections(sections, selected) {
|
|
|
249
270
|
if (!selected) return sections
|
|
250
271
|
return {
|
|
251
272
|
settings: selected.has('settings') ? sections.settings : undefined,
|
|
273
|
+
mcp: selected.has('mcp') ? sections.mcp : undefined,
|
|
252
274
|
providerKeys: selected.has('providerKeys') ? sections.providerKeys : undefined,
|
|
253
275
|
customProviders: selected.has('customProviders') ? sections.customProviders : undefined,
|
|
254
276
|
projects: selected.has('projects') ? sections.projects : undefined,
|
|
@@ -267,7 +289,8 @@ function parseImportPayload(body) {
|
|
|
267
289
|
const backup = validateBackupPayload(payload)
|
|
268
290
|
const requestedSections = body?.backup && typeof body === 'object' ? body.sections : undefined
|
|
269
291
|
const selected = normalizeRestoreSections(requestedSections, backup.sections)
|
|
270
|
-
|
|
292
|
+
const mode = normalizeMode(body?.mode)
|
|
293
|
+
return { backup: backupWithSelectedSections(backup, selected), mode }
|
|
271
294
|
}
|
|
272
295
|
|
|
273
296
|
function countKeys(value) {
|
|
@@ -277,6 +300,7 @@ function countKeys(value) {
|
|
|
277
300
|
function buildSummary(sections) {
|
|
278
301
|
const summary = {}
|
|
279
302
|
if (sections.settings !== undefined) summary.settings = countKeys(sections.settings)
|
|
303
|
+
if (sections.mcp !== undefined) summary.mcp = Array.isArray(sections.mcp?.mcpServers) ? sections.mcp.mcpServers.length : countKeys(sections.mcp)
|
|
280
304
|
if (sections.providerKeys !== undefined) summary.providerKeys = countKeys(sections.providerKeys)
|
|
281
305
|
if (sections.customProviders !== undefined) summary.customProviders = countKeys(sections.customProviders)
|
|
282
306
|
if (sections.projects !== undefined) summary.projects = sections.projects.projects.length
|
|
@@ -318,46 +342,86 @@ async function writeSafetyBackup() {
|
|
|
318
342
|
return file
|
|
319
343
|
}
|
|
320
344
|
|
|
321
|
-
|
|
345
|
+
// Merge two plain-object stores: backup entries override local on key collision,
|
|
346
|
+
// local-only keys are preserved.
|
|
347
|
+
function mergeRecordStore(localValue, backupValue) {
|
|
348
|
+
return { ...(localValue && typeof localValue === 'object' ? localValue : {}), ...backupValue }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Merge projects config: dedupe the projects array by id (backup wins on
|
|
352
|
+
// collision, local-only entries preserved), take activeProjectId / globalSkills
|
|
353
|
+
// from backup.
|
|
354
|
+
function mergeProjectConfig(localConfig, backupConfig) {
|
|
355
|
+
const localProjects = Array.isArray(localConfig?.projects) ? localConfig.projects : []
|
|
356
|
+
const backupProjects = Array.isArray(backupConfig.projects) ? backupConfig.projects : []
|
|
357
|
+
const merged = new Map()
|
|
358
|
+
for (const project of localProjects) {
|
|
359
|
+
if (project && typeof project.id === 'string') merged.set(project.id, project)
|
|
360
|
+
}
|
|
361
|
+
for (const project of backupProjects) {
|
|
362
|
+
if (project && typeof project.id === 'string') merged.set(project.id, project)
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
activeProjectId: typeof backupConfig.activeProjectId === 'string' ? backupConfig.activeProjectId : (localConfig?.activeProjectId ?? null),
|
|
366
|
+
globalSkills: Array.isArray(backupConfig.globalSkills) ? backupConfig.globalSkills : (Array.isArray(localConfig?.globalSkills) ? localConfig.globalSkills : []),
|
|
367
|
+
projects: [...merged.values()],
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function restoreValidatedBackup(backup, mode = 'replace') {
|
|
372
|
+
const merge = mode === 'merge'
|
|
322
373
|
const { sections } = backup
|
|
323
374
|
const summary = {}
|
|
324
375
|
|
|
325
376
|
if (sections.settings !== undefined) {
|
|
326
|
-
await
|
|
327
|
-
|
|
377
|
+
const value = merge ? mergeRecordStore(await readStore('settings'), sections.settings) : sections.settings
|
|
378
|
+
await writeStore('settings', value)
|
|
379
|
+
summary.settings = countKeys(value)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (sections.mcp !== undefined) {
|
|
383
|
+
const value = merge ? mergeRecordStore(await readStore('mcp'), sections.mcp) : sections.mcp
|
|
384
|
+
await writeStore('mcp', value)
|
|
385
|
+
summary.mcp = Array.isArray(value?.mcpServers) ? value.mcpServers.length : countKeys(value)
|
|
328
386
|
}
|
|
329
387
|
|
|
330
388
|
if (sections.providerKeys !== undefined) {
|
|
331
|
-
await
|
|
332
|
-
|
|
389
|
+
const value = merge ? mergeRecordStore(await readStore('provider-keys'), sections.providerKeys) : sections.providerKeys
|
|
390
|
+
await writeStore('provider-keys', value)
|
|
391
|
+
summary.providerKeys = countKeys(value)
|
|
333
392
|
}
|
|
334
393
|
|
|
335
394
|
if (sections.customProviders !== undefined) {
|
|
336
|
-
await
|
|
337
|
-
|
|
395
|
+
const value = merge ? mergeRecordStore(await readStore('custom-providers'), sections.customProviders) : sections.customProviders
|
|
396
|
+
await writeStore('custom-providers', value)
|
|
397
|
+
summary.customProviders = countKeys(value)
|
|
338
398
|
}
|
|
339
399
|
|
|
340
400
|
if (sections.projects !== undefined) {
|
|
341
|
-
await
|
|
401
|
+
const value = merge ? mergeProjectConfig(await readProjectConfigData(), sections.projects) : sections.projects
|
|
402
|
+
await writeProjectConfigData(value)
|
|
342
403
|
await initializeActiveProject()
|
|
343
404
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
344
|
-
summary.projects =
|
|
405
|
+
summary.projects = value.projects.length
|
|
345
406
|
}
|
|
346
407
|
|
|
347
408
|
if (sections.scheduledTasks !== undefined) {
|
|
348
|
-
await
|
|
349
|
-
|
|
409
|
+
const value = merge ? mergeRecordStore(await readStore('scheduled-tasks'), sections.scheduledTasks) : sections.scheduledTasks
|
|
410
|
+
await writeStore('scheduled-tasks', value)
|
|
411
|
+
summary.scheduledTasks = countKeys(value)
|
|
350
412
|
}
|
|
351
413
|
|
|
352
414
|
if (sections.sessions !== undefined) {
|
|
353
415
|
const sessions = filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata)
|
|
354
|
-
await
|
|
355
|
-
|
|
416
|
+
const value = merge ? mergeRecordStore(await readStore('sessions'), sessions) : sessions
|
|
417
|
+
await writeStore('sessions', value)
|
|
418
|
+
summary.sessions = countKeys(value)
|
|
356
419
|
}
|
|
357
420
|
|
|
358
421
|
if (sections.sessionsMetadata !== undefined) {
|
|
359
|
-
await
|
|
360
|
-
|
|
422
|
+
const value = merge ? mergeRecordStore(await readStore('sessions-metadata'), sections.sessionsMetadata) : sections.sessionsMetadata
|
|
423
|
+
await writeStore('sessions-metadata', value)
|
|
424
|
+
summary.sessionsMetadata = countKeys(value)
|
|
361
425
|
}
|
|
362
426
|
|
|
363
427
|
return summary
|
|
@@ -382,9 +446,9 @@ export async function handleBackupApi(req, res, url) {
|
|
|
382
446
|
if (req.method === 'POST' && url.pathname === '/api/backup/import') {
|
|
383
447
|
await ensureStorage()
|
|
384
448
|
const body = await readJsonBody(req)
|
|
385
|
-
const backup = parseImportPayload(body)
|
|
449
|
+
const { backup, mode } = parseImportPayload(body)
|
|
386
450
|
const safetyBackupPath = await writeSafetyBackup()
|
|
387
|
-
const summary = await restoreValidatedBackup(backup)
|
|
451
|
+
const summary = await restoreValidatedBackup(backup, mode)
|
|
388
452
|
sendJson(res, 200, { ok: true, safetyBackupPath, summary })
|
|
389
453
|
return
|
|
390
454
|
}
|
package/server/routes/system.mjs
CHANGED
|
@@ -25,7 +25,8 @@ export async function handleSystemApi(req, res, url, context) {
|
|
|
25
25
|
throw error
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const result = await context.updateQuickForge()
|
|
29
|
+
sendJson(res, result.updateStarted ? 202 : 200, result)
|
|
29
30
|
return
|
|
30
31
|
}
|
|
31
32
|
|