@shawnstack/quickforge 1.5.2 → 1.5.3

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/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-Bt_dRvdG.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-BzaZg9Br.css">
24
24
  </head>
25
25
  <body>
26
26
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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",
@@ -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
  }
@@ -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
  }
@@ -46,6 +46,7 @@ export async function cleanOldLogs() {
46
46
 
47
47
  export const stores = new Set([
48
48
  'settings',
49
+ 'mcp',
49
50
  'provider-keys',
50
51
  'custom-providers',
51
52
  'plugins',
@@ -74,13 +75,102 @@ export function getStoreRevision(storeName) {
74
75
  return storeRevisions.get(storeName) || 0
75
76
  }
76
77
 
77
- const configStores = new Set(['settings', 'provider-keys', 'custom-providers', 'plugins'])
78
+ // Each configuration store is persisted to its own file under config/.
79
+ // "Solo" stores own a file whose root object *is* the store data.
80
+ // "Shared" stores share one file (providers.json) keyed by section, so that
81
+ // strongly-coupled provider definitions and their API keys stay in sync under
82
+ // a single write queue.
83
+ const soloConfigStores = {
84
+ settings: 'settings.json',
85
+ mcp: 'mcp-servers.json',
86
+ plugins: 'plugins.json',
87
+ }
88
+
89
+ const sharedConfigGroups = {
90
+ 'providers.json': {
91
+ queue: 'providers',
92
+ sections: {
93
+ 'provider-keys': 'providerKeys',
94
+ 'custom-providers': 'customProviders',
95
+ },
96
+ },
97
+ }
98
+
99
+ // Reverse index: storeName -> { file, queue, sectionKey|null }
100
+ const configStoreLocations = (() => {
101
+ const map = {}
102
+ for (const [store, file] of Object.entries(soloConfigStores)) {
103
+ map[store] = { file, queue: store, sectionKey: null }
104
+ }
105
+ for (const [file, group] of Object.entries(sharedConfigGroups)) {
106
+ for (const [store, sectionKey] of Object.entries(group.sections)) {
107
+ map[store] = { file, queue: group.queue, sectionKey }
108
+ }
109
+ }
110
+ return map
111
+ })()
112
+
113
+ function isConfigStore(storeName) {
114
+ return Boolean(configStoreLocations[storeName])
115
+ }
116
+
117
+ function configStoreFilePath(storeName) {
118
+ return path.join(configDir, configStoreLocations[storeName].file)
119
+ }
120
+
121
+ // Coerce a value into a plain object record (the shape every config store holds).
122
+ function asRecord(value) {
123
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
124
+ }
78
125
 
79
- const configStoreSections = {
80
- settings: ['app', 'settings'],
81
- 'provider-keys': ['credentials', 'providerKeys'],
82
- 'custom-providers': ['providers', 'customProviders'],
83
- plugins: ['extensions', 'plugins'],
126
+ // Where each config store lived in the legacy unified config.json, used only as
127
+ // a read fallback (D6 read-side safety net). Note: `mcp` was lifted out of
128
+ // `settings.mcpServers` — its legacy source is nested, not a direct section.
129
+ const legacyConfigSectionReaders = {
130
+ settings: (config) => asRecord(config.app?.settings),
131
+ mcp: (config) => ({ mcpServers: Array.isArray(config.app?.settings?.mcpServers) ? config.app.settings.mcpServers : [] }),
132
+ plugins: (config) => asRecord(config.extensions?.plugins),
133
+ 'provider-keys': (config) => asRecord(config.credentials?.providerKeys),
134
+ 'custom-providers': (config) => asRecord(config.providers?.customProviders),
135
+ }
136
+
137
+ // Read a single config store from its (possibly shared) file, with a fallback
138
+ // to the legacy unified config.json section when the split file is absent and
139
+ // the split migration has not completed yet (D6 read-side safety net, so an
140
+ // interrupted or partially-completed migration can never surface empty data).
141
+ async function readConfigStore(storeName) {
142
+ const loc = configStoreLocations[storeName]
143
+ const file = configStoreFilePath(storeName)
144
+
145
+ if (existsSync(file)) {
146
+ const content = await readJsonFile(file, {})
147
+ if (loc.sectionKey) return asRecord(content?.[loc.sectionKey])
148
+ return asRecord(content)
149
+ }
150
+
151
+ // Split file missing: only fall back to the legacy section while the split
152
+ // migration is still pending. Once `.split-migrated` exists the split files
153
+ // are authoritative, so a genuinely missing file means an empty store.
154
+ if (!existsSync(splitMigrationMarkerFile)) {
155
+ const reader = legacyConfigSectionReaders[storeName]
156
+ if (reader) return reader(await readConfigFile())
157
+ }
158
+
159
+ return {}
160
+ }
161
+
162
+ // Write a single config store back to its (possibly shared) file. For shared
163
+ // files the sibling sections are preserved. Must run inside the store's queue.
164
+ async function writeConfigStore(storeName, data) {
165
+ const loc = configStoreLocations[storeName]
166
+ const file = configStoreFilePath(storeName)
167
+ if (loc.sectionKey) {
168
+ const content = await readJsonFile(file, {})
169
+ content[loc.sectionKey] = data && typeof data === 'object' && !Array.isArray(data) ? data : {}
170
+ await writeJsonAtomic(file, content)
171
+ return
172
+ }
173
+ await writeJsonAtomic(file, data && typeof data === 'object' && !Array.isArray(data) ? data : {})
84
174
  }
85
175
 
86
176
  export function getDataDir() {
@@ -97,18 +187,16 @@ export const userCommandsDir = path.join(dataDir, 'commands')
97
187
 
98
188
  const quickForgeConfigFile = path.join(configDir, 'config.json')
99
189
  const configMigrationMarkerFile = path.join(configDir, '.layout-migrated')
190
+ const splitMigrationMarkerFile = path.join(configDir, '.split-migrated')
191
+ const projectsConfigFile = path.join(configDir, 'projects.json')
100
192
  const legacyStorageMigrationMarkerFile = path.join(storageDir, '.layout-migrated')
101
193
 
102
194
  export function storeFile(storeName) {
103
195
  assertStore(storeName)
104
- if (configStores.has(storeName)) return quickForgeConfigFile
196
+ if (isConfigStore(storeName)) return configStoreFilePath(storeName)
105
197
  return sessionStoreFile(storeName, { scope: 'global' })
106
198
  }
107
199
 
108
- export function configFile() {
109
- return quickForgeConfigFile
110
- }
111
-
112
200
  function legacyFlatStoreFile(storeName) {
113
201
  return path.join(storageDir, `${storeName}.json`)
114
202
  }
@@ -228,17 +316,6 @@ function normalizeConfig(value) {
228
316
  }
229
317
  }
230
318
 
231
- function configSection(config, storeName) {
232
- const [section, key] = configStoreSections[storeName]
233
- return config?.[section]?.[key] && typeof config[section][key] === 'object' ? config[section][key] : {}
234
- }
235
-
236
- function setConfigSection(config, storeName, data) {
237
- const [section, key] = configStoreSections[storeName]
238
- config[section] = config[section] && typeof config[section] === 'object' ? config[section] : {}
239
- config[section][key] = data && typeof data === 'object' ? data : {}
240
- }
241
-
242
319
  function sessionBucket(value) {
243
320
  if (value?.scope === 'project' && value?.projectId) {
244
321
  return { scope: 'project', projectId: String(value.projectId) }
@@ -322,6 +399,10 @@ async function readConfigFile() {
322
399
  return normalizeConfig(await readJsonFile(quickForgeConfigFile, defaultConfig()))
323
400
  }
324
401
 
402
+ // Used only within the migration chain (migrateUnifiedConfig). It persists the
403
+ // unified (pre-split) layout at layoutVersion 1 as an intermediate step; the
404
+ // subsequent migrateSplitConfig() then demotes config.json to metadata-only
405
+ // (layoutVersion 2). Do not use for normal runtime config writes.
325
406
  async function writeConfigFile(config) {
326
407
  const next = normalizeConfig(config)
327
408
  next.layoutVersion = 1
@@ -651,6 +732,68 @@ async function migrateUnifiedConfig() {
651
732
  await fs.writeFile(configMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
652
733
  }
653
734
 
735
+ // Split the legacy unified config.json into per-store files under config/.
736
+ // Each target file is only written when it does not already exist, so a
737
+ // partially-completed migration can safely resume. config.json is demoted to
738
+ // metadata only after the split succeeds.
739
+ async function migrateSplitConfig() {
740
+ if (existsSync(splitMigrationMarkerFile)) return
741
+
742
+ // Fresh installs have no unified config.json to split — just record marker.
743
+ if (!existsSync(quickForgeConfigFile)) {
744
+ await fs.writeFile(splitMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
745
+ return
746
+ }
747
+
748
+ const config = await readConfigFile()
749
+
750
+ // settings.json — mcpServers is stripped out (moved to its own store below)
751
+ if (!existsSync(path.join(configDir, 'settings.json'))) {
752
+ const oldSettings = config.app?.settings && typeof config.app.settings === 'object'
753
+ ? { ...config.app.settings }
754
+ : {}
755
+ delete oldSettings.mcpServers
756
+ await writeJsonAtomic(path.join(configDir, 'settings.json'), oldSettings)
757
+ }
758
+
759
+ // mcp-servers.json — lifted out of settings.mcpServers
760
+ if (!existsSync(path.join(configDir, 'mcp-servers.json'))) {
761
+ const mcpServers = config.app?.settings?.mcpServers
762
+ await writeJsonAtomic(path.join(configDir, 'mcp-servers.json'), {
763
+ mcpServers: Array.isArray(mcpServers) ? mcpServers : [],
764
+ })
765
+ }
766
+
767
+ // providers.json — customProviders + providerKeys kept together (coupled)
768
+ if (!existsSync(path.join(configDir, 'providers.json'))) {
769
+ const customProviders = config.providers?.customProviders && typeof config.providers.customProviders === 'object'
770
+ ? config.providers.customProviders
771
+ : {}
772
+ const providerKeys = config.credentials?.providerKeys && typeof config.credentials.providerKeys === 'object'
773
+ ? config.credentials.providerKeys
774
+ : {}
775
+ await writeJsonAtomic(path.join(configDir, 'providers.json'), { customProviders, providerKeys })
776
+ }
777
+
778
+ // plugins.json
779
+ if (!existsSync(path.join(configDir, 'plugins.json'))) {
780
+ const plugins = config.extensions?.plugins && typeof config.extensions.plugins === 'object'
781
+ ? config.extensions.plugins
782
+ : {}
783
+ await writeJsonAtomic(path.join(configDir, 'plugins.json'), plugins)
784
+ }
785
+
786
+ // projects.json
787
+ if (!existsSync(projectsConfigFile)) {
788
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(config.projects))
789
+ }
790
+
791
+ // Demote config.json to metadata only.
792
+ await writeJsonAtomic(quickForgeConfigFile, { layoutVersion: 2, migratedAt: new Date().toISOString() })
793
+
794
+ await fs.writeFile(splitMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
795
+ }
796
+
654
797
  const writeQueues = new Map()
655
798
 
656
799
  function enqueueWrite(queueName, operation) {
@@ -673,15 +816,13 @@ function enqueueWrite(queueName, operation) {
673
816
  */
674
817
  export async function atomicUpdate(storeName, updateFn) {
675
818
  assertStore(storeName)
676
- const queueName = configStores.has(storeName) ? 'config' : storeName
819
+ const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
677
820
  return enqueueWrite(queueName, async () => {
678
821
  await ensureStorage()
679
- if (configStores.has(storeName)) {
680
- const config = await readConfigFile()
681
- const data = configSection(config, storeName)
822
+ if (isConfigStore(storeName)) {
823
+ const data = await readConfigStore(storeName)
682
824
  const updated = updateFn(data)
683
- setConfigSection(config, storeName, updated)
684
- await writeConfigFile(config)
825
+ await writeConfigStore(storeName, updated)
685
826
  return updated
686
827
  }
687
828
  const data = await readSessionStore(storeName)
@@ -718,13 +859,11 @@ export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
718
859
  * Atomically read-modify-write the project config within the config queue.
719
860
  */
720
861
  export async function atomicProjectConfigUpdate(updateFn) {
721
- return enqueueWrite('config', async () => {
862
+ return enqueueWrite('projects', async () => {
722
863
  await ensureStorage()
723
- const config = await readConfigFile()
724
- const projectConfig = normalizeProjectConfig(config.projects)
864
+ const projectConfig = normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
725
865
  const updated = updateFn(projectConfig)
726
- config.projects = normalizeProjectConfig(updated)
727
- await writeConfigFile(config)
866
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(updated))
728
867
  return updated
729
868
  })
730
869
  }
@@ -755,9 +894,10 @@ export function ensureStorage() {
755
894
  ])
756
895
 
757
896
  await migrateUnifiedConfig()
897
+ await migrateSplitConfig()
758
898
 
759
899
  await Promise.all([
760
- ensureJsonFile(quickForgeConfigFile, defaultConfig()),
900
+ ensureJsonFile(quickForgeConfigFile, { layoutVersion: 2 }),
761
901
  ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
762
902
  ])
763
903
  })()
@@ -770,9 +910,8 @@ export async function readStore(storeName) {
770
910
  assertStore(storeName)
771
911
  await ensureStorage()
772
912
 
773
- if (configStores.has(storeName)) {
774
- const config = await readConfigFile()
775
- return configSection(config, storeName)
913
+ if (isConfigStore(storeName)) {
914
+ return readConfigStore(storeName)
776
915
  }
777
916
 
778
917
  return readSessionStore(storeName)
@@ -780,15 +919,13 @@ export async function readStore(storeName) {
780
919
 
781
920
  export async function writeStore(storeName, data) {
782
921
  assertStore(storeName)
783
- const queueName = configStores.has(storeName) ? 'config' : storeName
922
+ const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
784
923
 
785
924
  return enqueueWrite(queueName, async () => {
786
925
  await ensureStorage()
787
926
 
788
- if (configStores.has(storeName)) {
789
- const config = await readConfigFile()
790
- setConfigSection(config, storeName, data)
791
- await writeConfigFile(config)
927
+ if (isConfigStore(storeName)) {
928
+ await writeConfigStore(storeName, data)
792
929
  return
793
930
  }
794
931
 
@@ -798,16 +935,13 @@ export async function writeStore(storeName, data) {
798
935
 
799
936
  export async function readProjectConfigData() {
800
937
  await ensureStorage()
801
- const config = await readConfigFile()
802
- return normalizeProjectConfig(config.projects)
938
+ return normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
803
939
  }
804
940
 
805
941
  export async function writeProjectConfigData(projectConfig) {
806
- return enqueueWrite('config', async () => {
942
+ return enqueueWrite('projects', async () => {
807
943
  await ensureStorage()
808
- const config = await readConfigFile()
809
- config.projects = normalizeProjectConfig(projectConfig)
810
- await writeConfigFile(config)
944
+ await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(projectConfig))
811
945
  })
812
946
  }
813
947