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
package/src/tui/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
|
|
2
2
|
|
|
3
|
+
import { parseCommand } from '@/commands'
|
|
4
|
+
|
|
3
5
|
import { createClient as createClientDefault, type Client } from './client'
|
|
4
6
|
import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
|
|
5
7
|
import { colors, editorTheme, markdownTheme } from './theme'
|
|
@@ -9,6 +11,23 @@ export type TerminalFactory = () => Terminal
|
|
|
9
11
|
|
|
10
12
|
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
|
|
11
13
|
|
|
14
|
+
// Bare slash-command names (no leading `/`) the TUI intercepts client-side and
|
|
15
|
+
// turns into a clean process exit. The hatching ritual tells the agent to point
|
|
16
|
+
// users at `/quit` (see src/init/hatching.ts); without an intercept the literal
|
|
17
|
+
// text would be shipped to the LLM as a chat message. Grammar (case-insensitive,
|
|
18
|
+
// whitespace-tolerant, `//foo` escapes to a literal prompt) comes from
|
|
19
|
+
// `parseCommand` in src/commands so channel and TUI slash commands stay
|
|
20
|
+
// consistent. Arguments after the name disqualify the match: `/quit me a story`
|
|
21
|
+
// is a real prompt, not a command.
|
|
22
|
+
const QUIT_COMMAND_NAMES: ReadonlySet<string> = new Set(['quit', 'exit'])
|
|
23
|
+
|
|
24
|
+
function isQuitCommand(text: string): boolean {
|
|
25
|
+
const parsed = parseCommand(text)
|
|
26
|
+
return parsed !== null && parsed.args.length === 0 && QUIT_COMMAND_NAMES.has(parsed.name)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type VersionMismatch = { expected: string; actual: string }
|
|
30
|
+
|
|
12
31
|
export type TuiOptions = {
|
|
13
32
|
url: string
|
|
14
33
|
initialPrompt?: string
|
|
@@ -16,6 +35,13 @@ export type TuiOptions = {
|
|
|
16
35
|
createTerminal?: TerminalFactory
|
|
17
36
|
handshakeTimeoutMs?: number
|
|
18
37
|
exit?: (code: number) => void
|
|
38
|
+
// Locally-known typeclaw version the host CLI is running. When provided
|
|
39
|
+
// and the connected frame's serverVersion is defined and differs,
|
|
40
|
+
// onVersionMismatch is invoked AND a yellow warning line is rendered
|
|
41
|
+
// into the TUI history. The container-side local TUI omits this so no
|
|
42
|
+
// mismatch check fires when client and server are guaranteed in lockstep.
|
|
43
|
+
expectedVersion?: string
|
|
44
|
+
onVersionMismatch?: (info: VersionMismatch) => void
|
|
19
45
|
}
|
|
20
46
|
|
|
21
47
|
export function createTui({
|
|
@@ -25,6 +51,8 @@ export function createTui({
|
|
|
25
51
|
createTerminal = () => new ProcessTerminal(),
|
|
26
52
|
handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
27
53
|
exit = process.exit.bind(process),
|
|
54
|
+
expectedVersion,
|
|
55
|
+
onVersionMismatch,
|
|
28
56
|
}: TuiOptions) {
|
|
29
57
|
async function run(): Promise<void> {
|
|
30
58
|
const terminal = createTerminal()
|
|
@@ -44,7 +72,7 @@ export function createTui({
|
|
|
44
72
|
throw err
|
|
45
73
|
})
|
|
46
74
|
|
|
47
|
-
const
|
|
75
|
+
const handshake = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
|
|
48
76
|
status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
|
|
49
77
|
tui.requestRender()
|
|
50
78
|
client.close()
|
|
@@ -52,6 +80,7 @@ export function createTui({
|
|
|
52
80
|
exit(1)
|
|
53
81
|
throw err
|
|
54
82
|
})
|
|
83
|
+
const { sessionId, serverVersion } = handshake
|
|
55
84
|
status.setText(colors.dim(`session: ${sessionId}`))
|
|
56
85
|
tui.requestRender()
|
|
57
86
|
|
|
@@ -193,14 +222,18 @@ export function createTui({
|
|
|
193
222
|
return undefined
|
|
194
223
|
})
|
|
195
224
|
|
|
225
|
+
const shutdown = (code: number) => {
|
|
226
|
+
tui.stop()
|
|
227
|
+
client.close()
|
|
228
|
+
exit(code)
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
// Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
|
|
197
232
|
// so we must intercept the \x03 byte ourselves. The Editor would otherwise
|
|
198
233
|
// swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
|
|
199
234
|
tui.addInputListener((data) => {
|
|
200
235
|
if (matchesKey(data, Key.ctrl('c'))) {
|
|
201
|
-
|
|
202
|
-
client.close()
|
|
203
|
-
exit(0)
|
|
236
|
+
shutdown(0)
|
|
204
237
|
return { consume: true }
|
|
205
238
|
}
|
|
206
239
|
return undefined
|
|
@@ -208,6 +241,10 @@ export function createTui({
|
|
|
208
241
|
|
|
209
242
|
editor.onSubmit = (text) => {
|
|
210
243
|
if (text.trim().length === 0) return
|
|
244
|
+
if (isQuitCommand(text)) {
|
|
245
|
+
shutdown(0)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
211
248
|
editor.setText('')
|
|
212
249
|
editor.addToHistory(text)
|
|
213
250
|
tui.requestRender()
|
|
@@ -217,7 +254,22 @@ export function createTui({
|
|
|
217
254
|
tui.setFocus(editor)
|
|
218
255
|
tui.requestRender()
|
|
219
256
|
|
|
257
|
+
if (expectedVersion !== undefined && serverVersion !== undefined && serverVersion !== expectedVersion) {
|
|
258
|
+
const mismatch: VersionMismatch = { expected: expectedVersion, actual: serverVersion }
|
|
259
|
+
const warning = formatVersionMismatchWarning(mismatch)
|
|
260
|
+
appendHistory(new Text(colors.yellow(warning), 0, 0))
|
|
261
|
+
tui.requestRender()
|
|
262
|
+
onVersionMismatch?.(mismatch)
|
|
263
|
+
}
|
|
264
|
+
|
|
220
265
|
if (initialPrompt) {
|
|
266
|
+
// initialPrompt bypasses editor.onSubmit, so the quit intercept above
|
|
267
|
+
// would never run. Guard the same way so `typeclaw tui /quit` exits
|
|
268
|
+
// instead of leaking the command into the agent's chat context.
|
|
269
|
+
if (isQuitCommand(initialPrompt)) {
|
|
270
|
+
shutdown(0)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
221
273
|
await send(initialPrompt)
|
|
222
274
|
}
|
|
223
275
|
|
|
@@ -238,8 +290,12 @@ function redactUrl(url: string): string {
|
|
|
238
290
|
}
|
|
239
291
|
}
|
|
240
292
|
|
|
241
|
-
async function waitForConnected(
|
|
242
|
-
|
|
293
|
+
async function waitForConnected(
|
|
294
|
+
client: Client,
|
|
295
|
+
url: string,
|
|
296
|
+
timeoutMs: number,
|
|
297
|
+
): Promise<{ sessionId: string; serverVersion?: string }> {
|
|
298
|
+
return await new Promise<{ sessionId: string; serverVersion?: string }>((resolve, reject) => {
|
|
243
299
|
const timer = setTimeout(() => {
|
|
244
300
|
cleanup()
|
|
245
301
|
reject(new Error(`timed out waiting for connected message from ${url} after ${timeoutMs}ms`))
|
|
@@ -253,7 +309,10 @@ async function waitForConnected(client: Client, url: string, timeoutMs: number):
|
|
|
253
309
|
client.onMessage((msg) => {
|
|
254
310
|
if (msg.type === 'connected') {
|
|
255
311
|
cleanup()
|
|
256
|
-
resolve(
|
|
312
|
+
resolve({
|
|
313
|
+
sessionId: msg.sessionId,
|
|
314
|
+
...(msg.serverVersion !== undefined ? { serverVersion: msg.serverVersion } : {}),
|
|
315
|
+
})
|
|
257
316
|
}
|
|
258
317
|
if (msg.type === 'error') {
|
|
259
318
|
cleanup()
|
|
@@ -275,3 +334,7 @@ async function waitForConnected(client: Client, url: string, timeoutMs: number):
|
|
|
275
334
|
)
|
|
276
335
|
})
|
|
277
336
|
}
|
|
337
|
+
|
|
338
|
+
export function formatVersionMismatchWarning({ expected, actual }: VersionMismatch): string {
|
|
339
|
+
return `WARN: host CLI is v${expected}, agent container is v${actual}. Some commands may hang or fail. Try \`typeclaw restart --build\`.`
|
|
340
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Fixture source: cloudflare/cloudflared cmd/cloudflared/tunnel/quick_tunnel.go
|
|
2
|
+
# RunQuickTunnel logs the disclaimer, "Requesting new quick Tunnel on trycloudflare.com...",
|
|
3
|
+
# then AsciiBox(...) lines containing the resolved https://<subdomain>.trycloudflare.com URL.
|
|
4
|
+
# Reference fetched from:
|
|
5
|
+
# https://raw.githubusercontent.com/cloudflare/cloudflared/master/cmd/cloudflared/tunnel/quick_tunnel.go
|
|
6
|
+
2026-01-01T00:00:00Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
|
|
7
|
+
2026-01-01T00:00:00Z INF Requesting new quick Tunnel on trycloudflare.com...
|
|
8
|
+
2026-01-01T00:00:00Z INF +--------------------------------------------------------------------------------------------------+
|
|
9
|
+
2026-01-01T00:00:00Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
|
|
10
|
+
2026-01-01T00:00:00Z INF | https://wave-one-fixture.trycloudflare.com |
|
|
11
|
+
2026-01-01T00:00:00Z INF +--------------------------------------------------------------------------------------------------+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TunnelUrlChangedPayload } from './types'
|
|
2
|
+
|
|
3
|
+
export function isTunnelUrlChangedPayload(value: unknown): value is TunnelUrlChangedPayload {
|
|
4
|
+
if (value === null || typeof value !== 'object') return false
|
|
5
|
+
const v = value as Record<string, unknown>
|
|
6
|
+
if (v.kind !== 'tunnel-url-changed') return false
|
|
7
|
+
if (typeof v.tunnelName !== 'string') return false
|
|
8
|
+
if (typeof v.url !== 'string') return false
|
|
9
|
+
if (typeof v.rotatedAt !== 'string') return false
|
|
10
|
+
if (v.for === null || typeof v.for !== 'object') return false
|
|
11
|
+
const forKind = (v.for as Record<string, unknown>).kind
|
|
12
|
+
if (forKind !== 'channel' && forKind !== 'manual') return false
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createTunnelManager, type TunnelManager, type TunnelManagerOptions, type TunnelManagerLogger } from './manager'
|
|
2
|
+
export { createCloudflareQuickProvider, type CloudflareQuickProviderOptions } from './providers/cloudflare-quick'
|
|
3
|
+
export {
|
|
4
|
+
type TunnelConfig,
|
|
5
|
+
type TunnelFor,
|
|
6
|
+
type TunnelProvider,
|
|
7
|
+
type TunnelProviderHandle,
|
|
8
|
+
type TunnelState,
|
|
9
|
+
type TunnelStatus,
|
|
10
|
+
type TunnelUrlChangedPayload,
|
|
11
|
+
} from './types'
|
|
12
|
+
export { isTunnelUrlChangedPayload } from './events'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LOG_RING_MAX_BYTES = 1024 * 1024
|
|
4
|
+
|
|
5
|
+
export type LogLineSubscriber = (line: string) => void
|
|
6
|
+
|
|
7
|
+
export type LogRingOptions = {
|
|
8
|
+
maxBytes?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LogRing = {
|
|
12
|
+
append: (line: string) => void
|
|
13
|
+
snapshot: () => string[]
|
|
14
|
+
subscribe: (cb: LogLineSubscriber) => Unsubscribe
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder()
|
|
18
|
+
|
|
19
|
+
export function createLogRing(options: LogRingOptions = {}): LogRing {
|
|
20
|
+
const maxBytes = options.maxBytes ?? DEFAULT_LOG_RING_MAX_BYTES
|
|
21
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
|
22
|
+
throw new Error('LogRing maxBytes must be a positive integer')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
const sizes: number[] = []
|
|
27
|
+
const subscribers = new Set<LogLineSubscriber>()
|
|
28
|
+
let bytes = 0
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
append(line: string): void {
|
|
32
|
+
const size = encoder.encode(line).byteLength
|
|
33
|
+
lines.push(line)
|
|
34
|
+
sizes.push(size)
|
|
35
|
+
bytes += size
|
|
36
|
+
|
|
37
|
+
while (bytes > maxBytes && lines.length > 1) {
|
|
38
|
+
lines.shift()
|
|
39
|
+
bytes -= sizes.shift() ?? 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const subscriber of subscribers) subscriber(line)
|
|
43
|
+
},
|
|
44
|
+
snapshot(): string[] {
|
|
45
|
+
return [...lines]
|
|
46
|
+
},
|
|
47
|
+
subscribe(cb: LogLineSubscriber): Unsubscribe {
|
|
48
|
+
subscribers.add(cb)
|
|
49
|
+
return () => {
|
|
50
|
+
subscribers.delete(cb)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Stream } from '@/stream'
|
|
2
|
+
|
|
3
|
+
import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
|
|
4
|
+
import { createExternalProvider } from './providers/external'
|
|
5
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
|
|
6
|
+
|
|
7
|
+
export type TunnelManagerLogger = {
|
|
8
|
+
info: (m: string) => void
|
|
9
|
+
warn: (m: string) => void
|
|
10
|
+
error: (m: string) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type TunnelManagerOptions = {
|
|
14
|
+
tunnels: TunnelConfig[]
|
|
15
|
+
stream: Stream
|
|
16
|
+
resolveChannelUpstreamPort?: (channelName: string) => number | null
|
|
17
|
+
cloudflareQuickBinary?: string
|
|
18
|
+
logger?: TunnelManagerLogger
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TunnelManager = {
|
|
22
|
+
start: () => Promise<void>
|
|
23
|
+
stop: () => Promise<void>
|
|
24
|
+
snapshot: () => TunnelState[]
|
|
25
|
+
urlFor: (tunnelName: string) => string | null
|
|
26
|
+
tail: (tunnelName: string) => string[]
|
|
27
|
+
subscribeToLogs: (tunnelName: string, cb: (line: string) => void) => () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const consoleLogger: TunnelManagerLogger = {
|
|
31
|
+
info: (m) => console.log(m),
|
|
32
|
+
warn: (m) => console.warn(m),
|
|
33
|
+
error: (m) => console.error(m),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
|
|
37
|
+
const logger = options.logger ?? consoleLogger
|
|
38
|
+
const handles = new Map<string, TunnelProviderHandle>()
|
|
39
|
+
|
|
40
|
+
for (const config of options.tunnels) {
|
|
41
|
+
const handle = buildProvider(
|
|
42
|
+
config,
|
|
43
|
+
options.resolveChannelUpstreamPort,
|
|
44
|
+
(url) => publishUrlChange(options.stream, config, url, logger),
|
|
45
|
+
options.cloudflareQuickBinary,
|
|
46
|
+
)
|
|
47
|
+
handles.set(config.name, handle)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async start(): Promise<void> {
|
|
52
|
+
await Promise.all(
|
|
53
|
+
Array.from(handles.values()).map(async (h) => {
|
|
54
|
+
try {
|
|
55
|
+
await h.start()
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.error(
|
|
58
|
+
`[tunnels] ${h.snapshot().name}: start failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
async stop(): Promise<void> {
|
|
65
|
+
await Promise.all(
|
|
66
|
+
Array.from(handles.values()).map((h) =>
|
|
67
|
+
h.stop().catch((err: unknown) => {
|
|
68
|
+
logger.warn(
|
|
69
|
+
`[tunnels] ${h.snapshot().name}: stop failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
)
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
snapshot(): TunnelState[] {
|
|
76
|
+
return Array.from(handles.values()).map((h) => h.snapshot())
|
|
77
|
+
},
|
|
78
|
+
urlFor(tunnelName: string): string | null {
|
|
79
|
+
return handles.get(tunnelName)?.snapshot().url ?? null
|
|
80
|
+
},
|
|
81
|
+
tail(tunnelName: string): string[] {
|
|
82
|
+
return handles.get(tunnelName)?.tail() ?? []
|
|
83
|
+
},
|
|
84
|
+
subscribeToLogs(tunnelName: string, cb: (line: string) => void): () => void {
|
|
85
|
+
return handles.get(tunnelName)?.subscribeToLogs(cb) ?? (() => {})
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildProvider(
|
|
91
|
+
config: TunnelConfig,
|
|
92
|
+
resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
|
|
93
|
+
onUrlChange: (url: string) => void,
|
|
94
|
+
cloudflareQuickBinary: string | undefined,
|
|
95
|
+
): TunnelProviderHandle {
|
|
96
|
+
switch (config.provider) {
|
|
97
|
+
case 'external':
|
|
98
|
+
return createExternalProvider({ config, onUrlChange })
|
|
99
|
+
case 'cloudflare-quick':
|
|
100
|
+
return createCloudflareQuickProvider({
|
|
101
|
+
config,
|
|
102
|
+
upstreamPort: resolveUpstreamPort(config, resolveChannelUpstreamPort),
|
|
103
|
+
onUrlChange,
|
|
104
|
+
binary: cloudflareQuickBinary,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveUpstreamPort(
|
|
110
|
+
config: TunnelConfig,
|
|
111
|
+
resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
|
|
112
|
+
): number {
|
|
113
|
+
if (config.for.kind === 'manual') {
|
|
114
|
+
if (config.upstreamPort === undefined) {
|
|
115
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort is required for manual tunnels`)
|
|
116
|
+
}
|
|
117
|
+
return config.upstreamPort
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const upstreamPort = resolveChannelUpstreamPort?.(config.for.name) ?? null
|
|
121
|
+
if (upstreamPort === null) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`tunnel '${config.name}' (cloudflare-quick): no upstream port resolved for channel '${config.for.name}'`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return upstreamPort
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function publishUrlChange(stream: Stream, config: TunnelConfig, url: string, logger: TunnelManagerLogger): void {
|
|
130
|
+
const payload: TunnelUrlChangedPayload = {
|
|
131
|
+
kind: 'tunnel-url-changed',
|
|
132
|
+
tunnelName: config.name,
|
|
133
|
+
url,
|
|
134
|
+
for: config.for,
|
|
135
|
+
rotatedAt: new Date().toISOString(),
|
|
136
|
+
}
|
|
137
|
+
stream.publish({ target: { kind: 'broadcast' }, payload })
|
|
138
|
+
logger.info(`[tunnels] ${config.name}: URL set to ${url}`)
|
|
139
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
|
|
4
|
+
import { extractQuickTunnelUrl } from '../quick-url-parser'
|
|
5
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BINARY = 'cloudflared'
|
|
8
|
+
const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
|
|
9
|
+
const DEFAULT_MAX_FAILURES_WITHOUT_URL = 10
|
|
10
|
+
const DEFAULT_STOP_GRACE_MS = 5_000
|
|
11
|
+
|
|
12
|
+
export type CloudflareQuickProviderOptions = {
|
|
13
|
+
config: TunnelConfig
|
|
14
|
+
upstreamPort: number
|
|
15
|
+
onUrlChange: (url: string) => void
|
|
16
|
+
binary?: string
|
|
17
|
+
restartBackoffMs?: number[]
|
|
18
|
+
maxConsecutiveFailuresWithoutUrl?: number
|
|
19
|
+
stopGraceMs?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CloudflareQuickProviderHandle = TunnelProviderHandle & {
|
|
23
|
+
tail: () => string[]
|
|
24
|
+
subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createCloudflareQuickProvider(options: CloudflareQuickProviderOptions): CloudflareQuickProviderHandle {
|
|
28
|
+
const { config, upstreamPort, onUrlChange } = options
|
|
29
|
+
if (config.provider !== 'cloudflare-quick') {
|
|
30
|
+
throw new Error(`createCloudflareQuickProvider: provider must be 'cloudflare-quick', got '${config.provider}'`)
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535) {
|
|
33
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort must be a valid TCP port`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const binary = options.binary ?? DEFAULT_BINARY
|
|
37
|
+
const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
|
|
38
|
+
const maxConsecutiveFailuresWithoutUrl = options.maxConsecutiveFailuresWithoutUrl ?? DEFAULT_MAX_FAILURES_WITHOUT_URL
|
|
39
|
+
const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
|
|
40
|
+
const logs = createLogRing()
|
|
41
|
+
const state: TunnelState = {
|
|
42
|
+
name: config.name,
|
|
43
|
+
provider: 'cloudflare-quick',
|
|
44
|
+
for: config.for,
|
|
45
|
+
url: null,
|
|
46
|
+
status: 'stopped',
|
|
47
|
+
lastUrlAt: null,
|
|
48
|
+
detail: '',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let started = false
|
|
52
|
+
let stopping = false
|
|
53
|
+
let proc: ReturnType<typeof Bun.spawn> | null = null
|
|
54
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
55
|
+
let restartFailuresWithoutUrl = 0
|
|
56
|
+
let attemptEmittedUrl = false
|
|
57
|
+
|
|
58
|
+
async function launch(): Promise<void> {
|
|
59
|
+
if (!started || stopping) return
|
|
60
|
+
|
|
61
|
+
attemptEmittedUrl = false
|
|
62
|
+
state.status = 'starting'
|
|
63
|
+
state.detail = 'starting cloudflared'
|
|
64
|
+
const spawned = Bun.spawn(
|
|
65
|
+
[binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
|
|
66
|
+
{ stdout: 'ignore', stderr: 'pipe' },
|
|
67
|
+
)
|
|
68
|
+
proc = spawned
|
|
69
|
+
|
|
70
|
+
void pumpStderr(spawned.stderr, logs, (line) => {
|
|
71
|
+
const url = extractQuickTunnelUrl(line)
|
|
72
|
+
if (url === null) return
|
|
73
|
+
attemptEmittedUrl = true
|
|
74
|
+
restartFailuresWithoutUrl = 0
|
|
75
|
+
state.url = url
|
|
76
|
+
state.status = 'healthy'
|
|
77
|
+
state.lastUrlAt = Date.now()
|
|
78
|
+
state.detail = 'quick tunnel URL emitted'
|
|
79
|
+
onUrlChange(url)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
void spawned.exited.then((code) => {
|
|
83
|
+
if (proc !== spawned) return
|
|
84
|
+
proc = null
|
|
85
|
+
if (!started || stopping) return
|
|
86
|
+
handleExit(code)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleExit(code: number): void {
|
|
91
|
+
if (!attemptEmittedUrl) restartFailuresWithoutUrl += 1
|
|
92
|
+
if (restartFailuresWithoutUrl >= maxConsecutiveFailuresWithoutUrl) {
|
|
93
|
+
state.status = 'permanently-failed'
|
|
94
|
+
state.detail = `cloudflared exited ${code}; retry cap reached before URL emission`
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
state.status = 'unhealthy'
|
|
99
|
+
state.detail = `cloudflared exited ${code}; restarting`
|
|
100
|
+
const delay = restartBackoffMs[Math.min(restartFailuresWithoutUrl - 1, restartBackoffMs.length - 1)] ?? 30_000
|
|
101
|
+
retryTimer = setTimeout(() => {
|
|
102
|
+
retryTimer = null
|
|
103
|
+
void launch()
|
|
104
|
+
}, delay)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async start(): Promise<void> {
|
|
109
|
+
if (started) return
|
|
110
|
+
started = true
|
|
111
|
+
stopping = false
|
|
112
|
+
restartFailuresWithoutUrl = 0
|
|
113
|
+
await launch()
|
|
114
|
+
},
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
if (!started && proc === null) return
|
|
117
|
+
started = false
|
|
118
|
+
stopping = true
|
|
119
|
+
if (retryTimer !== null) {
|
|
120
|
+
clearTimeout(retryTimer)
|
|
121
|
+
retryTimer = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const running = proc
|
|
125
|
+
proc = null
|
|
126
|
+
if (running !== null) {
|
|
127
|
+
running.kill('SIGTERM')
|
|
128
|
+
await Promise.race([
|
|
129
|
+
running.exited,
|
|
130
|
+
sleep(stopGraceMs).then(() => {
|
|
131
|
+
running.kill('SIGKILL')
|
|
132
|
+
return running.exited
|
|
133
|
+
}),
|
|
134
|
+
])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
stopping = false
|
|
138
|
+
state.status = 'stopped'
|
|
139
|
+
state.detail = ''
|
|
140
|
+
},
|
|
141
|
+
snapshot(): TunnelState {
|
|
142
|
+
return { ...state }
|
|
143
|
+
},
|
|
144
|
+
tail(): string[] {
|
|
145
|
+
return logs.snapshot()
|
|
146
|
+
},
|
|
147
|
+
subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
|
|
148
|
+
return logs.subscribe(cb)
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function pumpStderr(
|
|
154
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
155
|
+
logs: LogRing,
|
|
156
|
+
onLine: (line: string) => void,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
if (stream === null) return
|
|
159
|
+
const reader = stream.getReader()
|
|
160
|
+
const decoder = new TextDecoder()
|
|
161
|
+
let buffered = ''
|
|
162
|
+
try {
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read()
|
|
165
|
+
if (done) break
|
|
166
|
+
buffered += decoder.decode(value, { stream: true })
|
|
167
|
+
let newlineIndex = buffered.indexOf('\n')
|
|
168
|
+
while (newlineIndex !== -1) {
|
|
169
|
+
const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
|
|
170
|
+
logs.append(line)
|
|
171
|
+
onLine(line)
|
|
172
|
+
buffered = buffered.slice(newlineIndex + 1)
|
|
173
|
+
newlineIndex = buffered.indexOf('\n')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
buffered += decoder.decode()
|
|
177
|
+
if (buffered !== '') {
|
|
178
|
+
const line = buffered.replace(/\r$/, '')
|
|
179
|
+
logs.append(line)
|
|
180
|
+
onLine(line)
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
reader.releaseLock()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleep(ms: number): Promise<void> {
|
|
188
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
189
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
2
|
+
|
|
3
|
+
export type ExternalProviderOptions = {
|
|
4
|
+
config: TunnelConfig
|
|
5
|
+
onUrlChange: (url: string) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createExternalProvider(options: ExternalProviderOptions): TunnelProviderHandle {
|
|
9
|
+
const { config, onUrlChange } = options
|
|
10
|
+
if (config.provider !== 'external') {
|
|
11
|
+
throw new Error(`createExternalProvider: provider must be 'external', got '${config.provider}'`)
|
|
12
|
+
}
|
|
13
|
+
const url = config.externalUrl
|
|
14
|
+
if (url === undefined || url.trim() === '') {
|
|
15
|
+
throw new Error(`tunnel '${config.name}' (external): externalUrl is required`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let started = false
|
|
19
|
+
const state: TunnelState = {
|
|
20
|
+
name: config.name,
|
|
21
|
+
provider: 'external',
|
|
22
|
+
for: config.for,
|
|
23
|
+
url: null,
|
|
24
|
+
status: 'stopped',
|
|
25
|
+
lastUrlAt: null,
|
|
26
|
+
detail: '',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async start(): Promise<void> {
|
|
31
|
+
if (started) return
|
|
32
|
+
started = true
|
|
33
|
+
state.url = url
|
|
34
|
+
state.status = 'healthy'
|
|
35
|
+
state.lastUrlAt = Date.now()
|
|
36
|
+
onUrlChange(url)
|
|
37
|
+
},
|
|
38
|
+
async stop(): Promise<void> {
|
|
39
|
+
if (!started) return
|
|
40
|
+
started = false
|
|
41
|
+
state.status = 'stopped'
|
|
42
|
+
},
|
|
43
|
+
snapshot(): TunnelState {
|
|
44
|
+
return { ...state }
|
|
45
|
+
},
|
|
46
|
+
tail(): string[] {
|
|
47
|
+
return []
|
|
48
|
+
},
|
|
49
|
+
subscribeToLogs(): () => void {
|
|
50
|
+
return () => {}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
export type TunnelProvider = 'external' | 'cloudflare-quick'
|
|
4
|
+
|
|
5
|
+
export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
|
|
6
|
+
|
|
7
|
+
export type TunnelConfig = {
|
|
8
|
+
name: string
|
|
9
|
+
provider: TunnelProvider
|
|
10
|
+
for: TunnelFor
|
|
11
|
+
externalUrl?: string
|
|
12
|
+
upstreamPort?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
|
|
16
|
+
|
|
17
|
+
export type TunnelState = {
|
|
18
|
+
name: string
|
|
19
|
+
provider: TunnelProvider
|
|
20
|
+
for: TunnelFor
|
|
21
|
+
url: string | null
|
|
22
|
+
status: TunnelStatus
|
|
23
|
+
lastUrlAt: number | null
|
|
24
|
+
detail: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TunnelProviderHandle = {
|
|
28
|
+
start: () => Promise<void>
|
|
29
|
+
stop: () => Promise<void>
|
|
30
|
+
snapshot: () => TunnelState
|
|
31
|
+
tail: () => string[]
|
|
32
|
+
subscribeToLogs: (cb: TunnelLogSubscriber) => Unsubscribe
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TunnelLogSubscriber = (line: string) => void
|
|
36
|
+
|
|
37
|
+
export type TunnelUrlChangedPayload = {
|
|
38
|
+
kind: 'tunnel-url-changed'
|
|
39
|
+
tunnelName: string
|
|
40
|
+
url: string
|
|
41
|
+
for: TunnelFor
|
|
42
|
+
rotatedAt: string
|
|
43
|
+
}
|