@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
@@ -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
 
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ const [oldPidArg, packageName, latestVersion, serverScript, serverCwd, logFile, ...serverArgs] = process.argv.slice(2)
7
+ const oldPid = Number(oldPidArg)
8
+
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms))
11
+ }
12
+
13
+ function isProcessRunning(pid) {
14
+ if (!pid) return false
15
+ try {
16
+ process.kill(pid, 0)
17
+ return true
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ function ensureLogDir(filePath) {
24
+ try { fs.mkdirSync(path.dirname(filePath), { recursive: true }) } catch { /* ignore */ }
25
+ }
26
+
27
+ function timestamp() {
28
+ return new Date().toISOString()
29
+ }
30
+
31
+ function getNpmCommand() {
32
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm'
33
+ }
34
+
35
+ ensureLogDir(logFile)
36
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' })
37
+
38
+ function log(message) {
39
+ logStream.write(`${timestamp()} ${message}\n`)
40
+ }
41
+
42
+ function pipeOutput(stream, prefix) {
43
+ stream?.on('data', (chunk) => {
44
+ const text = String(chunk)
45
+ for (const line of text.split(/\r?\n/)) {
46
+ if (line.trim()) log(`${prefix} ${line}`)
47
+ }
48
+ })
49
+ }
50
+
51
+ function spawnAndWait(command, args, options) {
52
+ return new Promise((resolve, reject) => {
53
+ const child = spawn(command, args, options)
54
+ pipeOutput(child.stdout, '[stdout]')
55
+ pipeOutput(child.stderr, '[stderr]')
56
+ child.once('error', reject)
57
+ child.once('exit', (code, signal) => resolve({ code, signal }))
58
+ })
59
+ }
60
+
61
+ async function main() {
62
+ const target = `${packageName}@latest`
63
+ log(`QuickForge external updater started. target=${target} latest=${latestVersion || 'unknown'} oldPid=${oldPid || 'unknown'}`)
64
+ log(`Updater cwd=${process.cwd()}`)
65
+ log(`Server script=${serverScript}`)
66
+
67
+ for (let i = 0; i < 600 && isProcessRunning(oldPid); i += 1) {
68
+ if (i === 0) log(`Waiting for old QuickForge process ${oldPid} to exit...`)
69
+ await sleep(100)
70
+ }
71
+
72
+ if (isProcessRunning(oldPid)) {
73
+ log(`Old QuickForge process ${oldPid} is still running after timeout; continuing with npm install.`)
74
+ } else {
75
+ log('Old QuickForge process has exited.')
76
+ }
77
+
78
+ const npmCommand = getNpmCommand()
79
+ const npmArgs = ['install', '-g', target]
80
+ log(`Running: ${npmCommand} ${npmArgs.join(' ')}`)
81
+ const installResult = await spawnAndWait(npmCommand, npmArgs, {
82
+ cwd: process.cwd(),
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ shell: process.platform === 'win32',
85
+ windowsHide: true,
86
+ env: process.env,
87
+ })
88
+
89
+ if (installResult.code !== 0) {
90
+ log(`npm install failed. code=${installResult.code} signal=${installResult.signal || ''}`)
91
+ process.exitCode = installResult.code || 1
92
+ return
93
+ }
94
+
95
+ log('npm install completed successfully.')
96
+ log(`Starting QuickForge server: ${process.execPath} ${[serverScript, ...serverArgs].join(' ')}`)
97
+ const child = spawn(process.execPath, [serverScript, ...serverArgs], {
98
+ cwd: serverCwd || undefined,
99
+ detached: true,
100
+ stdio: 'ignore',
101
+ windowsHide: true,
102
+ shell: false,
103
+ env: {
104
+ ...process.env,
105
+ QUICKFORGE_NO_OPEN: '1',
106
+ QUICKFORGE_RESTARTED_FROM_UPDATE: '1',
107
+ },
108
+ })
109
+
110
+ child.unref()
111
+ log(`QuickForge server spawned. pid=${child.pid || 'unknown'}`)
112
+ }
113
+
114
+ try {
115
+ await main()
116
+ } catch (error) {
117
+ log(`Updater failed: ${error?.stack || error?.message || error}`)
118
+ process.exitCode = 1
119
+ } finally {
120
+ await new Promise((resolve) => logStream.end(resolve))
121
+ }