@fonlan/dsh-task-kanban 0.1.0
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/LICENSE +21 -0
- package/README.md +45 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1128 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1514 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/server/git.d.ts +16 -0
- package/lib/types/server/models.d.ts +4 -0
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +84 -0
- package/lib/types/server/settings.d.ts +27 -0
- package/lib/types/server/task-store.d.ts +27 -0
- package/lib/types/server/tools.d.ts +11 -0
- package/lib/types/shared/card.d.ts +86 -0
- package/lib/types/shared/lanes.d.ts +9 -0
- package/lib/types/shared/plan.d.ts +12 -0
- package/package.json +105 -0
- package/src/client/api.ts +42 -0
- package/src/client/board.css +361 -0
- package/src/client/board.tsx +516 -0
- package/src/client/index.tsx +47 -0
- package/src/client/kanban-state.ts +104 -0
- package/src/client/locales.ts +127 -0
- package/src/client/sections.tsx +209 -0
- package/src/client/slot-augment.d.ts +15 -0
- package/src/index.ts +34 -0
- package/src/server/git.ts +68 -0
- package/src/server/models.ts +20 -0
- package/src/server/prompts.ts +54 -0
- package/src/server/rpc.ts +167 -0
- package/src/server/runner.ts +834 -0
- package/src/server/settings.ts +69 -0
- package/src/server/task-store.ts +128 -0
- package/src/server/tools.ts +131 -0
- package/src/shared/card.ts +115 -0
- package/src/shared/lanes.ts +43 -0
- package/src/shared/plan.ts +54 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced JSON API under /plugins/@fonlan/dsh-task-kanban/api/<method>. Same
|
|
3
|
+
* browser-trust fence as the /api gateway and /sidebar routes: loopback
|
|
4
|
+
* Host-header or configured trustedHosts, same-origin browser markers.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import type { Lane } from '../shared/card.js'
|
|
10
|
+
import type { KanbanRunner } from './runner.js'
|
|
11
|
+
import type { KanbanSettingsFace } from './settings.js'
|
|
12
|
+
import { listModels } from './models.js'
|
|
13
|
+
|
|
14
|
+
const API_PREFIX = '/plugins/@fonlan/dsh-task-kanban/api'
|
|
15
|
+
|
|
16
|
+
// ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
|
|
17
|
+
|
|
18
|
+
function header(headers: IncomingMessage['headers'], name: string): string | undefined {
|
|
19
|
+
const value = headers[name]
|
|
20
|
+
return typeof value === 'string' ? value : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
24
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
25
|
+
const parts = hostname.split('.')
|
|
26
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
30
|
+
const host = header(req.headers, 'host')
|
|
31
|
+
if (host === undefined) return false
|
|
32
|
+
let hostUrl: URL
|
|
33
|
+
try {
|
|
34
|
+
hostUrl = new URL(`http://${host}`)
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
|
|
39
|
+
if (!trustedHosts.some((entry) => entry === hostUrl.hostname || entry === hostUrl.host)) return false
|
|
40
|
+
}
|
|
41
|
+
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
42
|
+
const origin = header(req.headers, 'origin')
|
|
43
|
+
if (origin === undefined) return true
|
|
44
|
+
try {
|
|
45
|
+
return new URL(origin).host === hostUrl.host
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function trustedHostsOf(ctx: Context): string[] {
|
|
52
|
+
for (const entry of ctx.get('loader')?.entries?.() ?? []) {
|
|
53
|
+
if (entry.options?.name === 'connection') {
|
|
54
|
+
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
|
55
|
+
return config?.trustedHosts ?? []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── wire helpers ────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function writeJson(res: ServerResponse, body: unknown): void {
|
|
64
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
65
|
+
res.end(JSON.stringify(body))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
|
|
69
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
70
|
+
res.end(JSON.stringify({ ok: false, error: { code, message } }))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireString(value: unknown, name: string): string {
|
|
74
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
75
|
+
throw new Error(`${name} 不能为空`)
|
|
76
|
+
}
|
|
77
|
+
return value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
81
|
+
const chunks: Buffer[] = []
|
|
82
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
83
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
84
|
+
if (raw.trim() === '') return {}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(raw) as unknown
|
|
87
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
|
88
|
+
} catch {
|
|
89
|
+
return {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── routes ──────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export function registerApiRoutes(
|
|
96
|
+
ctx: Context,
|
|
97
|
+
runner: KanbanRunner,
|
|
98
|
+
settings: KanbanSettingsFace,
|
|
99
|
+
): () => void {
|
|
100
|
+
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
|
|
101
|
+
const disposers: Array<() => void> = []
|
|
102
|
+
|
|
103
|
+
const route = (name: string, handler: (payload: Record<string, unknown>) => Promise<unknown>): void => {
|
|
104
|
+
disposers.push(ctx.webServer.register({
|
|
105
|
+
kind: 'prefix',
|
|
106
|
+
path: `${API_PREFIX}/${name}`,
|
|
107
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
108
|
+
if (!fence(req)) {
|
|
109
|
+
writeError(res, 'forbidden', 'forbidden', 403)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
113
|
+
writeError(res, 'method', 'method not allowed', 405)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const payload = await readJsonBody(req)
|
|
117
|
+
try {
|
|
118
|
+
const value = await handler(payload)
|
|
119
|
+
writeJson(res, { ok: true, value })
|
|
120
|
+
} catch (error) {
|
|
121
|
+
writeError(res, 'error', error instanceof Error ? error.message : String(error))
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
route('list', async (p) => runner.listCards(requireString(p.workspacePath, 'workspacePath')))
|
|
128
|
+
|
|
129
|
+
route('create', async (p) => {
|
|
130
|
+
const workspacePath = requireString(p.workspacePath, 'workspacePath')
|
|
131
|
+
const requirement = requireString(p.requirement, 'requirement')
|
|
132
|
+
const model = typeof p.model === 'string' ? p.model : ''
|
|
133
|
+
const provider = typeof p.provider === 'string' ? p.provider : undefined
|
|
134
|
+
return runner.createTask({ workspacePath, requirement, model, provider })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
route('move', async (p) => {
|
|
138
|
+
const cardId = requireString(p.cardId, 'cardId')
|
|
139
|
+
const toLane = p.toLane as Lane
|
|
140
|
+
if (!['demand', 'queue', 'running', 'completed', 'merged'].includes(toLane)) {
|
|
141
|
+
throw new Error('无效的目标泳道')
|
|
142
|
+
}
|
|
143
|
+
return runner.moveTask(cardId, toLane)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
route('stop', async (p) => runner.stopTask(requireString(p.cardId, 'cardId')))
|
|
147
|
+
|
|
148
|
+
route('retry', async (p) => runner.retryTask(requireString(p.cardId, 'cardId')))
|
|
149
|
+
|
|
150
|
+
route('remove', async (p) => runner.deleteTask(requireString(p.cardId, 'cardId')))
|
|
151
|
+
|
|
152
|
+
route('settings.get', async () => settings.get())
|
|
153
|
+
|
|
154
|
+
route('settings.set', async (p) => {
|
|
155
|
+
const patch: { maxParallelWorkers?: number; defaultModel?: string } = {}
|
|
156
|
+
if (typeof p.maxParallelWorkers === 'number') patch.maxParallelWorkers = p.maxParallelWorkers
|
|
157
|
+
if (typeof p.defaultModel === 'string') patch.defaultModel = p.defaultModel
|
|
158
|
+
await settings.update(patch)
|
|
159
|
+
return settings.get()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
route('models.list', async () => listModels(ctx))
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
for (const d of disposers) d()
|
|
166
|
+
}
|
|
167
|
+
}
|