@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.
Files changed (28) hide show
  1. package/README.md +108 -12
  2. package/dist/assets/{AgentProfilesPage-DKyIh3dE.js → AgentProfilesPage-CGm7ZzRM.js} +1 -1
  3. package/dist/assets/ChatPanelHost-nzOC6Tbg.js +242 -0
  4. package/dist/assets/{PluginsPage-CN-SFQ_s.js → PluginsPage-K3o4AB2E.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-C0htXZk2.js → ScheduledTasksPage-BVjejep8.js} +1 -1
  6. package/dist/assets/{SharedConversationPage-CxbAx1fN.js → SharedConversationPage-BwmUajLr.js} +1 -1
  7. package/dist/assets/{TerminalDock-_voUf7d-.js → TerminalDock-D-GWlS7P.js} +1 -1
  8. package/dist/assets/{WorkspaceInspector-Ci4FuaZH.js → WorkspaceInspector-CEU6nnM-.js} +2 -2
  9. package/dist/assets/{WorkspaceReaderDialog-D75__GFg.js → WorkspaceReaderDialog-D9Qy6LUm.js} +1 -1
  10. package/dist/assets/{diff-line-counts-DHyWKEXk.js → diff-line-counts-DCot_pZu.js} +1 -1
  11. package/dist/assets/index-CkQWeO9c.css +3 -0
  12. package/dist/assets/index-DTiIspXQ.js +1482 -0
  13. package/dist/favicon.svg +16 -16
  14. package/dist/index.html +2 -2
  15. package/dist/pwa-icon-192.png +0 -0
  16. package/dist/pwa-icon-512.png +0 -0
  17. package/dist/pwa-maskable-512.png +0 -0
  18. package/package.json +2 -1
  19. package/server/index.mjs +76 -6
  20. package/server/mcp/config.mjs +20 -20
  21. package/server/public-api.mjs +196 -0
  22. package/server/routes/backup.mjs +84 -20
  23. package/server/routes/system.mjs +2 -1
  24. package/server/storage.mjs +182 -48
  25. package/server/update-supervisor.mjs +121 -0
  26. package/dist/assets/ChatPanelHost-BUZ6scv9.js +0 -242
  27. package/dist/assets/index-BI7xZuj-.css +0 -3
  28. 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="#bd86ff" />
5
- <stop offset="1" stop-color="#7e14ff" />
6
- </linearGradient>
7
- <linearGradient id="bolt" x1="0" y1="0" x2="1" y2="1">
8
- <stop offset="0" stop-color="#7dd4ff" />
9
- <stop offset="1" stop-color="#37a6ff" />
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="#cdeeff" opacity="0.5" />
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-BzOV50wA.js"></script>
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-BI7xZuj-.css">
23
+ <link rel="stylesheet" crossorigin href="/assets/index-CkQWeO9c.css">
24
24
  </head>
25
25
  <body>
26
26
  <div id="root"></div>
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.5.2",
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, installLatestVersion } from './utils/package-update.mjs'
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(`Updating QuickForge from ${update.currentVersion} to ${update.latestVersion}.`)
193
- await installLatestVersion(update.name, { cwd: projectRoot })
194
- logger.info('QuickForge update completed.')
195
- return { ...update, ok: true, updated: true }
196
- } finally {
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
 
@@ -163,43 +163,43 @@ export function normalizeMcpServers(value) {
163
163
  }
164
164
 
165
165
  export async function readMcpServers() {
166
- const settings = await readStore('settings')
167
- return normalizeMcpServers(settings?.[MCP_CONFIG_KEY])
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('settings', (settings) => {
173
- settings[MCP_CONFIG_KEY] = normalized.map((server) => ({ ...server, updatedAt: new Date().toISOString() }))
174
- return settings
175
- }).then((settings) => normalizeMcpServers(settings?.[MCP_CONFIG_KEY]))
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('settings', (settings) => {
181
- const servers = normalizeMcpServers(settings?.[MCP_CONFIG_KEY])
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
- settings[MCP_CONFIG_KEY] = servers
187
- return settings
188
- }).then((settings) => normalizeMcpServers(settings?.[MCP_CONFIG_KEY]))
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('settings', (settings) => {
194
- settings[MCP_CONFIG_KEY] = normalizeMcpServers(settings?.[MCP_CONFIG_KEY]).filter((server) => server.name !== normalizedName)
195
- return settings
196
- }).then((settings) => normalizeMcpServers(settings?.[MCP_CONFIG_KEY]))
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('settings', (settings) => {
202
- const servers = normalizeMcpServers(settings?.[MCP_CONFIG_KEY])
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
- settings[MCP_CONFIG_KEY] = servers
215
- return settings
216
- }).then((settings) => normalizeMcpServers(settings?.[MCP_CONFIG_KEY]))
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
+ }
@@ -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
- return backupWithSelectedSections(backup, selected)
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
- async function restoreValidatedBackup(backup) {
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 writeStore('settings', sections.settings)
327
- summary.settings = countKeys(sections.settings)
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 writeStore('provider-keys', sections.providerKeys)
332
- summary.providerKeys = countKeys(sections.providerKeys)
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 writeStore('custom-providers', sections.customProviders)
337
- summary.customProviders = countKeys(sections.customProviders)
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 writeProjectConfigData(sections.projects)
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 = sections.projects.projects.length
405
+ summary.projects = value.projects.length
345
406
  }
346
407
 
347
408
  if (sections.scheduledTasks !== undefined) {
348
- await writeStore('scheduled-tasks', sections.scheduledTasks)
349
- summary.scheduledTasks = countKeys(sections.scheduledTasks)
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 writeStore('sessions', sessions)
355
- summary.sessions = countKeys(sessions)
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 writeStore('sessions-metadata', sections.sessionsMetadata)
360
- summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
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
  }
@@ -25,7 +25,8 @@ export async function handleSystemApi(req, res, url, context) {
25
25
  throw error
26
26
  }
27
27
 
28
- sendJson(res, 200, await context.updateQuickForge())
28
+ const result = await context.updateQuickForge()
29
+ sendJson(res, result.updateStarted ? 202 : 200, result)
29
30
  return
30
31
  }
31
32