typeclaw 0.1.0 → 0.1.2
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 +12 -12
- package/package.json +3 -2
- package/src/agent/auth.ts +10 -4
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/security/index.ts +5 -1
- package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
- package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
- package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
- package/src/channels/adapters/kakaotalk.ts +58 -3
- package/src/channels/router.ts +40 -2
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/ensure-deps.ts +2 -2
- package/src/init/index.ts +23 -13
- package/src/init/run-bun-install.ts +17 -1
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/secrets/env.ts +43 -0
- package/src/secrets/index.ts +2 -0
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/tsconfig.json +30 -0
- package/typeclaw.schema.json +50 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { findAgentDir } from '@/init'
|
|
2
|
+
import type { DoctorCheckPayload } from '@/shared'
|
|
3
|
+
|
|
4
|
+
import { buildStaticChecks } from './checks'
|
|
5
|
+
import { commitAutoFixes, type SpawnGit } from './commit'
|
|
6
|
+
import {
|
|
7
|
+
fetchPluginDoctorChecks as defaultFetchPluginDoctorChecks,
|
|
8
|
+
fetchPluginDoctorFix as defaultFetchPluginDoctorFix,
|
|
9
|
+
type PluginBridgeChecksResult,
|
|
10
|
+
type PluginBridgeFetchChecks,
|
|
11
|
+
type PluginBridgeFetchFix,
|
|
12
|
+
type PluginBridgeFixResult,
|
|
13
|
+
} from './plugin-bridge'
|
|
14
|
+
import type {
|
|
15
|
+
CheckContext,
|
|
16
|
+
CheckResult,
|
|
17
|
+
DoctorCheck,
|
|
18
|
+
DoctorReport,
|
|
19
|
+
DoctorRunResult,
|
|
20
|
+
FixAttempt,
|
|
21
|
+
ReportEntry,
|
|
22
|
+
ReportSummary,
|
|
23
|
+
Severity,
|
|
24
|
+
} from './types'
|
|
25
|
+
|
|
26
|
+
export * from './types'
|
|
27
|
+
export { buildStaticChecks } from './checks'
|
|
28
|
+
export { formatJson, formatReport } from './report'
|
|
29
|
+
export { buildCommitMessage, commitAutoFixes } from './commit'
|
|
30
|
+
export {
|
|
31
|
+
fetchPluginDoctorChecks,
|
|
32
|
+
fetchPluginDoctorFix,
|
|
33
|
+
type PluginBridgeChecksResult,
|
|
34
|
+
type PluginBridgeFixResult,
|
|
35
|
+
} from './plugin-bridge'
|
|
36
|
+
|
|
37
|
+
export type RunDoctorOptions = {
|
|
38
|
+
cwd?: string
|
|
39
|
+
only?: string[]
|
|
40
|
+
fix?: boolean
|
|
41
|
+
staticChecks?: DoctorCheck[]
|
|
42
|
+
fetchPluginChecks?: PluginBridgeFetchChecks
|
|
43
|
+
fetchPluginFix?: PluginBridgeFetchFix
|
|
44
|
+
spawnGit?: SpawnGit
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runDoctor(opts: RunDoctorOptions = {}): Promise<DoctorRunResult> {
|
|
48
|
+
const cwd = opts.cwd ?? findAgentDir(process.cwd()) ?? process.cwd()
|
|
49
|
+
const hasAgentFolder = findAgentDir(cwd) === cwd
|
|
50
|
+
const ctx: CheckContext = { cwd, hasAgentFolder }
|
|
51
|
+
|
|
52
|
+
const staticChecks = (opts.staticChecks ?? buildStaticChecks()).filter((c) => isAllowed(c, opts.only, c.category))
|
|
53
|
+
const staticResults = await runStaticChecks(staticChecks, ctx)
|
|
54
|
+
|
|
55
|
+
const fetchPluginChecks = opts.fetchPluginChecks ?? defaultFetchPluginDoctorChecks
|
|
56
|
+
const pluginResults = await collectPluginChecks(fetchPluginChecks, ctx, opts.only)
|
|
57
|
+
|
|
58
|
+
const initial = buildReport(ctx, staticResults, pluginResults)
|
|
59
|
+
|
|
60
|
+
if (opts.fix !== true || initial.ok) {
|
|
61
|
+
return { initial }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fetchPluginFix = opts.fetchPluginFix ?? defaultFetchPluginDoctorFix
|
|
65
|
+
const attempts: FixAttempt[] = []
|
|
66
|
+
attempts.push(...(await runStaticFixes(staticResults, ctx)))
|
|
67
|
+
attempts.push(...(await runPluginFixes(pluginResults.entries, fetchPluginFix, ctx)))
|
|
68
|
+
|
|
69
|
+
const commit = hasAgentFolder
|
|
70
|
+
? await commitAutoFixes({ cwd, attempts, ...(opts.spawnGit !== undefined ? { spawnGit: opts.spawnGit } : {}) })
|
|
71
|
+
: { kind: 'skipped' as const, reason: 'no agent folder; nothing to commit' }
|
|
72
|
+
|
|
73
|
+
const finalStaticResults = await runStaticChecks(staticChecks, ctx)
|
|
74
|
+
const finalPluginResults = await collectPluginChecks(fetchPluginChecks, ctx, opts.only)
|
|
75
|
+
const final = buildReport(ctx, finalStaticResults, finalPluginResults)
|
|
76
|
+
|
|
77
|
+
return { initial, fixAttempts: attempts, commit, final }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type StaticResult = {
|
|
81
|
+
check: DoctorCheck
|
|
82
|
+
result: CheckResult
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type PluginCollect = {
|
|
86
|
+
entries: PluginEntry[]
|
|
87
|
+
reachability: PluginBridgeChecksResult
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type PluginEntry = {
|
|
91
|
+
payload: DoctorCheckPayload
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runStaticChecks(checks: DoctorCheck[], ctx: CheckContext): Promise<StaticResult[]> {
|
|
95
|
+
const out: StaticResult[] = []
|
|
96
|
+
for (const check of checks) {
|
|
97
|
+
if (check.applies && !check.applies(ctx)) {
|
|
98
|
+
out.push({ check, result: { status: 'skipped', message: 'not applicable' } })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
out.push({ check, result: await check.run(ctx) })
|
|
103
|
+
} catch (err) {
|
|
104
|
+
out.push({ check, result: { status: 'error', message: err instanceof Error ? err.message : String(err) } })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function collectPluginChecks(
|
|
111
|
+
fetcher: PluginBridgeFetchChecks,
|
|
112
|
+
ctx: CheckContext,
|
|
113
|
+
only: string[] | undefined,
|
|
114
|
+
): Promise<PluginCollect> {
|
|
115
|
+
if (!ctx.hasAgentFolder) {
|
|
116
|
+
return { entries: [], reachability: { kind: 'unreachable', reason: 'no agent folder' } }
|
|
117
|
+
}
|
|
118
|
+
const reachability = await fetcher({ cwd: ctx.cwd })
|
|
119
|
+
if (reachability.kind !== 'ok') return { entries: [], reachability }
|
|
120
|
+
const filtered = reachability.checks.filter((c) => isAllowed({ category: c.category }, only, c.category))
|
|
121
|
+
return { entries: filtered.map((payload) => ({ payload })), reachability }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildReport(ctx: CheckContext, staticResults: StaticResult[], plugin: PluginCollect): DoctorReport {
|
|
125
|
+
const entries: ReportEntry[] = []
|
|
126
|
+
for (const { check, result } of staticResults) {
|
|
127
|
+
const entry: ReportEntry = {
|
|
128
|
+
name: check.name,
|
|
129
|
+
category: check.category,
|
|
130
|
+
description: check.description,
|
|
131
|
+
source: 'static',
|
|
132
|
+
status: result.status,
|
|
133
|
+
message: result.message,
|
|
134
|
+
}
|
|
135
|
+
if (result.details !== undefined) entry.details = result.details
|
|
136
|
+
if (result.fix !== undefined) {
|
|
137
|
+
entry.fix = { description: result.fix.description, canAutoFix: result.fix.autoFix !== undefined }
|
|
138
|
+
}
|
|
139
|
+
entries.push(entry)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (ctx.hasAgentFolder && plugin.reachability.kind !== 'ok') {
|
|
143
|
+
entries.push(reachabilityNote(plugin.reachability))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const { payload } of plugin.entries) {
|
|
147
|
+
const entry: ReportEntry = {
|
|
148
|
+
name: payload.checkName,
|
|
149
|
+
category: payload.category,
|
|
150
|
+
description: payload.description,
|
|
151
|
+
source: 'plugin',
|
|
152
|
+
pluginName: payload.pluginName,
|
|
153
|
+
status: payload.status,
|
|
154
|
+
message: payload.message,
|
|
155
|
+
}
|
|
156
|
+
if (payload.details !== undefined) entry.details = payload.details
|
|
157
|
+
if (payload.fix !== undefined) {
|
|
158
|
+
entry.fix = { description: payload.fix.description, canAutoFix: payload.fix.hasApply }
|
|
159
|
+
}
|
|
160
|
+
entries.push(entry)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const summary = summarize(entries)
|
|
164
|
+
const ok = summary.error === 0 && summary.warning === 0
|
|
165
|
+
return { cwd: ctx.cwd, hasAgentFolder: ctx.hasAgentFolder, entries, summary, ok }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function reachabilityNote(reach: PluginBridgeChecksResult): ReportEntry {
|
|
169
|
+
const message =
|
|
170
|
+
reach.kind === 'unreachable'
|
|
171
|
+
? `plugin checks deferred: container not reachable (${reach.reason})`
|
|
172
|
+
: reach.kind === 'timeout'
|
|
173
|
+
? 'plugin checks deferred: container did not respond in time'
|
|
174
|
+
: `plugin checks deferred: ${reach.kind === 'error' ? reach.reason : 'unknown reason'}`
|
|
175
|
+
return {
|
|
176
|
+
name: 'plugin-checks-deferred',
|
|
177
|
+
category: 'container',
|
|
178
|
+
description: 'plugin doctor checks require a running container',
|
|
179
|
+
source: 'static',
|
|
180
|
+
status: 'info',
|
|
181
|
+
message,
|
|
182
|
+
details: ['Run `typeclaw start` then re-run `typeclaw doctor` to include plugin checks.'],
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function summarize(entries: ReportEntry[]): ReportSummary {
|
|
187
|
+
const summary: ReportSummary = { ok: 0, warning: 0, error: 0, info: 0, skipped: 0 }
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
summary[e.status as keyof ReportSummary]++
|
|
190
|
+
}
|
|
191
|
+
return summary
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isAllowed(target: { category: string }, only: string[] | undefined, category: string): boolean {
|
|
195
|
+
if (!only || only.length === 0) return true
|
|
196
|
+
if (only.includes(target.category) || only.includes(category)) return true
|
|
197
|
+
if (category.startsWith('plugin:')) return only.includes('plugin') || only.includes(category)
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runStaticFixes(results: StaticResult[], ctx: CheckContext): Promise<FixAttempt[]> {
|
|
202
|
+
const attempts: FixAttempt[] = []
|
|
203
|
+
for (const { check, result } of results) {
|
|
204
|
+
if (result.status === 'ok' || result.status === 'skipped' || result.status === 'info') continue
|
|
205
|
+
const apply = result.fix?.autoFix
|
|
206
|
+
if (!apply) continue
|
|
207
|
+
try {
|
|
208
|
+
const fix = await apply(ctx)
|
|
209
|
+
attempts.push({
|
|
210
|
+
name: check.name,
|
|
211
|
+
source: 'static',
|
|
212
|
+
ok: true,
|
|
213
|
+
summary: fix.summary,
|
|
214
|
+
changedPaths: fix.changedPaths,
|
|
215
|
+
})
|
|
216
|
+
} catch (err) {
|
|
217
|
+
attempts.push({
|
|
218
|
+
name: check.name,
|
|
219
|
+
source: 'static',
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return attempts
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runPluginFixes(
|
|
229
|
+
entries: PluginEntry[],
|
|
230
|
+
fetchFix: PluginBridgeFetchFix,
|
|
231
|
+
ctx: CheckContext,
|
|
232
|
+
): Promise<FixAttempt[]> {
|
|
233
|
+
const attempts: FixAttempt[] = []
|
|
234
|
+
for (const { payload } of entries) {
|
|
235
|
+
if (payload.status === 'ok') continue
|
|
236
|
+
if (payload.fix === undefined || payload.fix.hasApply !== true) continue
|
|
237
|
+
const result = await fetchFix({ cwd: ctx.cwd, checkId: payload.id })
|
|
238
|
+
if (result.kind !== 'ok') {
|
|
239
|
+
attempts.push({
|
|
240
|
+
name: `${payload.pluginName}.${payload.checkName}`,
|
|
241
|
+
source: 'plugin',
|
|
242
|
+
ok: false,
|
|
243
|
+
reason:
|
|
244
|
+
result.kind === 'unreachable'
|
|
245
|
+
? result.reason
|
|
246
|
+
: result.kind === 'timeout'
|
|
247
|
+
? 'timeout'
|
|
248
|
+
: (result as { reason: string }).reason,
|
|
249
|
+
})
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (result.payload.ok) {
|
|
253
|
+
const accepted = sanitizeChangedPathsForHost(ctx.cwd, result.payload.changedPaths)
|
|
254
|
+
attempts.push({
|
|
255
|
+
name: `${payload.pluginName}.${payload.checkName}`,
|
|
256
|
+
source: 'plugin',
|
|
257
|
+
ok: true,
|
|
258
|
+
summary: result.payload.summary,
|
|
259
|
+
changedPaths: accepted,
|
|
260
|
+
})
|
|
261
|
+
} else {
|
|
262
|
+
attempts.push({
|
|
263
|
+
name: `${payload.pluginName}.${payload.checkName}`,
|
|
264
|
+
source: 'plugin',
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: result.payload.error,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return attempts
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Defense in depth: even though the container-side runner sanitizes
|
|
274
|
+
// changedPaths, re-validate on the host before `git add` so a future protocol
|
|
275
|
+
// change cannot bypass the security boundary by accident.
|
|
276
|
+
function sanitizeChangedPathsForHost(_cwd: string, paths: readonly string[]): string[] {
|
|
277
|
+
const out: string[] = []
|
|
278
|
+
for (const raw of paths) {
|
|
279
|
+
if (typeof raw !== 'string' || raw.length === 0) continue
|
|
280
|
+
if (raw.startsWith('/') || raw.includes('\\')) continue
|
|
281
|
+
if (raw.split('/').includes('..')) continue
|
|
282
|
+
out.push(raw)
|
|
283
|
+
}
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type { Severity }
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { resolveHostPort } from '@/container'
|
|
2
|
+
import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
|
|
3
|
+
|
|
4
|
+
export type PluginBridgeOptions = {
|
|
5
|
+
cwd: string
|
|
6
|
+
url?: string
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PluginBridgeFetchChecks = (opts: PluginBridgeOptions) => Promise<PluginBridgeChecksResult>
|
|
11
|
+
export type PluginBridgeFetchFix = (opts: PluginBridgeOptions & { checkId: string }) => Promise<PluginBridgeFixResult>
|
|
12
|
+
|
|
13
|
+
export type PluginBridgeChecksResult =
|
|
14
|
+
| { kind: 'ok'; checks: DoctorCheckPayload[] }
|
|
15
|
+
| { kind: 'unreachable'; reason: string }
|
|
16
|
+
| { kind: 'timeout' }
|
|
17
|
+
| { kind: 'error'; reason: string }
|
|
18
|
+
|
|
19
|
+
export type PluginBridgeFixResult =
|
|
20
|
+
| { kind: 'ok'; payload: DoctorFixPayload }
|
|
21
|
+
| { kind: 'unreachable'; reason: string }
|
|
22
|
+
| { kind: 'timeout' }
|
|
23
|
+
| { kind: 'error'; reason: string }
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
26
|
+
|
|
27
|
+
export async function fetchPluginDoctorChecks(opts: PluginBridgeOptions): Promise<PluginBridgeChecksResult> {
|
|
28
|
+
const reach = await dial(opts)
|
|
29
|
+
if (reach.kind !== 'ok') return reach
|
|
30
|
+
const { ws, timeoutMs } = reach
|
|
31
|
+
const requestId = randomId()
|
|
32
|
+
try {
|
|
33
|
+
return await withRequest<PluginBridgeChecksResult>(
|
|
34
|
+
ws,
|
|
35
|
+
timeoutMs,
|
|
36
|
+
requestId,
|
|
37
|
+
(msg) => {
|
|
38
|
+
if (msg.type === 'doctor_result' && msg.requestId === requestId) {
|
|
39
|
+
return { kind: 'ok', checks: msg.checks }
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
},
|
|
43
|
+
{ type: 'doctor', requestId },
|
|
44
|
+
)
|
|
45
|
+
} finally {
|
|
46
|
+
ws.close()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function fetchPluginDoctorFix(
|
|
51
|
+
opts: PluginBridgeOptions & { checkId: string },
|
|
52
|
+
): Promise<PluginBridgeFixResult> {
|
|
53
|
+
const reach = await dial(opts)
|
|
54
|
+
if (reach.kind !== 'ok') return reach
|
|
55
|
+
const { ws, timeoutMs } = reach
|
|
56
|
+
const requestId = randomId()
|
|
57
|
+
try {
|
|
58
|
+
return await withRequest<PluginBridgeFixResult>(
|
|
59
|
+
ws,
|
|
60
|
+
timeoutMs,
|
|
61
|
+
requestId,
|
|
62
|
+
(msg) => {
|
|
63
|
+
if (msg.type === 'doctor_fix_result' && msg.requestId === requestId) {
|
|
64
|
+
return { kind: 'ok', payload: msg.result }
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
},
|
|
68
|
+
{ type: 'doctor_fix', requestId, checkId: opts.checkId },
|
|
69
|
+
)
|
|
70
|
+
} finally {
|
|
71
|
+
ws.close()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type DialResult = { kind: 'ok'; ws: WebSocket; timeoutMs: number } | { kind: 'unreachable'; reason: string }
|
|
76
|
+
|
|
77
|
+
async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
78
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
79
|
+
let url = opts.url
|
|
80
|
+
if (url === undefined) {
|
|
81
|
+
try {
|
|
82
|
+
const port = await resolveHostPort({ cwd: opts.cwd })
|
|
83
|
+
url = `ws://localhost:${port}`
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const ws = new WebSocket(url)
|
|
89
|
+
try {
|
|
90
|
+
await new Promise<void>((resolve, reject) => {
|
|
91
|
+
const cleanup = () => {
|
|
92
|
+
ws.removeEventListener('open', onOpen)
|
|
93
|
+
ws.removeEventListener('error', onError)
|
|
94
|
+
}
|
|
95
|
+
const onOpen = () => {
|
|
96
|
+
cleanup()
|
|
97
|
+
resolve()
|
|
98
|
+
}
|
|
99
|
+
const onError = (err: unknown) => {
|
|
100
|
+
cleanup()
|
|
101
|
+
reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
|
|
102
|
+
}
|
|
103
|
+
ws.addEventListener('open', onOpen, { once: true })
|
|
104
|
+
ws.addEventListener('error', onError, { once: true })
|
|
105
|
+
})
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
108
|
+
}
|
|
109
|
+
return { kind: 'ok', ws, timeoutMs }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function withRequest<R extends { kind: string }>(
|
|
113
|
+
ws: WebSocket,
|
|
114
|
+
timeoutMs: number,
|
|
115
|
+
_requestId: string,
|
|
116
|
+
match: (msg: ServerMessage) => R | null,
|
|
117
|
+
outgoing: ClientMessage,
|
|
118
|
+
): Promise<R | { kind: 'timeout' } | { kind: 'error'; reason: string }> {
|
|
119
|
+
ws.send(JSON.stringify(outgoing))
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
ws.removeEventListener('message', onMessage)
|
|
123
|
+
resolve({ kind: 'timeout' })
|
|
124
|
+
}, timeoutMs)
|
|
125
|
+
const onMessage = (event: MessageEvent) => {
|
|
126
|
+
let msg: ServerMessage
|
|
127
|
+
try {
|
|
128
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
129
|
+
} catch (err) {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
ws.removeEventListener('message', onMessage)
|
|
132
|
+
resolve({ kind: 'error', reason: err instanceof Error ? err.message : String(err) })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const result = match(msg)
|
|
136
|
+
if (result === null) return
|
|
137
|
+
clearTimeout(timer)
|
|
138
|
+
ws.removeEventListener('message', onMessage)
|
|
139
|
+
resolve(result)
|
|
140
|
+
}
|
|
141
|
+
ws.addEventListener('message', onMessage)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function randomId(): string {
|
|
146
|
+
return `doc-${crypto.randomUUID()}`
|
|
147
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { styleText } from 'node:util'
|
|
2
|
+
|
|
3
|
+
import type { DoctorReport, ReportEntry, Severity } from './types'
|
|
4
|
+
|
|
5
|
+
export type FormatOptions = { useColor?: boolean; verbose?: boolean }
|
|
6
|
+
|
|
7
|
+
type ColorFn = (s: string) => string
|
|
8
|
+
type Palette = {
|
|
9
|
+
bold: ColorFn
|
|
10
|
+
dim: ColorFn
|
|
11
|
+
green: ColorFn
|
|
12
|
+
yellow: ColorFn
|
|
13
|
+
red: ColorFn
|
|
14
|
+
cyan: ColorFn
|
|
15
|
+
gray: ColorFn
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const identity: ColorFn = (s) => s
|
|
19
|
+
const NO_PALETTE: Palette = {
|
|
20
|
+
bold: identity,
|
|
21
|
+
dim: identity,
|
|
22
|
+
green: identity,
|
|
23
|
+
yellow: identity,
|
|
24
|
+
red: identity,
|
|
25
|
+
cyan: identity,
|
|
26
|
+
gray: identity,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COLOR_PALETTE: Palette = {
|
|
30
|
+
bold: (s) => styleText('bold', s),
|
|
31
|
+
dim: (s) => styleText('dim', s),
|
|
32
|
+
green: (s) => styleText('green', s),
|
|
33
|
+
yellow: (s) => styleText('yellow', s),
|
|
34
|
+
red: (s) => styleText('red', s),
|
|
35
|
+
cyan: (s) => styleText('cyan', s),
|
|
36
|
+
gray: (s) => styleText('gray', s),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatReport(report: DoctorReport, opts: FormatOptions = {}): string {
|
|
40
|
+
const verbose = opts.verbose ?? false
|
|
41
|
+
const useColor = opts.useColor ?? false
|
|
42
|
+
const p: Palette = useColor ? COLOR_PALETTE : NO_PALETTE
|
|
43
|
+
|
|
44
|
+
const lines: string[] = []
|
|
45
|
+
lines.push(`${p.bold('typeclaw doctor')} ${p.dim(report.cwd)}`)
|
|
46
|
+
lines.push('')
|
|
47
|
+
|
|
48
|
+
const byCategory = groupByCategory(report.entries)
|
|
49
|
+
for (const [category, entries] of byCategory) {
|
|
50
|
+
const worst = worstSeverity(entries.map((e) => e.status))
|
|
51
|
+
lines.push(`${categoryBox(worst, p)} ${p.bold(category)}`)
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
lines.push(` ${markerForEntry(entry, p)} ${entry.message}${describeOriginSuffix(entry, p)}`)
|
|
54
|
+
if (verbose) {
|
|
55
|
+
for (const detail of entry.details ?? []) {
|
|
56
|
+
lines.push(` ${p.dim('•')} ${p.dim(detail)}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (entry.fix !== undefined) {
|
|
60
|
+
const tag = entry.fix.canAutoFix ? p.cyan('→ Fix (auto):') : p.cyan('→ Fix:')
|
|
61
|
+
lines.push(` ${tag} ${entry.fix.description}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
lines.push('')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
lines.push(summaryLine(report, p))
|
|
68
|
+
return lines.join('\n').replace(/\n+$/, '\n').trimEnd()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatJson(report: DoctorReport): string {
|
|
72
|
+
return JSON.stringify(report, null, 2)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function groupByCategory(entries: ReportEntry[]): Map<string, ReportEntry[]> {
|
|
76
|
+
const m = new Map<string, ReportEntry[]>()
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const arr = m.get(entry.category)
|
|
79
|
+
if (arr) arr.push(entry)
|
|
80
|
+
else m.set(entry.category, [entry])
|
|
81
|
+
}
|
|
82
|
+
return m
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function worstSeverity(statuses: Severity[]): Severity {
|
|
86
|
+
if (statuses.some((s) => s === 'error')) return 'error'
|
|
87
|
+
if (statuses.some((s) => s === 'warning')) return 'warning'
|
|
88
|
+
if (statuses.some((s) => s === 'info')) return 'info'
|
|
89
|
+
if (statuses.every((s) => s === 'skipped')) return 'skipped'
|
|
90
|
+
return 'ok'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function categoryBox(status: Severity, p: Palette): string {
|
|
94
|
+
switch (status) {
|
|
95
|
+
case 'ok':
|
|
96
|
+
return p.green('[✓]')
|
|
97
|
+
case 'warning':
|
|
98
|
+
return p.yellow('[!]')
|
|
99
|
+
case 'error':
|
|
100
|
+
return p.red('[✗]')
|
|
101
|
+
case 'skipped':
|
|
102
|
+
return p.dim('[-]')
|
|
103
|
+
case 'info':
|
|
104
|
+
return p.cyan('[i]')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function markerForEntry(entry: ReportEntry, p: Palette): string {
|
|
109
|
+
switch (entry.status) {
|
|
110
|
+
case 'ok':
|
|
111
|
+
return p.green('✓')
|
|
112
|
+
case 'warning':
|
|
113
|
+
return p.yellow('!')
|
|
114
|
+
case 'error':
|
|
115
|
+
return p.red('✗')
|
|
116
|
+
case 'skipped':
|
|
117
|
+
return p.dim('-')
|
|
118
|
+
case 'info':
|
|
119
|
+
return p.cyan('i')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function describeOriginSuffix(entry: ReportEntry, p: Palette): string {
|
|
124
|
+
const name = entry.pluginName ? `${entry.name} [plugin:${entry.pluginName}]` : entry.name
|
|
125
|
+
return ` ${p.dim(`(${name})`)}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function summaryLine(report: DoctorReport, p: Palette): string {
|
|
129
|
+
const { ok, warning, error, info, skipped } = report.summary
|
|
130
|
+
const parts: string[] = []
|
|
131
|
+
parts.push(`${ok} ok`)
|
|
132
|
+
if (warning > 0) parts.push(p.yellow(`${warning} warning${warning === 1 ? '' : 's'}`))
|
|
133
|
+
if (error > 0) parts.push(p.red(`${error} error${error === 1 ? '' : 's'}`))
|
|
134
|
+
if (info > 0) parts.push(p.dim(`${info} info`))
|
|
135
|
+
if (skipped > 0) parts.push(p.dim(`${skipped} skipped`))
|
|
136
|
+
|
|
137
|
+
const headLine = `${p.bold('Summary:')} ${parts.join(', ')}`
|
|
138
|
+
if (report.ok) {
|
|
139
|
+
return `${p.green('●')} ${headLine}`
|
|
140
|
+
}
|
|
141
|
+
return `${p.red('●')} ${headLine}`
|
|
142
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type Severity = 'ok' | 'warning' | 'error' | 'info' | 'skipped'
|
|
2
|
+
|
|
3
|
+
export type DoctorCategory =
|
|
4
|
+
| 'docker'
|
|
5
|
+
| 'agent-folder'
|
|
6
|
+
| 'config'
|
|
7
|
+
| 'mounts'
|
|
8
|
+
| 'hostd'
|
|
9
|
+
| 'ports'
|
|
10
|
+
| 'container'
|
|
11
|
+
| 'runtime'
|
|
12
|
+
| 'compose'
|
|
13
|
+
| string
|
|
14
|
+
|
|
15
|
+
export type FixResult = {
|
|
16
|
+
summary: string
|
|
17
|
+
changedPaths: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FixSuggestion = {
|
|
21
|
+
description: string
|
|
22
|
+
autoFix?: (ctx: CheckContext) => Promise<FixResult>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CheckResult = {
|
|
26
|
+
status: Severity
|
|
27
|
+
message: string
|
|
28
|
+
details?: string[]
|
|
29
|
+
fix?: FixSuggestion
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CheckContext = {
|
|
33
|
+
cwd: string
|
|
34
|
+
hasAgentFolder: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DoctorCheck = {
|
|
38
|
+
name: string
|
|
39
|
+
category: DoctorCategory
|
|
40
|
+
description: string
|
|
41
|
+
applies?: (ctx: CheckContext) => boolean
|
|
42
|
+
run: (ctx: CheckContext) => Promise<CheckResult>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ReportEntry = {
|
|
46
|
+
name: string
|
|
47
|
+
category: DoctorCategory
|
|
48
|
+
description: string
|
|
49
|
+
source: 'static' | 'plugin'
|
|
50
|
+
pluginName?: string
|
|
51
|
+
status: Severity
|
|
52
|
+
message: string
|
|
53
|
+
details?: string[]
|
|
54
|
+
fix?: { description: string; canAutoFix: boolean }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ReportSummary = {
|
|
58
|
+
ok: number
|
|
59
|
+
warning: number
|
|
60
|
+
error: number
|
|
61
|
+
info: number
|
|
62
|
+
skipped: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DoctorReport = {
|
|
66
|
+
cwd: string
|
|
67
|
+
hasAgentFolder: boolean
|
|
68
|
+
entries: ReportEntry[]
|
|
69
|
+
summary: ReportSummary
|
|
70
|
+
ok: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type FixAttempt =
|
|
74
|
+
| { name: string; source: 'static' | 'plugin'; ok: true; summary: string; changedPaths: string[] }
|
|
75
|
+
| { name: string; source: 'static' | 'plugin'; ok: false; reason: string }
|
|
76
|
+
|
|
77
|
+
export type CommitOutcome =
|
|
78
|
+
| { kind: 'committed'; commitSha: string; pathsStaged: string[] }
|
|
79
|
+
| { kind: 'skipped'; reason: string }
|
|
80
|
+
| { kind: 'failed'; reason: string }
|
|
81
|
+
|
|
82
|
+
export type DoctorRunResult = {
|
|
83
|
+
initial: DoctorReport
|
|
84
|
+
fixAttempts?: FixAttempt[]
|
|
85
|
+
commit?: CommitOutcome
|
|
86
|
+
final?: DoctorReport
|
|
87
|
+
}
|