typeclaw 0.9.2 → 0.11.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/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
package/src/cli/tunnel.ts
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { select, text, isCancel, cancel, log } from '@clack/prompts'
|
|
4
|
+
import { select, text, password, isCancel, cancel, log } from '@clack/prompts'
|
|
5
5
|
import { defineCommand } from 'citty'
|
|
6
6
|
|
|
7
7
|
import { loadConfigSync, validateConfig } from '@/config'
|
|
8
8
|
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
9
|
-
import { findAgentDir, isInitialized } from '@/init'
|
|
9
|
+
import { appendOrReplaceEnvKey, findAgentDir, hasEnvKey, isInitialized } from '@/init'
|
|
10
10
|
import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
|
|
11
11
|
import type { TunnelConfig, TunnelFor, TunnelProvider } from '@/tunnels'
|
|
12
12
|
|
|
13
13
|
import { c, errorLine } from './ui'
|
|
14
14
|
|
|
15
15
|
type AddArgs = {
|
|
16
|
-
name
|
|
16
|
+
name?: string
|
|
17
17
|
provider?: string
|
|
18
18
|
forChannel?: string
|
|
19
19
|
forManual?: boolean
|
|
20
20
|
upstreamPort?: string
|
|
21
21
|
externalUrl?: string
|
|
22
|
+
hostname?: string
|
|
23
|
+
tokenEnv?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SetArgs = {
|
|
27
|
+
name?: string
|
|
28
|
+
provider?: string
|
|
29
|
+
upstreamPort?: string
|
|
30
|
+
externalUrl?: string
|
|
31
|
+
hostname?: string
|
|
32
|
+
tokenEnv?: string
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
type RemoveArgs = { name: string }
|
|
@@ -31,12 +42,32 @@ type LiveResult<T> = { ok: true; value: T } | { ok: false; reason: string }
|
|
|
31
42
|
|
|
32
43
|
export type TextValidator = (value: string) => string | undefined
|
|
33
44
|
|
|
45
|
+
export type TunnelSetField = 'provider' | 'externalUrl' | 'hostname' | 'tokenEnv' | 'upstreamPort'
|
|
46
|
+
|
|
34
47
|
export type TunnelPrompts = {
|
|
35
48
|
selectProvider: () => Promise<TunnelProvider | symbol>
|
|
36
49
|
selectOwner: () => Promise<'channel' | 'manual' | symbol>
|
|
50
|
+
// Only `runTunnelSetFlow` uses this; `runTunnelAddFlow` callers can omit it.
|
|
51
|
+
selectSetField?: (
|
|
52
|
+
choices: readonly { value: TunnelSetField; label: string; hint?: string }[],
|
|
53
|
+
) => Promise<TunnelSetField | symbol>
|
|
54
|
+
// Only `runTunnelSetFlow` uses this when the positional `name` is omitted
|
|
55
|
+
// and more than one tunnel is configured. Optional so existing callers
|
|
56
|
+
// (especially `runTunnelAddFlow` tests) don't have to stub it.
|
|
57
|
+
selectExistingTunnel?: (
|
|
58
|
+
choices: readonly { value: string; label: string; hint?: string }[],
|
|
59
|
+
) => Promise<string | symbol>
|
|
37
60
|
text: (message: string, validate?: TextValidator) => Promise<string | symbol>
|
|
61
|
+
// Only fires when the user picks `cloudflare-named` AND the resolved
|
|
62
|
+
// `tokenEnv` is missing from the agent's `.env`. Optional so existing
|
|
63
|
+
// callers/tests don't have to stub it; flows that need it but don't get
|
|
64
|
+
// one fall back to skipping the token write (the user can still set
|
|
65
|
+
// `.env` by hand). Same compat pattern as `selectSetField`.
|
|
66
|
+
password?: (message: string, validate?: TextValidator) => Promise<string | symbol>
|
|
38
67
|
}
|
|
39
68
|
|
|
69
|
+
const DEFAULT_TUNNEL_TOKEN_ENV = 'CLOUDFLARE_TUNNEL_TOKEN'
|
|
70
|
+
|
|
40
71
|
const DEFAULT_TIMEOUT_MS = 15_000
|
|
41
72
|
|
|
42
73
|
const defaultPrompts: TunnelPrompts = {
|
|
@@ -45,6 +76,11 @@ const defaultPrompts: TunnelPrompts = {
|
|
|
45
76
|
message: 'Tunnel provider',
|
|
46
77
|
options: [
|
|
47
78
|
{ value: 'cloudflare-quick', label: 'Cloudflare Quick Tunnel', hint: 'no signup, URL rotates on restart' },
|
|
79
|
+
{
|
|
80
|
+
value: 'cloudflare-named',
|
|
81
|
+
label: 'Cloudflare Named Tunnel',
|
|
82
|
+
hint: 'stable URL, needs Cloudflare account + domain',
|
|
83
|
+
},
|
|
48
84
|
{ value: 'external', label: 'External URL', hint: 'bring your own reverse proxy' },
|
|
49
85
|
],
|
|
50
86
|
}),
|
|
@@ -56,28 +92,55 @@ const defaultPrompts: TunnelPrompts = {
|
|
|
56
92
|
{ value: 'manual', label: 'Manual upstream' },
|
|
57
93
|
],
|
|
58
94
|
}),
|
|
95
|
+
selectSetField: (choices) =>
|
|
96
|
+
select<TunnelSetField>({
|
|
97
|
+
message: 'Which field do you want to change?',
|
|
98
|
+
options: choices.map((choice) => ({
|
|
99
|
+
value: choice.value,
|
|
100
|
+
label: choice.label,
|
|
101
|
+
...(choice.hint !== undefined ? { hint: choice.hint } : {}),
|
|
102
|
+
})),
|
|
103
|
+
}),
|
|
104
|
+
selectExistingTunnel: (choices) =>
|
|
105
|
+
select<string>({
|
|
106
|
+
message: 'Pick a tunnel to edit',
|
|
107
|
+
options: choices.map((choice) => ({
|
|
108
|
+
value: choice.value,
|
|
109
|
+
label: choice.label,
|
|
110
|
+
...(choice.hint !== undefined ? { hint: choice.hint } : {}),
|
|
111
|
+
})),
|
|
112
|
+
}),
|
|
59
113
|
text: (message, validate) =>
|
|
60
114
|
text({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
|
|
115
|
+
password: (message, validate) =>
|
|
116
|
+
password({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
|
|
61
117
|
}
|
|
62
118
|
|
|
63
119
|
const addSub = defineCommand({
|
|
64
120
|
meta: { name: 'add', description: 'add a public tunnel entry to typeclaw.json' },
|
|
65
121
|
args: {
|
|
66
|
-
name: { type: 'positional', required:
|
|
67
|
-
provider: { type: 'string', description: 'external | cloudflare-quick' },
|
|
122
|
+
name: { type: 'positional', required: false, description: 'tunnel name (omit to prompt interactively)' },
|
|
123
|
+
provider: { type: 'string', description: 'external | cloudflare-quick | cloudflare-named' },
|
|
68
124
|
'for-channel': { type: 'string', description: 'own this tunnel from a channel adapter' },
|
|
69
125
|
'for-manual': { type: 'boolean', description: 'create a manually-owned tunnel' },
|
|
70
126
|
'upstream-port': { type: 'string', description: 'container-local upstream port for manual tunnels' },
|
|
71
127
|
'external-url': { type: 'string', description: 'https URL for provider=external' },
|
|
128
|
+
hostname: { type: 'string', description: 'https URL for provider=cloudflare-named (dashboard Public Hostname)' },
|
|
129
|
+
'token-env': {
|
|
130
|
+
type: 'string',
|
|
131
|
+
description: 'env var name holding the cloudflared token (provider=cloudflare-named)',
|
|
132
|
+
},
|
|
72
133
|
},
|
|
73
134
|
async run({ args }) {
|
|
74
135
|
const result = await runTunnelAddFlow(ensureAgentDir(), {
|
|
75
|
-
name: String(args.name),
|
|
136
|
+
...(args.name !== undefined ? { name: String(args.name) } : {}),
|
|
76
137
|
...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
|
|
77
138
|
...(args['for-channel'] !== undefined ? { forChannel: String(args['for-channel']) } : {}),
|
|
78
139
|
...(args['for-manual'] === true ? { forManual: true } : {}),
|
|
79
140
|
...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
|
|
80
141
|
...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
|
|
142
|
+
...(args.hostname !== undefined ? { hostname: String(args.hostname) } : {}),
|
|
143
|
+
...(args['token-env'] !== undefined ? { tokenEnv: String(args['token-env']) } : {}),
|
|
81
144
|
})
|
|
82
145
|
if (!result.ok) {
|
|
83
146
|
console.error(errorLine(result.reason))
|
|
@@ -131,6 +194,50 @@ const removeSub = defineCommand({
|
|
|
131
194
|
},
|
|
132
195
|
})
|
|
133
196
|
|
|
197
|
+
const setSub = defineCommand({
|
|
198
|
+
meta: {
|
|
199
|
+
name: 'set',
|
|
200
|
+
description: 'edit an existing tunnel entry in typeclaw.json (symmetric with `typeclaw channel set`)',
|
|
201
|
+
},
|
|
202
|
+
args: {
|
|
203
|
+
name: { type: 'positional', required: false, description: 'tunnel name (omit to pick interactively)' },
|
|
204
|
+
provider: { type: 'string', description: 'external | cloudflare-quick | cloudflare-named' },
|
|
205
|
+
'upstream-port': { type: 'string', description: 'container-local upstream port (manual non-named tunnels)' },
|
|
206
|
+
'external-url': { type: 'string', description: 'https URL for provider=external' },
|
|
207
|
+
hostname: { type: 'string', description: 'https URL for provider=cloudflare-named (dashboard Public Hostname)' },
|
|
208
|
+
'token-env': {
|
|
209
|
+
type: 'string',
|
|
210
|
+
description: 'env var name holding the cloudflared token (provider=cloudflare-named)',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
async run({ args }) {
|
|
214
|
+
const result = await runTunnelSetFlow(ensureAgentDir(), {
|
|
215
|
+
...(args.name !== undefined ? { name: String(args.name) } : {}),
|
|
216
|
+
...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
|
|
217
|
+
...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
|
|
218
|
+
...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
|
|
219
|
+
...(args.hostname !== undefined ? { hostname: String(args.hostname) } : {}),
|
|
220
|
+
...(args['token-env'] !== undefined ? { tokenEnv: String(args['token-env']) } : {}),
|
|
221
|
+
})
|
|
222
|
+
if (!result.ok) {
|
|
223
|
+
console.error(errorLine(result.reason))
|
|
224
|
+
process.exit(1)
|
|
225
|
+
}
|
|
226
|
+
log.success(`Updated tunnel "${result.value.name}" in typeclaw.json.`)
|
|
227
|
+
if (result.value.for.kind === 'channel') {
|
|
228
|
+
// The container-side adapter (see src/channels/adapters/github/index.ts)
|
|
229
|
+
// re-runs webhook registration on every start. A restart is required
|
|
230
|
+
// anyway because `tunnels` is restart-required (FIELD_EFFECTS in
|
|
231
|
+
// src/config/config.ts), so on the next start the adapter picks up the
|
|
232
|
+
// new URL and re-points its managed webhooks at it. No CLI-side
|
|
233
|
+
// eager re-install needed.
|
|
234
|
+
log.info(`Run typeclaw restart to apply (the ${result.value.for.name} adapter will re-register its webhooks).`)
|
|
235
|
+
} else {
|
|
236
|
+
log.info('Run typeclaw restart to apply.')
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
|
|
134
241
|
const logsSub = defineCommand({
|
|
135
242
|
meta: { name: 'logs', description: 'print or follow a tunnel log ring' },
|
|
136
243
|
args: {
|
|
@@ -160,7 +267,7 @@ const logsSub = defineCommand({
|
|
|
160
267
|
|
|
161
268
|
export const tunnelCommand = defineCommand({
|
|
162
269
|
meta: { name: 'tunnel', description: 'manage public tunnels for channels and manual upstreams' },
|
|
163
|
-
subCommands: { add: addSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
|
|
270
|
+
subCommands: { add: addSub, set: setSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
|
|
164
271
|
})
|
|
165
272
|
|
|
166
273
|
export async function runTunnelAddFlow(
|
|
@@ -178,13 +285,15 @@ export async function runTunnelAddFlow(
|
|
|
178
285
|
const validation = validateConfig(cwd)
|
|
179
286
|
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
180
287
|
const config = loadConfigSync(cwd)
|
|
181
|
-
|
|
182
|
-
|
|
288
|
+
const existingNames = new Set(config.tunnels.map((entry) => entry.name))
|
|
289
|
+
const name = args.name ?? (await promptText('Tunnel name', prompts, makeTunnelNameValidator(existingNames)))
|
|
290
|
+
const nameError = validateTunnelName(name, existingNames)
|
|
291
|
+
if (nameError !== undefined) return { ok: false, reason: nameError }
|
|
183
292
|
|
|
184
293
|
const provider = await resolveProvider(args.provider, prompts)
|
|
185
294
|
const tunnelFor = await resolveFor(args, prompts)
|
|
186
295
|
let upstreamPort: number | undefined
|
|
187
|
-
if (tunnelFor.kind === 'manual') {
|
|
296
|
+
if (tunnelFor.kind === 'manual' && provider !== 'cloudflare-named') {
|
|
188
297
|
const raw = args.upstreamPort ?? (await promptText('Upstream port', prompts, validateUpstreamPort))
|
|
189
298
|
const portError = validateUpstreamPort(raw)
|
|
190
299
|
if (portError !== undefined) return { ok: false, reason: `upstream port: ${portError}` }
|
|
@@ -196,17 +305,42 @@ export async function runTunnelAddFlow(
|
|
|
196
305
|
const urlError = validateHttpsUrl(externalUrl)
|
|
197
306
|
if (urlError !== undefined) return { ok: false, reason: `external URL: ${urlError}` }
|
|
198
307
|
}
|
|
308
|
+
let hostname: string | undefined
|
|
309
|
+
let tokenEnv: string | undefined
|
|
310
|
+
if (provider === 'cloudflare-named') {
|
|
311
|
+
hostname =
|
|
312
|
+
args.hostname ??
|
|
313
|
+
(await promptText(
|
|
314
|
+
'Public hostname configured in the Cloudflare dashboard (https://...)',
|
|
315
|
+
prompts,
|
|
316
|
+
validateHttpsUrl,
|
|
317
|
+
))
|
|
318
|
+
const hostnameError = validateHttpsUrl(hostname)
|
|
319
|
+
if (hostnameError !== undefined) return { ok: false, reason: `hostname: ${hostnameError}` }
|
|
320
|
+
tokenEnv = args.tokenEnv ?? DEFAULT_TUNNEL_TOKEN_ENV
|
|
321
|
+
const tokenError = validateTokenEnv(tokenEnv)
|
|
322
|
+
if (tokenError !== undefined) return { ok: false, reason: `token-env: ${tokenError}` }
|
|
323
|
+
// Only prompt for the token VALUE in interactive mode. `--provider` on
|
|
324
|
+
// the CLI signals scripted invocation; bombarding a script with a
|
|
325
|
+
// password prompt it can't satisfy would deadlock CI runs.
|
|
326
|
+
if (args.provider === undefined) {
|
|
327
|
+
const tokenPromptResult = await maybePromptTunnelTokenValue(cwd, tokenEnv, prompts)
|
|
328
|
+
if (!tokenPromptResult.ok) return tokenPromptResult
|
|
329
|
+
}
|
|
330
|
+
}
|
|
199
331
|
|
|
200
332
|
const tunnel: TunnelConfig = {
|
|
201
|
-
name
|
|
333
|
+
name,
|
|
202
334
|
provider,
|
|
203
335
|
for: tunnelFor,
|
|
204
336
|
...(externalUrl !== undefined ? { externalUrl } : {}),
|
|
205
337
|
...(upstreamPort !== undefined ? { upstreamPort } : {}),
|
|
338
|
+
...(hostname !== undefined ? { hostname } : {}),
|
|
339
|
+
...(tokenEnv !== undefined ? { tokenEnv } : {}),
|
|
206
340
|
}
|
|
207
341
|
const raw = readRawConfig(cwd)
|
|
208
342
|
raw.tunnels = [...config.tunnels, tunnel]
|
|
209
|
-
if (provider === 'cloudflare-quick') {
|
|
343
|
+
if (provider === 'cloudflare-quick' || provider === 'cloudflare-named') {
|
|
210
344
|
raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
|
|
211
345
|
}
|
|
212
346
|
writeRawConfig(cwd, raw)
|
|
@@ -224,7 +358,7 @@ export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{
|
|
|
224
358
|
if (tunnel.for.kind === 'channel') {
|
|
225
359
|
return {
|
|
226
360
|
ok: false,
|
|
227
|
-
reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}"
|
|
361
|
+
reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}". Use \`typeclaw tunnel set ${args.name}\` to change its provider/URL, or hand-edit typeclaw.json to remove both the channel block and the tunnel.`,
|
|
228
362
|
}
|
|
229
363
|
}
|
|
230
364
|
const raw = readRawConfig(cwd)
|
|
@@ -234,6 +368,243 @@ export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{
|
|
|
234
368
|
return { ok: true, value: { removed: tunnel } }
|
|
235
369
|
}
|
|
236
370
|
|
|
371
|
+
export async function runTunnelSetFlow(
|
|
372
|
+
cwd: string,
|
|
373
|
+
args: SetArgs,
|
|
374
|
+
prompts: TunnelPrompts = defaultPrompts,
|
|
375
|
+
): Promise<LiveResult<TunnelConfig>> {
|
|
376
|
+
const validation = validateConfig(cwd)
|
|
377
|
+
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
378
|
+
const config = loadConfigSync(cwd)
|
|
379
|
+
if (config.tunnels.length === 0) {
|
|
380
|
+
return { ok: false, reason: 'no tunnels configured. Run `typeclaw tunnel add` first.' }
|
|
381
|
+
}
|
|
382
|
+
const nameResult =
|
|
383
|
+
args.name !== undefined
|
|
384
|
+
? { ok: true as const, value: args.name }
|
|
385
|
+
: await resolveExistingTunnelName(config.tunnels, prompts)
|
|
386
|
+
if (!nameResult.ok) return nameResult
|
|
387
|
+
const existing = config.tunnels.find((entry) => entry.name === nameResult.value)
|
|
388
|
+
if (existing === undefined) return { ok: false, reason: `unknown tunnel: ${nameResult.value}` }
|
|
389
|
+
|
|
390
|
+
const flagFields = collectSetFlagFields(args)
|
|
391
|
+
const interactive = flagFields.length === 0
|
|
392
|
+
|
|
393
|
+
let nextProvider = existing.provider
|
|
394
|
+
let nextExternalUrl = existing.externalUrl
|
|
395
|
+
let nextHostname = existing.hostname
|
|
396
|
+
let nextTokenEnv = existing.tokenEnv
|
|
397
|
+
let nextUpstreamPort = existing.upstreamPort
|
|
398
|
+
|
|
399
|
+
if (args.provider !== undefined) {
|
|
400
|
+
const resolved = await resolveProvider(args.provider, prompts)
|
|
401
|
+
nextProvider = resolved
|
|
402
|
+
} else if (interactive) {
|
|
403
|
+
const choices = buildSetFieldChoices(existing)
|
|
404
|
+
if (choices.length === 0) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
reason: `tunnel "${existing.name}" has no editable fields for provider "${existing.provider}"`,
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (prompts.selectSetField === undefined) {
|
|
411
|
+
return { ok: false, reason: 'interactive set requires selectSetField prompt (pass a flag, e.g. --provider)' }
|
|
412
|
+
}
|
|
413
|
+
const field = await prompts.selectSetField(choices)
|
|
414
|
+
if (isCancel(field)) {
|
|
415
|
+
cancel('Aborted.')
|
|
416
|
+
process.exit(0)
|
|
417
|
+
}
|
|
418
|
+
if (field === 'provider') {
|
|
419
|
+
nextProvider = await resolveProvider(undefined, prompts)
|
|
420
|
+
} else {
|
|
421
|
+
const interactivePatch = await collectInteractiveFieldPatch(field, prompts)
|
|
422
|
+
if (!interactivePatch.ok) return interactivePatch
|
|
423
|
+
nextExternalUrl = interactivePatch.value.externalUrl ?? nextExternalUrl
|
|
424
|
+
nextHostname = interactivePatch.value.hostname ?? nextHostname
|
|
425
|
+
nextTokenEnv = interactivePatch.value.tokenEnv ?? nextTokenEnv
|
|
426
|
+
nextUpstreamPort = interactivePatch.value.upstreamPort ?? nextUpstreamPort
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (args.externalUrl !== undefined) {
|
|
431
|
+
const err = validateHttpsUrl(args.externalUrl)
|
|
432
|
+
if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
|
|
433
|
+
nextExternalUrl = args.externalUrl
|
|
434
|
+
}
|
|
435
|
+
if (args.hostname !== undefined) {
|
|
436
|
+
const err = validateHttpsUrl(args.hostname)
|
|
437
|
+
if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
|
|
438
|
+
nextHostname = args.hostname
|
|
439
|
+
}
|
|
440
|
+
if (args.tokenEnv !== undefined) {
|
|
441
|
+
const err = validateTokenEnv(args.tokenEnv)
|
|
442
|
+
if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
|
|
443
|
+
nextTokenEnv = args.tokenEnv
|
|
444
|
+
}
|
|
445
|
+
if (args.upstreamPort !== undefined) {
|
|
446
|
+
const err = validateUpstreamPort(args.upstreamPort)
|
|
447
|
+
if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
|
|
448
|
+
nextUpstreamPort = Number(args.upstreamPort)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// On a provider switch, drop fields the new provider forbids and require
|
|
452
|
+
// fields the new provider needs. This mirrors the per-provider refinements
|
|
453
|
+
// in tunnelEntrySchema (src/config/config.ts) so the schema-validation
|
|
454
|
+
// round-trip after write doesn't fail on stale fields from the old shape.
|
|
455
|
+
if (nextProvider !== existing.provider) {
|
|
456
|
+
if (nextProvider !== 'external') nextExternalUrl = undefined
|
|
457
|
+
if (nextProvider !== 'cloudflare-named') {
|
|
458
|
+
nextHostname = undefined
|
|
459
|
+
nextTokenEnv = undefined
|
|
460
|
+
}
|
|
461
|
+
if (nextProvider === 'cloudflare-named') nextUpstreamPort = undefined
|
|
462
|
+
if (nextProvider === 'external' && nextExternalUrl === undefined) {
|
|
463
|
+
const raw =
|
|
464
|
+
args.externalUrl ??
|
|
465
|
+
(interactive ? await promptText('External HTTPS URL', prompts, validateHttpsUrl) : undefined)
|
|
466
|
+
if (raw === undefined) return { ok: false, reason: "provider 'external' requires --external-url" }
|
|
467
|
+
const err = validateHttpsUrl(raw)
|
|
468
|
+
if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
|
|
469
|
+
nextExternalUrl = raw
|
|
470
|
+
}
|
|
471
|
+
if (nextProvider === 'cloudflare-named') {
|
|
472
|
+
if (nextHostname === undefined) {
|
|
473
|
+
const raw =
|
|
474
|
+
args.hostname ??
|
|
475
|
+
(interactive
|
|
476
|
+
? await promptText(
|
|
477
|
+
'Public hostname configured in the Cloudflare dashboard (https://...)',
|
|
478
|
+
prompts,
|
|
479
|
+
validateHttpsUrl,
|
|
480
|
+
)
|
|
481
|
+
: undefined)
|
|
482
|
+
if (raw === undefined) return { ok: false, reason: "provider 'cloudflare-named' requires --hostname" }
|
|
483
|
+
const err = validateHttpsUrl(raw)
|
|
484
|
+
if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
|
|
485
|
+
nextHostname = raw
|
|
486
|
+
}
|
|
487
|
+
if (nextTokenEnv === undefined) {
|
|
488
|
+
const raw = args.tokenEnv ?? DEFAULT_TUNNEL_TOKEN_ENV
|
|
489
|
+
const err = validateTokenEnv(raw)
|
|
490
|
+
if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
|
|
491
|
+
nextTokenEnv = raw
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (existing.for.kind === 'manual' && nextProvider !== 'cloudflare-named' && nextUpstreamPort === undefined) {
|
|
495
|
+
const raw = interactive ? await promptText('Upstream port', prompts, validateUpstreamPort) : undefined
|
|
496
|
+
if (raw === undefined)
|
|
497
|
+
return { ok: false, reason: 'manual tunnels require --upstream-port (except cloudflare-named)' }
|
|
498
|
+
const err = validateUpstreamPort(raw)
|
|
499
|
+
if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
|
|
500
|
+
nextUpstreamPort = Number(raw)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const next: TunnelConfig = {
|
|
505
|
+
name: existing.name,
|
|
506
|
+
provider: nextProvider,
|
|
507
|
+
for: existing.for,
|
|
508
|
+
...(nextExternalUrl !== undefined ? { externalUrl: nextExternalUrl } : {}),
|
|
509
|
+
...(nextUpstreamPort !== undefined ? { upstreamPort: nextUpstreamPort } : {}),
|
|
510
|
+
...(nextHostname !== undefined ? { hostname: nextHostname } : {}),
|
|
511
|
+
...(nextTokenEnv !== undefined ? { tokenEnv: nextTokenEnv } : {}),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (interactive && next.provider === 'cloudflare-named' && next.tokenEnv !== undefined) {
|
|
515
|
+
const tokenPromptResult = await maybePromptTunnelTokenValue(cwd, next.tokenEnv, prompts)
|
|
516
|
+
if (!tokenPromptResult.ok) return tokenPromptResult
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const raw = readRawConfig(cwd)
|
|
520
|
+
raw.tunnels = config.tunnels.map((entry) => (entry.name === existing.name ? next : entry))
|
|
521
|
+
if (nextProvider === 'cloudflare-quick' || nextProvider === 'cloudflare-named') {
|
|
522
|
+
raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
|
|
523
|
+
}
|
|
524
|
+
writeRawConfig(cwd, raw)
|
|
525
|
+
// The strict gate above already validated the on-disk shape; calling
|
|
526
|
+
// validateConfig again here catches any post-write schema violation (e.g.
|
|
527
|
+
// a provider/field combination the explicit checks above missed) and
|
|
528
|
+
// surfaces it as a clean LiveResult instead of a thrown error on the next
|
|
529
|
+
// `loadConfigSync`. We roll back the file on failure so the user's
|
|
530
|
+
// typeclaw.json doesn't end up in an invalid state.
|
|
531
|
+
const postWrite = validateConfig(cwd)
|
|
532
|
+
if (!postWrite.ok) {
|
|
533
|
+
raw.tunnels = config.tunnels
|
|
534
|
+
writeRawConfig(cwd, raw)
|
|
535
|
+
return { ok: false, reason: postWrite.reason }
|
|
536
|
+
}
|
|
537
|
+
loadConfigSync(cwd)
|
|
538
|
+
return { ok: true, value: next }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function collectSetFlagFields(args: SetArgs): TunnelSetField[] {
|
|
542
|
+
const out: TunnelSetField[] = []
|
|
543
|
+
if (args.provider !== undefined) out.push('provider')
|
|
544
|
+
if (args.externalUrl !== undefined) out.push('externalUrl')
|
|
545
|
+
if (args.hostname !== undefined) out.push('hostname')
|
|
546
|
+
if (args.tokenEnv !== undefined) out.push('tokenEnv')
|
|
547
|
+
if (args.upstreamPort !== undefined) out.push('upstreamPort')
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buildSetFieldChoices(existing: TunnelConfig): { value: TunnelSetField; label: string; hint?: string }[] {
|
|
552
|
+
const choices: { value: TunnelSetField; label: string; hint?: string }[] = [
|
|
553
|
+
{ value: 'provider', label: 'Provider', hint: `currently ${existing.provider}` },
|
|
554
|
+
]
|
|
555
|
+
if (existing.provider === 'external') {
|
|
556
|
+
choices.push({ value: 'externalUrl', label: 'External URL', hint: existing.externalUrl ?? '-' })
|
|
557
|
+
}
|
|
558
|
+
if (existing.provider === 'cloudflare-named') {
|
|
559
|
+
choices.push({ value: 'hostname', label: 'Hostname', hint: existing.hostname ?? '-' })
|
|
560
|
+
choices.push({ value: 'tokenEnv', label: 'Token env var name', hint: existing.tokenEnv ?? '-' })
|
|
561
|
+
}
|
|
562
|
+
if (existing.for.kind === 'manual' && existing.provider !== 'cloudflare-named') {
|
|
563
|
+
choices.push({
|
|
564
|
+
value: 'upstreamPort',
|
|
565
|
+
label: 'Upstream port',
|
|
566
|
+
hint: existing.upstreamPort !== undefined ? String(existing.upstreamPort) : '-',
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
return choices
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function collectInteractiveFieldPatch(
|
|
573
|
+
field: Exclude<TunnelSetField, 'provider'>,
|
|
574
|
+
prompts: TunnelPrompts,
|
|
575
|
+
): Promise<LiveResult<{ externalUrl?: string; hostname?: string; tokenEnv?: string; upstreamPort?: number }>> {
|
|
576
|
+
switch (field) {
|
|
577
|
+
case 'externalUrl': {
|
|
578
|
+
const value = await promptText('External HTTPS URL', prompts, validateHttpsUrl)
|
|
579
|
+
const err = validateHttpsUrl(value)
|
|
580
|
+
if (err !== undefined) return { ok: false, reason: `external URL: ${err}` }
|
|
581
|
+
return { ok: true, value: { externalUrl: value } }
|
|
582
|
+
}
|
|
583
|
+
case 'hostname': {
|
|
584
|
+
const value = await promptText(
|
|
585
|
+
'Public hostname configured in the Cloudflare dashboard (https://...)',
|
|
586
|
+
prompts,
|
|
587
|
+
validateHttpsUrl,
|
|
588
|
+
)
|
|
589
|
+
const err = validateHttpsUrl(value)
|
|
590
|
+
if (err !== undefined) return { ok: false, reason: `hostname: ${err}` }
|
|
591
|
+
return { ok: true, value: { hostname: value } }
|
|
592
|
+
}
|
|
593
|
+
case 'tokenEnv': {
|
|
594
|
+
const value = await promptText('Env var name holding the tunnel token', prompts, validateTokenEnv)
|
|
595
|
+
const err = validateTokenEnv(value)
|
|
596
|
+
if (err !== undefined) return { ok: false, reason: `token-env: ${err}` }
|
|
597
|
+
return { ok: true, value: { tokenEnv: value } }
|
|
598
|
+
}
|
|
599
|
+
case 'upstreamPort': {
|
|
600
|
+
const value = await promptText('Upstream port', prompts, validateUpstreamPort)
|
|
601
|
+
const err = validateUpstreamPort(value)
|
|
602
|
+
if (err !== undefined) return { ok: false, reason: `upstream port: ${err}` }
|
|
603
|
+
return { ok: true, value: { upstreamPort: Number(value) } }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
237
608
|
export async function fetchTunnelList(opts: {
|
|
238
609
|
cwd: string
|
|
239
610
|
url?: string
|
|
@@ -378,8 +749,50 @@ function parseLiveArgs(args: LiveArgs): { url?: string; timeoutMs: number } {
|
|
|
378
749
|
return { ...(args.url !== undefined ? { url: args.url } : {}), timeoutMs }
|
|
379
750
|
}
|
|
380
751
|
|
|
752
|
+
async function resolveExistingTunnelName(
|
|
753
|
+
tunnels: readonly TunnelConfig[],
|
|
754
|
+
prompts: TunnelPrompts,
|
|
755
|
+
): Promise<LiveResult<string>> {
|
|
756
|
+
if (tunnels.length === 1) return { ok: true, value: tunnels[0]!.name }
|
|
757
|
+
if (prompts.selectExistingTunnel === undefined) {
|
|
758
|
+
return {
|
|
759
|
+
ok: false,
|
|
760
|
+
reason: 'interactive set requires selectExistingTunnel prompt (pass the tunnel name positionally)',
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const choices = tunnels.map((entry) => ({
|
|
764
|
+
value: entry.name,
|
|
765
|
+
label: entry.name,
|
|
766
|
+
hint: `${entry.provider} · ${entry.for.kind === 'channel' ? `channel:${entry.for.name}` : 'manual'}`,
|
|
767
|
+
}))
|
|
768
|
+
const choice = await prompts.selectExistingTunnel(choices)
|
|
769
|
+
if (isCancel(choice)) {
|
|
770
|
+
cancel('Aborted.')
|
|
771
|
+
process.exit(0)
|
|
772
|
+
}
|
|
773
|
+
return { ok: true, value: choice }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function maybePromptTunnelTokenValue(
|
|
777
|
+
cwd: string,
|
|
778
|
+
tokenEnv: string,
|
|
779
|
+
prompts: TunnelPrompts,
|
|
780
|
+
): Promise<LiveResult<void>> {
|
|
781
|
+
if (hasEnvKey(cwd, tokenEnv)) return { ok: true, value: undefined }
|
|
782
|
+
if (prompts.password === undefined) return { ok: true, value: undefined }
|
|
783
|
+
const value = await prompts.password(`Cloudflare tunnel token (will be written to .env as ${tokenEnv})`, (v) =>
|
|
784
|
+
v.length > 0 ? undefined : 'Token is required',
|
|
785
|
+
)
|
|
786
|
+
if (isCancel(value)) {
|
|
787
|
+
cancel('Aborted.')
|
|
788
|
+
process.exit(0)
|
|
789
|
+
}
|
|
790
|
+
appendOrReplaceEnvKey(cwd, tokenEnv, value)
|
|
791
|
+
return { ok: true, value: undefined }
|
|
792
|
+
}
|
|
793
|
+
|
|
381
794
|
async function resolveProvider(input: string | undefined, prompts: TunnelPrompts): Promise<TunnelProvider> {
|
|
382
|
-
if (input === 'external' || input === 'cloudflare-quick') return input
|
|
795
|
+
if (input === 'external' || input === 'cloudflare-quick' || input === 'cloudflare-named') return input
|
|
383
796
|
if (input !== undefined) throw new Error(`unknown tunnel provider: ${input}`)
|
|
384
797
|
const choice = await prompts.selectProvider()
|
|
385
798
|
if (isCancel(choice)) {
|
|
@@ -419,6 +832,24 @@ function validateNonEmpty(requiredMessage: string): TextValidator {
|
|
|
419
832
|
return (value) => (value.trim().length > 0 ? undefined : requiredMessage)
|
|
420
833
|
}
|
|
421
834
|
|
|
835
|
+
// Mirrors the regex on `tunnelEntrySchema.name` in src/config/config.ts so
|
|
836
|
+
// the interactive prompt rejects shapes the post-write schema validation
|
|
837
|
+
// would reject anyway, but with a clear inline error instead of a Zod dump.
|
|
838
|
+
const TUNNEL_NAME_REGEX = /^[a-z0-9][a-z0-9-_]*$/
|
|
839
|
+
|
|
840
|
+
function validateTunnelName(value: string, existing: ReadonlySet<string>): string | undefined {
|
|
841
|
+
if (value.trim().length === 0) return 'Tunnel name is required'
|
|
842
|
+
if (!TUNNEL_NAME_REGEX.test(value)) {
|
|
843
|
+
return 'Tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)'
|
|
844
|
+
}
|
|
845
|
+
if (existing.has(value)) return `tunnel "${value}" already exists`
|
|
846
|
+
return undefined
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function makeTunnelNameValidator(existing: ReadonlySet<string>): TextValidator {
|
|
850
|
+
return (value) => validateTunnelName(value, existing)
|
|
851
|
+
}
|
|
852
|
+
|
|
422
853
|
function validateUpstreamPort(value: string): string | undefined {
|
|
423
854
|
if (value.trim().length === 0) return 'Upstream port is required'
|
|
424
855
|
const port = Number(value)
|
|
@@ -437,6 +868,14 @@ function validateHttpsUrl(value: string): string | undefined {
|
|
|
437
868
|
}
|
|
438
869
|
}
|
|
439
870
|
|
|
871
|
+
function validateTokenEnv(value: string): string | undefined {
|
|
872
|
+
if (value.trim().length === 0) return 'Env var name is required'
|
|
873
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(value)) {
|
|
874
|
+
return 'Must be an env var name like CLOUDFLARE_TUNNEL_TOKEN (uppercase, digits, underscore)'
|
|
875
|
+
}
|
|
876
|
+
return undefined
|
|
877
|
+
}
|
|
878
|
+
|
|
440
879
|
function ensureAgentDir(): string {
|
|
441
880
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
442
881
|
if (!isInitialized(cwd)) {
|
package/src/config/config.ts
CHANGED
|
@@ -250,16 +250,24 @@ export type NetworkConfig = z.infer<typeof networkSchema>
|
|
|
250
250
|
|
|
251
251
|
// Reverse-proxy tunnels expose a container-private port to the public internet
|
|
252
252
|
// via a managed subprocess (cloudflared) or a user-supplied external URL.
|
|
253
|
-
// See AGENTS.md `## Tunnels`.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// the tunnel manager reads this list once at boot.
|
|
253
|
+
// See AGENTS.md `## Tunnels`. Keeping the enum scoped to what's implemented
|
|
254
|
+
// means validateConfig() rejects unsupported providers at `typeclaw start`
|
|
255
|
+
// time, before the container is torn down and rebuilt. `restart-required`
|
|
256
|
+
// because the tunnel manager reads this list once at boot.
|
|
258
257
|
const tunnelForSchema = z.discriminatedUnion('kind', [
|
|
259
258
|
z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
|
|
260
259
|
z.object({ kind: z.literal('manual') }),
|
|
261
260
|
])
|
|
262
261
|
|
|
262
|
+
// `tokenEnv` is the NAME of an env var, not the token itself. Restrict to the
|
|
263
|
+
// shell-portable identifier shape (uppercase + digits + underscore, leading
|
|
264
|
+
// non-digit) so a value typed here can't break `--env-file` parsing or shell
|
|
265
|
+
// expansion inside the container. Matches the convention every other env var
|
|
266
|
+
// in the codebase already follows (TYPECLAW_*, OPENAI_*, etc.).
|
|
267
|
+
const tokenEnvNameSchema = z.string().regex(/^[A-Z_][A-Z0-9_]*$/, {
|
|
268
|
+
message: 'tokenEnv must be an env var name like CLOUDFLARE_TUNNEL_TOKEN (uppercase, digits, underscore)',
|
|
269
|
+
})
|
|
270
|
+
|
|
263
271
|
const tunnelEntrySchema = z
|
|
264
272
|
.object({
|
|
265
273
|
name: z
|
|
@@ -268,7 +276,7 @@ const tunnelEntrySchema = z
|
|
|
268
276
|
.regex(/^[a-z0-9][a-z0-9-_]*$/, {
|
|
269
277
|
message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
|
|
270
278
|
}),
|
|
271
|
-
provider: z.enum(['external', 'cloudflare-quick']),
|
|
279
|
+
provider: z.enum(['external', 'cloudflare-quick', 'cloudflare-named']),
|
|
272
280
|
for: tunnelForSchema,
|
|
273
281
|
externalUrl: z
|
|
274
282
|
.string()
|
|
@@ -276,11 +284,31 @@ const tunnelEntrySchema = z
|
|
|
276
284
|
.refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
|
|
277
285
|
.optional(),
|
|
278
286
|
upstreamPort: z.number().int().min(1).max(65535).optional(),
|
|
287
|
+
hostname: z
|
|
288
|
+
.string()
|
|
289
|
+
.url()
|
|
290
|
+
.refine((u) => u.startsWith('https://'), { message: 'hostname must use https://' })
|
|
291
|
+
.optional(),
|
|
292
|
+
tokenEnv: tokenEnvNameSchema.optional(),
|
|
279
293
|
})
|
|
280
294
|
.refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
|
|
281
295
|
message: "tunnels[].externalUrl is required when provider is 'external'",
|
|
282
296
|
})
|
|
283
|
-
.refine((v) => v.
|
|
297
|
+
.refine((v) => v.provider !== 'cloudflare-named' || (v.hostname !== undefined && v.hostname.trim() !== ''), {
|
|
298
|
+
message: "tunnels[].hostname is required when provider is 'cloudflare-named'",
|
|
299
|
+
})
|
|
300
|
+
.refine((v) => v.provider !== 'cloudflare-named' || (v.tokenEnv !== undefined && v.tokenEnv.trim() !== ''), {
|
|
301
|
+
message: "tunnels[].tokenEnv is required when provider is 'cloudflare-named'",
|
|
302
|
+
})
|
|
303
|
+
// cloudflared learns the upstream from the Cloudflare dashboard's Public
|
|
304
|
+
// Hostname mapping, not from typeclaw. An `upstreamPort` here would be
|
|
305
|
+
// silently ignored; reject at parse time so the contradiction surfaces in
|
|
306
|
+
// the config file rather than as a debugging surprise.
|
|
307
|
+
.refine((v) => v.provider !== 'cloudflare-named' || v.upstreamPort === undefined, {
|
|
308
|
+
message:
|
|
309
|
+
"tunnels[].upstreamPort must not be set when provider is 'cloudflare-named' (cloudflared reads the upstream from the Cloudflare dashboard)",
|
|
310
|
+
})
|
|
311
|
+
.refine((v) => v.for.kind !== 'manual' || v.provider === 'cloudflare-named' || v.upstreamPort !== undefined, {
|
|
284
312
|
message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
|
|
285
313
|
})
|
|
286
314
|
|