typeclaw 0.1.1 → 0.1.3
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 +16 -12
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- 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/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -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 +31 -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 +113 -9
- 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/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- 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 +15 -0
- package/src/run/index.ts +19 -5
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- 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-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- package/src/secrets/env.ts +0 -43
package/src/hostd/daemon.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { Socket, UnixSocketListener } from 'bun'
|
|
|
7
7
|
import type { PortForward } from '@/config'
|
|
8
8
|
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
9
|
import type { PortForwardEvent } from '@/portbroker'
|
|
10
|
+
import { kakaoChannelBlockSchema } from '@/secrets/schema'
|
|
11
|
+
import { SecretsBackend } from '@/secrets/storage'
|
|
10
12
|
|
|
11
13
|
import { isDaemonReachable } from './client'
|
|
12
14
|
import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
|
|
@@ -16,6 +18,7 @@ import type {
|
|
|
16
18
|
Request,
|
|
17
19
|
Response as RpcResponse,
|
|
18
20
|
RestartResult,
|
|
21
|
+
SecretsPatchResult,
|
|
19
22
|
ShutdownResult,
|
|
20
23
|
StatusResult,
|
|
21
24
|
VersionResult,
|
|
@@ -351,6 +354,26 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
351
354
|
return { ok: true, result }
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
const handleSecretsPatch = async (req: {
|
|
358
|
+
containerName: string
|
|
359
|
+
patch: { channels: { kakaotalk: unknown } }
|
|
360
|
+
}): Promise<RpcResponse> =>
|
|
361
|
+
runSerially(req.containerName, async () => {
|
|
362
|
+
const cwd = cwds.get(req.containerName)
|
|
363
|
+
if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
|
|
364
|
+
const parsed = kakaoChannelBlockSchema.safeParse(req.patch?.channels?.kakaotalk)
|
|
365
|
+
if (!parsed.success) {
|
|
366
|
+
return { ok: false, reason: parsed.error.issues.map((issue) => issue.message).join('; ') }
|
|
367
|
+
}
|
|
368
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
369
|
+
await backend.updateChannelsAsync(async (channels) => ({
|
|
370
|
+
result: undefined,
|
|
371
|
+
next: { ...channels, kakaotalk: parsed.data },
|
|
372
|
+
}))
|
|
373
|
+
const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
|
|
374
|
+
return { ok: true, result }
|
|
375
|
+
})
|
|
376
|
+
|
|
354
377
|
const handleHttpInfo = (): RpcResponse => {
|
|
355
378
|
const result: HttpInfoResult = { port: httpPort }
|
|
356
379
|
return { ok: true, result }
|
|
@@ -392,6 +415,8 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
392
415
|
return handleStatus(req)
|
|
393
416
|
case 'restart':
|
|
394
417
|
return handleRestart(req)
|
|
418
|
+
case 'secrets-patch':
|
|
419
|
+
return handleSecretsPatch(req)
|
|
395
420
|
case 'http-info':
|
|
396
421
|
return handleHttpInfo()
|
|
397
422
|
case 'version':
|
|
@@ -454,13 +479,13 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
454
479
|
} catch {
|
|
455
480
|
return json({ ok: false, reason: 'invalid request json' }, 400)
|
|
456
481
|
}
|
|
457
|
-
if (rpc.kind !== 'restart') {
|
|
458
|
-
return json({ ok: false, reason: 'http transport only supports restart' }, 403)
|
|
482
|
+
if (rpc.kind !== 'restart' && rpc.kind !== 'secrets-patch') {
|
|
483
|
+
return json({ ok: false, reason: 'http transport only supports restart and secrets-patch' }, 403)
|
|
459
484
|
}
|
|
460
485
|
if (restartTokens.get(rpc.containerName) !== token) {
|
|
461
486
|
return json({ ok: false, reason: 'invalid restart token' }, 403)
|
|
462
487
|
}
|
|
463
|
-
return json(await handleRestart(rpc))
|
|
488
|
+
return json(rpc.kind === 'restart' ? await handleRestart(rpc) : await handleSecretsPatch(rpc))
|
|
464
489
|
}
|
|
465
490
|
|
|
466
491
|
const httpHostname = opts.httpHost ?? '0.0.0.0'
|
package/src/hostd/protocol.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PortForward } from '@/config'
|
|
2
|
+
import type { KakaoChannelBlock } from '@/secrets/schema'
|
|
2
3
|
|
|
3
4
|
export type Request =
|
|
4
5
|
| {
|
|
@@ -14,6 +15,7 @@ export type Request =
|
|
|
14
15
|
| { kind: 'list' }
|
|
15
16
|
| { kind: 'status'; containerName: string }
|
|
16
17
|
| { kind: 'restart'; containerName: string; build?: boolean }
|
|
18
|
+
| { kind: 'secrets-patch'; containerName: string; patch: { channels: { kakaotalk: KakaoChannelBlock } } }
|
|
17
19
|
| { kind: 'http-info' }
|
|
18
20
|
| { kind: 'version' }
|
|
19
21
|
| { kind: 'shutdown' }
|
|
@@ -35,6 +37,11 @@ export type RestartResult = {
|
|
|
35
37
|
scheduled: true
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
export type SecretsPatchResult = {
|
|
41
|
+
containerName: string
|
|
42
|
+
patched: true
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
export type HttpInfoResult = {
|
|
39
46
|
port: number
|
|
40
47
|
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { resolveScaffoldVersion } from './cli-version'
|
|
6
|
+
|
|
7
|
+
const PACKAGE_FILE = 'package.json'
|
|
8
|
+
const TYPECLAW = 'typeclaw'
|
|
9
|
+
|
|
10
|
+
// Two semver quirks drive every branch in this module:
|
|
11
|
+
//
|
|
12
|
+
// 1. Pre-1.0 caret: `^0.1.0` resolves to `>=0.1.0 <0.2.0`. A CLI bump from
|
|
13
|
+
// 0.1.x to 0.2.0 falls OUT of the agent's range, so we must rewrite the
|
|
14
|
+
// spec for those crossings.
|
|
15
|
+
//
|
|
16
|
+
// 2. `bun install` honors the lockfile: when the lockfile entry already
|
|
17
|
+
// satisfies the declared spec, `bun install` is a no-op even if a newer
|
|
18
|
+
// in-range version exists upstream. To actually upgrade an in-range dep
|
|
19
|
+
// we MUST use `bun update <pkg> --latest`. See src/init/run-bun-install.ts.
|
|
20
|
+
//
|
|
21
|
+
// The decision matrix anchors on the INSTALLED version (the truth), not the
|
|
22
|
+
// declared range floor (a promise the agent may not yet have kept).
|
|
23
|
+
|
|
24
|
+
export type AutoUpgradeOutcome =
|
|
25
|
+
| { kind: 'skipped-dev-mode' }
|
|
26
|
+
| { kind: 'skipped-no-dep' }
|
|
27
|
+
| { kind: 'skipped-non-release-spec'; declared: string }
|
|
28
|
+
| { kind: 'skipped-already-running' }
|
|
29
|
+
| { kind: 'up-to-date'; installedVersion: string }
|
|
30
|
+
| { kind: 'exact-pin-respected'; declared: string; cliVersion: string }
|
|
31
|
+
| { kind: 'spec-rewritten'; from: string; to: string; cliVersion: string }
|
|
32
|
+
| { kind: 'reinstall-needed'; from: string; to: string }
|
|
33
|
+
|
|
34
|
+
export type AutoUpgradeOptions = {
|
|
35
|
+
cwd: string
|
|
36
|
+
// Test seam: lets tests simulate dev-mode (null) and arbitrary release
|
|
37
|
+
// versions without depending on the test runner's actual CLI version.
|
|
38
|
+
scaffoldVersion?: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function autoUpgradeTypeclawDep(options: AutoUpgradeOptions): Promise<AutoUpgradeOutcome> {
|
|
42
|
+
const { cwd } = options
|
|
43
|
+
const scaffold = options.scaffoldVersion !== undefined ? options.scaffoldVersion : resolveScaffoldVersion()
|
|
44
|
+
if (scaffold === null) return { kind: 'skipped-dev-mode' }
|
|
45
|
+
|
|
46
|
+
const cliVersion = stripCaret(scaffold)
|
|
47
|
+
if (cliVersion === null) return { kind: 'skipped-dev-mode' }
|
|
48
|
+
|
|
49
|
+
const pkg = await readAgentPackageJson(cwd)
|
|
50
|
+
if (pkg === null) return { kind: 'skipped-no-dep' }
|
|
51
|
+
|
|
52
|
+
const declared = pkg.parsed.dependencies?.[TYPECLAW]
|
|
53
|
+
if (typeof declared !== 'string') return { kind: 'skipped-no-dep' }
|
|
54
|
+
|
|
55
|
+
const declaredKind = classifyDepSpec(declared)
|
|
56
|
+
if (declaredKind.kind === 'non-release') {
|
|
57
|
+
return { kind: 'skipped-non-release-spec', declared }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const installed = readInstalledTypeclawVersion(cwd)
|
|
61
|
+
|
|
62
|
+
// "Upgrade only, never downgrade" — anchored on the INSTALLED version
|
|
63
|
+
// (the truth), with the declared range floor used ONLY when nothing is
|
|
64
|
+
// installed yet (best proxy for "what would land if bun install ran").
|
|
65
|
+
//
|
|
66
|
+
// Anchoring on `installed` first closes the half-applied-rewrite hole:
|
|
67
|
+
// a previous start may have written ^0.2.0 to package.json but failed
|
|
68
|
+
// its install, leaving node_modules at 0.1.x. The declared floor would
|
|
69
|
+
// wrongly say "up-to-date"; the installed version correctly says "retry."
|
|
70
|
+
if (installed !== null && compareReleaseVersions(installed, cliVersion) >= 0) {
|
|
71
|
+
return { kind: 'up-to-date', installedVersion: installed }
|
|
72
|
+
}
|
|
73
|
+
if (installed === null && declaredKind.kind !== 'exact') {
|
|
74
|
+
const declaredFloor = formatTriple(declaredKind.version)
|
|
75
|
+
if (compareReleaseVersions(declaredFloor, cliVersion) >= 0) {
|
|
76
|
+
return { kind: 'up-to-date', installedVersion: declaredFloor }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (declaredKind.kind === 'exact') {
|
|
81
|
+
const declaredVersion = formatTriple(declaredKind.version)
|
|
82
|
+
// Exact pin matches CLI but installed is stale (or missing): we still
|
|
83
|
+
// need to install. The user wrote the right spec — they just haven't
|
|
84
|
+
// materialized it yet. Return reinstall-needed; caller will run
|
|
85
|
+
// `bun update typeclaw --latest` against that exact spec.
|
|
86
|
+
if (declaredVersion === cliVersion) {
|
|
87
|
+
return { kind: 'reinstall-needed', from: installed ?? '<missing>', to: cliVersion }
|
|
88
|
+
}
|
|
89
|
+
// Exact pin diverges from CLI. User intent wins; we warn but never
|
|
90
|
+
// rewrite. If installed is ALSO ahead of CLI (e.g. exact pin 0.1.5,
|
|
91
|
+
// CLI 0.1.2), the up-to-date check above already returned.
|
|
92
|
+
return { kind: 'exact-pin-respected', declared, cliVersion }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!rangeSatisfies(declaredKind, cliVersion)) {
|
|
96
|
+
const newSpec = `^${cliVersion}`
|
|
97
|
+
await writeDepSpec(cwd, pkg.raw, pkg.parsed, newSpec)
|
|
98
|
+
return { kind: 'spec-rewritten', from: declared, to: newSpec, cliVersion }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Declared range includes CLI. Three sub-cases:
|
|
102
|
+
// - installed === null: fresh agent, nothing on disk yet. ensureDeps
|
|
103
|
+
// will install for the missing-dep reason; nothing for us to add.
|
|
104
|
+
// - installed > CLI but in range: we already returned up-to-date above.
|
|
105
|
+
// - installed < CLI: force an upgrade via `bun update typeclaw --latest`.
|
|
106
|
+
if (installed === null) {
|
|
107
|
+
return { kind: 'up-to-date', installedVersion: cliVersion }
|
|
108
|
+
}
|
|
109
|
+
return { kind: 'reinstall-needed', from: installed, to: cliVersion }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function outcomeForcesInstall(outcome: AutoUpgradeOutcome): boolean {
|
|
113
|
+
return outcome.kind === 'spec-rewritten' || outcome.kind === 'reinstall-needed'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The version we expect to find in node_modules/typeclaw after the
|
|
117
|
+
// auto-upgrade-triggered install completes. Callers use this to verify
|
|
118
|
+
// the install actually moved the on-disk version (not just resolved the
|
|
119
|
+
// lockfile). Returns null when no install was forced — verification is
|
|
120
|
+
// skipped on no-op outcomes.
|
|
121
|
+
export function expectedInstalledAfterUpgrade(outcome: AutoUpgradeOutcome): string | null {
|
|
122
|
+
if (outcome.kind === 'spec-rewritten') return outcome.cliVersion
|
|
123
|
+
if (outcome.kind === 'reinstall-needed') return outcome.to
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function describeAutoUpgrade(outcome: AutoUpgradeOutcome): string {
|
|
128
|
+
switch (outcome.kind) {
|
|
129
|
+
case 'spec-rewritten':
|
|
130
|
+
return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
|
|
131
|
+
case 'reinstall-needed':
|
|
132
|
+
return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
|
|
133
|
+
case 'exact-pin-respected':
|
|
134
|
+
return `Agent typeclaw is exact-pinned to ${outcome.declared}; CLI is ${outcome.cliVersion}. Not upgrading (remove the exact pin to allow auto-upgrade).`
|
|
135
|
+
default:
|
|
136
|
+
return ''
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readInstalledTypeclawVersionFromAgent(cwd: string): string | null {
|
|
141
|
+
return readInstalledTypeclawVersion(cwd)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ParsedPackage = {
|
|
145
|
+
raw: string
|
|
146
|
+
parsed: { dependencies?: Record<string, string> } & Record<string, unknown>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readAgentPackageJson(cwd: string): Promise<ParsedPackage | null> {
|
|
150
|
+
const path = join(cwd, PACKAGE_FILE)
|
|
151
|
+
if (!existsSync(path)) return null
|
|
152
|
+
let raw: string
|
|
153
|
+
try {
|
|
154
|
+
raw = await readFile(path, 'utf8')
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
let parsed: unknown
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(raw)
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
165
|
+
return { raw, parsed: parsed as ParsedPackage['parsed'] }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readInstalledTypeclawVersion(cwd: string): string | null {
|
|
169
|
+
const path = join(cwd, 'node_modules', TYPECLAW, PACKAGE_FILE)
|
|
170
|
+
if (!existsSync(path)) return null
|
|
171
|
+
let raw: string
|
|
172
|
+
try {
|
|
173
|
+
raw = readFileSync(path, 'utf8')
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(raw) as { version?: string }
|
|
179
|
+
if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
|
|
180
|
+
} catch {}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type DepSpecKind =
|
|
185
|
+
| { kind: 'exact'; version: [number, number, number]; raw: string }
|
|
186
|
+
| { kind: 'caret'; version: [number, number, number] }
|
|
187
|
+
| { kind: 'tilde'; version: [number, number, number] }
|
|
188
|
+
| { kind: 'non-release' }
|
|
189
|
+
|
|
190
|
+
function classifyDepSpec(spec: string): DepSpecKind {
|
|
191
|
+
const trimmed = spec.trim()
|
|
192
|
+
const exactMatch = trimmed.match(/^=?(\d+)\.(\d+)\.(\d+)$/)
|
|
193
|
+
if (exactMatch) {
|
|
194
|
+
const [, a, b, c] = exactMatch
|
|
195
|
+
return { kind: 'exact', version: parseTriple(a!, b!, c!), raw: trimmed }
|
|
196
|
+
}
|
|
197
|
+
const caretMatch = trimmed.match(/^\^(\d+)\.(\d+)\.(\d+)$/)
|
|
198
|
+
if (caretMatch) {
|
|
199
|
+
const [, a, b, c] = caretMatch
|
|
200
|
+
return { kind: 'caret', version: parseTriple(a!, b!, c!) }
|
|
201
|
+
}
|
|
202
|
+
const tildeMatch = trimmed.match(/^~(\d+)\.(\d+)\.(\d+)$/)
|
|
203
|
+
if (tildeMatch) {
|
|
204
|
+
const [, a, b, c] = tildeMatch
|
|
205
|
+
return { kind: 'tilde', version: parseTriple(a!, b!, c!) }
|
|
206
|
+
}
|
|
207
|
+
return { kind: 'non-release' }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseTriple(a: string, b: string, c: string): [number, number, number] {
|
|
211
|
+
return [Number.parseInt(a, 10), Number.parseInt(b, 10), Number.parseInt(c, 10)]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatTriple(v: [number, number, number]): string {
|
|
215
|
+
return `${v[0]}.${v[1]}.${v[2]}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// npm/bun caret+tilde semantics, narrowed to plain X.Y.Z bases:
|
|
219
|
+
// ^0.1.2 → >=0.1.2 <0.2.0 (pre-1.0 caret pins minor — the bug we fix)
|
|
220
|
+
// ^1.2.3 → >=1.2.3 <2.0.0
|
|
221
|
+
// ~0.1.2 → >=0.1.2 <0.2.0
|
|
222
|
+
// ~1.2.3 → >=1.2.3 <1.3.0
|
|
223
|
+
function rangeSatisfies(range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>, version: string): boolean {
|
|
224
|
+
const v = parseVersion(version)
|
|
225
|
+
if (v === null) return false
|
|
226
|
+
const [base, ceiling] = rangeBounds(range)
|
|
227
|
+
return compareTriples(v, base) >= 0 && compareTriples(v, ceiling) < 0
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function rangeBounds(
|
|
231
|
+
range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>,
|
|
232
|
+
): [[number, number, number], [number, number, number]] {
|
|
233
|
+
const [maj, min, pat] = range.version
|
|
234
|
+
if (range.kind === 'caret') {
|
|
235
|
+
if (maj > 0)
|
|
236
|
+
return [
|
|
237
|
+
[maj, min, pat],
|
|
238
|
+
[maj + 1, 0, 0],
|
|
239
|
+
]
|
|
240
|
+
if (min > 0)
|
|
241
|
+
return [
|
|
242
|
+
[maj, min, pat],
|
|
243
|
+
[maj, min + 1, 0],
|
|
244
|
+
]
|
|
245
|
+
return [
|
|
246
|
+
[maj, min, pat],
|
|
247
|
+
[maj, min, pat + 1],
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
return [
|
|
251
|
+
[maj, min, pat],
|
|
252
|
+
[maj, min + 1, 0],
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseVersion(version: string): [number, number, number] | null {
|
|
257
|
+
const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
|
|
258
|
+
if (!m) return null
|
|
259
|
+
const [, a, b, c] = m
|
|
260
|
+
return parseTriple(a!, b!, c!)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function compareTriples(a: [number, number, number], b: [number, number, number]): number {
|
|
264
|
+
for (let i = 0; i < 3; i++) {
|
|
265
|
+
const ai = a[i]!
|
|
266
|
+
const bi = b[i]!
|
|
267
|
+
if (ai !== bi) return ai - bi
|
|
268
|
+
}
|
|
269
|
+
return 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function compareReleaseVersions(a: string, b: string): number {
|
|
273
|
+
const av = parseVersion(a)
|
|
274
|
+
const bv = parseVersion(b)
|
|
275
|
+
if (av === null || bv === null) return 0
|
|
276
|
+
return compareTriples(av, bv)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stripCaret(scaffold: string): string | null {
|
|
280
|
+
const m = scaffold.match(/^\^?(\d+\.\d+\.\d+)$/)
|
|
281
|
+
return m ? (m[1] ?? null) : null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isReleaseVersion(version: string): boolean {
|
|
285
|
+
return /^\d+\.\d+\.\d+$/.test(version)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function writeDepSpec(cwd: string, raw: string, parsed: ParsedPackage['parsed'], newSpec: string): Promise<void> {
|
|
289
|
+
// Scoped edit: replace the typeclaw spec ONLY inside the dependencies
|
|
290
|
+
// object. The previous implementation used `raw.replace(/"typeclaw":.../)`
|
|
291
|
+
// unscoped, which would silently rewrite devDependencies.typeclaw if it
|
|
292
|
+
// appeared before dependencies.typeclaw in the file (the original spec
|
|
293
|
+
// never moves). We slice the dependencies object's textual range, edit
|
|
294
|
+
// inside it, then splice back to preserve whitespace, key order, and
|
|
295
|
+
// trailing newline. If the slice fails (unusual JSON shape), fall back
|
|
296
|
+
// to a full JSON round-trip — formatting churn is acceptable; silently
|
|
297
|
+
// updating the wrong key is not.
|
|
298
|
+
const scoped = sliceDependenciesRange(raw, parsed)
|
|
299
|
+
if (scoped !== null) {
|
|
300
|
+
const { start, end } = scoped
|
|
301
|
+
const block = raw.slice(start, end)
|
|
302
|
+
const replaced = block.replace(
|
|
303
|
+
/("typeclaw"\s*:\s*)"[^"]+"/,
|
|
304
|
+
(_m, prefix: string) => `${prefix}${JSON.stringify(newSpec)}`,
|
|
305
|
+
)
|
|
306
|
+
if (replaced !== block) {
|
|
307
|
+
await writeFile(join(cwd, PACKAGE_FILE), `${raw.slice(0, start)}${replaced}${raw.slice(end)}`)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const deps = { ...parsed.dependencies, [TYPECLAW]: newSpec }
|
|
312
|
+
const next = { ...parsed, dependencies: deps }
|
|
313
|
+
const indent = detectIndent(raw)
|
|
314
|
+
await writeFile(join(cwd, PACKAGE_FILE), `${JSON.stringify(next, null, indent)}\n`)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Returns the [start, end) byte range of the "dependencies" object's value
|
|
318
|
+
// in `raw`, or null if it can't be located unambiguously. Uses a brace-
|
|
319
|
+
// counting tokenizer that respects string literals so a `dependencies` key
|
|
320
|
+
// inside a string value (e.g. inside a `description`) cannot fool it.
|
|
321
|
+
function sliceDependenciesRange(raw: string, parsed: ParsedPackage['parsed']): { start: number; end: number } | null {
|
|
322
|
+
if (parsed.dependencies === undefined || parsed.dependencies === null) return null
|
|
323
|
+
const keyMatch = raw.match(/"dependencies"\s*:\s*\{/)
|
|
324
|
+
if (!keyMatch || keyMatch.index === undefined) return null
|
|
325
|
+
const startOfOpenBrace = keyMatch.index + keyMatch[0].length - 1
|
|
326
|
+
const closeBrace = findMatchingCloseBrace(raw, startOfOpenBrace)
|
|
327
|
+
if (closeBrace === null) return null
|
|
328
|
+
return { start: startOfOpenBrace, end: closeBrace + 1 }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function findMatchingCloseBrace(raw: string, openIndex: number): number | null {
|
|
332
|
+
let depth = 0
|
|
333
|
+
let inString = false
|
|
334
|
+
let escape = false
|
|
335
|
+
for (let i = openIndex; i < raw.length; i++) {
|
|
336
|
+
const ch = raw[i]
|
|
337
|
+
if (escape) {
|
|
338
|
+
escape = false
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (inString) {
|
|
342
|
+
if (ch === '\\') escape = true
|
|
343
|
+
else if (ch === '"') inString = false
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
if (ch === '"') {
|
|
347
|
+
inString = true
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
if (ch === '{') depth++
|
|
351
|
+
else if (ch === '}') {
|
|
352
|
+
depth--
|
|
353
|
+
if (depth === 0) return i
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function detectIndent(raw: string): number | string {
|
|
360
|
+
// Default to 2 — matches `JSON.stringify(_, _, 2)` behavior and the
|
|
361
|
+
// project's existing scaffold style. Only override when we can see a
|
|
362
|
+
// clear non-2 indent on the first indented line.
|
|
363
|
+
const match = raw.match(/\n([\t ]+)\S/)
|
|
364
|
+
if (!match) return 2
|
|
365
|
+
const sample = match[1]!
|
|
366
|
+
if (sample.startsWith('\t')) return '\t'
|
|
367
|
+
return sample.length
|
|
368
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Single source of truth for "what version of typeclaw is this agent on,
|
|
5
|
+
// and where does that mean we should pin the base image / write the dep
|
|
6
|
+
// spec." Sync I/O at module load — relative paths are stable in both a dev
|
|
7
|
+
// checkout and a real install, so the parent-walk an earlier draft used
|
|
8
|
+
// was unnecessary side effect. See AGENTS.md "Rules of thumb" for the
|
|
9
|
+
// install-vs-dev distinction this module encodes.
|
|
10
|
+
|
|
11
|
+
export const GHCR_BASE_IMAGE_REPO = 'ghcr.io/typeclaw/typeclaw-base'
|
|
12
|
+
|
|
13
|
+
const CLI_PACKAGE_JSON_PATH = join(import.meta.dir, '..', '..', 'package.json')
|
|
14
|
+
|
|
15
|
+
const cliPkg = JSON.parse(readFileSync(CLI_PACKAGE_JSON_PATH, 'utf8')) as { name?: string; version?: string }
|
|
16
|
+
if (cliPkg.name !== 'typeclaw' || typeof cliPkg.version !== 'string') {
|
|
17
|
+
throw new Error(`Expected typeclaw package.json at ${CLI_PACKAGE_JSON_PATH}, got name=${cliPkg.name}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CLI_VERSION = cliPkg.version
|
|
21
|
+
|
|
22
|
+
const NODE_MODULES_SEGMENT = `${join('/', 'node_modules', '/')}`
|
|
23
|
+
|
|
24
|
+
function isInstalledCli(): boolean {
|
|
25
|
+
return CLI_PACKAGE_JSON_PATH.includes(NODE_MODULES_SEGMENT)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `^X.Y.Z` when the invoking CLI is itself an installed copy of typeclaw
|
|
29
|
+
// (suitable for writing into a freshly-scaffolded agent's package.json),
|
|
30
|
+
// `null` when the CLI is running from the source repo (caller falls back
|
|
31
|
+
// to `file:` so the agent tracks the local checkout).
|
|
32
|
+
export function resolveScaffoldVersion(): string | null {
|
|
33
|
+
if (!isInstalledCli()) return null
|
|
34
|
+
return `^${CLI_VERSION}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The version of typeclaw the AGENT will actually run inside the container.
|
|
38
|
+
// Prefers `<agent>/node_modules/typeclaw/package.json#version` because that
|
|
39
|
+
// is what the bind-mount exposes to the container at /agent/node_modules,
|
|
40
|
+
// and we want the base image's CLI version to match the runtime's. Falls
|
|
41
|
+
// back to parsing the agent's `dependencies.typeclaw` spec for fresh inits
|
|
42
|
+
// where `bun install` hasn't run yet, and to `null` when neither maps to
|
|
43
|
+
// a release version (dev mode, ranges, dist-tags, etc.).
|
|
44
|
+
export function resolveBaseImageVersion(agentDir: string): string | null {
|
|
45
|
+
return readInstalledTypeclawVersion(agentDir) ?? readVersionFromDepSpec(agentDir)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readInstalledTypeclawVersion(agentDir: string): string | null {
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(join(agentDir, 'node_modules', 'typeclaw', 'package.json'), 'utf8')
|
|
51
|
+
const parsed = JSON.parse(raw) as { version?: string }
|
|
52
|
+
if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
|
|
53
|
+
} catch {}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readVersionFromDepSpec(agentDir: string): string | null {
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(join(agentDir, 'package.json'), 'utf8')
|
|
60
|
+
const parsed = JSON.parse(raw) as { dependencies?: Record<string, string> }
|
|
61
|
+
const spec = parsed.dependencies?.typeclaw
|
|
62
|
+
if (typeof spec !== 'string') return null
|
|
63
|
+
return extractReleaseVersionFromSpec(spec)
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Accept only specs that name an exact release version we can map 1:1 to a
|
|
70
|
+
// GHCR tag (`X.Y.Z`, `^X.Y.Z`, `~X.Y.Z`, `=X.Y.Z`). Reject ranges, `latest`,
|
|
71
|
+
// `*`, dist-tags, `workspace:` / `git:` / `portal:` / `npm:` aliases. Being
|
|
72
|
+
// strict here delays versioned pinning rather than silently picking the
|
|
73
|
+
// wrong tag — the installed-typeclaw check above is the primary path.
|
|
74
|
+
function extractReleaseVersionFromSpec(spec: string): string | null {
|
|
75
|
+
const match = spec.trim().match(/^[\^~=]?(\d+\.\d+\.\d+)$/)
|
|
76
|
+
return match ? (match[1] ?? null) : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isReleaseVersion(version: string): boolean {
|
|
80
|
+
return /^\d+\.\d+\.\d+$/.test(version)
|
|
81
|
+
}
|