typeclaw 0.3.0 → 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 +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- 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/router.ts +28 -2
- 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/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- 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 +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- 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/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- package/typeclaw.schema.json +254 -1
package/src/init/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
|
+
import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
|
|
6
7
|
import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_MODEL_REF,
|
|
@@ -18,6 +19,7 @@ import { createTui } from '@/tui'
|
|
|
18
19
|
|
|
19
20
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
20
21
|
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
22
|
+
import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
21
23
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
22
24
|
import { HATCHING_PROMPT } from './hatching'
|
|
23
25
|
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
@@ -26,6 +28,9 @@ import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
|
|
|
26
28
|
|
|
27
29
|
export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
28
30
|
|
|
31
|
+
export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
32
|
+
export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
|
|
33
|
+
|
|
29
34
|
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
30
35
|
|
|
31
36
|
const CONFIG_FILE = 'typeclaw.json'
|
|
@@ -51,6 +56,7 @@ export type InitStep =
|
|
|
51
56
|
| 'oauth-login'
|
|
52
57
|
| 'scaffold'
|
|
53
58
|
| 'kakaotalk-auth'
|
|
59
|
+
| 'github-webhooks'
|
|
54
60
|
| 'install'
|
|
55
61
|
| 'dockerfile'
|
|
56
62
|
| 'git'
|
|
@@ -58,6 +64,20 @@ export type InitStep =
|
|
|
58
64
|
|
|
59
65
|
export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
|
|
60
66
|
|
|
67
|
+
// Structured credential block for the GitHub channel adapter. Mirrors the
|
|
68
|
+
// shape `runAddChannel({ channel: 'github', ... })` consumes so the wizard
|
|
69
|
+
// can hand off without re-encoding the auth union or webhook fields.
|
|
70
|
+
export type GithubInitCredentials = {
|
|
71
|
+
webhookSecret: string
|
|
72
|
+
tunnelProvider: GithubTunnelProvider
|
|
73
|
+
webhookUrl?: string
|
|
74
|
+
webhookPort?: number
|
|
75
|
+
repos: string[]
|
|
76
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
|
|
80
|
+
|
|
61
81
|
export type InitStepEvent =
|
|
62
82
|
| { step: 'preflight'; phase: 'start' }
|
|
63
83
|
| { step: 'preflight'; phase: 'done'; result: DockerAvailability }
|
|
@@ -67,6 +87,8 @@ export type InitStepEvent =
|
|
|
67
87
|
| { step: 'scaffold'; phase: 'done' }
|
|
68
88
|
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
69
89
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
90
|
+
| { step: 'github-webhooks'; phase: 'start' }
|
|
91
|
+
| { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
|
|
70
92
|
| { step: 'install'; phase: 'start' }
|
|
71
93
|
| { step: 'install'; phase: 'done'; result: InstallResult }
|
|
72
94
|
| { step: 'dockerfile'; phase: 'start' }
|
|
@@ -131,7 +153,16 @@ export type InitOptions = {
|
|
|
131
153
|
withSlack?: boolean
|
|
132
154
|
withTelegram?: boolean
|
|
133
155
|
withKakaotalk?: boolean
|
|
156
|
+
withGithub?: boolean
|
|
134
157
|
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
158
|
+
// Structured GitHub credentials collected by the wizard. When omitted and
|
|
159
|
+
// `withGithub` is true, the existing secrets.json#channels.github block is
|
|
160
|
+
// reused as-is (the wizard's "reuse existing credentials" path).
|
|
161
|
+
githubCredentials?: GithubInitCredentials
|
|
162
|
+
// Test seam for the eager-webhook-install path that fires after the github
|
|
163
|
+
// channel block is written. Production callers leave this undefined so the
|
|
164
|
+
// global `fetch` is used.
|
|
165
|
+
githubFetchImpl?: typeof fetch
|
|
135
166
|
onProgress?: (event: InitStepEvent) => void
|
|
136
167
|
runHatching?: HatchRunner
|
|
137
168
|
runBunInstall?: InstallRunner
|
|
@@ -159,7 +190,10 @@ export async function runInit({
|
|
|
159
190
|
withSlack,
|
|
160
191
|
withTelegram,
|
|
161
192
|
withKakaotalk = false,
|
|
193
|
+
withGithub = false,
|
|
162
194
|
runKakaotalkAuth,
|
|
195
|
+
githubCredentials,
|
|
196
|
+
githubFetchImpl,
|
|
163
197
|
onProgress,
|
|
164
198
|
runHatching = defaultRunHatching,
|
|
165
199
|
runBunInstall: installRunner = runBunInstall,
|
|
@@ -263,6 +297,30 @@ export async function runInit({
|
|
|
263
297
|
}
|
|
264
298
|
}
|
|
265
299
|
|
|
300
|
+
// Write the structured github channel block alongside scaffold's bot-token
|
|
301
|
+
// blocks. We do NOT delegate to runAddChannel because that's the `channel
|
|
302
|
+
// add` semantics — strict no-overwrite, throws when secrets.json#channels
|
|
303
|
+
// .github already exists. Init is a different contract: re-running it
|
|
304
|
+
// regenerates config from the wizard's current inputs (scaffold() already
|
|
305
|
+
// overwrites typeclaw.json wholesale on every run), so failing on an
|
|
306
|
+
// existing secret block here would brick the re-init recovery path the
|
|
307
|
+
// bot-token adapters all support.
|
|
308
|
+
if (withGithub && githubCredentials !== undefined) {
|
|
309
|
+
await writeGithubChannelForInit(cwd, githubCredentials)
|
|
310
|
+
if (githubCredentials.webhookUrl !== undefined && githubCredentials.repos.length > 0) {
|
|
311
|
+
emit({ step: 'github-webhooks', phase: 'start' })
|
|
312
|
+
const result = await installGithubWebhooksEagerly({
|
|
313
|
+
webhookUrl: githubCredentials.webhookUrl,
|
|
314
|
+
webhookSecret: githubCredentials.webhookSecret,
|
|
315
|
+
repos: githubCredentials.repos,
|
|
316
|
+
auth: githubCredentials.auth,
|
|
317
|
+
agentDir: cwd,
|
|
318
|
+
...(githubFetchImpl !== undefined ? { fetchImpl: githubFetchImpl } : {}),
|
|
319
|
+
})
|
|
320
|
+
emit({ step: 'github-webhooks', phase: 'done', result })
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
266
324
|
emit({ step: 'install', phase: 'start' })
|
|
267
325
|
const install = await installRunner(cwd)
|
|
268
326
|
emit({ step: 'install', phase: 'done', result: install })
|
|
@@ -280,6 +338,7 @@ export async function runInit({
|
|
|
280
338
|
if (wantsSlack) configuredChannels.push('slack-bot')
|
|
281
339
|
if (wantsTelegram) configuredChannels.push('telegram-bot')
|
|
282
340
|
if (withKakaotalk) configuredChannels.push('kakaotalk')
|
|
341
|
+
if (withGithub) configuredChannels.push('github')
|
|
283
342
|
|
|
284
343
|
emit({ step: 'hatching', phase: 'start' })
|
|
285
344
|
const hatching = await runHatching({
|
|
@@ -721,7 +780,7 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
|
|
|
721
780
|
// kakaotalk` anyway — better to re-auth now during init.
|
|
722
781
|
export async function hasExistingChannelSecrets(
|
|
723
782
|
root: string,
|
|
724
|
-
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
|
|
783
|
+
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk' | 'github',
|
|
725
784
|
): Promise<boolean> {
|
|
726
785
|
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
727
786
|
if (channels === null) return false
|
|
@@ -732,6 +791,15 @@ export async function hasExistingChannelSecrets(
|
|
|
732
791
|
return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
|
|
733
792
|
case 'telegram':
|
|
734
793
|
return hasSecretField(channels['telegram-bot'], 'token')
|
|
794
|
+
case 'github':
|
|
795
|
+
// GitHub credentials alone are not enough to scaffold a working
|
|
796
|
+
// channel: typeclaw.json#channels.github also needs webhookUrl and
|
|
797
|
+
// webhookPort, which only the user can supply. Always force a fresh
|
|
798
|
+
// prompt in the wizard so those fields end up in typeclaw.json. The
|
|
799
|
+
// existing `secrets.json#channels.github` (if any) is detected and
|
|
800
|
+
// surfaced as a hard error inside `runAddChannel` to prevent silent
|
|
801
|
+
// overwrites.
|
|
802
|
+
return false
|
|
735
803
|
case 'kakaotalk': {
|
|
736
804
|
const block = channels.kakaotalk
|
|
737
805
|
if (!isObjectRecord(block)) return false
|
|
@@ -802,15 +870,21 @@ function ignoreExists(error: NodeJS.ErrnoException): void {
|
|
|
802
870
|
// scaffold-test cases above demonstrates how easy it is to lose a single
|
|
803
871
|
// behavior under a mode flag.
|
|
804
872
|
|
|
805
|
-
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk'
|
|
873
|
+
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk' | 'github'
|
|
806
874
|
|
|
807
875
|
// Public adapter names match the typeclaw.json `channels.*` keys exactly.
|
|
808
876
|
// The CLI takes these as the optional positional arg, the picker shows
|
|
809
877
|
// these labels, and they're the keys we use to detect "already configured"
|
|
810
878
|
// when reading typeclaw.json.
|
|
811
|
-
export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
879
|
+
export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
880
|
+
'slack-bot',
|
|
881
|
+
'discord-bot',
|
|
882
|
+
'telegram-bot',
|
|
883
|
+
'kakaotalk',
|
|
884
|
+
'github',
|
|
885
|
+
]
|
|
812
886
|
|
|
813
|
-
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets'
|
|
887
|
+
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
814
888
|
|
|
815
889
|
export type AddChannelStepEvent =
|
|
816
890
|
| { step: 'config'; phase: 'start' }
|
|
@@ -819,6 +893,8 @@ export type AddChannelStepEvent =
|
|
|
819
893
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
820
894
|
| { step: 'secrets'; phase: 'start' }
|
|
821
895
|
| { step: 'secrets'; phase: 'done' }
|
|
896
|
+
| { step: 'github-webhooks'; phase: 'start' }
|
|
897
|
+
| { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
|
|
822
898
|
|
|
823
899
|
// Discriminated union per channel so the type system enforces "you must pass
|
|
824
900
|
// the right credentials for the channel you're adding". The CLI builds these
|
|
@@ -831,6 +907,16 @@ export type AddChannelOptions = {
|
|
|
831
907
|
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
832
908
|
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
833
909
|
| { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
|
|
910
|
+
| {
|
|
911
|
+
channel: 'github'
|
|
912
|
+
webhookSecret: string
|
|
913
|
+
tunnelProvider: GithubTunnelProvider
|
|
914
|
+
webhookUrl?: string
|
|
915
|
+
webhookPort?: number
|
|
916
|
+
repos: string[]
|
|
917
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
918
|
+
fetchImpl?: typeof fetch
|
|
919
|
+
}
|
|
834
920
|
)
|
|
835
921
|
|
|
836
922
|
export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
@@ -852,7 +938,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
852
938
|
}
|
|
853
939
|
|
|
854
940
|
emit({ step: 'config', phase: 'start' })
|
|
855
|
-
await mergeChannelIntoConfig(options.cwd, options
|
|
941
|
+
await mergeChannelIntoConfig(options.cwd, options)
|
|
856
942
|
emit({ step: 'config', phase: 'done' })
|
|
857
943
|
|
|
858
944
|
emit({ step: 'secrets', phase: 'start' })
|
|
@@ -860,8 +946,16 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
860
946
|
if (Object.keys(tokens).length > 0) {
|
|
861
947
|
await appendChannelSecrets(options.cwd, options.channel, tokens)
|
|
862
948
|
}
|
|
949
|
+
if (options.channel === 'github') {
|
|
950
|
+
await appendGithubSecrets(options.cwd, options)
|
|
951
|
+
}
|
|
863
952
|
emit({ step: 'secrets', phase: 'done' })
|
|
864
953
|
|
|
954
|
+
if (options.channel === 'github') {
|
|
955
|
+
await appendGithubMatchRules(options.cwd, options.repos)
|
|
956
|
+
await maybeInstallGithubWebhooks(options, emit)
|
|
957
|
+
}
|
|
958
|
+
|
|
865
959
|
// Commit the typeclaw.json change so the agent folder isn't silently
|
|
866
960
|
// dirty after `typeclaw channel add`. Same `commitSystemFile` contract as
|
|
867
961
|
// every other host-side rewrite: no-op outside a git repo, when Bun is
|
|
@@ -870,6 +964,30 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
870
964
|
await commitSystemFile(options.cwd, CONFIG_FILE, `channel: add ${options.channel}`)
|
|
871
965
|
}
|
|
872
966
|
|
|
967
|
+
// Eager webhook registration is best-effort: a failure here MUST NOT roll
|
|
968
|
+
// back the typeclaw.json / secrets.json writes. The container-side adapter
|
|
969
|
+
// re-runs registration on every start, so a missing PAT scope or a transient
|
|
970
|
+
// 5xx today gets retried automatically on the next `typeclaw start`. We
|
|
971
|
+
// surface the per-repo outcome as a structured event so the CLI can render
|
|
972
|
+
// it, but we never throw.
|
|
973
|
+
async function maybeInstallGithubWebhooks(
|
|
974
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
975
|
+
emit: (event: AddChannelStepEvent) => void,
|
|
976
|
+
): Promise<void> {
|
|
977
|
+
if (options.webhookUrl === undefined) return
|
|
978
|
+
if (options.repos.length === 0) return
|
|
979
|
+
emit({ step: 'github-webhooks', phase: 'start' })
|
|
980
|
+
const result = await installGithubWebhooksEagerly({
|
|
981
|
+
webhookUrl: options.webhookUrl,
|
|
982
|
+
webhookSecret: options.webhookSecret,
|
|
983
|
+
repos: options.repos,
|
|
984
|
+
auth: options.auth,
|
|
985
|
+
agentDir: options.cwd,
|
|
986
|
+
...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
|
|
987
|
+
})
|
|
988
|
+
emit({ step: 'github-webhooks', phase: 'done', result })
|
|
989
|
+
}
|
|
990
|
+
|
|
873
991
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
874
992
|
switch (options.channel) {
|
|
875
993
|
case 'discord-bot':
|
|
@@ -882,6 +1000,9 @@ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
|
882
1000
|
// KakaoTalk auth writes its structured multi-account block directly to
|
|
883
1001
|
// secrets.json#channels.kakaotalk before config mutation.
|
|
884
1002
|
return {}
|
|
1003
|
+
case 'github':
|
|
1004
|
+
// GitHub stores a structured PAT + webhook secret block directly.
|
|
1005
|
+
return {}
|
|
885
1006
|
}
|
|
886
1007
|
}
|
|
887
1008
|
|
|
@@ -908,7 +1029,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
|
|
|
908
1029
|
return present
|
|
909
1030
|
}
|
|
910
1031
|
|
|
911
|
-
async function mergeChannelIntoConfig(cwd: string,
|
|
1032
|
+
async function mergeChannelIntoConfig(cwd: string, options: AddChannelOptions): Promise<void> {
|
|
912
1033
|
const path = join(cwd, CONFIG_FILE)
|
|
913
1034
|
let parsed: Record<string, unknown>
|
|
914
1035
|
try {
|
|
@@ -928,19 +1049,152 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promis
|
|
|
928
1049
|
? (parsed.channels as Record<string, unknown>)
|
|
929
1050
|
: {}
|
|
930
1051
|
|
|
931
|
-
if (channel in existingChannels) {
|
|
1052
|
+
if (options.channel in existingChannels) {
|
|
932
1053
|
// Defense in depth — the CLI already filters configured channels out of
|
|
933
1054
|
// the picker and rejects them as the positional arg. Hitting this branch
|
|
934
1055
|
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
935
1056
|
// than silently overwrite the user's existing config.
|
|
936
|
-
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
1057
|
+
throw new Error(`Channel "${options.channel}" is already configured in ${CONFIG_FILE}.`)
|
|
937
1058
|
}
|
|
938
1059
|
|
|
1060
|
+
const nextChannelConfig = options.channel === 'github' ? buildGithubChannelConfig(options) : {}
|
|
1061
|
+
|
|
939
1062
|
parsed.channels = {
|
|
940
1063
|
...existingChannels,
|
|
941
|
-
[channel]:
|
|
1064
|
+
[options.channel]: nextChannelConfig,
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (options.channel === 'github') mergeGithubTunnelConfig(parsed, options)
|
|
1068
|
+
|
|
1069
|
+
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function buildGithubChannelConfig(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
|
|
1073
|
+
return {
|
|
1074
|
+
...(options.webhookUrl !== undefined ? { webhookUrl: options.webhookUrl } : {}),
|
|
1075
|
+
webhookPort: options.webhookPort ?? 8975,
|
|
1076
|
+
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1077
|
+
repos: options.repos,
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function mergeGithubTunnelConfig(
|
|
1082
|
+
parsed: Record<string, unknown>,
|
|
1083
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
1084
|
+
): void {
|
|
1085
|
+
if (options.tunnelProvider === 'none') return
|
|
1086
|
+
if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
|
|
1087
|
+
throw new Error('GitHub external tunnel requires webhookUrl')
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
|
|
1091
|
+
const tunnel =
|
|
1092
|
+
options.tunnelProvider === 'external'
|
|
1093
|
+
? {
|
|
1094
|
+
name: 'github-webhook',
|
|
1095
|
+
provider: 'external',
|
|
1096
|
+
externalUrl: options.webhookUrl,
|
|
1097
|
+
for: { kind: 'channel', name: 'github' },
|
|
1098
|
+
}
|
|
1099
|
+
: {
|
|
1100
|
+
name: 'github-webhook',
|
|
1101
|
+
provider: 'cloudflare-quick',
|
|
1102
|
+
for: { kind: 'channel', name: 'github' },
|
|
1103
|
+
}
|
|
1104
|
+
parsed.tunnels = [...existingTunnels, tunnel]
|
|
1105
|
+
|
|
1106
|
+
if (options.tunnelProvider === 'cloudflare-quick') {
|
|
1107
|
+
const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
|
|
1108
|
+
const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
|
|
1109
|
+
file.cloudflared = true
|
|
1110
|
+
docker.file = file
|
|
1111
|
+
parsed.docker = docker
|
|
942
1112
|
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Init-side counterpart of runAddChannel's github branch. Same three writes
|
|
1116
|
+
// (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
|
|
1117
|
+
// .match[]) but with overwrite semantics on the secrets/config side so a
|
|
1118
|
+
// re-run of `typeclaw init` after a partial failure works the same way it
|
|
1119
|
+
// does for the bot-token adapters. The match-rule writer is reused as-is
|
|
1120
|
+
// because its set-union is already idempotent.
|
|
1121
|
+
async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCredentials): Promise<void> {
|
|
1122
|
+
const configPath = join(cwd, CONFIG_FILE)
|
|
1123
|
+
const parsed = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>
|
|
1124
|
+
const existingChannels = isObjectRecord(parsed.channels) ? { ...parsed.channels } : {}
|
|
1125
|
+
existingChannels.github = {
|
|
1126
|
+
...(credentials.webhookUrl !== undefined ? { webhookUrl: credentials.webhookUrl } : {}),
|
|
1127
|
+
webhookPort: credentials.webhookPort ?? 8975,
|
|
1128
|
+
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1129
|
+
repos: credentials.repos,
|
|
1130
|
+
}
|
|
1131
|
+
parsed.channels = existingChannels
|
|
1132
|
+
mergeGithubTunnelConfig(parsed, { channel: 'github', ...credentials, cwd })
|
|
1133
|
+
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1134
|
+
|
|
1135
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1136
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
1137
|
+
channels.github = {
|
|
1138
|
+
auth:
|
|
1139
|
+
credentials.auth.type === 'pat'
|
|
1140
|
+
? { type: 'pat', token: { value: credentials.auth.pat } satisfies Secret }
|
|
1141
|
+
: {
|
|
1142
|
+
type: 'app',
|
|
1143
|
+
appId: credentials.auth.appId,
|
|
1144
|
+
privateKey: { value: credentials.auth.privateKey } satisfies Secret,
|
|
1145
|
+
...(credentials.auth.installationId !== undefined
|
|
1146
|
+
? { installationId: credentials.auth.installationId }
|
|
1147
|
+
: {}),
|
|
1148
|
+
},
|
|
1149
|
+
webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
|
|
1150
|
+
}
|
|
1151
|
+
backend.writeChannelsSync(channels as Channels)
|
|
943
1152
|
|
|
1153
|
+
await appendGithubMatchRules(cwd, credentials.repos)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async function appendGithubSecrets(
|
|
1157
|
+
cwd: string,
|
|
1158
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
1159
|
+
): Promise<void> {
|
|
1160
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1166
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
1167
|
+
if (channels.github !== undefined) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
'github is already set in secrets.json. Remove it before re-adding the channel, or edit it by hand.',
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
channels.github = {
|
|
1173
|
+
auth:
|
|
1174
|
+
options.auth.type === 'pat'
|
|
1175
|
+
? { type: 'pat', token: { value: options.auth.pat } satisfies Secret }
|
|
1176
|
+
: {
|
|
1177
|
+
type: 'app',
|
|
1178
|
+
appId: options.auth.appId,
|
|
1179
|
+
privateKey: { value: options.auth.privateKey } satisfies Secret,
|
|
1180
|
+
...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
|
|
1181
|
+
},
|
|
1182
|
+
webhookSecret: { value: options.webhookSecret } satisfies Secret,
|
|
1183
|
+
}
|
|
1184
|
+
backend.writeChannelsSync(channels as Channels)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Promise<void> {
|
|
1188
|
+
const path = join(cwd, CONFIG_FILE)
|
|
1189
|
+
const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
1190
|
+
const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
|
|
1191
|
+
const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
|
|
1192
|
+
const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
|
|
1193
|
+
const merged = new Set(existing)
|
|
1194
|
+
for (const repo of repos) merged.add(`github:${repo}`)
|
|
1195
|
+
member.match = Array.from(merged)
|
|
1196
|
+
roles.member = member
|
|
1197
|
+
parsed.roles = roles
|
|
944
1198
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
945
1199
|
}
|
|
946
1200
|
|
|
@@ -976,3 +1230,245 @@ async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: C
|
|
|
976
1230
|
channels[channel] = slot
|
|
977
1231
|
backend.writeChannelsSync(channels as Channels)
|
|
978
1232
|
}
|
|
1233
|
+
|
|
1234
|
+
// ----------------------------------------------------------------------------
|
|
1235
|
+
// `typeclaw channel set`
|
|
1236
|
+
//
|
|
1237
|
+
// Rotate credentials of an already-configured channel. Symmetric with
|
|
1238
|
+
// `typeclaw provider set` (see `setProvider` in src/config/providers-mutation.ts):
|
|
1239
|
+
// `add` is "add for the first time, refuse if already present", `set` is
|
|
1240
|
+
// "rotate the value, refuse if NOT yet present". Two separate verbs keep the
|
|
1241
|
+
// "add by mistake" footgun and the "rotate by mistake" footgun on opposite
|
|
1242
|
+
// sides of the CLI namespace.
|
|
1243
|
+
//
|
|
1244
|
+
// Per the env-wins / file-never-auto-mutated rule in AGENTS.md#secrets, these
|
|
1245
|
+
// helpers only touch the fields the user explicitly asked to rotate. Any
|
|
1246
|
+
// untouched field — including a sibling field bound to a custom env var —
|
|
1247
|
+
// keeps its existing Secret envelope verbatim.
|
|
1248
|
+
//
|
|
1249
|
+
// Kakaotalk has its own auth flow (encryption envelope + device_uuid + phone
|
|
1250
|
+
// passcode) and is rotated via `typeclaw channel reauth kakaotalk`, NOT via
|
|
1251
|
+
// these helpers. Trying to set('kakaotalk') would bypass the encryption
|
|
1252
|
+
// bridge and corrupt the per-account block; the CLI layer rejects it before
|
|
1253
|
+
// reaching here.
|
|
1254
|
+
|
|
1255
|
+
export type SetChannelTokensResult = { ok: true } | { ok: false; reason: string }
|
|
1256
|
+
|
|
1257
|
+
type BotTokenAdapter = 'discord-bot' | 'slack-bot' | 'telegram-bot'
|
|
1258
|
+
|
|
1259
|
+
// Required credential fields per adapter. Used post-merge to refuse a
|
|
1260
|
+
// rotation that would leave the adapter half-configured — e.g. rotating
|
|
1261
|
+
// only `botToken` when the on-disk slot was `{}` would silently leave
|
|
1262
|
+
// `appToken` missing and the Slack adapter would fail to start. Listing
|
|
1263
|
+
// the contract explicitly here keeps it close to the writer.
|
|
1264
|
+
const REQUIRED_CHANNEL_FIELDS: Record<BotTokenAdapter, readonly string[]> = {
|
|
1265
|
+
'discord-bot': ['token'],
|
|
1266
|
+
'slack-bot': ['botToken', 'appToken'],
|
|
1267
|
+
'telegram-bot': ['token'],
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Preserve a user-authored `{ env: 'CUSTOM_NAME' }` rebinding when rotating
|
|
1271
|
+
// the value behind it. Mirrors `buildSecret` in providers-mutation.ts for
|
|
1272
|
+
// the case where the prior credential had an explicit env-name binding —
|
|
1273
|
+
// the rotated value is written as `{ value, env }` so env-wins still works
|
|
1274
|
+
// at runtime. Without this, every `channel set` would silently strip the
|
|
1275
|
+
// rebinding and `process.env[<custom>]` would no longer override.
|
|
1276
|
+
function rotatedSecret(previous: unknown, value: string): Secret {
|
|
1277
|
+
if (isObjectRecord(previous)) {
|
|
1278
|
+
const env = (previous as { env?: unknown }).env
|
|
1279
|
+
if (typeof env === 'string' && env.length > 0) {
|
|
1280
|
+
return { value, env }
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return { value }
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Rotate one or more credential fields on an already-configured bot-token
|
|
1287
|
+
// adapter (discord-bot, slack-bot, telegram-bot). Refuses when the adapter
|
|
1288
|
+
// has no existing entry in secrets.json — callers must use `runAddChannel`
|
|
1289
|
+
// for first-time setup, so a typo in the adapter name can't silently create
|
|
1290
|
+
// a half-configured channel. Also refuses when the rotation would leave any
|
|
1291
|
+
// required field for the adapter unset (e.g. rotating only Slack's
|
|
1292
|
+
// `botToken` when `appToken` is missing from disk).
|
|
1293
|
+
export async function setChannelSecrets(
|
|
1294
|
+
cwd: string,
|
|
1295
|
+
channel: BotTokenAdapter,
|
|
1296
|
+
tokens: ChannelSecrets,
|
|
1297
|
+
): Promise<SetChannelTokensResult> {
|
|
1298
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1299
|
+
return {
|
|
1300
|
+
ok: false,
|
|
1301
|
+
reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (Object.keys(tokens).length === 0) return { ok: true }
|
|
1306
|
+
|
|
1307
|
+
return await runChannelMutation(cwd, async (current) => {
|
|
1308
|
+
const existingSlot = current[channel]
|
|
1309
|
+
if (!isObjectRecord(existingSlot)) {
|
|
1310
|
+
return {
|
|
1311
|
+
result: {
|
|
1312
|
+
ok: false,
|
|
1313
|
+
reason: `${channel} is not configured in secrets.json. Run \`typeclaw channel add ${channel}\` first.`,
|
|
1314
|
+
},
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const slot: Record<string, unknown> = { ...existingSlot }
|
|
1318
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
1319
|
+
slot[k] = rotatedSecret(existingSlot[k], v)
|
|
1320
|
+
}
|
|
1321
|
+
const missing = REQUIRED_CHANNEL_FIELDS[channel].filter((field) => !isSecretFieldSet(slot[field]))
|
|
1322
|
+
if (missing.length > 0) {
|
|
1323
|
+
return {
|
|
1324
|
+
result: {
|
|
1325
|
+
ok: false,
|
|
1326
|
+
reason: `${channel} would be left half-configured after this rotation: missing required field(s) ${missing.join(', ')} in secrets.json. Run \`typeclaw channel add ${channel}\` to re-add the channel, or fix secrets.json by hand.`,
|
|
1327
|
+
},
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const next: Record<string, unknown> = { ...current, [channel]: slot }
|
|
1331
|
+
return { result: { ok: true }, next }
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Discriminated union of what GitHub credentials the user wants to rotate.
|
|
1336
|
+
// The three secrets (PAT/private-key, webhook secret) rotate independently,
|
|
1337
|
+
// so the CLI lets the user pick which one(s) to touch in a single call.
|
|
1338
|
+
// `auth.type` must match the existing on-disk auth type — flipping between
|
|
1339
|
+
// PAT and App auth is a structural change, not a credential rotation, and
|
|
1340
|
+
// belongs in a future `channel migrate-auth` or hand-edit of secrets.json.
|
|
1341
|
+
export type GithubCredentialPatch = {
|
|
1342
|
+
webhookSecret?: string
|
|
1343
|
+
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Rotate one or more credential fields on an already-configured GitHub
|
|
1347
|
+
// channel. Like setChannelSecrets, refuses when secrets.json has no
|
|
1348
|
+
// existing github entry. Additionally refuses when the requested auth.type
|
|
1349
|
+
// doesn't match the on-disk type — see `GithubCredentialPatch` above.
|
|
1350
|
+
export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
|
|
1351
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1352
|
+
return {
|
|
1353
|
+
ok: false,
|
|
1354
|
+
reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (patch.webhookSecret === undefined && patch.auth === undefined) return { ok: true }
|
|
1359
|
+
|
|
1360
|
+
return await runChannelMutation(cwd, async (current) => {
|
|
1361
|
+
const existing = current.github
|
|
1362
|
+
if (!isObjectRecord(existing)) {
|
|
1363
|
+
return {
|
|
1364
|
+
result: {
|
|
1365
|
+
ok: false,
|
|
1366
|
+
reason: 'github is not configured in secrets.json. Run `typeclaw channel add github` first.',
|
|
1367
|
+
},
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const block: Record<string, unknown> = { ...existing }
|
|
1371
|
+
|
|
1372
|
+
if (patch.auth !== undefined) {
|
|
1373
|
+
const existingAuth = block.auth
|
|
1374
|
+
const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
|
|
1375
|
+
if (existingAuthType !== patch.auth.type) {
|
|
1376
|
+
return {
|
|
1377
|
+
result: {
|
|
1378
|
+
ok: false,
|
|
1379
|
+
reason: `github auth type mismatch: secrets.json currently uses "${existingAuthType ?? 'unknown'}" auth, but you tried to rotate a "${patch.auth.type}" credential. Edit secrets.json by hand to migrate between PAT and App auth.`,
|
|
1380
|
+
},
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (patch.auth.type === 'pat') {
|
|
1384
|
+
const previousToken = isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
|
|
1385
|
+
block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
|
|
1386
|
+
} else {
|
|
1387
|
+
const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1388
|
+
const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
|
|
1389
|
+
const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
|
|
1390
|
+
if (typeof appId !== 'number') {
|
|
1391
|
+
return {
|
|
1392
|
+
result: {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
reason:
|
|
1395
|
+
'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.',
|
|
1396
|
+
},
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
block.auth = {
|
|
1400
|
+
type: 'app',
|
|
1401
|
+
appId,
|
|
1402
|
+
privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
|
|
1403
|
+
...(installationId !== undefined ? { installationId } : {}),
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (patch.webhookSecret !== undefined) {
|
|
1409
|
+
block.webhookSecret = rotatedSecret(block.webhookSecret, patch.webhookSecret)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const next: Record<string, unknown> = { ...current, github: block }
|
|
1413
|
+
return { result: { ok: true }, next }
|
|
1414
|
+
})
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Wrapper that converts a thrown schema-validation error (from a hand-edited
|
|
1418
|
+
// malformed secrets.json) into a structured `{ ok: false }` result, so CLI
|
|
1419
|
+
// callers can render a clean message instead of a stack trace. The
|
|
1420
|
+
// `updateChannelsAsync` path itself is atomic; this wrapper only catches the
|
|
1421
|
+
// READ stage (envelope parse) failures.
|
|
1422
|
+
async function runChannelMutation(
|
|
1423
|
+
cwd: string,
|
|
1424
|
+
fn: (current: Record<string, unknown>) => Promise<{ result: SetChannelTokensResult; next?: Record<string, unknown> }>,
|
|
1425
|
+
): Promise<SetChannelTokensResult> {
|
|
1426
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1427
|
+
try {
|
|
1428
|
+
return await backend.updateChannelsAsync<SetChannelTokensResult>(fn)
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
return {
|
|
1431
|
+
ok: false,
|
|
1432
|
+
reason: `secrets.json is malformed: ${err instanceof Error ? err.message : String(err)}. Fix it by hand, then retry.`,
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function isSecretFieldSet(slot: unknown): boolean {
|
|
1438
|
+
if (typeof slot === 'string') return slot.length > 0
|
|
1439
|
+
if (isObjectRecord(slot)) {
|
|
1440
|
+
const value = (slot as { value?: unknown }).value
|
|
1441
|
+
if (typeof value === 'string' && value.length > 0) return true
|
|
1442
|
+
const env = (slot as { env?: unknown }).env
|
|
1443
|
+
if (typeof env === 'string' && env.length > 0) return true
|
|
1444
|
+
}
|
|
1445
|
+
return false
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function readGithubAuthTypeFromObject(auth: unknown): 'pat' | 'app' | undefined {
|
|
1449
|
+
if (!isObjectRecord(auth)) return undefined
|
|
1450
|
+
const type = (auth as { type?: unknown }).type
|
|
1451
|
+
if (type === 'pat' || type === 'app') return type
|
|
1452
|
+
return undefined
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Lightweight read-only probe used by the `channel set` CLI to drive its
|
|
1456
|
+
// "which secret do you want to rotate?" menu for GitHub. Returns the
|
|
1457
|
+
// current auth type ('pat' | 'app') so the prompt knows whether to ask for
|
|
1458
|
+
// a PAT or an App private key, without forcing the user to re-select auth
|
|
1459
|
+
// type when they're rotating a credential of the same kind. Returns `null`
|
|
1460
|
+
// when secrets.json is missing, malformed, or has no github entry — the
|
|
1461
|
+
// CLI surfaces that as a single user-facing "fix the file by hand" error.
|
|
1462
|
+
export function readGithubAuthType(cwd: string): 'pat' | 'app' | null {
|
|
1463
|
+
let channels: Channels | null
|
|
1464
|
+
try {
|
|
1465
|
+
channels = new SecretsBackend(join(cwd, 'secrets.json')).tryReadChannelsSync()
|
|
1466
|
+
} catch {
|
|
1467
|
+
return null
|
|
1468
|
+
}
|
|
1469
|
+
if (channels === null) return null
|
|
1470
|
+
const github = channels.github
|
|
1471
|
+
if (!isObjectRecord(github)) return null
|
|
1472
|
+
const auth = (github as { auth?: unknown }).auth
|
|
1473
|
+
return readGithubAuthTypeFromObject(auth) ?? null
|
|
1474
|
+
}
|