@shawnstack/quickforge 1.2.4 → 1.2.6
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 +6 -6
- package/dist/assets/{anthropic-BiTRBcug.js → anthropic-g5RJMS9O.js} +1 -1
- package/dist/assets/{azure-openai-responses-MuqXmHEI.js → azure-openai-responses-a7afzGwC.js} +1 -1
- package/dist/assets/{google-Bp1Z3Se-.js → google-DZsPNex3.js} +1 -1
- package/dist/assets/{google-gemini-cli-6iC3v7Mu.js → google-gemini-cli-DnAdZ-9e.js} +1 -1
- package/dist/assets/{google-vertex-DzmUuLVp.js → google-vertex-BDuS0bO0.js} +1 -1
- package/dist/assets/{icons-BzvJv-Bv.js → icons-DjhMV6OE.js} +1 -1
- package/dist/assets/index-B094j8RZ.css +3 -0
- package/dist/assets/{index-VdWqU8e1.js → index-CC71Wiy2.js} +660 -487
- package/dist/assets/{mistral-BSlX93lo.js → mistral-CuUpINR3.js} +1 -1
- package/dist/assets/{openai-codex-responses-CvN5Iwy4.js → openai-codex-responses-DchchAd8.js} +1 -1
- package/dist/assets/{openai-completions-CteXgyGA.js → openai-completions-CV15qkLX.js} +1 -1
- package/dist/assets/{openai-responses-Chg1ZGwU.js → openai-responses-BYlHDVWf.js} +1 -1
- package/dist/assets/{openai-responses-shared-C8jnJ317.js → openai-responses-shared-CIztTfIF.js} +1 -1
- package/dist/assets/{react-vendor-CdZo8gqc.js → react-vendor-BK8yG_FK.js} +1 -1
- package/dist/index.html +4 -4
- package/node_modules/protobufjs/dist/light/protobuf.js +36 -12
- package/node_modules/protobufjs/dist/light/protobuf.js.map +1 -1
- package/node_modules/protobufjs/dist/light/protobuf.min.js +3 -3
- package/node_modules/protobufjs/dist/light/protobuf.min.js.map +1 -1
- package/node_modules/protobufjs/dist/minimal/protobuf.js +2 -2
- package/node_modules/protobufjs/dist/minimal/protobuf.min.js +2 -2
- package/node_modules/protobufjs/dist/protobuf.js +71 -42
- package/node_modules/protobufjs/dist/protobuf.js.map +1 -1
- package/node_modules/protobufjs/dist/protobuf.min.js +3 -3
- package/node_modules/protobufjs/dist/protobuf.min.js.map +1 -1
- package/node_modules/protobufjs/index.d.ts +18 -5
- package/node_modules/protobufjs/package.json +1 -1
- package/node_modules/protobufjs/src/namespace.js +8 -4
- package/node_modules/protobufjs/src/parse.js +35 -30
- package/node_modules/protobufjs/src/root.js +4 -2
- package/node_modules/protobufjs/src/service.js +4 -2
- package/node_modules/protobufjs/src/type.js +4 -2
- package/node_modules/protobufjs/src/util.js +14 -0
- package/node_modules/ws/lib/sender.js +6 -1
- package/node_modules/ws/package.json +1 -1
- package/package.json +1 -1
- package/server/agent-manager.mjs +49 -6
- package/server/index.mjs +49 -4
- package/server/lan-access-store.mjs +215 -0
- package/server/routes/backup.mjs +184 -39
- package/server/routes/lan-access.mjs +201 -0
- package/server/share-store.mjs +7 -29
- package/server/system-prompt.mjs +2 -0
- package/server/tools/index.mjs +39 -13
- package/server/utils/password-auth.mjs +44 -0
- package/dist/assets/index-B9f6WrD6.css +0 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { ensureStorage, storageDir } from './storage.mjs'
|
|
4
|
+
import { createRandomToken, hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
|
|
5
|
+
|
|
6
|
+
const LAN_ACCESS_DIR = path.join(storageDir, 'security')
|
|
7
|
+
const LAN_ACCESS_FILE = path.join(LAN_ACCESS_DIR, 'lan-access.json')
|
|
8
|
+
const LAN_TOKEN_MAX_COUNT = 100
|
|
9
|
+
const DEFAULT_SESSION_TTL_HOURS = 12
|
|
10
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
11
|
+
const writeQueueName = 'lan-access'
|
|
12
|
+
const writeQueues = new Map()
|
|
13
|
+
|
|
14
|
+
function enqueueWrite(queueName, operation) {
|
|
15
|
+
const previous = writeQueues.get(queueName) || Promise.resolve()
|
|
16
|
+
const next = previous
|
|
17
|
+
.catch(() => undefined)
|
|
18
|
+
.then(operation)
|
|
19
|
+
writeQueues.set(queueName, next)
|
|
20
|
+
return next
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultLanAccessConfig() {
|
|
24
|
+
return {
|
|
25
|
+
enabled: false,
|
|
26
|
+
passwordHash: undefined,
|
|
27
|
+
passwordSalt: undefined,
|
|
28
|
+
passwordVersion: undefined,
|
|
29
|
+
authVersion: 1,
|
|
30
|
+
sessionTtlHours: DEFAULT_SESSION_TTL_HOURS,
|
|
31
|
+
updatedAt: new Date().toISOString(),
|
|
32
|
+
tokens: [],
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeSessionTtlHours(value) {
|
|
37
|
+
const numeric = Number(value)
|
|
38
|
+
if (!Number.isFinite(numeric)) return DEFAULT_SESSION_TTL_HOURS
|
|
39
|
+
return Math.min(24 * 7, Math.max(1, Math.round(numeric)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeConfig(value) {
|
|
43
|
+
const base = defaultLanAccessConfig()
|
|
44
|
+
const input = value && typeof value === 'object' ? value : {}
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
...input,
|
|
48
|
+
enabled: Boolean(input.enabled),
|
|
49
|
+
authVersion: Number(input.authVersion || base.authVersion),
|
|
50
|
+
sessionTtlHours: normalizeSessionTtlHours(input.sessionTtlHours),
|
|
51
|
+
tokens: pruneTokens(input.tokens),
|
|
52
|
+
updatedAt: typeof input.updatedAt === 'string' ? input.updatedAt : base.updatedAt,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureLanAccessStore() {
|
|
57
|
+
await ensureStorage()
|
|
58
|
+
await fs.mkdir(LAN_ACCESS_DIR, { recursive: true })
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(LAN_ACCESS_FILE)
|
|
61
|
+
} catch {
|
|
62
|
+
await fs.writeFile(LAN_ACCESS_FILE, `${JSON.stringify(defaultLanAccessConfig(), null, 2)}\n`, 'utf8')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readLanAccessFile() {
|
|
67
|
+
await ensureLanAccessStore()
|
|
68
|
+
try {
|
|
69
|
+
const raw = await fs.readFile(LAN_ACCESS_FILE, 'utf8')
|
|
70
|
+
const text = raw.trimStart()
|
|
71
|
+
return normalizeConfig(text ? JSON.parse(text) : {})
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code === 'ENOENT') return defaultLanAccessConfig()
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function writeLanAccessFile(config) {
|
|
79
|
+
await ensureLanAccessStore()
|
|
80
|
+
await fs.writeFile(LAN_ACCESS_FILE, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, 'utf8')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function publicStatus(config) {
|
|
84
|
+
return {
|
|
85
|
+
enabled: Boolean(config.enabled),
|
|
86
|
+
hasPassword: Boolean(config.passwordHash),
|
|
87
|
+
sessionTtlHours: config.sessionTtlHours,
|
|
88
|
+
authVersion: config.authVersion || 1,
|
|
89
|
+
activeTokenCount: pruneTokens(config.tokens).length,
|
|
90
|
+
updatedAt: config.updatedAt,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pruneTokens(tokens, now = Date.now()) {
|
|
95
|
+
return (Array.isArray(tokens) ? tokens : [])
|
|
96
|
+
.filter((tokenRecord) => {
|
|
97
|
+
if (!tokenRecord?.tokenHash) return false
|
|
98
|
+
if (!tokenRecord.expiresAt) return true
|
|
99
|
+
return Date.parse(tokenRecord.expiresAt) > now
|
|
100
|
+
})
|
|
101
|
+
.slice(-LAN_TOKEN_MAX_COUNT)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertPasswordAllowed(password) {
|
|
105
|
+
if (typeof password !== 'string' || password.trim().length < MIN_PASSWORD_LENGTH) {
|
|
106
|
+
const error = new Error(`LAN access password must be at least ${MIN_PASSWORD_LENGTH} characters.`)
|
|
107
|
+
error.statusCode = 400
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readLanAccessStatus() {
|
|
113
|
+
return publicStatus(await readLanAccessFile())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readLanAccessConfig() {
|
|
117
|
+
return readLanAccessFile()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function updateLanAccessSettings({ enabled, password, sessionTtlHours }) {
|
|
121
|
+
return enqueueWrite(writeQueueName, async () => {
|
|
122
|
+
const current = await readLanAccessFile()
|
|
123
|
+
const passwordProvided = typeof password === 'string' && password.length > 0
|
|
124
|
+
const nextEnabled = Boolean(enabled)
|
|
125
|
+
|
|
126
|
+
if (passwordProvided) assertPasswordAllowed(password)
|
|
127
|
+
if (nextEnabled && !passwordProvided && !current.passwordHash) {
|
|
128
|
+
const error = new Error('LAN access password is required before enabling full LAN access.')
|
|
129
|
+
error.statusCode = 400
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const passwordInfo = passwordProvided ? await hashPassword(password.trim()) : {}
|
|
134
|
+
const now = new Date().toISOString()
|
|
135
|
+
const authChanged = passwordProvided || current.enabled !== nextEnabled
|
|
136
|
+
const next = normalizeConfig({
|
|
137
|
+
...current,
|
|
138
|
+
...passwordInfo,
|
|
139
|
+
enabled: nextEnabled,
|
|
140
|
+
authVersion: authChanged ? (current.authVersion || 1) + 1 : (current.authVersion || 1),
|
|
141
|
+
sessionTtlHours: normalizeSessionTtlHours(sessionTtlHours ?? current.sessionTtlHours),
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
tokens: authChanged ? [] : pruneTokens(current.tokens),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await writeLanAccessFile(next)
|
|
147
|
+
return publicStatus(next)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function revokeLanAccessTokens() {
|
|
152
|
+
return enqueueWrite(writeQueueName, async () => {
|
|
153
|
+
const current = await readLanAccessFile()
|
|
154
|
+
const next = normalizeConfig({
|
|
155
|
+
...current,
|
|
156
|
+
authVersion: (current.authVersion || 1) + 1,
|
|
157
|
+
tokens: [],
|
|
158
|
+
updatedAt: new Date().toISOString(),
|
|
159
|
+
})
|
|
160
|
+
await writeLanAccessFile(next)
|
|
161
|
+
return publicStatus(next)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function lanAccessCookieName() {
|
|
166
|
+
return 'qf_lan_access'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function issueLanAccessToken(password) {
|
|
170
|
+
return enqueueWrite(writeQueueName, async () => {
|
|
171
|
+
const current = await readLanAccessFile()
|
|
172
|
+
if (!current.enabled || !current.passwordHash) {
|
|
173
|
+
const error = new Error('LAN access is not enabled.')
|
|
174
|
+
error.statusCode = 403
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
if (!(await verifyPassword(current, typeof password === 'string' ? password.trim() : ''))) {
|
|
178
|
+
const error = new Error('Invalid LAN access password')
|
|
179
|
+
error.statusCode = 401
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const secret = createRandomToken(32)
|
|
184
|
+
const tokenHash = sha256Base64Url(secret)
|
|
185
|
+
const issuedAt = new Date().toISOString()
|
|
186
|
+
const ttlMs = normalizeSessionTtlHours(current.sessionTtlHours) * 60 * 60 * 1000
|
|
187
|
+
const expiresAt = new Date(Date.now() + ttlMs).toISOString()
|
|
188
|
+
const next = normalizeConfig({
|
|
189
|
+
...current,
|
|
190
|
+
tokens: [
|
|
191
|
+
...pruneTokens(current.tokens),
|
|
192
|
+
{ tokenHash, issuedAt, expiresAt, authVersion: current.authVersion || 1 },
|
|
193
|
+
].slice(-LAN_TOKEN_MAX_COUNT),
|
|
194
|
+
updatedAt: issuedAt,
|
|
195
|
+
})
|
|
196
|
+
await writeLanAccessFile(next)
|
|
197
|
+
return {
|
|
198
|
+
token: `${current.authVersion || 1}.${secret}`,
|
|
199
|
+
expiresAt,
|
|
200
|
+
maxAge: Math.floor(ttlMs / 1000),
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function verifyLanAccessToken(token) {
|
|
206
|
+
const current = await readLanAccessFile()
|
|
207
|
+
if (!current.enabled || !current.passwordHash || !token || typeof token !== 'string') return false
|
|
208
|
+
const [versionText, secret] = token.split('.')
|
|
209
|
+
if (Number(versionText) !== (current.authVersion || 1) || !secret) return false
|
|
210
|
+
const actualHash = sha256Base64Url(secret)
|
|
211
|
+
return pruneTokens(current.tokens).some((tokenRecord) => {
|
|
212
|
+
if ((tokenRecord.authVersion || 1) !== (current.authVersion || 1)) return false
|
|
213
|
+
return safeHashEqual(tokenRecord.tokenHash, actualHash)
|
|
214
|
+
})
|
|
215
|
+
}
|
package/server/routes/backup.mjs
CHANGED
|
@@ -16,12 +16,18 @@ 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
20
|
|
|
20
21
|
function normalizeScope(value) {
|
|
21
22
|
const scope = String(value || 'all')
|
|
22
23
|
return backupScopes.has(scope) ? scope : 'all'
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function parseBoolean(value) {
|
|
27
|
+
const text = String(value || '').toLowerCase()
|
|
28
|
+
return text === '1' || text === 'true' || text === 'yes'
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function backupTimestamp(date = new Date()) {
|
|
26
32
|
return date.toISOString().replace(/[:.]/g, '-')
|
|
27
33
|
}
|
|
@@ -53,6 +59,7 @@ function assertProjectConfig(value) {
|
|
|
53
59
|
}
|
|
54
60
|
return {
|
|
55
61
|
activeProjectId: typeof projectConfig.activeProjectId === 'string' ? projectConfig.activeProjectId : null,
|
|
62
|
+
globalSkills: Array.isArray(projectConfig.globalSkills) ? projectConfig.globalSkills : [],
|
|
56
63
|
projects: projectConfig.projects,
|
|
57
64
|
}
|
|
58
65
|
}
|
|
@@ -94,27 +101,28 @@ function normalizeSessionMetadata(sessions, metadata) {
|
|
|
94
101
|
return nextMetadata
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
async function buildBackup(scope = 'all') {
|
|
104
|
+
async function buildBackup(scope = 'all', options = {}) {
|
|
98
105
|
const normalizedScope = normalizeScope(scope)
|
|
99
106
|
const includeConfig = normalizedScope === 'all' || normalizedScope === 'config'
|
|
100
107
|
const includeSessions = normalizedScope === 'all' || normalizedScope === 'sessions'
|
|
108
|
+
const includeSecrets = Boolean(options.includeSecrets && includeConfig)
|
|
101
109
|
const data = {}
|
|
102
110
|
|
|
103
111
|
if (includeConfig) {
|
|
104
112
|
const [settings, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
|
|
105
113
|
readStore('settings'),
|
|
106
|
-
readStore('provider-keys'),
|
|
114
|
+
includeSecrets ? readStore('provider-keys') : Promise.resolve(undefined),
|
|
107
115
|
readStore('custom-providers'),
|
|
108
116
|
readProjectConfigData(),
|
|
109
117
|
readStore('scheduled-tasks'),
|
|
110
118
|
])
|
|
111
119
|
Object.assign(data, {
|
|
112
120
|
settings,
|
|
113
|
-
providerKeys,
|
|
114
121
|
customProviders,
|
|
115
122
|
projects,
|
|
116
123
|
scheduledTasks,
|
|
117
124
|
})
|
|
125
|
+
if (includeSecrets) data.providerKeys = providerKeys
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
if (includeSessions) {
|
|
@@ -133,6 +141,7 @@ async function buildBackup(scope = 'all') {
|
|
|
133
141
|
version: BACKUP_VERSION,
|
|
134
142
|
exportedAt: new Date().toISOString(),
|
|
135
143
|
scope: normalizedScope,
|
|
144
|
+
includeSecrets,
|
|
136
145
|
data,
|
|
137
146
|
}
|
|
138
147
|
}
|
|
@@ -149,7 +158,7 @@ function normalizeBackupPayload(payload) {
|
|
|
149
158
|
? backup.data
|
|
150
159
|
: backup
|
|
151
160
|
|
|
152
|
-
const
|
|
161
|
+
const sections = {
|
|
153
162
|
settings: section(data, 'settings'),
|
|
154
163
|
providerKeys: section(data, 'providerKeys', 'provider-keys'),
|
|
155
164
|
customProviders: section(data, 'customProviders', 'custom-providers'),
|
|
@@ -159,17 +168,149 @@ function normalizeBackupPayload(payload) {
|
|
|
159
168
|
sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
if (Object.values(
|
|
171
|
+
if (Object.values(sections).every((value) => value === undefined)) {
|
|
163
172
|
const error = new Error('Backup does not contain any restorable sections')
|
|
164
173
|
error.statusCode = 400
|
|
165
174
|
throw error
|
|
166
175
|
}
|
|
167
176
|
|
|
168
|
-
return
|
|
177
|
+
return {
|
|
178
|
+
app: typeof backup.app === 'string' ? backup.app : null,
|
|
179
|
+
version: Number.isInteger(backup.version) ? backup.version : null,
|
|
180
|
+
exportedAt: typeof backup.exportedAt === 'string' ? backup.exportedAt : null,
|
|
181
|
+
scope: typeof backup.scope === 'string' ? backup.scope : null,
|
|
182
|
+
includeSecrets: backup.includeSecrets === true,
|
|
183
|
+
sections,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateBackupPayload(payload) {
|
|
188
|
+
const backup = normalizeBackupPayload(payload)
|
|
189
|
+
const { sections } = backup
|
|
190
|
+
|
|
191
|
+
const sessions = assertObjectSection(sections.sessions, 'sessions')
|
|
192
|
+
const sessionsMetadata = sessions !== undefined
|
|
193
|
+
? normalizeSessionMetadata(sessions, sections.sessionsMetadata)
|
|
194
|
+
: assertObjectSection(sections.sessionsMetadata, 'sessionsMetadata')
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
...backup,
|
|
198
|
+
sections: {
|
|
199
|
+
settings: assertObjectSection(sections.settings, 'settings'),
|
|
200
|
+
providerKeys: assertObjectSection(sections.providerKeys, 'providerKeys'),
|
|
201
|
+
customProviders: assertObjectSection(sections.customProviders, 'customProviders'),
|
|
202
|
+
projects: assertProjectConfig(sections.projects),
|
|
203
|
+
scheduledTasks: assertObjectSection(sections.scheduledTasks, 'scheduledTasks'),
|
|
204
|
+
sessions,
|
|
205
|
+
sessionsMetadata,
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeRestoreSections(value, sections) {
|
|
211
|
+
if (value === undefined || value === null) return null
|
|
212
|
+
if (!Array.isArray(value)) {
|
|
213
|
+
const error = new Error('Invalid restore sections')
|
|
214
|
+
error.statusCode = 400
|
|
215
|
+
throw error
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const selected = new Set()
|
|
219
|
+
for (const item of value) {
|
|
220
|
+
const id = String(item)
|
|
221
|
+
if (!restoreSectionIds.has(id)) {
|
|
222
|
+
const error = new Error(`Invalid restore section: ${id}`)
|
|
223
|
+
error.statusCode = 400
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
selected.add(id)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (selected.size === 0) {
|
|
230
|
+
const error = new Error('No restore sections selected')
|
|
231
|
+
error.statusCode = 400
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const unavailable = [...selected].filter((id) => {
|
|
236
|
+
if (id === 'conversations') return sections.sessions === undefined && sections.sessionsMetadata === undefined
|
|
237
|
+
return sections[id] === undefined
|
|
238
|
+
})
|
|
239
|
+
if (unavailable.length > 0) {
|
|
240
|
+
const error = new Error(`Selected restore section is not available in backup: ${unavailable.join(', ')}`)
|
|
241
|
+
error.statusCode = 400
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return selected
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function filterRestoreSections(sections, selected) {
|
|
249
|
+
if (!selected) return sections
|
|
250
|
+
return {
|
|
251
|
+
settings: selected.has('settings') ? sections.settings : undefined,
|
|
252
|
+
providerKeys: selected.has('providerKeys') ? sections.providerKeys : undefined,
|
|
253
|
+
customProviders: selected.has('customProviders') ? sections.customProviders : undefined,
|
|
254
|
+
projects: selected.has('projects') ? sections.projects : undefined,
|
|
255
|
+
scheduledTasks: selected.has('scheduledTasks') ? sections.scheduledTasks : undefined,
|
|
256
|
+
sessions: selected.has('conversations') ? sections.sessions : undefined,
|
|
257
|
+
sessionsMetadata: selected.has('conversations') ? sections.sessionsMetadata : undefined,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function backupWithSelectedSections(backup, selected) {
|
|
262
|
+
return selected ? { ...backup, sections: filterRestoreSections(backup.sections, selected) } : backup
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseImportPayload(body) {
|
|
266
|
+
const payload = body?.backup && typeof body.backup === 'object' ? body.backup : body
|
|
267
|
+
const backup = validateBackupPayload(payload)
|
|
268
|
+
const requestedSections = body?.backup && typeof body === 'object' ? body.sections : undefined
|
|
269
|
+
const selected = normalizeRestoreSections(requestedSections, backup.sections)
|
|
270
|
+
return backupWithSelectedSections(backup, selected)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function countKeys(value) {
|
|
274
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).length : 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildSummary(sections) {
|
|
278
|
+
const summary = {}
|
|
279
|
+
if (sections.settings !== undefined) summary.settings = countKeys(sections.settings)
|
|
280
|
+
if (sections.providerKeys !== undefined) summary.providerKeys = countKeys(sections.providerKeys)
|
|
281
|
+
if (sections.customProviders !== undefined) summary.customProviders = countKeys(sections.customProviders)
|
|
282
|
+
if (sections.projects !== undefined) summary.projects = sections.projects.projects.length
|
|
283
|
+
if (sections.scheduledTasks !== undefined) summary.scheduledTasks = countKeys(sections.scheduledTasks)
|
|
284
|
+
if (sections.sessions !== undefined) summary.sessions = countKeys(filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata))
|
|
285
|
+
if (sections.sessionsMetadata !== undefined) summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
|
|
286
|
+
return summary
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function inspectBackup(payload) {
|
|
290
|
+
const backup = validateBackupPayload(payload)
|
|
291
|
+
const summary = buildSummary(backup.sections)
|
|
292
|
+
const warnings = []
|
|
293
|
+
const containsSecrets = countKeys(backup.sections.providerKeys) > 0
|
|
294
|
+
|
|
295
|
+
if (containsSecrets) warnings.push('Backup contains API keys.')
|
|
296
|
+
if (backup.sections.sessions !== undefined || backup.sections.sessionsMetadata !== undefined) {
|
|
297
|
+
warnings.push('Importing conversations will replace local conversation data.')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
app: backup.app,
|
|
303
|
+
version: backup.version,
|
|
304
|
+
exportedAt: backup.exportedAt,
|
|
305
|
+
scope: backup.scope,
|
|
306
|
+
includeSecrets: containsSecrets || backup.includeSecrets,
|
|
307
|
+
sections: summary,
|
|
308
|
+
warnings,
|
|
309
|
+
}
|
|
169
310
|
}
|
|
170
311
|
|
|
171
312
|
async function writeSafetyBackup() {
|
|
172
|
-
const backup = await buildBackup('all')
|
|
313
|
+
const backup = await buildBackup('all', { includeSecrets: true })
|
|
173
314
|
const dir = path.join(storageDir, 'backups')
|
|
174
315
|
await fs.mkdir(dir, { recursive: true })
|
|
175
316
|
const file = path.join(dir, `quickforge-before-restore-${backupTimestamp()}.json`)
|
|
@@ -177,52 +318,46 @@ async function writeSafetyBackup() {
|
|
|
177
318
|
return file
|
|
178
319
|
}
|
|
179
320
|
|
|
180
|
-
async function
|
|
181
|
-
const
|
|
321
|
+
async function restoreValidatedBackup(backup) {
|
|
322
|
+
const { sections } = backup
|
|
182
323
|
const summary = {}
|
|
183
324
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
summary.settings = Object.keys(settings).length
|
|
325
|
+
if (sections.settings !== undefined) {
|
|
326
|
+
await writeStore('settings', sections.settings)
|
|
327
|
+
summary.settings = countKeys(sections.settings)
|
|
188
328
|
}
|
|
189
329
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
summary.providerKeys = Object.keys(providerKeys).length
|
|
330
|
+
if (sections.providerKeys !== undefined) {
|
|
331
|
+
await writeStore('provider-keys', sections.providerKeys)
|
|
332
|
+
summary.providerKeys = countKeys(sections.providerKeys)
|
|
194
333
|
}
|
|
195
334
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
summary.customProviders = Object.keys(customProviders).length
|
|
335
|
+
if (sections.customProviders !== undefined) {
|
|
336
|
+
await writeStore('custom-providers', sections.customProviders)
|
|
337
|
+
summary.customProviders = countKeys(sections.customProviders)
|
|
200
338
|
}
|
|
201
339
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await writeProjectConfigData(projects)
|
|
340
|
+
if (sections.projects !== undefined) {
|
|
341
|
+
await writeProjectConfigData(sections.projects)
|
|
205
342
|
await initializeActiveProject()
|
|
206
343
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
207
|
-
summary.projects = projects.projects.length
|
|
344
|
+
summary.projects = sections.projects.projects.length
|
|
208
345
|
}
|
|
209
346
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
summary.scheduledTasks = Object.keys(scheduledTasks).length
|
|
347
|
+
if (sections.scheduledTasks !== undefined) {
|
|
348
|
+
await writeStore('scheduled-tasks', sections.scheduledTasks)
|
|
349
|
+
summary.scheduledTasks = countKeys(sections.scheduledTasks)
|
|
214
350
|
}
|
|
215
351
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
summary.sessions = Object.keys(sessions).length
|
|
352
|
+
if (sections.sessions !== undefined) {
|
|
353
|
+
const sessions = filterSessionsByMetadata(sections.sessions, sections.sessionsMetadata)
|
|
354
|
+
await writeStore('sessions', sessions)
|
|
355
|
+
summary.sessions = countKeys(sessions)
|
|
221
356
|
}
|
|
222
357
|
|
|
223
|
-
if (sessionsMetadata !== undefined) {
|
|
224
|
-
await writeStore('sessions-metadata', sessionsMetadata)
|
|
225
|
-
summary.sessionsMetadata =
|
|
358
|
+
if (sections.sessionsMetadata !== undefined) {
|
|
359
|
+
await writeStore('sessions-metadata', sections.sessionsMetadata)
|
|
360
|
+
summary.sessionsMetadata = countKeys(sections.sessionsMetadata)
|
|
226
361
|
}
|
|
227
362
|
|
|
228
363
|
return summary
|
|
@@ -231,15 +366,25 @@ async function restoreBackup(payload) {
|
|
|
231
366
|
export async function handleBackupApi(req, res, url) {
|
|
232
367
|
if (req.method === 'GET' && url.pathname === '/api/backup/export') {
|
|
233
368
|
await ensureStorage()
|
|
234
|
-
sendJson(res, 200, await buildBackup(url.searchParams.get('scope')
|
|
369
|
+
sendJson(res, 200, await buildBackup(url.searchParams.get('scope'), {
|
|
370
|
+
includeSecrets: parseBoolean(url.searchParams.get('includeSecrets')),
|
|
371
|
+
}))
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (req.method === 'POST' && url.pathname === '/api/backup/inspect') {
|
|
376
|
+
await ensureStorage()
|
|
377
|
+
const body = await readJsonBody(req)
|
|
378
|
+
sendJson(res, 200, inspectBackup(body))
|
|
235
379
|
return
|
|
236
380
|
}
|
|
237
381
|
|
|
238
382
|
if (req.method === 'POST' && url.pathname === '/api/backup/import') {
|
|
239
383
|
await ensureStorage()
|
|
240
384
|
const body = await readJsonBody(req)
|
|
385
|
+
const backup = parseImportPayload(body)
|
|
241
386
|
const safetyBackupPath = await writeSafetyBackup()
|
|
242
|
-
const summary = await
|
|
387
|
+
const summary = await restoreValidatedBackup(backup)
|
|
243
388
|
sendJson(res, 200, { ok: true, safetyBackupPath, summary })
|
|
244
389
|
return
|
|
245
390
|
}
|