@shawnstack/quickforge 1.1.0 → 1.2.1

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 (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/index-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +14 -13
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +322 -32
  27. package/server/project-config.mjs +80 -31
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +46 -10
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +58 -10
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +247 -6
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +30 -0
  52. package/server/utils/response.mjs +8 -1
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,404 @@
1
+ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
+ import { readSessionValue, readStore } from '../storage.mjs'
3
+ import { abortRun, restoreAgent, runPrompt, getSessionState, getSessionEventBus, updateSessionModel, updateSessionThinkingLevel } from '../agent-manager.mjs'
4
+ import {
5
+ assertShareActive,
6
+ issueConversationShareToken,
7
+ parseCookies,
8
+ readConversationShare,
9
+ rollbackSharedSessionMessages,
10
+ shareCookieName,
11
+ verifySharePassword,
12
+ verifyShareToken,
13
+ } from '../share-store.mjs'
14
+
15
+ const MAX_SHARED_MESSAGE_BYTES = 64 * 1024
16
+ const CLIENT_MESSAGE_ID_FIELD = 'quickforgeClientMessageId'
17
+ const CLIENT_MESSAGE_ID_MAX_LENGTH = 128
18
+
19
+ function sanitizedClientMessageId(value) {
20
+ if (typeof value !== 'string') return undefined
21
+ const trimmed = value.trim()
22
+ if (!trimmed || trimmed.length > CLIENT_MESSAGE_ID_MAX_LENGTH) return undefined
23
+ return /^[A-Za-z0-9._:-]+$/.test(trimmed) ? trimmed : undefined
24
+ }
25
+
26
+ function objectMetadata(value) {
27
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
28
+ }
29
+
30
+ function attachClientMessageId(message, clientMessageId) {
31
+ if (!clientMessageId) return message
32
+ return {
33
+ ...message,
34
+ metadata: {
35
+ ...objectMetadata(message.metadata),
36
+ [CLIENT_MESSAGE_ID_FIELD]: clientMessageId,
37
+ },
38
+ }
39
+ }
40
+
41
+ function publicSharePayload(record) {
42
+ return {
43
+ id: record.id,
44
+ permission: record.permission,
45
+ title: record.titleSnapshot,
46
+ expiresAt: record.expiresAt,
47
+ revokedAt: record.revokedAt,
48
+ scope: record.scope,
49
+ projectId: record.projectId,
50
+ hasPassword: Boolean(record.passwordHash),
51
+ }
52
+ }
53
+
54
+ function sanitizeMessage(message) {
55
+ if (!message || typeof message !== 'object') return message
56
+ if (message.role === 'system') return null
57
+ return message
58
+ }
59
+
60
+ function sanitizeSession(session, record) {
61
+ const messages = Array.isArray(session?.messages) ? session.messages.map(sanitizeMessage).filter(Boolean) : []
62
+ return {
63
+ id: record.id,
64
+ shareId: record.id,
65
+ sessionId: record.sessionId,
66
+ title: session?.title || record.titleSnapshot || 'New chat',
67
+ permission: record.permission,
68
+ expiresAt: record.expiresAt,
69
+ scope: record.scope,
70
+ projectId: record.projectId,
71
+ systemPrompt: '',
72
+ model: sanitizeModel(session?.model),
73
+ thinkingLevel: session?.thinkingLevel || 'off',
74
+ tools: Array.isArray(session?.tools) ? session.tools : [],
75
+ yoloMode: Boolean(session?.yoloMode),
76
+ messages,
77
+ isStreaming: Boolean(session?.isStreaming || session?.taskStatus === 'running'),
78
+ taskStatus: session?.taskStatus || session?.status,
79
+ errorMessage: session?.errorMessage,
80
+ }
81
+ }
82
+
83
+ function sanitizeModel(model) {
84
+ if (!model || typeof model !== 'object') return { provider: 'shared', id: 'shared' }
85
+ return {
86
+ ...model,
87
+ apiKey: undefined,
88
+ key: undefined,
89
+ headers: undefined,
90
+ }
91
+ }
92
+
93
+ function sanitizeIncomingModel(model) {
94
+ if (!model || typeof model !== 'object') return null
95
+ const sanitized = sanitizeModel(model)
96
+ if (!sanitized.id || !sanitized.provider) return null
97
+ return sanitized
98
+ }
99
+
100
+ function sanitizeEvent(event) {
101
+ if (!event || typeof event !== 'object') return event
102
+ const next = { ...event }
103
+ if (next.message) next.message = sanitizeMessage(next.message)
104
+ if (Array.isArray(next.messages)) next.messages = next.messages.map(sanitizeMessage).filter(Boolean)
105
+ return next
106
+ }
107
+
108
+ function writeSseEvent(res, event, data) {
109
+ const payload = JSON.stringify(data)
110
+ res.write(`event: ${event}\n`)
111
+ for (const line of payload.split('\n')) {
112
+ res.write(`data: ${line}\n`)
113
+ }
114
+ res.write('\n')
115
+ }
116
+
117
+ async function handleSharedEvents(req, res, record) {
118
+ await restoreAgent(record.sessionId)
119
+ const eventBus = getSessionEventBus(record.sessionId)
120
+ if (!eventBus) {
121
+ const error = new Error('Session not found')
122
+ error.statusCode = 404
123
+ throw error
124
+ }
125
+
126
+ res.writeHead(200, {
127
+ 'content-type': 'text/event-stream',
128
+ 'cache-control': 'no-cache, no-transform',
129
+ 'connection': 'keep-alive',
130
+ 'x-accel-buffering': 'no',
131
+ })
132
+
133
+ writeSseEvent(res, 'state', await sharedSessionPayload(record))
134
+
135
+ const keepAlive = setInterval(() => {
136
+ try {
137
+ res.write(': ping\n\n')
138
+ } catch {
139
+ cleanup()
140
+ }
141
+ }, 15000)
142
+
143
+ const onAgentEvent = (event) => {
144
+ try {
145
+ const payload = sanitizeEvent(event)
146
+ writeSseEvent(res, payload.type || 'agent_event', payload)
147
+ } catch {
148
+ cleanup()
149
+ }
150
+ }
151
+
152
+ const cleanup = () => {
153
+ clearInterval(keepAlive)
154
+ eventBus.removeListener('agent_event', onAgentEvent)
155
+ if (!res.writableEnded) res.end()
156
+ }
157
+
158
+ eventBus.on('agent_event', onAgentEvent)
159
+ req.on('close', cleanup)
160
+ req.on('error', cleanup)
161
+ res.on('error', cleanup)
162
+ }
163
+
164
+ function passwordRequiredError() {
165
+ const error = new Error('Editable shares require a non-empty password')
166
+ error.statusCode = 403
167
+ return error
168
+ }
169
+
170
+ function setShareCookie(res, shareId, token) {
171
+ const maxAge = 60 * 60 * 24 * 7
172
+ const cookie = [
173
+ `${shareCookieName(shareId)}=${encodeURIComponent(token)}`,
174
+ 'HttpOnly',
175
+ 'SameSite=Lax',
176
+ `Max-Age=${maxAge}`,
177
+ `Path=/`,
178
+ ].join('; ')
179
+ res.setHeader('Set-Cookie', cookie)
180
+ }
181
+
182
+ async function requireShareAuth(req, shareId) {
183
+ const record = await readConversationShare(shareId)
184
+ assertShareActive(record)
185
+ if (!record.passwordHash) return record
186
+ const token = parseCookies(req.headers.cookie).get(shareCookieName(shareId))
187
+ if (!verifyShareToken(record, token)) {
188
+ const error = new Error('Share authentication required')
189
+ error.statusCode = 401
190
+ throw error
191
+ }
192
+ return record
193
+ }
194
+
195
+ function assertOperate(record) {
196
+ if (record.permission !== 'operate') {
197
+ const error = new Error('This shared conversation is read-only.')
198
+ error.statusCode = 403
199
+ throw error
200
+ }
201
+ if (!record.passwordHash) {
202
+ throw passwordRequiredError()
203
+ }
204
+ }
205
+
206
+ function messageFromBody(body, record, req) {
207
+ const content = typeof body?.content === 'string'
208
+ ? body.content
209
+ : typeof body?.message === 'string'
210
+ ? body.message
211
+ : typeof body?.message?.content === 'string'
212
+ ? body.message.content
213
+ : ''
214
+ const attachments = Array.isArray(body?.message?.attachments) ? body.message.attachments : undefined
215
+ if (!content.trim() && !attachments?.length) {
216
+ const error = new Error('Missing message content')
217
+ error.statusCode = 400
218
+ throw error
219
+ }
220
+ if (Buffer.byteLength(content, 'utf8') > MAX_SHARED_MESSAGE_BYTES) {
221
+ const error = new Error('Message is too large')
222
+ error.statusCode = 413
223
+ throw error
224
+ }
225
+ const clientMessageId = sanitizedClientMessageId(body?.clientMessageId)
226
+ || sanitizedClientMessageId(body?.message?.metadata?.[CLIENT_MESSAGE_ID_FIELD])
227
+ const metadata = {
228
+ ...objectMetadata(body?.message?.metadata),
229
+ source: 'lan-share',
230
+ shareId: record.id,
231
+ permission: record.permission,
232
+ remoteAddress: req.socket.remoteAddress,
233
+ }
234
+ if (clientMessageId) metadata[CLIENT_MESSAGE_ID_FIELD] = clientMessageId
235
+ const message = {
236
+ role: attachments?.length ? 'user-with-attachments' : 'user',
237
+ content,
238
+ timestamp: body?.message?.timestamp || new Date().toISOString(),
239
+ metadata,
240
+ }
241
+ if (attachments?.length) message.attachments = attachments
242
+ return attachClientMessageId(message, clientMessageId)
243
+ }
244
+
245
+ async function sharedSessionPayload(record) {
246
+ const activeState = getSessionState(record.sessionId)
247
+ if (activeState) return sanitizeSession(activeState, record)
248
+ const session = await readSessionValue(record.sessionId)
249
+ if (!session) {
250
+ const error = new Error('Session not found')
251
+ error.statusCode = 404
252
+ throw error
253
+ }
254
+ return sanitizeSession(session, record)
255
+ }
256
+
257
+ function sanitizeProvider(provider) {
258
+ if (!provider || typeof provider !== 'object') return null
259
+ const models = Array.isArray(provider.models)
260
+ ? provider.models.map(sanitizeModel).filter((model) => model?.id && model?.provider && model?.api)
261
+ : []
262
+ if (!models.length) return null
263
+ return {
264
+ id: provider.id || provider.name || models[0].provider,
265
+ name: provider.name || models[0].provider,
266
+ type: provider.type || models[0].api,
267
+ baseUrl: provider.baseUrl,
268
+ models,
269
+ }
270
+ }
271
+
272
+ async function listSharedModelProviders() {
273
+ const providers = await readStore('custom-providers')
274
+ return Object.values(providers || {}).map(sanitizeProvider).filter(Boolean)
275
+ }
276
+
277
+ async function readConfiguredModel(model) {
278
+ if (!model || typeof model !== 'object') return null
279
+ const providers = await readStore('custom-providers')
280
+ for (const provider of Object.values(providers || {})) {
281
+ if (!provider || typeof provider !== 'object') continue
282
+ const matched = Array.isArray(provider.models)
283
+ ? provider.models.find((candidate) => {
284
+ return candidate?.id === model.id && candidate?.provider === model.provider && candidate?.api === model.api
285
+ })
286
+ : undefined
287
+ if (matched) return matched
288
+ }
289
+ return null
290
+ }
291
+
292
+ export async function handleSharedConversationApi(req, res, url) {
293
+ const parts = url.pathname.split('/').filter(Boolean)
294
+ const shareId = decodeSegment(parts[2])
295
+ const action = parts[3]
296
+
297
+ if (!shareId) {
298
+ const error = new Error('Missing share id')
299
+ error.statusCode = 400
300
+ throw error
301
+ }
302
+
303
+ if (req.method === 'POST' && action === 'unlock') {
304
+ const body = await readJsonBody(req)
305
+ const record = await readConversationShare(shareId)
306
+ assertShareActive(record)
307
+ if (record.permission === 'operate' && !record.passwordHash) {
308
+ throw passwordRequiredError()
309
+ }
310
+ const ok = await verifySharePassword(record, body?.password)
311
+ if (!ok) {
312
+ const error = new Error('Invalid share password')
313
+ error.statusCode = 401
314
+ throw error
315
+ }
316
+ const { token, share } = await issueConversationShareToken(shareId)
317
+ setShareCookie(res, shareId, token)
318
+ sendJson(res, 200, { ok: true, share: publicSharePayload(share), permission: share.permission, title: share.titleSnapshot, expiresAt: share.expiresAt })
319
+ return
320
+ }
321
+
322
+ if (req.method === 'GET' && action === 'meta') {
323
+ const record = await readConversationShare(shareId)
324
+ assertShareActive(record)
325
+ sendJson(res, 200, { share: publicSharePayload(record) })
326
+ return
327
+ }
328
+
329
+ const record = await requireShareAuth(req, shareId)
330
+ if (record.permission === 'operate' && !record.passwordHash) throw passwordRequiredError()
331
+
332
+ if (req.method === 'GET' && action === 'session') {
333
+ sendJson(res, 200, await sharedSessionPayload(record))
334
+ return
335
+ }
336
+
337
+ if (req.method === 'GET' && action === 'models') {
338
+ assertOperate(record)
339
+ sendJson(res, 200, { providers: await listSharedModelProviders() })
340
+ return
341
+ }
342
+
343
+ if (req.method === 'GET' && action === 'events') {
344
+ await handleSharedEvents(req, res, record)
345
+ return
346
+ }
347
+
348
+ if (req.method === 'POST' && action === 'message') {
349
+ assertOperate(record)
350
+ const body = await readJsonBody(req)
351
+ await restoreAgent(record.sessionId)
352
+ const result = await runPrompt(record.sessionId, messageFromBody(body, record, req))
353
+ sendJson(res, 200, result)
354
+ return
355
+ }
356
+
357
+ if (req.method === 'POST' && action === 'model') {
358
+ assertOperate(record)
359
+ const body = await readJsonBody(req)
360
+ const model = sanitizeIncomingModel(body?.model)
361
+ if (!model) {
362
+ const error = new Error('Missing model in request body')
363
+ error.statusCode = 400
364
+ throw error
365
+ }
366
+ await restoreAgent(record.sessionId)
367
+ const configured = await readConfiguredModel(model)
368
+ sendJson(res, 200, updateSessionModel(record.sessionId, configured ? sanitizeModel(configured) : model))
369
+ return
370
+ }
371
+
372
+ if (req.method === 'POST' && action === 'thinking-level') {
373
+ assertOperate(record)
374
+ const body = await readJsonBody(req)
375
+ const thinkingLevel = body?.thinkingLevel
376
+ if (!thinkingLevel || typeof thinkingLevel !== 'string') {
377
+ const error = new Error('Missing thinkingLevel in request body')
378
+ error.statusCode = 400
379
+ throw error
380
+ }
381
+ await restoreAgent(record.sessionId)
382
+ sendJson(res, 200, updateSessionThinkingLevel(record.sessionId, thinkingLevel))
383
+ return
384
+ }
385
+
386
+ if (req.method === 'POST' && action === 'abort') {
387
+ assertOperate(record)
388
+ const result = await abortRun(record.sessionId)
389
+ sendJson(res, 200, result)
390
+ return
391
+ }
392
+
393
+ if (req.method === 'POST' && action === 'rollback') {
394
+ assertOperate(record)
395
+ const body = await readJsonBody(req)
396
+ const result = await rollbackSharedSessionMessages(record, body?.messageIndex)
397
+ sendJson(res, 200, { ok: true, rollbackIndex: result.rollbackIndex, session: sanitizeSession(result.session, record) })
398
+ return
399
+ }
400
+
401
+ const error = new Error('This operation is not allowed for shared conversations.')
402
+ error.statusCode = 403
403
+ throw error
404
+ }
@@ -0,0 +1,84 @@
1
+ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
+ import { readSessionValue } from '../storage.mjs'
3
+ import { getLanUrls } from '../utils/network.mjs'
4
+ import {
5
+ createConversationShare,
6
+ listConversationShares,
7
+ revokeConversationShare,
8
+ } from '../share-store.mjs'
9
+
10
+ function localBaseUrl(req, port) {
11
+ const forwardedProto = req.headers['x-forwarded-proto']
12
+ const protocol = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto || 'http'
13
+ const host = req.headers.host || `127.0.0.1:${port}`
14
+ return `${protocol}://${host}`
15
+ }
16
+
17
+ function clipboardText({ url }) {
18
+ return url
19
+ }
20
+
21
+ function shareUrlForRequest(req, shareId, port) {
22
+ const lanBase = getLanUrls(port)[0]
23
+ const baseUrl = lanBase || localBaseUrl(req, port)
24
+ return `${baseUrl}/share/${encodeURIComponent(shareId)}`
25
+ }
26
+
27
+ export async function handleSharesApi(req, res, url, context = {}) {
28
+ const parts = url.pathname.split('/').filter(Boolean)
29
+
30
+ if (req.method === 'GET' && url.pathname === '/api/shares') {
31
+ const sessionId = url.searchParams.get('sessionId') || undefined
32
+ sendJson(res, 200, { shares: await listConversationShares(sessionId) })
33
+ return
34
+ }
35
+
36
+ if (req.method === 'POST' && url.pathname === '/api/shares') {
37
+ const body = await readJsonBody(req)
38
+ const sessionId = body?.sessionId
39
+ const permission = body?.permission
40
+ const passwordProvided = typeof body?.password === 'string'
41
+ const password = passwordProvided ? body.password.trim() : undefined
42
+ const expiresAt = typeof body?.expiresAt === 'string' && body.expiresAt ? body.expiresAt : undefined
43
+
44
+ const session = sessionId ? await readSessionValue(sessionId) : null
45
+ if (!session) {
46
+ const error = new Error('Session not found')
47
+ error.statusCode = 404
48
+ throw error
49
+ }
50
+
51
+ const share = await createConversationShare({
52
+ sessionId,
53
+ permission,
54
+ password: passwordProvided ? password : undefined,
55
+ expiresAt,
56
+ titleSnapshot: session.title,
57
+ scope: session.scope,
58
+ projectId: session.projectId,
59
+ createdFromHost: req.socket.remoteAddress,
60
+ })
61
+ const shareUrl = shareUrlForRequest(req, share.id, context.port)
62
+ const text = clipboardText({ url: shareUrl })
63
+ sendJson(res, 201, {
64
+ ok: true,
65
+ share,
66
+ url: shareUrl,
67
+ password: passwordProvided ? password : undefined,
68
+ clipboardText: text,
69
+ lanUrls: getLanUrls(context.port),
70
+ })
71
+ return
72
+ }
73
+
74
+ if (req.method === 'DELETE' && parts.length === 3 && parts[0] === 'api' && parts[1] === 'shares') {
75
+ const shareId = decodeSegment(parts[2])
76
+ const share = await revokeConversationShare(shareId)
77
+ sendJson(res, 200, { ok: true, share })
78
+ return
79
+ }
80
+
81
+ const error = new Error('Not found')
82
+ error.statusCode = 404
83
+ throw error
84
+ }
@@ -0,0 +1,145 @@
1
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
2
+ import { getActiveProject, projectContextFromId, readProjectConfig } from '../project-config.mjs'
3
+ import { atomicProjectConfigUpdate } from '../storage.mjs'
4
+ import {
5
+ filterKnownGlobalSkillNames,
6
+ filterKnownProjectSkillNames,
7
+ projectSkillSearchPaths,
8
+ listGlobalSkillSummaries,
9
+ listProjectSkillSummaries,
10
+ skillSearchPaths,
11
+ } from '../skills.mjs'
12
+
13
+ function getProject(config, projectId) {
14
+ if (!projectId) return getActiveProject(config)
15
+ return config.projects.find((project) => project.id === projectId)
16
+ }
17
+
18
+ function selectedSkillsForProject(project) {
19
+ return Array.isArray(project?.skills) ? project.skills : []
20
+ }
21
+
22
+ function selectedGlobalSkills(config) {
23
+ return Array.isArray(config.globalSkills) ? config.globalSkills : []
24
+ }
25
+
26
+ function filterSelectedSkills(selectedSkills, skills) {
27
+ const known = new Set(skills.map((skill) => skill.name))
28
+ return selectedSkills.filter((name) => known.has(name))
29
+ }
30
+
31
+ async function projectWorkspaceRoot(projectId) {
32
+ if (!projectId) return null
33
+ try {
34
+ const context = await projectContextFromId(projectId)
35
+ return context.workspaceRoot
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ export async function handleSkillsApi(req, res, url) {
42
+ const config = await readProjectConfig()
43
+ const projectId = url.searchParams.get('projectId')
44
+ const scope = url.searchParams.get('scope') === 'global' ? 'global' : 'project'
45
+
46
+ if (req.method === 'GET' && url.pathname === '/api/skills') {
47
+ if (scope === 'global') {
48
+ sendJson(res, 200, {
49
+ scope: 'global',
50
+ skills: await listGlobalSkillSummaries(),
51
+ selectedSkills: selectedGlobalSkills(config),
52
+ searchPaths: skillSearchPaths.global,
53
+ })
54
+ return
55
+ }
56
+
57
+ const project = getProject(config, projectId)
58
+ const workspaceRoot = await projectWorkspaceRoot(project?.id)
59
+ const skills = workspaceRoot ? await listProjectSkillSummaries(workspaceRoot) : []
60
+ sendJson(res, 200, {
61
+ scope: 'project',
62
+ skills,
63
+ projectId: project?.id ?? null,
64
+ selectedSkills: filterSelectedSkills(selectedSkillsForProject(project), skills),
65
+ searchPaths: workspaceRoot ? projectSkillSearchPaths(workspaceRoot) : skillSearchPaths.project,
66
+ })
67
+ return
68
+ }
69
+
70
+ if (req.method === 'GET' && url.pathname === '/api/skills/global') {
71
+ sendJson(res, 200, {
72
+ scope: 'global',
73
+ selectedSkills: selectedGlobalSkills(config),
74
+ })
75
+ return
76
+ }
77
+
78
+ if (req.method === 'GET' && url.pathname === '/api/skills/project') {
79
+ const project = getProject(config, projectId)
80
+ sendJson(res, 200, {
81
+ scope: 'project',
82
+ projectId: project?.id ?? null,
83
+ selectedSkills: selectedSkillsForProject(project),
84
+ })
85
+ return
86
+ }
87
+
88
+ if (req.method === 'PUT' && url.pathname === '/api/skills/global') {
89
+ const body = await readJsonBody(req)
90
+ const selectedSkills = await filterKnownGlobalSkillNames(body?.selectedSkills)
91
+ const updated = await atomicProjectConfigUpdate((cfg) => {
92
+ cfg.globalSkills = selectedSkills
93
+ return cfg
94
+ })
95
+
96
+ sendJson(res, 200, {
97
+ scope: 'global',
98
+ selectedSkills: selectedGlobalSkills(updated),
99
+ projects: updated.projects,
100
+ })
101
+ return
102
+ }
103
+
104
+ if (req.method === 'PUT' && url.pathname === '/api/skills/project') {
105
+ const body = await readJsonBody(req)
106
+ const targetProjectId = body?.projectId || projectId
107
+ if (!targetProjectId) {
108
+ const error = new Error('Missing projectId')
109
+ error.statusCode = 400
110
+ throw error
111
+ }
112
+
113
+ const workspaceRoot = await projectWorkspaceRoot(targetProjectId)
114
+ if (!workspaceRoot) {
115
+ const error = new Error('Unknown project')
116
+ error.statusCode = 404
117
+ throw error
118
+ }
119
+
120
+ const selectedSkills = await filterKnownProjectSkillNames(body?.selectedSkills, workspaceRoot)
121
+ const updated = await atomicProjectConfigUpdate((cfg) => {
122
+ const project = cfg.projects.find((item) => item.id === targetProjectId)
123
+ if (!project) {
124
+ const error = new Error('Unknown project')
125
+ error.statusCode = 404
126
+ throw error
127
+ }
128
+ project.skills = selectedSkills
129
+ return cfg
130
+ })
131
+
132
+ const project = updated.projects.find((item) => item.id === targetProjectId)
133
+ sendJson(res, 200, {
134
+ scope: 'project',
135
+ projectId: targetProjectId,
136
+ selectedSkills: selectedSkillsForProject(project),
137
+ projects: updated.projects,
138
+ })
139
+ return
140
+ }
141
+
142
+ const error = new Error('Not found')
143
+ error.statusCode = 404
144
+ throw error
145
+ }
@@ -27,10 +27,11 @@ function getContentType(filePath) {
27
27
  export async function serveStatic(req, res, url) {
28
28
  const distDir = path.join(projectRoot, 'dist')
29
29
  const requested = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)
30
- const normalized = path.normalize(requested).replace(/^([.][.][\/])+/, '')
31
- let filePath = path.join(distDir, normalized)
30
+ const normalized = path.normalize(requested).replace(/^([.][.][\/])+/, '').replace(/^[/\\]+/, '')
31
+ let filePath = path.resolve(distDir, normalized)
32
32
 
33
- if (!filePath.startsWith(distDir)) {
33
+ const relative = path.relative(distDir, filePath)
34
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
34
35
  res.writeHead(403)
35
36
  res.end('Forbidden')
36
37
  return