typeclaw 0.3.1 → 0.5.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/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { select, text, isCancel, cancel, log } from '@clack/prompts'
|
|
5
|
+
import { defineCommand } from 'citty'
|
|
6
|
+
|
|
7
|
+
import { loadConfigSync } from '@/config'
|
|
8
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
9
|
+
import { findAgentDir, isInitialized } from '@/init'
|
|
10
|
+
import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
|
|
11
|
+
import type { TunnelConfig, TunnelFor, TunnelProvider } from '@/tunnels'
|
|
12
|
+
|
|
13
|
+
import { c, errorLine } from './ui'
|
|
14
|
+
|
|
15
|
+
type AddArgs = {
|
|
16
|
+
name: string
|
|
17
|
+
provider?: string
|
|
18
|
+
forChannel?: string
|
|
19
|
+
forManual?: boolean
|
|
20
|
+
upstreamPort?: string
|
|
21
|
+
externalUrl?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type RemoveArgs = { name: string }
|
|
25
|
+
|
|
26
|
+
type LiveArgs = { url?: string; timeout?: string }
|
|
27
|
+
|
|
28
|
+
type LogsArgs = LiveArgs & { name: string; follow?: boolean }
|
|
29
|
+
|
|
30
|
+
type LiveResult<T> = { ok: true; value: T } | { ok: false; reason: string }
|
|
31
|
+
|
|
32
|
+
export type TextValidator = (value: string) => string | undefined
|
|
33
|
+
|
|
34
|
+
export type TunnelPrompts = {
|
|
35
|
+
selectProvider: () => Promise<TunnelProvider | symbol>
|
|
36
|
+
selectOwner: () => Promise<'channel' | 'manual' | symbol>
|
|
37
|
+
text: (message: string, validate?: TextValidator) => Promise<string | symbol>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
41
|
+
|
|
42
|
+
const defaultPrompts: TunnelPrompts = {
|
|
43
|
+
selectProvider: () =>
|
|
44
|
+
select<TunnelProvider>({
|
|
45
|
+
message: 'Tunnel provider',
|
|
46
|
+
options: [
|
|
47
|
+
{ value: 'cloudflare-quick', label: 'Cloudflare Quick Tunnel', hint: 'no signup, URL rotates on restart' },
|
|
48
|
+
{ value: 'external', label: 'External URL', hint: 'bring your own reverse proxy' },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
selectOwner: () =>
|
|
52
|
+
select<'channel' | 'manual'>({
|
|
53
|
+
message: 'Tunnel owner',
|
|
54
|
+
options: [
|
|
55
|
+
{ value: 'channel', label: 'Channel' },
|
|
56
|
+
{ value: 'manual', label: 'Manual upstream' },
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
text: (message, validate) =>
|
|
60
|
+
text({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const addSub = defineCommand({
|
|
64
|
+
meta: { name: 'add', description: 'add a public tunnel entry to typeclaw.json' },
|
|
65
|
+
args: {
|
|
66
|
+
name: { type: 'positional', required: true, description: 'tunnel name' },
|
|
67
|
+
provider: { type: 'string', description: 'external | cloudflare-quick' },
|
|
68
|
+
'for-channel': { type: 'string', description: 'own this tunnel from a channel adapter' },
|
|
69
|
+
'for-manual': { type: 'boolean', description: 'create a manually-owned tunnel' },
|
|
70
|
+
'upstream-port': { type: 'string', description: 'container-local upstream port for manual tunnels' },
|
|
71
|
+
'external-url': { type: 'string', description: 'https URL for provider=external' },
|
|
72
|
+
},
|
|
73
|
+
async run({ args }) {
|
|
74
|
+
const result = await runTunnelAddFlow(ensureAgentDir(), {
|
|
75
|
+
name: String(args.name),
|
|
76
|
+
...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
|
|
77
|
+
...(args['for-channel'] !== undefined ? { forChannel: String(args['for-channel']) } : {}),
|
|
78
|
+
...(args['for-manual'] === true ? { forManual: true } : {}),
|
|
79
|
+
...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
|
|
80
|
+
...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
|
|
81
|
+
})
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
console.error(errorLine(result.reason))
|
|
84
|
+
process.exit(1)
|
|
85
|
+
}
|
|
86
|
+
log.success(`Added tunnel "${result.value.name}" to typeclaw.json.`)
|
|
87
|
+
log.info('Run typeclaw restart to apply.')
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const listSub = defineCommand({
|
|
92
|
+
meta: { name: 'list', description: 'list live tunnels from the running agent' },
|
|
93
|
+
args: liveArgs(),
|
|
94
|
+
async run({ args }) {
|
|
95
|
+
const result = await fetchTunnelList({ cwd: ensureAgentDir(), ...parseLiveArgs(args as LiveArgs) })
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
console.error(errorLine(result.reason))
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
process.stdout.write(`${formatTunnelList(result.value)}\n`)
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const statusSub = defineCommand({
|
|
105
|
+
meta: { name: 'status', description: 'show one live tunnel in detail' },
|
|
106
|
+
args: { name: { type: 'positional', required: true, description: 'tunnel name' }, ...liveArgs() },
|
|
107
|
+
async run({ args }) {
|
|
108
|
+
const live = parseLiveArgs(args as LiveArgs)
|
|
109
|
+
const result = await fetchTunnelStatus({ cwd: ensureAgentDir(), name: String(args.name), ...live })
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
console.error(errorLine(result.reason))
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
const logs = await fetchTunnelLogs({ cwd: ensureAgentDir(), name: String(args.name), follow: false, ...live })
|
|
115
|
+
const lines = logs.ok ? logs.value : []
|
|
116
|
+
process.stdout.write(`${formatTunnelStatus(result.value, lines)}\n`)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const removeSub = defineCommand({
|
|
121
|
+
meta: { name: 'remove', description: 'remove a manually-owned tunnel from typeclaw.json' },
|
|
122
|
+
args: { name: { type: 'positional', required: true, description: 'tunnel name' } },
|
|
123
|
+
async run({ args }) {
|
|
124
|
+
const result = runTunnelRemoveFlow(ensureAgentDir(), args as RemoveArgs)
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
console.error(errorLine(result.reason))
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
log.success(`Removed tunnel "${args.name}" from typeclaw.json.`)
|
|
130
|
+
log.info('Run typeclaw restart to apply.')
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const logsSub = defineCommand({
|
|
135
|
+
meta: { name: 'logs', description: 'print or follow a tunnel log ring' },
|
|
136
|
+
args: {
|
|
137
|
+
name: { type: 'positional', required: true, description: 'tunnel name' },
|
|
138
|
+
follow: { type: 'boolean', alias: 'f', description: 'follow new log lines' },
|
|
139
|
+
...liveArgs(),
|
|
140
|
+
},
|
|
141
|
+
async run({ args }) {
|
|
142
|
+
const live = parseLiveArgs(args as LiveArgs)
|
|
143
|
+
const result = await streamTunnelLogs(
|
|
144
|
+
{
|
|
145
|
+
cwd: ensureAgentDir(),
|
|
146
|
+
name: String(args.name),
|
|
147
|
+
follow: args.follow === true,
|
|
148
|
+
...live,
|
|
149
|
+
},
|
|
150
|
+
(line) => {
|
|
151
|
+
process.stdout.write(`${line}\n`)
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
console.error(errorLine(result.reason))
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
export const tunnelCommand = defineCommand({
|
|
162
|
+
meta: { name: 'tunnel', description: 'manage public tunnels for channels and manual upstreams' },
|
|
163
|
+
subCommands: { add: addSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
export async function runTunnelAddFlow(
|
|
167
|
+
cwd: string,
|
|
168
|
+
args: AddArgs,
|
|
169
|
+
prompts: TunnelPrompts = defaultPrompts,
|
|
170
|
+
): Promise<LiveResult<TunnelConfig>> {
|
|
171
|
+
const config = loadConfigSync(cwd)
|
|
172
|
+
if (config.tunnels.some((entry) => entry.name === args.name))
|
|
173
|
+
return { ok: false, reason: `tunnel "${args.name}" already exists` }
|
|
174
|
+
|
|
175
|
+
const provider = await resolveProvider(args.provider, prompts)
|
|
176
|
+
const tunnelFor = await resolveFor(args, prompts)
|
|
177
|
+
let upstreamPort: number | undefined
|
|
178
|
+
if (tunnelFor.kind === 'manual') {
|
|
179
|
+
const raw = args.upstreamPort ?? (await promptText('Upstream port', prompts, validateUpstreamPort))
|
|
180
|
+
const portError = validateUpstreamPort(raw)
|
|
181
|
+
if (portError !== undefined) return { ok: false, reason: `upstream port: ${portError}` }
|
|
182
|
+
upstreamPort = Number(raw)
|
|
183
|
+
}
|
|
184
|
+
let externalUrl: string | undefined
|
|
185
|
+
if (provider === 'external') {
|
|
186
|
+
externalUrl = args.externalUrl ?? (await promptText('External HTTPS URL', prompts, validateHttpsUrl))
|
|
187
|
+
const urlError = validateHttpsUrl(externalUrl)
|
|
188
|
+
if (urlError !== undefined) return { ok: false, reason: `external URL: ${urlError}` }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const tunnel: TunnelConfig = {
|
|
192
|
+
name: args.name,
|
|
193
|
+
provider,
|
|
194
|
+
for: tunnelFor,
|
|
195
|
+
...(externalUrl !== undefined ? { externalUrl } : {}),
|
|
196
|
+
...(upstreamPort !== undefined ? { upstreamPort } : {}),
|
|
197
|
+
}
|
|
198
|
+
const raw = readRawConfig(cwd)
|
|
199
|
+
raw.tunnels = [...config.tunnels, tunnel]
|
|
200
|
+
if (provider === 'cloudflare-quick') {
|
|
201
|
+
raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
|
|
202
|
+
}
|
|
203
|
+
writeRawConfig(cwd, raw)
|
|
204
|
+
loadConfigSync(cwd)
|
|
205
|
+
return { ok: true, value: tunnel }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
|
|
209
|
+
const config = loadConfigSync(cwd)
|
|
210
|
+
const tunnel = config.tunnels.find((entry) => entry.name === args.name)
|
|
211
|
+
if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
|
|
212
|
+
if (tunnel.for.kind === 'channel') {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}"; run typeclaw channel remove ${tunnel.for.name}`,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const raw = readRawConfig(cwd)
|
|
219
|
+
raw.tunnels = config.tunnels.filter((entry) => entry.name !== args.name)
|
|
220
|
+
writeRawConfig(cwd, raw)
|
|
221
|
+
loadConfigSync(cwd)
|
|
222
|
+
return { ok: true, value: { removed: tunnel } }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function fetchTunnelList(opts: {
|
|
226
|
+
cwd: string
|
|
227
|
+
url?: string
|
|
228
|
+
timeoutMs?: number
|
|
229
|
+
}): Promise<LiveResult<TunnelSnapshot[]>> {
|
|
230
|
+
return withTuiSocket(opts, async (ws, timeoutMs) => {
|
|
231
|
+
const requestId = `tunnel-list-${crypto.randomUUID()}`
|
|
232
|
+
const msg: ClientMessage = { type: 'tunnel_list_request', requestId }
|
|
233
|
+
ws.send(JSON.stringify(msg))
|
|
234
|
+
const reply = await waitForServerMessage(
|
|
235
|
+
ws,
|
|
236
|
+
timeoutMs,
|
|
237
|
+
(m) => m.type === 'tunnel_list_response' && m.requestId === requestId,
|
|
238
|
+
)
|
|
239
|
+
if (reply.type !== 'tunnel_list_response') throw new Error('unreachable')
|
|
240
|
+
return reply.ok ? { ok: true, value: reply.tunnels } : { ok: false, reason: reply.error }
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function fetchTunnelStatus(opts: {
|
|
245
|
+
cwd: string
|
|
246
|
+
name: string
|
|
247
|
+
url?: string
|
|
248
|
+
timeoutMs?: number
|
|
249
|
+
}): Promise<LiveResult<TunnelSnapshot>> {
|
|
250
|
+
return withTuiSocket(opts, async (ws, timeoutMs) => {
|
|
251
|
+
const requestId = `tunnel-status-${crypto.randomUUID()}`
|
|
252
|
+
const msg: ClientMessage = { type: 'tunnel_status_request', requestId, name: opts.name }
|
|
253
|
+
ws.send(JSON.stringify(msg))
|
|
254
|
+
const reply = await waitForServerMessage(
|
|
255
|
+
ws,
|
|
256
|
+
timeoutMs,
|
|
257
|
+
(m) => m.type === 'tunnel_status_response' && m.requestId === requestId,
|
|
258
|
+
)
|
|
259
|
+
if (reply.type !== 'tunnel_status_response') throw new Error('unreachable')
|
|
260
|
+
return reply.ok ? { ok: true, value: reply.tunnel } : { ok: false, reason: reply.error }
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function fetchTunnelLogs(opts: {
|
|
265
|
+
cwd: string
|
|
266
|
+
name: string
|
|
267
|
+
url?: string
|
|
268
|
+
timeoutMs?: number
|
|
269
|
+
follow?: false
|
|
270
|
+
}): Promise<LiveResult<string[]>> {
|
|
271
|
+
const lines: string[] = []
|
|
272
|
+
const result = await streamTunnelLogs({ ...opts, follow: false }, (line) => lines.push(line))
|
|
273
|
+
return result.ok ? { ok: true, value: lines } : result
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function streamTunnelLogs(
|
|
277
|
+
opts: { cwd: string; name: string; url?: string; timeoutMs?: number; follow?: boolean },
|
|
278
|
+
onLine: (line: string) => void,
|
|
279
|
+
): Promise<LiveResult<void>> {
|
|
280
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
281
|
+
const urlResult = await resolveWsUrl(opts.cwd, opts.url, '/tunnel-logs')
|
|
282
|
+
if (!urlResult.ok) return urlResult
|
|
283
|
+
const ws = new WebSocket(urlResult.value)
|
|
284
|
+
try {
|
|
285
|
+
await waitForOpen(ws, timeoutMs)
|
|
286
|
+
ws.send(JSON.stringify({ type: 'subscribe', name: opts.name, follow: opts.follow === true }))
|
|
287
|
+
return await new Promise<LiveResult<void>>((resolve) => {
|
|
288
|
+
const timer = setTimeout(() => resolve({ ok: false, reason: 'timed out waiting for tunnel logs' }), timeoutMs)
|
|
289
|
+
const onSigint = () => {
|
|
290
|
+
cleanup()
|
|
291
|
+
ws.close()
|
|
292
|
+
resolve({ ok: true, value: undefined })
|
|
293
|
+
}
|
|
294
|
+
const cleanup = () => {
|
|
295
|
+
clearTimeout(timer)
|
|
296
|
+
process.off('SIGINT', onSigint)
|
|
297
|
+
ws.removeEventListener('message', onMessage)
|
|
298
|
+
}
|
|
299
|
+
const onMessage = (event: MessageEvent) => {
|
|
300
|
+
const msg = JSON.parse(String(event.data)) as TunnelLogsServerMessage
|
|
301
|
+
if (msg.type === 'snapshot') for (const line of msg.lines) onLine(line)
|
|
302
|
+
else if (msg.type === 'line') onLine(msg.line)
|
|
303
|
+
else if (msg.type === 'error') {
|
|
304
|
+
cleanup()
|
|
305
|
+
ws.close()
|
|
306
|
+
resolve({ ok: false, reason: msg.message })
|
|
307
|
+
} else if (msg.type === 'end') {
|
|
308
|
+
cleanup()
|
|
309
|
+
ws.close()
|
|
310
|
+
resolve({ ok: true, value: undefined })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
process.once('SIGINT', onSigint)
|
|
314
|
+
ws.addEventListener('message', onMessage)
|
|
315
|
+
})
|
|
316
|
+
} catch (err) {
|
|
317
|
+
ws.close()
|
|
318
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function formatTunnelList(tunnels: readonly TunnelSnapshot[]): string {
|
|
323
|
+
if (tunnels.length === 0) return c.dim('No tunnels configured.')
|
|
324
|
+
const rows = tunnels.map((t) => [
|
|
325
|
+
t.name,
|
|
326
|
+
t.provider,
|
|
327
|
+
formatFor(t.for),
|
|
328
|
+
t.url ?? '-',
|
|
329
|
+
t.status,
|
|
330
|
+
formatLast(t.lastUrlAt),
|
|
331
|
+
])
|
|
332
|
+
const widths = [4, 8, 3, 3, 6, 12].map((min, i) => Math.max(min, ...rows.map((row) => row[i]!.length)))
|
|
333
|
+
const header = ['NAME', 'PROVIDER', 'FOR', 'URL', 'STATUS', 'LAST-ROTATED']
|
|
334
|
+
.map((h, i) => h.padEnd(widths[i]!))
|
|
335
|
+
.join(' ')
|
|
336
|
+
return [c.dim(header), ...rows.map((row) => row.map((cell, i) => cell.padEnd(widths[i]!)).join(' '))].join('\n')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function formatTunnelStatus(tunnel: TunnelSnapshot, lines: readonly string[]): string {
|
|
340
|
+
const out = [
|
|
341
|
+
`${c.bold(tunnel.name)} ${c.dim(`[${tunnel.provider}]`)}`,
|
|
342
|
+
` ${c.dim('for ')} ${formatFor(tunnel.for)}`,
|
|
343
|
+
` ${c.dim('current URL')} ${tunnel.url ?? '-'}`,
|
|
344
|
+
` ${c.dim('status ')} ${tunnel.status}`,
|
|
345
|
+
` ${c.dim('lastUrlAt ')} ${formatLast(tunnel.lastUrlAt)}`,
|
|
346
|
+
` ${c.dim('detail ')} ${tunnel.detail}`,
|
|
347
|
+
]
|
|
348
|
+
if (lines.length > 0) out.push('', c.dim('Recent logs:'), ...lines.map((line) => ` ${line}`))
|
|
349
|
+
return out.join('\n')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function liveArgs() {
|
|
353
|
+
return {
|
|
354
|
+
url: { type: 'string', description: 'agent websocket url' },
|
|
355
|
+
timeout: {
|
|
356
|
+
type: 'string',
|
|
357
|
+
description: 'milliseconds to wait for the agent to respond',
|
|
358
|
+
default: String(DEFAULT_TIMEOUT_MS),
|
|
359
|
+
},
|
|
360
|
+
} as const
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseLiveArgs(args: LiveArgs): { url?: string; timeoutMs: number } {
|
|
364
|
+
const timeoutMs = Number(args.timeout ?? DEFAULT_TIMEOUT_MS)
|
|
365
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`invalid --timeout value: ${args.timeout}`)
|
|
366
|
+
return { ...(args.url !== undefined ? { url: args.url } : {}), timeoutMs }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function resolveProvider(input: string | undefined, prompts: TunnelPrompts): Promise<TunnelProvider> {
|
|
370
|
+
if (input === 'external' || input === 'cloudflare-quick') return input
|
|
371
|
+
if (input !== undefined) throw new Error(`unknown tunnel provider: ${input}`)
|
|
372
|
+
const choice = await prompts.selectProvider()
|
|
373
|
+
if (isCancel(choice)) {
|
|
374
|
+
cancel('Aborted.')
|
|
375
|
+
process.exit(0)
|
|
376
|
+
}
|
|
377
|
+
return choice
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function resolveFor(args: AddArgs, prompts: TunnelPrompts): Promise<TunnelFor> {
|
|
381
|
+
if (args.forChannel !== undefined && args.forManual === true)
|
|
382
|
+
throw new Error('choose either --for-channel or --for-manual, not both')
|
|
383
|
+
if (args.forChannel !== undefined) return { kind: 'channel', name: args.forChannel }
|
|
384
|
+
if (args.forManual === true) return { kind: 'manual' }
|
|
385
|
+
const choice = await prompts.selectOwner()
|
|
386
|
+
if (isCancel(choice)) {
|
|
387
|
+
cancel('Aborted.')
|
|
388
|
+
process.exit(0)
|
|
389
|
+
}
|
|
390
|
+
if (choice === 'manual') return { kind: 'manual' }
|
|
391
|
+
return {
|
|
392
|
+
kind: 'channel',
|
|
393
|
+
name: await promptText('Channel name', prompts, validateNonEmpty('Channel name is required')),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function promptText(message: string, prompts: TunnelPrompts, validate?: TextValidator): Promise<string> {
|
|
398
|
+
const value = await prompts.text(message, validate)
|
|
399
|
+
if (isCancel(value)) {
|
|
400
|
+
cancel('Aborted.')
|
|
401
|
+
process.exit(0)
|
|
402
|
+
}
|
|
403
|
+
return String(value)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function validateNonEmpty(requiredMessage: string): TextValidator {
|
|
407
|
+
return (value) => (value.trim().length > 0 ? undefined : requiredMessage)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function validateUpstreamPort(value: string): string | undefined {
|
|
411
|
+
if (value.trim().length === 0) return 'Upstream port is required'
|
|
412
|
+
const port = Number(value)
|
|
413
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return 'Must be an integer between 1 and 65535'
|
|
414
|
+
return undefined
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function validateHttpsUrl(value: string): string | undefined {
|
|
418
|
+
if (value.trim().length === 0) return 'URL is required'
|
|
419
|
+
if (!value.startsWith('https://')) return 'URL must start with https://'
|
|
420
|
+
try {
|
|
421
|
+
new URL(value)
|
|
422
|
+
return undefined
|
|
423
|
+
} catch {
|
|
424
|
+
return 'Must be a valid URL'
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function ensureAgentDir(): string {
|
|
429
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
430
|
+
if (!isInitialized(cwd)) {
|
|
431
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
432
|
+
process.exit(1)
|
|
433
|
+
}
|
|
434
|
+
return cwd
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function readRawConfig(cwd: string): Record<string, unknown> {
|
|
438
|
+
const file = join(cwd, 'typeclaw.json')
|
|
439
|
+
try {
|
|
440
|
+
return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return {}
|
|
443
|
+
throw err
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function writeRawConfig(cwd: string, config: Record<string, unknown>): void {
|
|
448
|
+
writeFileSync(join(cwd, 'typeclaw.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
452
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function withTuiSocket<T>(
|
|
456
|
+
opts: { cwd: string; url?: string; timeoutMs?: number },
|
|
457
|
+
fn: (ws: WebSocket, timeoutMs: number) => Promise<LiveResult<T>>,
|
|
458
|
+
): Promise<LiveResult<T>> {
|
|
459
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
460
|
+
const url = await resolveWsUrl(opts.cwd, opts.url)
|
|
461
|
+
if (!url.ok) return url
|
|
462
|
+
const ws = new WebSocket(url.value)
|
|
463
|
+
try {
|
|
464
|
+
await waitForOpen(ws, timeoutMs)
|
|
465
|
+
return await fn(ws, timeoutMs)
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
468
|
+
} finally {
|
|
469
|
+
ws.close()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function resolveWsUrl(cwd: string, input?: string, pathname = '/'): Promise<LiveResult<string>> {
|
|
474
|
+
try {
|
|
475
|
+
const url = input === undefined ? new URL(`ws://127.0.0.1:${await resolveHostPort({ cwd })}`) : new URL(input)
|
|
476
|
+
if (input === undefined) {
|
|
477
|
+
const token = await resolveTuiToken({ cwd })
|
|
478
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
479
|
+
}
|
|
480
|
+
url.pathname = pathname
|
|
481
|
+
return { ok: true, value: url.toString() }
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function waitForOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
const timer = setTimeout(() => reject(new Error('timed out connecting to agent websocket')), timeoutMs)
|
|
490
|
+
ws.addEventListener(
|
|
491
|
+
'open',
|
|
492
|
+
() => {
|
|
493
|
+
clearTimeout(timer)
|
|
494
|
+
resolve()
|
|
495
|
+
},
|
|
496
|
+
{ once: true },
|
|
497
|
+
)
|
|
498
|
+
ws.addEventListener(
|
|
499
|
+
'error',
|
|
500
|
+
(err) => {
|
|
501
|
+
clearTimeout(timer)
|
|
502
|
+
reject(err)
|
|
503
|
+
},
|
|
504
|
+
{ once: true },
|
|
505
|
+
)
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function waitForServerMessage(
|
|
510
|
+
ws: WebSocket,
|
|
511
|
+
timeoutMs: number,
|
|
512
|
+
predicate: (msg: ServerMessage) => boolean,
|
|
513
|
+
): Promise<ServerMessage> {
|
|
514
|
+
return new Promise((resolve, reject) => {
|
|
515
|
+
const timer = setTimeout(() => reject(new Error('timed out waiting for agent response')), timeoutMs)
|
|
516
|
+
const onMessage = (event: MessageEvent) => {
|
|
517
|
+
const msg = JSON.parse(String(event.data)) as ServerMessage
|
|
518
|
+
if (!predicate(msg)) return
|
|
519
|
+
clearTimeout(timer)
|
|
520
|
+
ws.removeEventListener('message', onMessage)
|
|
521
|
+
resolve(msg)
|
|
522
|
+
}
|
|
523
|
+
ws.addEventListener('message', onMessage)
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatFor(value: TunnelFor): string {
|
|
528
|
+
return value.kind === 'channel' ? `channel:${value.name}` : 'manual'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function formatLast(value: number | null): string {
|
|
532
|
+
return value === null ? '-' : new Date(value).toISOString()
|
|
533
|
+
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -106,9 +106,14 @@ export function renderStartSuccess(result: StartLikeResult): string {
|
|
|
106
106
|
|
|
107
107
|
export type NextStepHint = { label: string; command: string }
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// `details` goes into the body, not the title: clack's `note()` sizes the
|
|
110
|
+
// box to the title's visual width and never wraps titles, so a long title
|
|
111
|
+
// breaks the layout on narrow terminals. Body content is wrapped to fit.
|
|
112
|
+
export function done(opts: { title: string; details?: string; hints: NextStepHint[] }): void {
|
|
113
|
+
const lines: string[] = []
|
|
114
|
+
if (opts.details !== undefined && opts.details !== '') lines.push(opts.details)
|
|
115
|
+
for (const h of opts.hints) lines.push(`${c.dim(h.label)} ${c.cyan(h.command)}`)
|
|
116
|
+
note(lines.join('\n'), opts.title)
|
|
112
117
|
outro(c.green('Done.'))
|
|
113
118
|
}
|
|
114
119
|
|