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,426 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, relative } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { loadConfigSync, validateConfig } from '@/config'
|
|
7
|
+
import {
|
|
8
|
+
checkDockerAvailable,
|
|
9
|
+
containerNameFromCwd,
|
|
10
|
+
defaultDockerExec,
|
|
11
|
+
imageTagFromCwd,
|
|
12
|
+
inspectContainer,
|
|
13
|
+
resolveHostPort,
|
|
14
|
+
type DockerExec,
|
|
15
|
+
} from '@/container'
|
|
16
|
+
import { isDaemonReachable, send } from '@/hostd'
|
|
17
|
+
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
18
|
+
import { detectMissingDeps } from '@/init/ensure-deps'
|
|
19
|
+
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
20
|
+
|
|
21
|
+
import type { DoctorCheck } from './types'
|
|
22
|
+
|
|
23
|
+
const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
24
|
+
|
|
25
|
+
export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
|
|
26
|
+
const dockerExec = opts.dockerExec ?? defaultDockerExec
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
dockerDaemon(dockerExec),
|
|
30
|
+
bunRuntime(),
|
|
31
|
+
agentFolderInitialized(),
|
|
32
|
+
agentFolderRequiredDirs(),
|
|
33
|
+
agentFolderDockerfileTemplate(),
|
|
34
|
+
agentFolderGitignoreTemplate(),
|
|
35
|
+
agentFolderNodeModules(),
|
|
36
|
+
agentFolderEnvFile(),
|
|
37
|
+
agentFolderGitRepo(),
|
|
38
|
+
configValid(),
|
|
39
|
+
hostdHomeWritable(),
|
|
40
|
+
hostdReachable(),
|
|
41
|
+
hostdRegistration(),
|
|
42
|
+
containerState(dockerExec),
|
|
43
|
+
containerHostPort(),
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dockerDaemon(exec: DockerExec): DoctorCheck {
|
|
48
|
+
return {
|
|
49
|
+
name: 'docker.daemon-reachable',
|
|
50
|
+
category: 'docker',
|
|
51
|
+
description: 'Docker daemon is reachable',
|
|
52
|
+
async run() {
|
|
53
|
+
const result = await checkDockerAvailable(exec)
|
|
54
|
+
if (result.ok) return { status: 'ok', message: 'docker info responded' }
|
|
55
|
+
return {
|
|
56
|
+
status: 'error',
|
|
57
|
+
message: result.reason === 'binary-missing' ? 'docker binary missing on $PATH' : 'docker daemon down',
|
|
58
|
+
details: [result.detail],
|
|
59
|
+
fix:
|
|
60
|
+
result.reason === 'binary-missing'
|
|
61
|
+
? { description: 'Install Docker (Docker Desktop, OrbStack, or docker-ce).' }
|
|
62
|
+
: { description: 'Start the Docker daemon (Docker Desktop, OrbStack, or `systemctl start docker`).' },
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bunRuntime(): DoctorCheck {
|
|
69
|
+
return {
|
|
70
|
+
name: 'runtime.bun-available',
|
|
71
|
+
category: 'runtime',
|
|
72
|
+
description: 'Bun runtime is available',
|
|
73
|
+
async run() {
|
|
74
|
+
const bun = (globalThis as { Bun?: unknown }).Bun
|
|
75
|
+
if (bun === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
status: 'error',
|
|
78
|
+
message: 'Bun runtime is not available',
|
|
79
|
+
fix: { description: 'Install Bun (https://bun.sh) and ensure the typeclaw CLI runs under it.' },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { status: 'ok', message: `Bun ${process.versions.bun ?? 'present'}` }
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function agentFolderInitialized(): DoctorCheck {
|
|
88
|
+
return {
|
|
89
|
+
name: 'agent-folder.is-initialized',
|
|
90
|
+
category: 'agent-folder',
|
|
91
|
+
description: 'agent folder contains typeclaw.json',
|
|
92
|
+
async run(ctx) {
|
|
93
|
+
if (ctx.hasAgentFolder) return { status: 'ok', message: 'typeclaw.json present' }
|
|
94
|
+
return {
|
|
95
|
+
status: 'info',
|
|
96
|
+
message: 'no typeclaw.json found in or above current directory',
|
|
97
|
+
details: ['Host-stage checks unrelated to the agent folder still ran.'],
|
|
98
|
+
fix: { description: 'Run `typeclaw init` in the directory you want to use as an agent folder.' },
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function agentFolderRequiredDirs(): DoctorCheck {
|
|
105
|
+
return {
|
|
106
|
+
name: 'agent-folder.required-dirs',
|
|
107
|
+
category: 'agent-folder',
|
|
108
|
+
description: 'required agent directories exist',
|
|
109
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
110
|
+
async run(ctx) {
|
|
111
|
+
const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
|
|
112
|
+
if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
|
|
113
|
+
return {
|
|
114
|
+
status: 'warning',
|
|
115
|
+
message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
|
|
116
|
+
details: missing.map((d) => `missing: ${d}/`),
|
|
117
|
+
fix: {
|
|
118
|
+
description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
|
|
119
|
+
autoFix: async () => {
|
|
120
|
+
for (const d of missing) {
|
|
121
|
+
mkdirSync(join(ctx.cwd, d), { recursive: true })
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
|
|
125
|
+
changedPaths: missing.map((d) => `${d}/`),
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function agentFolderDockerfileTemplate(): DoctorCheck {
|
|
135
|
+
return {
|
|
136
|
+
name: 'agent-folder.dockerfile-managed',
|
|
137
|
+
category: 'agent-folder',
|
|
138
|
+
description: 'Dockerfile matches the typeclaw template',
|
|
139
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
140
|
+
async run(ctx) {
|
|
141
|
+
const dockerfilePath = join(ctx.cwd, DOCKERFILE)
|
|
142
|
+
const expected = buildExpectedDockerfile(ctx.cwd)
|
|
143
|
+
if (expected === null) {
|
|
144
|
+
return { status: 'info', message: 'config invalid; cannot compute expected Dockerfile' }
|
|
145
|
+
}
|
|
146
|
+
const actual = await safeRead(dockerfilePath)
|
|
147
|
+
if (actual === expected) return { status: 'ok', message: 'Dockerfile matches template' }
|
|
148
|
+
return {
|
|
149
|
+
status: 'warning',
|
|
150
|
+
message: actual === null ? 'Dockerfile missing' : 'Dockerfile diverges from template',
|
|
151
|
+
details: ['The Dockerfile is regenerated on every `typeclaw start`, so a divergent file will be overwritten.'],
|
|
152
|
+
fix: {
|
|
153
|
+
description: 'Regenerate the Dockerfile from the typeclaw template.',
|
|
154
|
+
autoFix: async () => {
|
|
155
|
+
await writeAtomic(dockerfilePath, expected)
|
|
156
|
+
return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function agentFolderGitignoreTemplate(): DoctorCheck {
|
|
165
|
+
return {
|
|
166
|
+
name: 'agent-folder.gitignore-managed',
|
|
167
|
+
category: 'agent-folder',
|
|
168
|
+
description: '.gitignore matches the typeclaw template',
|
|
169
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
170
|
+
async run(ctx) {
|
|
171
|
+
const gitignorePath = join(ctx.cwd, GITIGNORE_FILE)
|
|
172
|
+
const expected = buildExpectedGitignore(ctx.cwd)
|
|
173
|
+
if (expected === null) {
|
|
174
|
+
return { status: 'info', message: 'config invalid; cannot compute expected .gitignore' }
|
|
175
|
+
}
|
|
176
|
+
const actual = await safeRead(gitignorePath)
|
|
177
|
+
if (actual === expected) return { status: 'ok', message: '.gitignore matches template' }
|
|
178
|
+
return {
|
|
179
|
+
status: 'warning',
|
|
180
|
+
message: actual === null ? '.gitignore missing' : '.gitignore diverges from template',
|
|
181
|
+
fix: {
|
|
182
|
+
description: 'Regenerate .gitignore from the typeclaw template.',
|
|
183
|
+
autoFix: async () => {
|
|
184
|
+
await writeAtomic(gitignorePath, expected)
|
|
185
|
+
return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function agentFolderNodeModules(): DoctorCheck {
|
|
194
|
+
return {
|
|
195
|
+
name: 'agent-folder.node-modules-complete',
|
|
196
|
+
category: 'agent-folder',
|
|
197
|
+
description: 'node_modules satisfies package.json dependencies',
|
|
198
|
+
applies: (ctx) => ctx.hasAgentFolder && existsSync(join(ctx.cwd, 'package.json')),
|
|
199
|
+
async run(ctx) {
|
|
200
|
+
const missing = await detectMissingDeps(ctx.cwd)
|
|
201
|
+
if (missing.length === 0) return { status: 'ok', message: 'node_modules complete' }
|
|
202
|
+
return {
|
|
203
|
+
status: 'error',
|
|
204
|
+
message: `${missing.length} package(s) missing from node_modules`,
|
|
205
|
+
details: missing.map((m) => `missing: ${m}`),
|
|
206
|
+
fix: { description: 'Run `bun install` inside the agent folder.' },
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function agentFolderEnvFile(): DoctorCheck {
|
|
213
|
+
return {
|
|
214
|
+
name: 'agent-folder.env-file',
|
|
215
|
+
category: 'agent-folder',
|
|
216
|
+
description: '.env file is present',
|
|
217
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
218
|
+
async run(ctx) {
|
|
219
|
+
if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
|
|
220
|
+
return {
|
|
221
|
+
status: 'warning',
|
|
222
|
+
message: '.env is missing',
|
|
223
|
+
details: ['Channels and external API integrations will not have their secrets injected.'],
|
|
224
|
+
fix: { description: 'Create a .env file with the credentials your agent needs.' },
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function agentFolderGitRepo(): DoctorCheck {
|
|
231
|
+
return {
|
|
232
|
+
name: 'agent-folder.git-repo',
|
|
233
|
+
category: 'agent-folder',
|
|
234
|
+
description: 'agent folder is a git repo',
|
|
235
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
236
|
+
async run(ctx) {
|
|
237
|
+
if (existsSync(join(ctx.cwd, '.git'))) return { status: 'ok', message: '.git present' }
|
|
238
|
+
return {
|
|
239
|
+
status: 'warning',
|
|
240
|
+
message: 'agent folder is not a git repo',
|
|
241
|
+
details: ['typeclaw doctor --fix cannot commit changes without a git repo.'],
|
|
242
|
+
fix: { description: 'Run `git init` in the agent folder.' },
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function configValid(): DoctorCheck {
|
|
249
|
+
return {
|
|
250
|
+
name: 'config.valid',
|
|
251
|
+
category: 'config',
|
|
252
|
+
description: 'typeclaw.json is valid and mounts are accessible',
|
|
253
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
254
|
+
async run(ctx) {
|
|
255
|
+
const result = validateConfig(ctx.cwd)
|
|
256
|
+
if (result.ok) return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
|
|
257
|
+
return {
|
|
258
|
+
status: 'error',
|
|
259
|
+
message: result.reason,
|
|
260
|
+
fix: { description: 'Edit typeclaw.json to resolve the validation error above.' },
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hostdHomeWritable(): DoctorCheck {
|
|
267
|
+
return {
|
|
268
|
+
name: 'hostd.home-writable',
|
|
269
|
+
category: 'hostd',
|
|
270
|
+
description: 'hostd home (~/.typeclaw/) is writable',
|
|
271
|
+
async run() {
|
|
272
|
+
const home = process.env.TYPECLAW_HOME ?? join(homedir(), '.typeclaw')
|
|
273
|
+
try {
|
|
274
|
+
mkdirSync(home, { recursive: true })
|
|
275
|
+
return { status: 'ok', message: `${home} writable` }
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return {
|
|
278
|
+
status: 'error',
|
|
279
|
+
message: `cannot create ${home}: ${err instanceof Error ? err.message : String(err)}`,
|
|
280
|
+
fix: { description: 'Ensure your home directory is writable, or set TYPECLAW_HOME to an alternate path.' },
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function hostdReachable(): DoctorCheck {
|
|
288
|
+
return {
|
|
289
|
+
name: 'hostd.reachable',
|
|
290
|
+
category: 'hostd',
|
|
291
|
+
description: 'host daemon is reachable over the Unix socket',
|
|
292
|
+
async run() {
|
|
293
|
+
const reachable = await isDaemonReachable()
|
|
294
|
+
if (reachable) return { status: 'ok', message: 'daemon socket replied to list RPC' }
|
|
295
|
+
return {
|
|
296
|
+
status: 'info',
|
|
297
|
+
message: 'host daemon is not running',
|
|
298
|
+
details: [
|
|
299
|
+
'This is normal when no agent has been started yet. `typeclaw start` will spawn the daemon on demand.',
|
|
300
|
+
],
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hostdRegistration(): DoctorCheck {
|
|
307
|
+
return {
|
|
308
|
+
name: 'hostd.registration',
|
|
309
|
+
category: 'hostd',
|
|
310
|
+
description: 'agent container is registered with hostd',
|
|
311
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
312
|
+
async run(ctx) {
|
|
313
|
+
if (!(await isDaemonReachable())) {
|
|
314
|
+
return { status: 'skipped', message: 'hostd unreachable (covered by hostd.reachable)' }
|
|
315
|
+
}
|
|
316
|
+
const containerName = containerNameFromCwd(ctx.cwd)
|
|
317
|
+
const reply = await send({ kind: 'status', containerName })
|
|
318
|
+
if (reply.ok) return { status: 'ok', message: 'registered with hostd' }
|
|
319
|
+
return {
|
|
320
|
+
status: 'info',
|
|
321
|
+
message: 'agent is not registered with hostd',
|
|
322
|
+
details: [reply.reason],
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function containerState(exec: DockerExec): DoctorCheck {
|
|
329
|
+
return {
|
|
330
|
+
name: 'container.state',
|
|
331
|
+
category: 'container',
|
|
332
|
+
description: 'agent container Docker state',
|
|
333
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
334
|
+
async run(ctx) {
|
|
335
|
+
const available = await checkDockerAvailable(exec)
|
|
336
|
+
if (!available.ok) {
|
|
337
|
+
return { status: 'skipped', message: 'docker unavailable (covered by docker.daemon-reachable)' }
|
|
338
|
+
}
|
|
339
|
+
const name = containerNameFromCwd(ctx.cwd)
|
|
340
|
+
const state = await inspectContainer(name)
|
|
341
|
+
if (!state.exists) {
|
|
342
|
+
return {
|
|
343
|
+
status: 'info',
|
|
344
|
+
message: `container ${name} does not exist`,
|
|
345
|
+
details: [`expected image tag: ${imageTagFromCwd(ctx.cwd)}`],
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (state.running) return { status: 'ok', message: `container ${name} is running` }
|
|
349
|
+
return {
|
|
350
|
+
status: 'warning',
|
|
351
|
+
message: `container ${name} is stopped`,
|
|
352
|
+
fix: { description: 'Run `typeclaw start` to bring the container back up.' },
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function containerHostPort(): DoctorCheck {
|
|
359
|
+
return {
|
|
360
|
+
name: 'container.host-port-resolves',
|
|
361
|
+
category: 'container',
|
|
362
|
+
description: 'running container exposes its host port',
|
|
363
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
364
|
+
async run(ctx) {
|
|
365
|
+
const name = containerNameFromCwd(ctx.cwd)
|
|
366
|
+
const state = await inspectContainer(name)
|
|
367
|
+
if (!state.exists || !state.running) {
|
|
368
|
+
return { status: 'skipped', message: 'container not running' }
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const port = await resolveHostPort({ cwd: ctx.cwd })
|
|
372
|
+
return { status: 'ok', message: `host port ${port} -> container` }
|
|
373
|
+
} catch (err) {
|
|
374
|
+
return {
|
|
375
|
+
status: 'warning',
|
|
376
|
+
message: `cannot resolve host port: ${err instanceof Error ? err.message : String(err)}`,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function buildExpectedDockerfile(cwd: string): string | null {
|
|
384
|
+
try {
|
|
385
|
+
const cfg = loadConfigStrictForTemplate(cwd)
|
|
386
|
+
if (cfg === null) return null
|
|
387
|
+
return buildDockerfile(cfg.dockerfile)
|
|
388
|
+
} catch {
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildExpectedGitignore(cwd: string): string | null {
|
|
394
|
+
try {
|
|
395
|
+
const cfg = loadConfigStrictForTemplate(cwd)
|
|
396
|
+
if (cfg === null) return null
|
|
397
|
+
return buildGitignore(cfg.gitignore)
|
|
398
|
+
} catch {
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function loadConfigStrictForTemplate(
|
|
404
|
+
cwd: string,
|
|
405
|
+
): { dockerfile: Parameters<typeof buildDockerfile>[0]; gitignore: Parameters<typeof buildGitignore>[0] } | null {
|
|
406
|
+
const result = validateConfig(cwd)
|
|
407
|
+
if (!result.ok) return null
|
|
408
|
+
const cfg = loadConfigSync(cwd)
|
|
409
|
+
return { dockerfile: cfg.dockerfile, gitignore: cfg.gitignore }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function safeRead(path: string): Promise<string | null> {
|
|
413
|
+
try {
|
|
414
|
+
return await readFile(path, 'utf8')
|
|
415
|
+
} catch {
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function writeAtomic(path: string, content: string): Promise<void> {
|
|
421
|
+
await writeFile(path, content)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function relativeToCwd(cwd: string, path: string): string {
|
|
425
|
+
return relative(cwd, path) || '.'
|
|
426
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { CommitOutcome, FixAttempt } from './types'
|
|
5
|
+
|
|
6
|
+
export type CommitOptions = {
|
|
7
|
+
cwd: string
|
|
8
|
+
attempts: FixAttempt[]
|
|
9
|
+
spawnGit?: SpawnGit
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type GitResult = { exitCode: number; stdout: string; stderr: string }
|
|
13
|
+
export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
|
|
14
|
+
|
|
15
|
+
export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcome> {
|
|
16
|
+
const successes = opts.attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
|
|
17
|
+
if (successes.length === 0) {
|
|
18
|
+
return { kind: 'skipped', reason: 'no successful auto-fixes' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
|
|
22
|
+
if (pathsStaged.length === 0) {
|
|
23
|
+
return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!existsSync(join(opts.cwd, '.git'))) {
|
|
27
|
+
return { kind: 'skipped', reason: 'agent folder is not a git repo (.git missing)' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spawnGit = opts.spawnGit ?? defaultSpawnGit
|
|
31
|
+
|
|
32
|
+
const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
|
|
33
|
+
if (add.exitCode !== 0) {
|
|
34
|
+
return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const message = buildCommitMessage(opts.attempts)
|
|
38
|
+
const commit = await spawnGit(['commit', '-m', message], opts.cwd)
|
|
39
|
+
if (commit.exitCode !== 0) {
|
|
40
|
+
return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sha = await spawnGit(['rev-parse', 'HEAD'], opts.cwd)
|
|
44
|
+
const commitSha = sha.exitCode === 0 ? sha.stdout.trim() : ''
|
|
45
|
+
return { kind: 'committed', commitSha, pathsStaged }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildCommitMessage(attempts: FixAttempt[]): string {
|
|
49
|
+
const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
|
|
50
|
+
const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
|
|
51
|
+
const body = successes.map((a) => `- [${a.source}] ${a.name}: ${a.summary}`).join('\n')
|
|
52
|
+
return `${subject}\n\n${body}\n`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultSpawnGit: SpawnGit = async (args, cwd) => {
|
|
56
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
57
|
+
if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
|
|
58
|
+
try {
|
|
59
|
+
const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
60
|
+
const exitCode = await proc.exited
|
|
61
|
+
const stdout = await new Response(proc.stdout).text()
|
|
62
|
+
const stderr = await new Response(proc.stderr).text()
|
|
63
|
+
return { exitCode, stdout, stderr }
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function uniqueSorted(values: string[]): string[] {
|
|
70
|
+
return [...new Set(values)].sort()
|
|
71
|
+
}
|