@shawnstack/quickforge 1.5.3 → 1.5.5

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 (35) hide show
  1. package/README.md +108 -12
  2. package/dist/assets/{AgentProfilesPage-BToo_R3Y.js → AgentProfilesPage-nVhgwanY.js} +1 -1
  3. package/dist/assets/ChatPanelHost-u0K5IWMF.js +242 -0
  4. package/dist/assets/{PluginsPage-DwzV2vQ4.js → PluginsPage-BVRTC0rz.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-D37TE2cM.js +2 -0
  6. package/dist/assets/{SharedConversationPage-CHE9qABz.js → SharedConversationPage-D5hnzsZC.js} +1 -1
  7. package/dist/assets/TerminalDock-NvH9esAS.js +2 -0
  8. package/dist/assets/WorkspaceInspector-DbnO1fei.js +3 -0
  9. package/dist/assets/{WorkspaceReaderDialog-Bai7v3V0.js → WorkspaceReaderDialog-BcxIbNBq.js} +1 -1
  10. package/dist/assets/diff-line-counts-N83e7__F.js +10 -0
  11. package/dist/assets/{icons-DzxBk7tb.js → icons-Uo4Gd-eK.js} +1 -1
  12. package/dist/assets/index-DiaCCmXE.js +1482 -0
  13. package/dist/assets/index-KdiXReMI.css +3 -0
  14. package/dist/assets/{monaco-dMY7_GLO.js → monaco-DtXl4zfe.js} +1 -1
  15. package/dist/assets/{react-vendor-DsAeMFcm.js → react-vendor-BjDQPVuK.js} +1 -1
  16. package/dist/assets/useAppTheme-Bm6HIzLF.js +1 -0
  17. package/dist/favicon.svg +16 -16
  18. package/dist/index.html +4 -4
  19. package/dist/pwa-icon-192.png +0 -0
  20. package/dist/pwa-icon-512.png +0 -0
  21. package/dist/pwa-maskable-512.png +0 -0
  22. package/dist/sw.js +1 -1
  23. package/package.json +2 -1
  24. package/server/agent-manager.mjs +8 -1
  25. package/server/index.mjs +76 -6
  26. package/server/public-api.mjs +196 -0
  27. package/server/routes/system.mjs +2 -1
  28. package/server/update-supervisor.mjs +121 -0
  29. package/dist/assets/ChatPanelHost-BTqhhkWK.js +0 -242
  30. package/dist/assets/ScheduledTasksPage-Cbm6LVk3.js +0 -2
  31. package/dist/assets/TerminalDock-Loi8A4pJ.js +0 -2
  32. package/dist/assets/WorkspaceInspector-Nf5xELW7.js +0 -3
  33. package/dist/assets/diff-line-counts-CCPYa_e0.js +0 -10
  34. package/dist/assets/index-Bt_dRvdG.js +0 -1476
  35. package/dist/assets/index-BzaZg9Br.css +0 -3
package/dist/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'quickforge-pwa-v1'
1
+ const CACHE_NAME = 'quickforge-pwa-v2'
2
2
  const PRECACHE_URLS = [
3
3
  '/',
4
4
  '/favicon.svg',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
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"
@@ -1551,13 +1551,20 @@ async function persistSession(session) {
1551
1551
  // Write to storage atomically (read-modify-write within queue)
1552
1552
  try {
1553
1553
  await writeSessionValue(sessionId, sessionData)
1554
+ let archivedAt
1554
1555
  await atomicSessionMetadataUpdate(scope, projectId, (data) => {
1556
+ const existingMetadata = data[sessionId]
1557
+ archivedAt = existingMetadata?.archivedAt
1555
1558
  data[sessionId] = {
1556
1559
  ...metadata,
1557
- pinnedAt: data[sessionId]?.pinnedAt,
1560
+ pinnedAt: existingMetadata?.pinnedAt,
1561
+ ...(archivedAt ? { archivedAt } : {}),
1558
1562
  }
1559
1563
  return data
1560
1564
  })
1565
+ if (archivedAt) {
1566
+ await writeSessionValue(sessionId, { ...sessionData, archivedAt })
1567
+ }
1561
1568
  } catch (err) {
1562
1569
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
1563
1570
  }
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
 
@@ -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
+ }
@@ -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
 
@@ -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
+ }