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
package/src/cli/channel.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
1
4
|
import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
|
|
2
5
|
import { defineCommand } from 'citty'
|
|
3
6
|
|
|
@@ -6,11 +9,17 @@ import { start, status, stop } from '@/container'
|
|
|
6
9
|
import {
|
|
7
10
|
CHANNEL_KINDS,
|
|
8
11
|
findAgentDir,
|
|
12
|
+
formatEagerGithubWebhookInstallResult,
|
|
9
13
|
isInitialized,
|
|
10
14
|
readConfiguredChannels,
|
|
15
|
+
readGithubAuthType,
|
|
11
16
|
runAddChannel,
|
|
17
|
+
setChannelSecrets,
|
|
18
|
+
setGithubSecrets,
|
|
12
19
|
type AddChannelStepEvent,
|
|
13
20
|
type ChannelKind,
|
|
21
|
+
type GithubCredentialPatch,
|
|
22
|
+
type GithubTunnelProvider,
|
|
14
23
|
type KakaotalkAuthResult,
|
|
15
24
|
} from '@/init'
|
|
16
25
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
@@ -23,6 +32,7 @@ const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
|
23
32
|
'discord-bot': 'Discord',
|
|
24
33
|
'telegram-bot': 'Telegram',
|
|
25
34
|
kakaotalk: 'KakaoTalk',
|
|
35
|
+
github: 'GitHub',
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
const addSub = defineCommand({
|
|
@@ -60,6 +70,11 @@ const addSub = defineCommand({
|
|
|
60
70
|
...credentials,
|
|
61
71
|
onProgress: reportProgress(events),
|
|
62
72
|
})
|
|
73
|
+
if (credentials.channel === 'github' && credentials.tunnelProvider === 'none') {
|
|
74
|
+
log.warn(
|
|
75
|
+
'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
63
78
|
} catch (error) {
|
|
64
79
|
console.error(errorLine(error instanceof Error ? error.message : String(error)))
|
|
65
80
|
process.exit(1)
|
|
@@ -69,11 +84,58 @@ const addSub = defineCommand({
|
|
|
69
84
|
},
|
|
70
85
|
})
|
|
71
86
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
87
|
+
// Adapters whose credentials are rotated via the generic `channel set` flow:
|
|
88
|
+
// one or more named token fields, no passcode-on-phone, no encryption envelope.
|
|
89
|
+
// KakaoTalk is excluded by design — it has its own `channel reauth` flow that
|
|
90
|
+
// replays the full interactive login (see REAUTHABLE_ADAPTERS below). GitHub
|
|
91
|
+
// is included here but routed through its own prompt path because it has
|
|
92
|
+
// three independent secrets (PAT or App private key + webhook secret) and a
|
|
93
|
+
// structural auth-type flip is forbidden during rotation.
|
|
94
|
+
const SETTABLE_ADAPTERS = ['slack-bot', 'discord-bot', 'telegram-bot', 'github'] as const
|
|
95
|
+
type SettableAdapter = (typeof SETTABLE_ADAPTERS)[number]
|
|
96
|
+
|
|
97
|
+
const setSub = defineCommand({
|
|
98
|
+
meta: {
|
|
99
|
+
name: 'set',
|
|
100
|
+
description: 'rotate credentials of an already-configured channel adapter (symmetric with `typeclaw provider set`)',
|
|
101
|
+
},
|
|
102
|
+
args: {
|
|
103
|
+
adapter: {
|
|
104
|
+
type: 'positional',
|
|
105
|
+
description: `which adapter to rotate (${SETTABLE_ADAPTERS.join(' | ')}); omit to pick interactively`,
|
|
106
|
+
required: false,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
async run({ args }) {
|
|
110
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
111
|
+
|
|
112
|
+
if (!isInitialized(cwd)) {
|
|
113
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const configured = await readConfiguredChannels(cwd)
|
|
118
|
+
|
|
119
|
+
if (args.adapter === 'kakaotalk') {
|
|
120
|
+
console.error(
|
|
121
|
+
errorLine(
|
|
122
|
+
'KakaoTalk uses an interactive auth flow (phone passcode + device_uuid). Use `typeclaw channel reauth kakaotalk` to rotate its credentials.',
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const adapter =
|
|
129
|
+
args.adapter === undefined
|
|
130
|
+
? await pickSettableAdapter(configured)
|
|
131
|
+
: validateSetAdapterArg(args.adapter, configured)
|
|
132
|
+
|
|
133
|
+
intro(`Rotating channel: ${CHANNEL_LABELS[adapter]}`)
|
|
134
|
+
|
|
135
|
+
await runSet(cwd, adapter)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
77
139
|
const REAUTHABLE_ADAPTERS = ['kakaotalk'] as const
|
|
78
140
|
type ReauthableAdapter = (typeof REAUTHABLE_ADAPTERS)[number]
|
|
79
141
|
|
|
@@ -114,6 +176,7 @@ export const channelCommand = defineCommand({
|
|
|
114
176
|
},
|
|
115
177
|
subCommands: {
|
|
116
178
|
add: addSub,
|
|
179
|
+
set: setSub,
|
|
117
180
|
reauth: reauthSub,
|
|
118
181
|
},
|
|
119
182
|
})
|
|
@@ -220,11 +283,18 @@ async function readExistingKakaotalkEmail(cwd: string): Promise<string | undefin
|
|
|
220
283
|
// We can't reliably distinguish the last two cases from outside the container
|
|
221
284
|
// without calling reload first, so the next-step hints surface both paths.
|
|
222
285
|
async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter): Promise<void> {
|
|
223
|
-
|
|
286
|
+
await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS[adapter], 're-authenticated')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function maybePromptCredentialRefresh(
|
|
290
|
+
cwd: string,
|
|
291
|
+
label: string,
|
|
292
|
+
verbPast: 're-authenticated' | 'credentials updated',
|
|
293
|
+
): Promise<void> {
|
|
224
294
|
const current = await status({ cwd }).catch(() => null)
|
|
225
295
|
if (current === null || current.kind !== 'running') {
|
|
226
296
|
done({
|
|
227
|
-
title: c.green(`${label}
|
|
297
|
+
title: c.green(`${label} ${verbPast}.`),
|
|
228
298
|
hints: [
|
|
229
299
|
{ label: 'Start the agent:', command: 'typeclaw start' },
|
|
230
300
|
{ label: 'Then check status:', command: 'typeclaw status' },
|
|
@@ -240,7 +310,7 @@ async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter)
|
|
|
240
310
|
})
|
|
241
311
|
if (isCancel(restartNow) || !restartNow) {
|
|
242
312
|
done({
|
|
243
|
-
title: c.green(`${label}
|
|
313
|
+
title: c.green(`${label} ${verbPast}.`),
|
|
244
314
|
hints: [
|
|
245
315
|
{ label: 'Try a live reload first:', command: 'typeclaw reload' },
|
|
246
316
|
{ label: 'If reload reports restart-required:', command: 'typeclaw restart' },
|
|
@@ -260,9 +330,7 @@ async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter)
|
|
|
260
330
|
process.exit(1)
|
|
261
331
|
}
|
|
262
332
|
done({
|
|
263
|
-
title: c.green(
|
|
264
|
-
`${label} re-authenticated. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
|
|
265
|
-
),
|
|
333
|
+
title: c.green(`${label} ${verbPast}. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`),
|
|
266
334
|
hints: [
|
|
267
335
|
{ label: 'Attach TUI:', command: 'typeclaw tui' },
|
|
268
336
|
{ label: 'Follow logs:', command: 'typeclaw logs -f' },
|
|
@@ -309,11 +377,232 @@ async function pickChannel(configured: Set<ChannelKind>): Promise<ChannelKind> {
|
|
|
309
377
|
return selected
|
|
310
378
|
}
|
|
311
379
|
|
|
380
|
+
function isSettableAdapter(value: string): value is SettableAdapter {
|
|
381
|
+
return (SETTABLE_ADAPTERS as ReadonlyArray<string>).includes(value)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function validateSetAdapterArg(adapter: string, configured: Set<ChannelKind>): SettableAdapter {
|
|
385
|
+
if (!isSettableAdapter(adapter)) {
|
|
386
|
+
if (isChannelKind(adapter)) {
|
|
387
|
+
console.error(
|
|
388
|
+
errorLine(
|
|
389
|
+
`Adapter "${adapter}" does not support \`channel set\`. Use \`typeclaw channel reauth ${adapter}\` instead.`,
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
} else {
|
|
393
|
+
console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${SETTABLE_ADAPTERS.join(', ')}.`))
|
|
394
|
+
}
|
|
395
|
+
process.exit(1)
|
|
396
|
+
}
|
|
397
|
+
if (!configured.has(adapter)) {
|
|
398
|
+
console.error(
|
|
399
|
+
errorLine(
|
|
400
|
+
`${CHANNEL_LABELS[adapter]} ("${adapter}") is not configured in typeclaw.json. Run \`typeclaw channel add ${adapter}\` first.`,
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
process.exit(1)
|
|
404
|
+
}
|
|
405
|
+
return adapter
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function pickSettableAdapter(configured: Set<ChannelKind>): Promise<SettableAdapter> {
|
|
409
|
+
const available = SETTABLE_ADAPTERS.filter((kind) => configured.has(kind))
|
|
410
|
+
if (available.length === 0) {
|
|
411
|
+
console.error(
|
|
412
|
+
errorLine(
|
|
413
|
+
'No rotatable channels are configured. Run `typeclaw channel add <adapter>` first, or use `typeclaw channel reauth kakaotalk` for KakaoTalk.',
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
process.exit(1)
|
|
417
|
+
}
|
|
418
|
+
if (available.length === 1) return available[0]!
|
|
419
|
+
|
|
420
|
+
const selected = await select<SettableAdapter>({
|
|
421
|
+
message: 'Pick a channel to rotate credentials for',
|
|
422
|
+
options: available.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
|
|
423
|
+
initialValue: available[0],
|
|
424
|
+
})
|
|
425
|
+
if (isCancel(selected)) {
|
|
426
|
+
cancel('Aborted.')
|
|
427
|
+
process.exit(0)
|
|
428
|
+
}
|
|
429
|
+
return selected
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function runSet(cwd: string, adapter: SettableAdapter): Promise<void> {
|
|
433
|
+
switch (adapter) {
|
|
434
|
+
case 'discord-bot':
|
|
435
|
+
await runSetDiscord(cwd)
|
|
436
|
+
break
|
|
437
|
+
case 'telegram-bot':
|
|
438
|
+
await runSetTelegram(cwd)
|
|
439
|
+
break
|
|
440
|
+
case 'slack-bot':
|
|
441
|
+
await runSetSlack(cwd)
|
|
442
|
+
break
|
|
443
|
+
case 'github':
|
|
444
|
+
await runSetGithub(cwd)
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function runSetDiscord(cwd: string): Promise<void> {
|
|
450
|
+
const token = await promptDiscordToken()
|
|
451
|
+
const result = await setChannelSecrets(cwd, 'discord-bot', { token })
|
|
452
|
+
if (!result.ok) {
|
|
453
|
+
console.error(errorLine(result.reason))
|
|
454
|
+
process.exit(1)
|
|
455
|
+
}
|
|
456
|
+
await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['discord-bot'], 'credentials updated')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function runSetTelegram(cwd: string): Promise<void> {
|
|
460
|
+
const token = await promptTelegramToken()
|
|
461
|
+
const result = await setChannelSecrets(cwd, 'telegram-bot', { token })
|
|
462
|
+
if (!result.ok) {
|
|
463
|
+
console.error(errorLine(result.reason))
|
|
464
|
+
process.exit(1)
|
|
465
|
+
}
|
|
466
|
+
await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['telegram-bot'], 'credentials updated')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
type SlackSetChoice = 'bot' | 'app' | 'both'
|
|
470
|
+
|
|
471
|
+
async function runSetSlack(cwd: string): Promise<void> {
|
|
472
|
+
note(
|
|
473
|
+
[
|
|
474
|
+
'Rotate at https://api.slack.com/apps → your app:',
|
|
475
|
+
' Bot token (xoxb-...) — OAuth & Permissions → Reset Token.',
|
|
476
|
+
' App-level token (xapp-...) — Basic Information → App-Level Tokens → Revoke and regenerate.',
|
|
477
|
+
'Slack only shows the app-level token once on screen — copy it before closing.',
|
|
478
|
+
].join('\n'),
|
|
479
|
+
'Rotate the Slack tokens',
|
|
480
|
+
)
|
|
481
|
+
const choice = await select<SlackSetChoice>({
|
|
482
|
+
message: 'Which Slack token do you want to rotate?',
|
|
483
|
+
options: [
|
|
484
|
+
{ value: 'bot', label: 'Bot user token (xoxb-...) — used to post messages as the bot (recommended)' },
|
|
485
|
+
{ value: 'app', label: 'App-level token (xapp-...) — required for Socket Mode' },
|
|
486
|
+
{ value: 'both', label: 'Both tokens — rotate the bot token and the app-level token' },
|
|
487
|
+
],
|
|
488
|
+
initialValue: 'bot',
|
|
489
|
+
})
|
|
490
|
+
if (isCancel(choice)) {
|
|
491
|
+
cancel('Aborted.')
|
|
492
|
+
process.exit(0)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const tokens: Record<string, string> = {}
|
|
496
|
+
if (choice === 'bot' || choice === 'both') tokens.botToken = await promptSlackBotToken()
|
|
497
|
+
if (choice === 'app' || choice === 'both') tokens.appToken = await promptSlackAppToken()
|
|
498
|
+
|
|
499
|
+
const result = await setChannelSecrets(cwd, 'slack-bot', tokens)
|
|
500
|
+
if (!result.ok) {
|
|
501
|
+
console.error(errorLine(result.reason))
|
|
502
|
+
process.exit(1)
|
|
503
|
+
}
|
|
504
|
+
await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['slack-bot'], 'credentials updated')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
type GithubSetChoice = 'auth' | 'webhook' | 'both'
|
|
508
|
+
|
|
509
|
+
async function runSetGithub(cwd: string): Promise<void> {
|
|
510
|
+
const authType = readGithubAuthType(cwd)
|
|
511
|
+
if (authType === null) {
|
|
512
|
+
console.error(
|
|
513
|
+
errorLine(
|
|
514
|
+
'GitHub auth block is missing or malformed in secrets.json. Run `typeclaw channel add github` first, or fix the file by hand.',
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
process.exit(1)
|
|
518
|
+
}
|
|
519
|
+
const authLabel =
|
|
520
|
+
authType === 'pat'
|
|
521
|
+
? 'Personal access token (PAT) — the authentication credential (recommended)'
|
|
522
|
+
: 'GitHub App private key — the authentication credential (recommended)'
|
|
523
|
+
const choice = await select<GithubSetChoice>({
|
|
524
|
+
message: 'Which GitHub secret do you want to rotate?',
|
|
525
|
+
options: [
|
|
526
|
+
{ value: 'auth', label: authLabel },
|
|
527
|
+
{ value: 'webhook', label: 'Webhook secret — shared secret for verifying GitHub payloads' },
|
|
528
|
+
{ value: 'both', label: 'Both secrets — rotate the auth credential and the webhook secret' },
|
|
529
|
+
],
|
|
530
|
+
initialValue: 'auth',
|
|
531
|
+
})
|
|
532
|
+
if (isCancel(choice)) {
|
|
533
|
+
cancel('Aborted.')
|
|
534
|
+
process.exit(0)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const patch: GithubCredentialPatch = {}
|
|
538
|
+
|
|
539
|
+
if (choice === 'auth' || choice === 'both') {
|
|
540
|
+
if (authType === 'pat') {
|
|
541
|
+
note(
|
|
542
|
+
[
|
|
543
|
+
'Rotate at https://github.com/settings/personal-access-tokens.',
|
|
544
|
+
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
545
|
+
'Metadata read, and Webhooks read/write.',
|
|
546
|
+
].join('\n'),
|
|
547
|
+
'Rotate the GitHub PAT',
|
|
548
|
+
)
|
|
549
|
+
const { pat } = await promptGithubPatAuth()
|
|
550
|
+
patch.auth = { type: 'pat', pat }
|
|
551
|
+
} else {
|
|
552
|
+
note(
|
|
553
|
+
[
|
|
554
|
+
'Rotate at https://github.com/settings/apps/<your-app> → Private keys → Generate a private key.',
|
|
555
|
+
'GitHub immediately downloads the new .pem. The previous key keeps working until you delete it,',
|
|
556
|
+
'so it is safe to rotate without downtime.',
|
|
557
|
+
].join('\n'),
|
|
558
|
+
'Rotate the GitHub App private key',
|
|
559
|
+
)
|
|
560
|
+
const privateKeyInput = await text({
|
|
561
|
+
message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
|
|
562
|
+
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
563
|
+
})
|
|
564
|
+
if (isCancel(privateKeyInput)) {
|
|
565
|
+
cancel('Aborted.')
|
|
566
|
+
process.exit(0)
|
|
567
|
+
}
|
|
568
|
+
patch.auth = { type: 'app', privateKey: await resolvePrivateKeyInput(privateKeyInput) }
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (choice === 'webhook' || choice === 'both') {
|
|
573
|
+
const secret = await password({
|
|
574
|
+
message: 'New webhook secret (leave blank to auto-generate)',
|
|
575
|
+
})
|
|
576
|
+
if (isCancel(secret)) {
|
|
577
|
+
cancel('Aborted.')
|
|
578
|
+
process.exit(0)
|
|
579
|
+
}
|
|
580
|
+
const enteredSecret = typeof secret === 'string' ? secret : ''
|
|
581
|
+
patch.webhookSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const result = await setGithubSecrets(cwd, patch)
|
|
585
|
+
if (!result.ok) {
|
|
586
|
+
console.error(errorLine(result.reason))
|
|
587
|
+
process.exit(1)
|
|
588
|
+
}
|
|
589
|
+
await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS.github, 'credentials updated')
|
|
590
|
+
}
|
|
591
|
+
|
|
312
592
|
type CollectedCredentials =
|
|
313
593
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
314
594
|
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
315
595
|
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
316
596
|
| { channel: 'kakaotalk'; runKakaotalkAuth: (options: { cwd: string }) => Promise<KakaotalkAuthResult> }
|
|
597
|
+
| {
|
|
598
|
+
channel: 'github'
|
|
599
|
+
webhookSecret: string
|
|
600
|
+
tunnelProvider: GithubTunnelProvider
|
|
601
|
+
webhookUrl?: string
|
|
602
|
+
webhookPort?: number
|
|
603
|
+
repos: string[]
|
|
604
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
605
|
+
}
|
|
317
606
|
|
|
318
607
|
async function collectCredentials(channel: ChannelKind): Promise<CollectedCredentials> {
|
|
319
608
|
switch (channel) {
|
|
@@ -338,9 +627,192 @@ async function collectCredentials(channel: ChannelKind): Promise<CollectedCreden
|
|
|
338
627
|
}),
|
|
339
628
|
}
|
|
340
629
|
}
|
|
630
|
+
case 'github': {
|
|
631
|
+
const creds = await promptGithubCredentials()
|
|
632
|
+
return { channel, ...creds }
|
|
633
|
+
}
|
|
341
634
|
}
|
|
342
635
|
}
|
|
343
636
|
|
|
637
|
+
async function promptGithubCredentials(): Promise<{
|
|
638
|
+
webhookSecret: string
|
|
639
|
+
tunnelProvider: GithubTunnelProvider
|
|
640
|
+
webhookUrl?: string
|
|
641
|
+
webhookPort?: number
|
|
642
|
+
repos: string[]
|
|
643
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
644
|
+
}> {
|
|
645
|
+
note(
|
|
646
|
+
[
|
|
647
|
+
'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
|
|
648
|
+
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
649
|
+
'Metadata read, and Webhooks read/write (TypeClaw will create and manage the repository webhooks for you).',
|
|
650
|
+
].join('\n'),
|
|
651
|
+
'Get GitHub credentials',
|
|
652
|
+
)
|
|
653
|
+
const authType = await select({
|
|
654
|
+
message: 'GitHub authentication type',
|
|
655
|
+
options: [
|
|
656
|
+
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
657
|
+
{ value: 'app', label: 'GitHub App installation token' },
|
|
658
|
+
],
|
|
659
|
+
})
|
|
660
|
+
if (isCancel(authType)) {
|
|
661
|
+
cancel('Aborted.')
|
|
662
|
+
process.exit(0)
|
|
663
|
+
}
|
|
664
|
+
const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
|
|
665
|
+
note('GitHub webhooks need a public URL. TypeClaw can manage a tunnel for you.', 'GitHub webhook tunnel')
|
|
666
|
+
const tunnelProvider = await select<GithubTunnelProvider>({
|
|
667
|
+
message: 'Tunnel provider',
|
|
668
|
+
options: [
|
|
669
|
+
{
|
|
670
|
+
value: 'cloudflare-quick',
|
|
671
|
+
label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
|
|
672
|
+
},
|
|
673
|
+
{ value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
|
|
674
|
+
{ value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
|
|
675
|
+
],
|
|
676
|
+
initialValue: 'cloudflare-quick',
|
|
677
|
+
})
|
|
678
|
+
if (isCancel(tunnelProvider)) {
|
|
679
|
+
cancel('Aborted.')
|
|
680
|
+
process.exit(0)
|
|
681
|
+
}
|
|
682
|
+
const webhookUrl =
|
|
683
|
+
tunnelProvider === 'external'
|
|
684
|
+
? await text({
|
|
685
|
+
message: 'Public webhook URL (GitHub will POST events here)',
|
|
686
|
+
validate: (value) => validateUrl(value ?? '', 'Webhook URL is required'),
|
|
687
|
+
})
|
|
688
|
+
: undefined
|
|
689
|
+
if (isCancel(webhookUrl)) {
|
|
690
|
+
cancel('Aborted.')
|
|
691
|
+
process.exit(0)
|
|
692
|
+
}
|
|
693
|
+
const port = await text({
|
|
694
|
+
message: 'Local webhook port inside the agent container',
|
|
695
|
+
initialValue: '8975',
|
|
696
|
+
validate: (value) => {
|
|
697
|
+
const parsed = Number(value)
|
|
698
|
+
return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Port must be a positive integer'
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
if (isCancel(port)) {
|
|
702
|
+
cancel('Aborted.')
|
|
703
|
+
process.exit(0)
|
|
704
|
+
}
|
|
705
|
+
const secret = await password({
|
|
706
|
+
message: 'Webhook secret (leave blank to auto-generate)',
|
|
707
|
+
})
|
|
708
|
+
if (isCancel(secret)) {
|
|
709
|
+
cancel('Aborted.')
|
|
710
|
+
process.exit(0)
|
|
711
|
+
}
|
|
712
|
+
// clack's password() returns `undefined` on an empty submission (it has no
|
|
713
|
+
// validate guard and never coerces to ''), so we normalize before the
|
|
714
|
+
// length checks below to avoid a TypeError on the "leave blank" path.
|
|
715
|
+
const enteredSecret = typeof secret === 'string' ? secret : ''
|
|
716
|
+
const reposRaw = await text({
|
|
717
|
+
message: 'Repositories to allow (comma-separated owner/repo)',
|
|
718
|
+
validate: (value) => (parseRepos(value ?? '').length > 0 ? undefined : 'At least one owner/repo is required'),
|
|
719
|
+
})
|
|
720
|
+
if (isCancel(reposRaw)) {
|
|
721
|
+
cancel('Aborted.')
|
|
722
|
+
process.exit(0)
|
|
723
|
+
}
|
|
724
|
+
const resolvedSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
|
|
725
|
+
return {
|
|
726
|
+
webhookSecret: resolvedSecret,
|
|
727
|
+
tunnelProvider,
|
|
728
|
+
...(webhookUrl !== undefined ? { webhookUrl } : {}),
|
|
729
|
+
webhookPort: Number(port),
|
|
730
|
+
repos: parseRepos(reposRaw),
|
|
731
|
+
auth,
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string }> {
|
|
736
|
+
const pat = await password({
|
|
737
|
+
message: 'GitHub fine-grained PAT',
|
|
738
|
+
validate: (value) => (value && value.length > 0 ? undefined : 'PAT is required'),
|
|
739
|
+
})
|
|
740
|
+
if (isCancel(pat)) {
|
|
741
|
+
cancel('Aborted.')
|
|
742
|
+
process.exit(0)
|
|
743
|
+
}
|
|
744
|
+
return { type: 'pat', pat }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function promptGithubAppAuth(): Promise<{
|
|
748
|
+
type: 'app'
|
|
749
|
+
appId: number
|
|
750
|
+
privateKey: string
|
|
751
|
+
installationId?: number
|
|
752
|
+
}> {
|
|
753
|
+
const appId = await text({
|
|
754
|
+
message: 'GitHub App ID',
|
|
755
|
+
validate: (value) => validatePositiveInteger(value ?? '', 'App ID is required'),
|
|
756
|
+
})
|
|
757
|
+
if (isCancel(appId)) {
|
|
758
|
+
cancel('Aborted.')
|
|
759
|
+
process.exit(0)
|
|
760
|
+
}
|
|
761
|
+
const privateKeyInput = await text({
|
|
762
|
+
message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
|
|
763
|
+
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
764
|
+
})
|
|
765
|
+
if (isCancel(privateKeyInput)) {
|
|
766
|
+
cancel('Aborted.')
|
|
767
|
+
process.exit(0)
|
|
768
|
+
}
|
|
769
|
+
const installationId = await text({
|
|
770
|
+
message: 'Installation ID (optional; leave blank to auto-discover)',
|
|
771
|
+
validate: (value) =>
|
|
772
|
+
value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
|
|
773
|
+
})
|
|
774
|
+
if (isCancel(installationId)) {
|
|
775
|
+
cancel('Aborted.')
|
|
776
|
+
process.exit(0)
|
|
777
|
+
}
|
|
778
|
+
const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
|
|
779
|
+
return {
|
|
780
|
+
type: 'app',
|
|
781
|
+
appId: Number(appId),
|
|
782
|
+
privateKey: await resolvePrivateKeyInput(privateKeyInput),
|
|
783
|
+
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function resolvePrivateKeyInput(input: string): Promise<string> {
|
|
788
|
+
const normalized = input.replace(/\\n/g, '\n')
|
|
789
|
+
if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
|
|
790
|
+
return await readFile(input, 'utf8')
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function parseRepos(input: string): string[] {
|
|
794
|
+
return input
|
|
795
|
+
.split(',')
|
|
796
|
+
.map((v) => v.trim())
|
|
797
|
+
.filter((v) => /^[^\s/]+\/[^\s/]+$/.test(v))
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function validateUrl(value: string, requiredMessage: string): string | undefined {
|
|
801
|
+
if (!value || value.length === 0) return requiredMessage
|
|
802
|
+
try {
|
|
803
|
+
new URL(value)
|
|
804
|
+
return undefined
|
|
805
|
+
} catch {
|
|
806
|
+
return 'Must be a valid URL'
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function validatePositiveInteger(value: string, requiredMessage: string): string | undefined {
|
|
811
|
+
if (!value || value.length === 0) return requiredMessage
|
|
812
|
+
const parsed = Number(value)
|
|
813
|
+
return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Must be a positive integer'
|
|
814
|
+
}
|
|
815
|
+
|
|
344
816
|
async function promptDiscordToken(): Promise<string> {
|
|
345
817
|
note(
|
|
346
818
|
[
|
|
@@ -406,19 +878,7 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
|
|
|
406
878
|
].join('\n'),
|
|
407
879
|
'Get a Slack bot',
|
|
408
880
|
)
|
|
409
|
-
const
|
|
410
|
-
message: 'Slack bot token (xoxb-...)',
|
|
411
|
-
validate: (value) =>
|
|
412
|
-
value && value.length > 0
|
|
413
|
-
? value.startsWith('xoxb-')
|
|
414
|
-
? undefined
|
|
415
|
-
: 'Bot token must start with "xoxb-"'
|
|
416
|
-
: 'Token is required',
|
|
417
|
-
})
|
|
418
|
-
if (isCancel(botToken)) {
|
|
419
|
-
cancel('Aborted.')
|
|
420
|
-
process.exit(0)
|
|
421
|
-
}
|
|
881
|
+
const bot = await promptSlackBotToken()
|
|
422
882
|
note(
|
|
423
883
|
[
|
|
424
884
|
'Slack does not accept connections:write inside the manifest, so',
|
|
@@ -432,6 +892,28 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
|
|
|
432
892
|
].join('\n'),
|
|
433
893
|
'Generate the Slack app-level token',
|
|
434
894
|
)
|
|
895
|
+
const app = await promptSlackAppToken()
|
|
896
|
+
return { bot, app }
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function promptSlackBotToken(): Promise<string> {
|
|
900
|
+
const botToken = await password({
|
|
901
|
+
message: 'Slack bot token (xoxb-...)',
|
|
902
|
+
validate: (value) =>
|
|
903
|
+
value && value.length > 0
|
|
904
|
+
? value.startsWith('xoxb-')
|
|
905
|
+
? undefined
|
|
906
|
+
: 'Bot token must start with "xoxb-"'
|
|
907
|
+
: 'Token is required',
|
|
908
|
+
})
|
|
909
|
+
if (isCancel(botToken)) {
|
|
910
|
+
cancel('Aborted.')
|
|
911
|
+
process.exit(0)
|
|
912
|
+
}
|
|
913
|
+
return botToken
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function promptSlackAppToken(): Promise<string> {
|
|
435
917
|
const appToken = await password({
|
|
436
918
|
message: 'Slack app-level token (xapp-...) — Socket Mode requires this',
|
|
437
919
|
validate: (value) =>
|
|
@@ -445,7 +927,7 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
|
|
|
445
927
|
cancel('Aborted.')
|
|
446
928
|
process.exit(0)
|
|
447
929
|
}
|
|
448
|
-
return
|
|
930
|
+
return appToken
|
|
449
931
|
}
|
|
450
932
|
|
|
451
933
|
async function promptTelegramToken(): Promise<string> {
|
|
@@ -534,6 +1016,9 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
|
|
|
534
1016
|
case 'secrets':
|
|
535
1017
|
s.stop('Saved credentials to secrets.json.')
|
|
536
1018
|
break
|
|
1019
|
+
case 'github-webhooks':
|
|
1020
|
+
s.stop(formatEagerGithubWebhookInstallResult(event.result))
|
|
1021
|
+
break
|
|
537
1022
|
}
|
|
538
1023
|
}
|
|
539
1024
|
}
|
|
@@ -542,6 +1027,7 @@ const START_MESSAGES: Record<AddChannelStepEvent['step'], string> = {
|
|
|
542
1027
|
'kakaotalk-auth': 'Logging in to KakaoTalk...',
|
|
543
1028
|
config: 'Updating typeclaw.json...',
|
|
544
1029
|
secrets: 'Saving credentials to secrets.json...',
|
|
1030
|
+
'github-webhooks': 'Installing GitHub repository webhooks...',
|
|
545
1031
|
}
|
|
546
1032
|
|
|
547
1033
|
function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
|