typeclaw 0.3.1 → 0.4.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/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/bundled-plugins/security/index.ts +3 -2
- 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 +286 -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/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- 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 +256 -27
- package/src/cli/model.ts +4 -2
- 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/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- 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 +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +110 -3
- 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-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- 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 +35 -4
- 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/typeclaw.schema.json +254 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { loadConfigSync } from '@/config/config'
|
|
5
|
+
import {
|
|
6
|
+
loadPluginEntry,
|
|
7
|
+
type LoadPluginEntryFn,
|
|
8
|
+
type PluginCommand,
|
|
9
|
+
type ResolvedPlugin,
|
|
10
|
+
validateCommandDeclaration,
|
|
11
|
+
} from '@/plugin'
|
|
12
|
+
|
|
13
|
+
export type DiscoveredCommand = {
|
|
14
|
+
pluginName: string
|
|
15
|
+
pluginVersion: string | undefined
|
|
16
|
+
commandName: string
|
|
17
|
+
command: PluginCommand
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DiscoveryResult = {
|
|
21
|
+
agentDir: string
|
|
22
|
+
commands: DiscoveredCommand[]
|
|
23
|
+
loadErrors: { entry: string; error: string }[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DiscoverOptions = {
|
|
27
|
+
cwd: string
|
|
28
|
+
loadEntry?: LoadPluginEntryFn
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Resolves the agent folder by walking up from cwd until typeclaw.json is
|
|
32
|
+
// found. Returns null when no agent folder is reachable (e.g. typeclaw run
|
|
33
|
+
// from a random shell prompt without an agent).
|
|
34
|
+
export function resolveAgentDir(cwd: string): string | null {
|
|
35
|
+
let cur = cwd
|
|
36
|
+
while (true) {
|
|
37
|
+
if (existsSync(join(cur, 'typeclaw.json'))) return cur
|
|
38
|
+
const parent = join(cur, '..')
|
|
39
|
+
const resolved = normalize(parent)
|
|
40
|
+
if (resolved === cur) return null
|
|
41
|
+
cur = resolved
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalize(p: string): string {
|
|
46
|
+
return p.replace(/\/+$/, '') || '/'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Discovers plugin commands available to the agent at `cwd`. Loads each
|
|
50
|
+
// plugin module to read its static `defined.commands`, but NEVER invokes
|
|
51
|
+
// the plugin factory — that's a runtime concern reserved for `typeclaw run`.
|
|
52
|
+
//
|
|
53
|
+
// Returns an empty result (no error) when no agent folder is resolvable, so
|
|
54
|
+
// `typeclaw --help` outside any agent prints just built-ins.
|
|
55
|
+
//
|
|
56
|
+
// Side effects: `loadConfigSync(agentDir)` may rewrite `typeclaw.json` and
|
|
57
|
+
// commit the result when the on-disk shape is a legacy schema needing
|
|
58
|
+
// migration. This is by design — running ANY typeclaw subcommand should
|
|
59
|
+
// converge the config on the canonical shape. The migration is idempotent
|
|
60
|
+
// (running twice is a no-op).
|
|
61
|
+
export async function discoverCommands(opts: DiscoverOptions): Promise<DiscoveryResult> {
|
|
62
|
+
const agentDir = resolveAgentDir(opts.cwd)
|
|
63
|
+
if (agentDir === null) {
|
|
64
|
+
return { agentDir: opts.cwd, commands: [], loadErrors: [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let config
|
|
68
|
+
try {
|
|
69
|
+
config = loadConfigSync(agentDir)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
72
|
+
return { agentDir, commands: [], loadErrors: [{ entry: '<config>', error: detail }] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const loadEntry = opts.loadEntry ?? loadPluginEntry
|
|
76
|
+
const commands: DiscoveredCommand[] = []
|
|
77
|
+
const loadErrors: { entry: string; error: string }[] = []
|
|
78
|
+
const seenNames = new Set<string>()
|
|
79
|
+
|
|
80
|
+
for (const entry of config.plugins) {
|
|
81
|
+
let resolved: ResolvedPlugin
|
|
82
|
+
try {
|
|
83
|
+
resolved = await loadEntry(entry, agentDir)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
86
|
+
loadErrors.push({ entry, error: detail })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const declared = resolved.defined.commands
|
|
91
|
+
if (declared === undefined) continue
|
|
92
|
+
|
|
93
|
+
for (const [commandName, command] of Object.entries(declared)) {
|
|
94
|
+
try {
|
|
95
|
+
validateCommandDeclaration(resolved.name, commandName, command)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
loadErrors.push({ entry, error: err instanceof Error ? err.message : String(err) })
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (seenNames.has(commandName)) {
|
|
101
|
+
loadErrors.push({
|
|
102
|
+
entry,
|
|
103
|
+
error: `command "${commandName}" already declared by another plugin; ignoring`,
|
|
104
|
+
})
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
seenNames.add(commandName)
|
|
108
|
+
commands.push({
|
|
109
|
+
pluginName: resolved.name,
|
|
110
|
+
pluginVersion: resolved.version,
|
|
111
|
+
commandName,
|
|
112
|
+
command,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { agentDir, commands, loadErrors }
|
|
118
|
+
}
|
package/src/cli/tui.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
|
-
import {
|
|
5
|
+
import { CLI_VERSION } from '@/init/cli-version'
|
|
6
|
+
import { createTui, formatVersionMismatchWarning } from '@/tui'
|
|
6
7
|
|
|
7
8
|
import { errorLine } from './ui'
|
|
8
9
|
|
|
@@ -25,7 +26,14 @@ export const tui = defineCommand({
|
|
|
25
26
|
},
|
|
26
27
|
async run({ args }) {
|
|
27
28
|
const url = args.url ?? (await defaultUrl())
|
|
28
|
-
const tui = createTui({
|
|
29
|
+
const tui = createTui({
|
|
30
|
+
url,
|
|
31
|
+
...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
|
|
32
|
+
expectedVersion: CLI_VERSION,
|
|
33
|
+
onVersionMismatch: (info) => {
|
|
34
|
+
process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
|
|
35
|
+
},
|
|
36
|
+
})
|
|
29
37
|
await tui.run()
|
|
30
38
|
},
|
|
31
39
|
})
|
|
@@ -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
|
|