@shawnstack/quickforge 1.5.1 → 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/README.md +10 -10
- package/dist/assets/AgentProfilesPage-BToo_R3Y.js +1 -0
- package/dist/assets/ChatPanelHost-BTqhhkWK.js +242 -0
- package/dist/assets/{PluginsPage-kRzB5k8J.js → PluginsPage-DwzV2vQ4.js} +1 -1
- package/dist/assets/ScheduledTasksPage-Cbm6LVk3.js +2 -0
- package/dist/assets/{SharedConversationPage-EQdZgWCM.js → SharedConversationPage-CHE9qABz.js} +1 -1
- package/dist/assets/TerminalDock-Loi8A4pJ.js +2 -0
- package/dist/assets/WorkspaceInspector-Nf5xELW7.js +3 -0
- package/dist/assets/{WorkspaceReaderDialog-BwzZ8Tgv.js → WorkspaceReaderDialog-Bai7v3V0.js} +1 -1
- package/dist/assets/{diff-line-counts-CeZC7b0z.js → diff-line-counts-CCPYa_e0.js} +1 -1
- package/dist/assets/icons-DzxBk7tb.js +1 -0
- package/dist/assets/index-Bt_dRvdG.js +1476 -0
- package/dist/assets/{index-DuTUuAMk.css → index-BzaZg9Br.css} +1 -1
- package/dist/assets/{monaco-CNEfYIy1.js → monaco-dMY7_GLO.js} +1 -1
- package/dist/assets/{react-vendor-CZCcjpSR.js → react-vendor-DsAeMFcm.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +1 -0
- package/server/agent-manager.mjs +6 -6
- package/server/agent-profile-files.mjs +2 -1
- package/server/channels/process-channel.mjs +1 -0
- package/server/channels/providers/wechat.mjs +1 -0
- package/server/custom-commands.mjs +4 -3
- package/server/mcp/config.mjs +20 -20
- package/server/plugins/registry.mjs +1 -1
- package/server/project-config.mjs +2 -2
- package/server/routes/agent.mjs +0 -1
- package/server/routes/backup.mjs +84 -20
- package/server/routes/project.mjs +3 -2
- package/server/routes/scheduled-tasks.mjs +13 -121
- package/server/routes/static.mjs +1 -1
- package/server/routes/storage.mjs +13 -7
- package/server/session-utils.mjs +2 -1
- package/server/skills.mjs +3 -2
- package/server/storage.mjs +182 -49
- package/server/utils/logger.mjs +0 -1
- package/server/utils/package-update.mjs +2 -2
- package/server/utils/scheduled-tasks.mjs +127 -0
- package/dist/assets/AgentProfilesPage-BIwd5Nzg.js +0 -1
- package/dist/assets/ChatPanelHost-De-DMjx5.js +0 -242
- package/dist/assets/ScheduledTasksPage-ZnjohaPS.js +0 -2
- package/dist/assets/TerminalDock-P2pJH_tx.js +0 -2
- package/dist/assets/WorkspaceInspector-CkLAqYQ6.js +0 -3
- package/dist/assets/icons-DJqt-rnw.js +0 -1
- package/dist/assets/index-CcGy4TXo.js +0 -1354
|
@@ -5,14 +5,24 @@ import { createAgent, getSessionEventBus, agentEvents, persistSessionState } fro
|
|
|
5
5
|
import { agentProfileSnapshot, getAgentProfile } from '../agent-profiles.mjs'
|
|
6
6
|
import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
|
|
7
7
|
import { logger } from '../utils/logger.mjs'
|
|
8
|
+
import {
|
|
9
|
+
dayMs,
|
|
10
|
+
formatLocalDateTime,
|
|
11
|
+
hourMs,
|
|
12
|
+
minuteMs,
|
|
13
|
+
nextCronRun,
|
|
14
|
+
nextDailyRun,
|
|
15
|
+
nextMonthlyRun,
|
|
16
|
+
nextWeeklyRun,
|
|
17
|
+
normalizeExecutionMode,
|
|
18
|
+
parseExecuteTime,
|
|
19
|
+
timeFromDate,
|
|
20
|
+
} from '../utils/scheduled-tasks.mjs'
|
|
8
21
|
|
|
9
22
|
const STORE = 'scheduled-tasks'
|
|
10
23
|
const RUN_CHECK_INTERVAL_MS = 30 * 1000
|
|
11
24
|
const MAX_RUN_HISTORY_PER_TASK = 200
|
|
12
25
|
const cronRegex = /^(\*|\d{1,2}|\d{1,2}-\d{1,2}|\d{1,2}\/\d{1,2}|\*\/\d{1,2})(\s+(\*|\d{1,2}|\d{1,2}-\d{1,2}|\d{1,2}\/\d{1,2}|\*\/\d{1,2})){4}$/
|
|
13
|
-
const minuteMs = 60 * 1000
|
|
14
|
-
const hourMs = 60 * minuteMs
|
|
15
|
-
const dayMs = 24 * hourMs
|
|
16
26
|
const editableScheduleTypes = new Set(['once', 'daily', 'weekly', 'monthly'])
|
|
17
27
|
const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
|
18
28
|
|
|
@@ -24,13 +34,6 @@ function executionModeFor(task) {
|
|
|
24
34
|
return task?.executionMode === 'parallel' ? 'parallel' : 'serial'
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
function normalizeExecutionMode(value) {
|
|
28
|
-
if (value === undefined || value === null || value === '') return 'serial'
|
|
29
|
-
const mode = String(value)
|
|
30
|
-
if (mode === 'serial' || mode === 'parallel') return mode
|
|
31
|
-
throw requestError('executionMode must be serial or parallel')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
37
|
function currentRunIdsFor(task) {
|
|
35
38
|
const ids = []
|
|
36
39
|
if (Array.isArray(task?.currentRunIds)) ids.push(...task.currentRunIds.filter(Boolean))
|
|
@@ -72,20 +75,6 @@ function createId() {
|
|
|
72
75
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
function pad(value) {
|
|
76
|
-
return String(value).padStart(2, '0')
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function formatLocalDateTime(date) {
|
|
80
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function timeFromDate(value) {
|
|
84
|
-
const date = value ? new Date(value) : null
|
|
85
|
-
if (!date || Number.isNaN(date.getTime())) return undefined
|
|
86
|
-
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
87
|
-
}
|
|
88
|
-
|
|
89
78
|
function requestError(message, statusCode = 400) {
|
|
90
79
|
const error = new Error(message)
|
|
91
80
|
error.statusCode = statusCode
|
|
@@ -98,67 +87,12 @@ function nonEmptyString(value, fieldName) {
|
|
|
98
87
|
return text
|
|
99
88
|
}
|
|
100
89
|
|
|
101
|
-
function parseExecuteTime(value) {
|
|
102
|
-
const text = String(value ?? '').trim()
|
|
103
|
-
const match = text.match(/^(\d{1,2}):(\d{2})$/)
|
|
104
|
-
if (!match) throw requestError('executeTime must use HH:mm format')
|
|
105
|
-
const hours = Number(match[1])
|
|
106
|
-
const minutes = Number(match[2])
|
|
107
|
-
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
108
|
-
throw requestError('executeTime is out of range')
|
|
109
|
-
}
|
|
110
|
-
return `${pad(hours)}:${pad(minutes)}`
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function dateWithTime(base, executeTime) {
|
|
114
|
-
const [hours, minutes] = parseExecuteTime(executeTime).split(':').map(Number)
|
|
115
|
-
const date = new Date(base)
|
|
116
|
-
date.setHours(hours, minutes, 0, 0)
|
|
117
|
-
return date
|
|
118
|
-
}
|
|
119
|
-
|
|
120
90
|
function parseDateTime(value, fieldName) {
|
|
121
91
|
const date = new Date(value)
|
|
122
92
|
if (Number.isNaN(date.getTime())) throw requestError(`${fieldName} is invalid`)
|
|
123
93
|
return date
|
|
124
94
|
}
|
|
125
95
|
|
|
126
|
-
function nextDailyRun(executeTime, base = new Date()) {
|
|
127
|
-
const next = dateWithTime(base, executeTime)
|
|
128
|
-
if (next.getTime() <= base.getTime()) next.setDate(next.getDate() + 1)
|
|
129
|
-
return next
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function nextWeeklyRun(weekDay, executeTime, base = new Date()) {
|
|
133
|
-
const targetDay = Number(weekDay)
|
|
134
|
-
if (!Number.isInteger(targetDay) || targetDay < 0 || targetDay > 6) {
|
|
135
|
-
throw requestError('weekDay must be between 0 and 6')
|
|
136
|
-
}
|
|
137
|
-
const next = dateWithTime(base, executeTime)
|
|
138
|
-
let daysToAdd = (targetDay - next.getDay() + 7) % 7
|
|
139
|
-
if (daysToAdd === 0 && next.getTime() <= base.getTime()) daysToAdd = 7
|
|
140
|
-
next.setDate(next.getDate() + daysToAdd)
|
|
141
|
-
return next
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function monthlyCandidate(year, month, monthDay, executeTime) {
|
|
145
|
-
const targetDay = Number(monthDay)
|
|
146
|
-
if (!Number.isInteger(targetDay) || targetDay < 1 || targetDay > 31) {
|
|
147
|
-
throw requestError('monthDay must be between 1 and 31')
|
|
148
|
-
}
|
|
149
|
-
const [hours, minutes] = parseExecuteTime(executeTime).split(':').map(Number)
|
|
150
|
-
const lastDay = new Date(year, month + 1, 0).getDate()
|
|
151
|
-
return new Date(year, month, Math.min(targetDay, lastDay), hours, minutes, 0, 0)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function nextMonthlyRun(monthDay, executeTime, base = new Date()) {
|
|
155
|
-
let next = monthlyCandidate(base.getFullYear(), base.getMonth(), monthDay, executeTime)
|
|
156
|
-
if (next.getTime() <= base.getTime()) {
|
|
157
|
-
next = monthlyCandidate(base.getFullYear(), base.getMonth() + 1, monthDay, executeTime)
|
|
158
|
-
}
|
|
159
|
-
return next
|
|
160
|
-
}
|
|
161
|
-
|
|
162
96
|
function scheduleRuleFor(task) {
|
|
163
97
|
if (task.scheduleType === 'once') return `单次 ${formatLocalDateTime(new Date(task.executeAt ?? task.nextRunAt))}`
|
|
164
98
|
if (task.scheduleType === 'daily') return `每天 ${task.executeTime}`
|
|
@@ -167,48 +101,6 @@ function scheduleRuleFor(task) {
|
|
|
167
101
|
return task.scheduleRule || task.cronExpression || '定时执行'
|
|
168
102
|
}
|
|
169
103
|
|
|
170
|
-
function parseCronField(field, min, max) {
|
|
171
|
-
if (field === '*') return { any: true, values: [] }
|
|
172
|
-
const values = new Set()
|
|
173
|
-
for (const part of field.split(',')) {
|
|
174
|
-
if (/^\*\/\d+$/.test(part)) {
|
|
175
|
-
const step = Number(part.slice(2))
|
|
176
|
-
for (let value = min; value <= max; value += step) values.add(value)
|
|
177
|
-
} else if (/^\d+-\d+$/.test(part)) {
|
|
178
|
-
const [start, end] = part.split('-').map(Number)
|
|
179
|
-
for (let value = Math.max(start, min); value <= Math.min(end, max); value += 1) values.add(value)
|
|
180
|
-
} else if (/^\d+$/.test(part)) {
|
|
181
|
-
const value = Number(part)
|
|
182
|
-
if (value >= min && value <= max) values.add(value)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return { any: false, values: [...values] }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function cronMatches(date, cronExpression) {
|
|
189
|
-
const fields = String(cronExpression || '').trim().split(/\s+/)
|
|
190
|
-
if (fields.length !== 5) return false
|
|
191
|
-
const checks = [
|
|
192
|
-
[date.getMinutes(), parseCronField(fields[0], 0, 59)],
|
|
193
|
-
[date.getHours(), parseCronField(fields[1], 0, 23)],
|
|
194
|
-
[date.getDate(), parseCronField(fields[2], 1, 31)],
|
|
195
|
-
[date.getMonth() + 1, parseCronField(fields[3], 1, 12)],
|
|
196
|
-
[date.getDay(), parseCronField(fields[4], 0, 6)],
|
|
197
|
-
]
|
|
198
|
-
return checks.every(([value, rule]) => rule.any || rule.values.includes(value))
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function nextCronRun(cronExpression, base = new Date()) {
|
|
202
|
-
const cursor = new Date(base.getTime() + minuteMs)
|
|
203
|
-
cursor.setSeconds(0, 0)
|
|
204
|
-
const maxChecks = 366 * 24 * 60
|
|
205
|
-
for (let index = 0; index < maxChecks; index += 1) {
|
|
206
|
-
if (cronMatches(cursor, cronExpression)) return cursor
|
|
207
|
-
cursor.setMinutes(cursor.getMinutes() + 1)
|
|
208
|
-
}
|
|
209
|
-
return null
|
|
210
|
-
}
|
|
211
|
-
|
|
212
104
|
function normalizeAiJson(text) {
|
|
213
105
|
const raw = String(text || '').trim()
|
|
214
106
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
package/server/routes/static.mjs
CHANGED
|
@@ -48,7 +48,7 @@ function shouldFallbackToIndex(url, pathname) {
|
|
|
48
48
|
export async function serveStatic(req, res, url) {
|
|
49
49
|
const distDir = path.join(projectRoot, 'dist')
|
|
50
50
|
const requested = decodeURIComponent(requestPathname(url))
|
|
51
|
-
const normalized = path.normalize(requested).replace(/^([.][.][
|
|
51
|
+
const normalized = path.normalize(requested).replace(/^([.][.][/])+/, '').replace(/^[/\\]+/, '')
|
|
52
52
|
let filePath = path.resolve(distDir, normalized)
|
|
53
53
|
|
|
54
54
|
const relative = path.relative(distDir, filePath)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
3
2
|
import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
|
|
4
3
|
import { directorySize } from '../utils/workspace.mjs'
|
|
@@ -7,8 +6,8 @@ const metadataIndexCache = new Map()
|
|
|
7
6
|
const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
|
|
8
7
|
const METADATA_INDEX_CACHE_TTL_MS = 1000
|
|
9
8
|
|
|
10
|
-
function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
|
|
11
|
-
return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
|
|
9
|
+
function metadataIndexCacheKey({ scope, projectId, indexName, direction, archived }) {
|
|
10
|
+
return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction, archived: archived || '' })
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
function sortIndexedValues(values, store, indexName, direction) {
|
|
@@ -34,7 +33,7 @@ function sortIndexedValues(values, store, indexName, direction) {
|
|
|
34
33
|
return values
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
async function readIndexedValues(store, indexName, direction, scope, projectId) {
|
|
36
|
+
async function readIndexedValues(store, indexName, direction, scope, projectId, archived) {
|
|
38
37
|
if (store !== 'sessions-metadata') {
|
|
39
38
|
let data
|
|
40
39
|
if (scope && store === 'sessions') {
|
|
@@ -46,7 +45,7 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
|
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
const revision = getStoreRevision(store)
|
|
49
|
-
const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
|
|
48
|
+
const key = metadataIndexCacheKey({ scope, projectId, indexName, direction, archived })
|
|
50
49
|
const cached = metadataIndexCache.get(key)
|
|
51
50
|
const now = Date.now()
|
|
52
51
|
if (cached && cached.revision === revision && now - cached.cachedAt < METADATA_INDEX_CACHE_TTL_MS) return cached.values
|
|
@@ -55,7 +54,13 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
|
|
|
55
54
|
? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
|
|
56
55
|
: await readStore(store)
|
|
57
56
|
const values = sortIndexedValues(
|
|
58
|
-
Object.values(data)
|
|
57
|
+
Object.values(data)
|
|
58
|
+
.filter((value) => value?.messageCount !== 0)
|
|
59
|
+
.filter((value) => {
|
|
60
|
+
if (archived === 'only') return Boolean(value?.archivedAt)
|
|
61
|
+
if (archived === 'include') return true
|
|
62
|
+
return !value?.archivedAt
|
|
63
|
+
}),
|
|
59
64
|
store,
|
|
60
65
|
indexName,
|
|
61
66
|
direction,
|
|
@@ -107,10 +112,11 @@ export async function handleStorageApi(req, res, url) {
|
|
|
107
112
|
const projectId = url.searchParams.get('projectId')
|
|
108
113
|
const limitParam = url.searchParams.get('limit')
|
|
109
114
|
const offsetParam = url.searchParams.get('offset')
|
|
115
|
+
const archived = url.searchParams.get('archived')
|
|
110
116
|
|
|
111
117
|
await ensureStorage()
|
|
112
118
|
|
|
113
|
-
const values = await readIndexedValues(store, indexName, direction, scope, projectId)
|
|
119
|
+
const values = await readIndexedValues(store, indexName, direction, scope, projectId, archived)
|
|
114
120
|
|
|
115
121
|
const total = values.length
|
|
116
122
|
const limit = limitParam ? parseInt(limitParam, 10) : undefined
|
package/server/session-utils.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { streamSimple } from '@earendil-works/pi-ai'
|
|
|
2
2
|
import { buildInstructionsPayload, projectContextFromId } from './project-config.mjs'
|
|
3
3
|
import { composeSystemPrompt } from './system-prompt.mjs'
|
|
4
4
|
import { listSubagentProfiles } from './agent-profiles.mjs'
|
|
5
|
+
import { logger } from './utils/logger.mjs'
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// System prompt
|
|
@@ -109,7 +110,7 @@ export async function generateAiTitle(messages, model, thinkingLevel, getApiKey)
|
|
|
109
110
|
const title = normalizeAiTitle(titleText)
|
|
110
111
|
return title || null
|
|
111
112
|
} catch (error) {
|
|
112
|
-
|
|
113
|
+
logger.warn('Failed to generate AI title:', error.message || error)
|
|
113
114
|
return null
|
|
114
115
|
}
|
|
115
116
|
}
|
package/server/skills.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { dataDir } from './storage.mjs'
|
|
5
5
|
import { getEnabledPluginSkillSources } from './plugins/registry.mjs'
|
|
6
|
+
import { logger } from './utils/logger.mjs'
|
|
6
7
|
|
|
7
8
|
const userSkillsDir = path.join(dataDir, 'skills')
|
|
8
9
|
const sharedUserSkillsDir = path.join(os.homedir(), '.agents', 'skills')
|
|
@@ -347,7 +348,7 @@ async function loadSkillsFromSources(sources) {
|
|
|
347
348
|
if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
|
|
348
349
|
skillsByName.set(skill.name, skill)
|
|
349
350
|
} catch (error) {
|
|
350
|
-
|
|
351
|
+
logger.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
|
|
351
352
|
}
|
|
352
353
|
}
|
|
353
354
|
}
|
|
@@ -371,7 +372,7 @@ async function loadSkillsFromExplicitSources(sources) {
|
|
|
371
372
|
if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
|
|
372
373
|
skillsByName.set(skill.name, skill)
|
|
373
374
|
} catch (error) {
|
|
374
|
-
|
|
375
|
+
logger.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
|
|
375
376
|
}
|
|
376
377
|
}
|
|
377
378
|
}
|
package/server/storage.mjs
CHANGED
|
@@ -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,14 +75,102 @@ export function getStoreRevision(storeName) {
|
|
|
74
75
|
return storeRevisions.get(storeName) || 0
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
|
79
125
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 : {})
|
|
85
174
|
}
|
|
86
175
|
|
|
87
176
|
export function getDataDir() {
|
|
@@ -98,18 +187,16 @@ export const userCommandsDir = path.join(dataDir, 'commands')
|
|
|
98
187
|
|
|
99
188
|
const quickForgeConfigFile = path.join(configDir, 'config.json')
|
|
100
189
|
const configMigrationMarkerFile = path.join(configDir, '.layout-migrated')
|
|
190
|
+
const splitMigrationMarkerFile = path.join(configDir, '.split-migrated')
|
|
191
|
+
const projectsConfigFile = path.join(configDir, 'projects.json')
|
|
101
192
|
const legacyStorageMigrationMarkerFile = path.join(storageDir, '.layout-migrated')
|
|
102
193
|
|
|
103
194
|
export function storeFile(storeName) {
|
|
104
195
|
assertStore(storeName)
|
|
105
|
-
if (
|
|
196
|
+
if (isConfigStore(storeName)) return configStoreFilePath(storeName)
|
|
106
197
|
return sessionStoreFile(storeName, { scope: 'global' })
|
|
107
198
|
}
|
|
108
199
|
|
|
109
|
-
export function configFile() {
|
|
110
|
-
return quickForgeConfigFile
|
|
111
|
-
}
|
|
112
|
-
|
|
113
200
|
function legacyFlatStoreFile(storeName) {
|
|
114
201
|
return path.join(storageDir, `${storeName}.json`)
|
|
115
202
|
}
|
|
@@ -229,17 +316,6 @@ function normalizeConfig(value) {
|
|
|
229
316
|
}
|
|
230
317
|
}
|
|
231
318
|
|
|
232
|
-
function configSection(config, storeName) {
|
|
233
|
-
const [section, key] = configStoreSections[storeName]
|
|
234
|
-
return config?.[section]?.[key] && typeof config[section][key] === 'object' ? config[section][key] : {}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function setConfigSection(config, storeName, data) {
|
|
238
|
-
const [section, key] = configStoreSections[storeName]
|
|
239
|
-
config[section] = config[section] && typeof config[section] === 'object' ? config[section] : {}
|
|
240
|
-
config[section][key] = data && typeof data === 'object' ? data : {}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
319
|
function sessionBucket(value) {
|
|
244
320
|
if (value?.scope === 'project' && value?.projectId) {
|
|
245
321
|
return { scope: 'project', projectId: String(value.projectId) }
|
|
@@ -323,6 +399,10 @@ async function readConfigFile() {
|
|
|
323
399
|
return normalizeConfig(await readJsonFile(quickForgeConfigFile, defaultConfig()))
|
|
324
400
|
}
|
|
325
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.
|
|
326
406
|
async function writeConfigFile(config) {
|
|
327
407
|
const next = normalizeConfig(config)
|
|
328
408
|
next.layoutVersion = 1
|
|
@@ -652,6 +732,68 @@ async function migrateUnifiedConfig() {
|
|
|
652
732
|
await fs.writeFile(configMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
|
|
653
733
|
}
|
|
654
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
|
+
|
|
655
797
|
const writeQueues = new Map()
|
|
656
798
|
|
|
657
799
|
function enqueueWrite(queueName, operation) {
|
|
@@ -674,15 +816,13 @@ function enqueueWrite(queueName, operation) {
|
|
|
674
816
|
*/
|
|
675
817
|
export async function atomicUpdate(storeName, updateFn) {
|
|
676
818
|
assertStore(storeName)
|
|
677
|
-
const queueName =
|
|
819
|
+
const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
|
|
678
820
|
return enqueueWrite(queueName, async () => {
|
|
679
821
|
await ensureStorage()
|
|
680
|
-
if (
|
|
681
|
-
const
|
|
682
|
-
const data = configSection(config, storeName)
|
|
822
|
+
if (isConfigStore(storeName)) {
|
|
823
|
+
const data = await readConfigStore(storeName)
|
|
683
824
|
const updated = updateFn(data)
|
|
684
|
-
|
|
685
|
-
await writeConfigFile(config)
|
|
825
|
+
await writeConfigStore(storeName, updated)
|
|
686
826
|
return updated
|
|
687
827
|
}
|
|
688
828
|
const data = await readSessionStore(storeName)
|
|
@@ -719,13 +859,11 @@ export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
|
|
|
719
859
|
* Atomically read-modify-write the project config within the config queue.
|
|
720
860
|
*/
|
|
721
861
|
export async function atomicProjectConfigUpdate(updateFn) {
|
|
722
|
-
return enqueueWrite('
|
|
862
|
+
return enqueueWrite('projects', async () => {
|
|
723
863
|
await ensureStorage()
|
|
724
|
-
const
|
|
725
|
-
const projectConfig = normalizeProjectConfig(config.projects)
|
|
864
|
+
const projectConfig = normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
|
|
726
865
|
const updated = updateFn(projectConfig)
|
|
727
|
-
|
|
728
|
-
await writeConfigFile(config)
|
|
866
|
+
await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(updated))
|
|
729
867
|
return updated
|
|
730
868
|
})
|
|
731
869
|
}
|
|
@@ -756,9 +894,10 @@ export function ensureStorage() {
|
|
|
756
894
|
])
|
|
757
895
|
|
|
758
896
|
await migrateUnifiedConfig()
|
|
897
|
+
await migrateSplitConfig()
|
|
759
898
|
|
|
760
899
|
await Promise.all([
|
|
761
|
-
ensureJsonFile(quickForgeConfigFile,
|
|
900
|
+
ensureJsonFile(quickForgeConfigFile, { layoutVersion: 2 }),
|
|
762
901
|
ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
|
|
763
902
|
])
|
|
764
903
|
})()
|
|
@@ -771,9 +910,8 @@ export async function readStore(storeName) {
|
|
|
771
910
|
assertStore(storeName)
|
|
772
911
|
await ensureStorage()
|
|
773
912
|
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
return configSection(config, storeName)
|
|
913
|
+
if (isConfigStore(storeName)) {
|
|
914
|
+
return readConfigStore(storeName)
|
|
777
915
|
}
|
|
778
916
|
|
|
779
917
|
return readSessionStore(storeName)
|
|
@@ -781,15 +919,13 @@ export async function readStore(storeName) {
|
|
|
781
919
|
|
|
782
920
|
export async function writeStore(storeName, data) {
|
|
783
921
|
assertStore(storeName)
|
|
784
|
-
const queueName =
|
|
922
|
+
const queueName = isConfigStore(storeName) ? configStoreLocations[storeName].queue : storeName
|
|
785
923
|
|
|
786
924
|
return enqueueWrite(queueName, async () => {
|
|
787
925
|
await ensureStorage()
|
|
788
926
|
|
|
789
|
-
if (
|
|
790
|
-
|
|
791
|
-
setConfigSection(config, storeName, data)
|
|
792
|
-
await writeConfigFile(config)
|
|
927
|
+
if (isConfigStore(storeName)) {
|
|
928
|
+
await writeConfigStore(storeName, data)
|
|
793
929
|
return
|
|
794
930
|
}
|
|
795
931
|
|
|
@@ -799,16 +935,13 @@ export async function writeStore(storeName, data) {
|
|
|
799
935
|
|
|
800
936
|
export async function readProjectConfigData() {
|
|
801
937
|
await ensureStorage()
|
|
802
|
-
|
|
803
|
-
return normalizeProjectConfig(config.projects)
|
|
938
|
+
return normalizeProjectConfig(await readJsonFile(projectsConfigFile, defaultProjectConfig()))
|
|
804
939
|
}
|
|
805
940
|
|
|
806
941
|
export async function writeProjectConfigData(projectConfig) {
|
|
807
|
-
return enqueueWrite('
|
|
942
|
+
return enqueueWrite('projects', async () => {
|
|
808
943
|
await ensureStorage()
|
|
809
|
-
|
|
810
|
-
config.projects = normalizeProjectConfig(projectConfig)
|
|
811
|
-
await writeConfigFile(config)
|
|
944
|
+
await writeJsonAtomic(projectsConfigFile, normalizeProjectConfig(projectConfig))
|
|
812
945
|
})
|
|
813
946
|
}
|
|
814
947
|
|
package/server/utils/logger.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { logsDir } from '../storage.mjs'
|
|
|
4
4
|
|
|
5
5
|
// --- Level control ---
|
|
6
6
|
const LEVELS = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 }
|
|
7
|
-
const levelNames = Object.keys(LEVELS)
|
|
8
7
|
|
|
9
8
|
const envLevel = (process.env.QUICKFORGE_LOG_LEVEL || '').toUpperCase()
|
|
10
9
|
const minLevel = LEVELS[envLevel] ?? LEVELS.INFO
|
|
@@ -27,7 +27,7 @@ export async function getPackageInfo(projectRoot) {
|
|
|
27
27
|
bugsUrl: typeof pkg.bugs === 'string' ? pkg.bugs : pkg.bugs?.url || '',
|
|
28
28
|
}
|
|
29
29
|
} catch (error) {
|
|
30
|
-
throw new Error(`Unable to read package metadata: ${error.message}
|
|
30
|
+
throw new Error(`Unable to read package metadata: ${error.message}`, { cause: error })
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -109,7 +109,7 @@ export async function fetchLatestVersion(packageName) {
|
|
|
109
109
|
if (!latest || typeof latest !== 'string') throw new Error('latest version not found in registry response')
|
|
110
110
|
return latest
|
|
111
111
|
} catch (error) {
|
|
112
|
-
if (error.name === 'AbortError') throw new Error('request timeout')
|
|
112
|
+
if (error.name === 'AbortError') throw new Error('request timeout', { cause: error })
|
|
113
113
|
throw error
|
|
114
114
|
} finally {
|
|
115
115
|
clearTimeout(timeout)
|