1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13
14import * as HostProcess from "@t3tools/shared/HostProcess";
15
16import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
17import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
18import type { ServerSettings } from "@t3tools/contracts";
19
20import * as GitHubClient from "../client/definition.ts";
21
22/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */
23const TOKEN_TTL = Duration.minutes(5);
24/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
25const MISSING_TTL = Duration.seconds(10);
26
27export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]);
28export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
29
30export interface GitHubCredential {
31 readonly host: string;
32 readonly token: Redacted.Redacted<string>;
33 readonly source: GitHubCredentialSource;
34 /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */
35 readonly fingerprint: string;
36}
37
38/** Nothing in the environment, and no `gh` on PATH to ask. */
39export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingError>()(
40 "GitHubCliMissingError",
41 { host: Schema.String },
42) {
43 override get message(): string {
44 return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`;
45 }
46}
47
48/** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */
49export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()(
50 "GitHubNotSignedInError",
51 { host: Schema.String, account: Schema.optional(Schema.String) },
52) {
53 override get message(): string {
54 return this.account === undefined
55 ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`
56 : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`;
57 }
58}
59
60/** The user turned the host off in Settings. */
61export class GitHubHostDisabledError extends Schema.TaggedError<GitHubHostDisabledError>()(
62 "GitHubHostDisabledError",
63 { host: Schema.String },
64) {
65 override get message(): string {
66 return `GitHub host ${this.host} is turned off in Settings → Source Control.`;
67 }
68}
69
70/** `gh auth token` timed out or failed for a reason other than having no login. */
71export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedError>()(
72 "GitHubCliFailedError",
73 { host: Schema.String, cause: Schema.Defect() },
74) {
75 override get message(): string {
76 return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`;
77 }
78}
79
80/** There is no token for the host. */
81export type GitHubCredentialUnavailableError =
82 | GitHubCliMissingError
83 | GitHubNotSignedInError
84 | GitHubHostDisabledError
85 | GitHubCliFailedError;
86
87/**
88 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
89 * so another source (an in-app OAuth login) slots in here without touching any of them.
90 */
91export class GitHubCredentials extends Context.Service<
92 GitHubCredentials,
93 {
94 readonly get: (
95 host: string,
96 ) => Effect.Effect<GitHubCredential, GitHubCredentialUnavailableError>;
97 /** Drops the held token after GitHub refused it, so the next read asks its source again. */
98 readonly invalidate: (host: string) => Effect.Effect<void>;
99 }
100>()("@t3tools/source-control-github/server/GitHubCredentials") {}
101
102function normalizeHost(host: string): string {
103 return host.trim().toLowerCase();
104}
105
106/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */
107function isGitHubDotCom(host: string): boolean {
108 return host === "github.com" || host.endsWith(".ghe.com");
109}
110
111/**
112 * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to
113 * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL
114 * picks the host and a hostile one must not receive an enterprise token.
115 */
116export function environmentToken(
117 host: string,
118 env: Readonly<Record<string, string | undefined>>,
119): string | null {
120 const names = isGitHubDotCom(host)
121 ? ["GH_TOKEN", "GITHUB_TOKEN"]
122 : env.GH_HOST?.trim().toLowerCase() === host
123 ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]
124 : [];
125 for (const name of names) {
126 const value = env[name]?.trim();
127 if (value) return value;
128 }
129 return null;
130}
131
132/** @public Service construction is part of the canonical Effect module API. */
133export const make = Effect.gen(function* () {
134 const sourceControlHost = yield* SourceControlHost.SourceControlHost;
135 const process = sourceControlHost.process;
136 const crypto = yield* Crypto.Crypto;
137 const environment = yield* HostProcess.Environment;
138 const workingDirectory = yield* HostProcess.WorkingDirectory;
139
140 /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */
141 const fingerprintOf = (host: string, token: string) =>
142 crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(
143 Effect.map((digest) => `${host}:${Hex.encode(digest)}`),
144 // Hashing a string in memory has no platform failure worth a typed error.
145 Effect.orDie,
146 );
147
148 const fromGh = (host: string, account: string | undefined) =>
149 process
150 .run({
151 operation: "GitHubCredentials.get",
152 command: "gh",
153 args: [
154 "auth",
155 "token",
156 "--hostname",
157 host,
158 ...(account === undefined ? [] : ["--user", account]),
159 ],
160 cwd: workingDirectory,
161 // Never let gh print the token into a debug log.
162 env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" },
163 timeoutMs: 10_000,
164 })
165 .pipe(
166 Effect.mapError((error) =>
167 error._tag === "VcsProcessSpawnError" &&
168 error.cause instanceof PlatformError.PlatformError &&
169 error.cause.reason._tag === "NotFound"
170 ? new GitHubCliMissingError({ host })
171 : // gh exits non-zero with "no oauth token" when it has no login for the host.
172 error._tag === "VcsProcessExitError"
173 ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) })
174 : new GitHubCliFailedError({ host, cause: error }),
175 ),
176 Effect.map((output) => output.stdout.trim()),
177 Effect.filterOrFail(
178 (token) => token !== "",
179 () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }),
180 ),
181 );
182
183 const readSettings = (settings: ServerSettings) =>
184 readSourceControlHostSettings(
185 GitHubClient.settings,
186 settings.sourceControlHosts[GitHubClient.definition.kind],
187 );
188
189 /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */
190 const hostChoice = (host: string) =>
191 sourceControlHost.settings.get.pipe(
192 Effect.map((settings) => readSettings(settings).hosts[host]),
193 Effect.orElseSucceed(() => undefined),
194 );
195
196 /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */
197 const savedToken = (host: string) =>
198 sourceControlHost.settings.get.pipe(
199 Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null),
200 Effect.orElseSucceed(() => null),
201 );
202
203 /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */
204 const cacheKey = (host: string, account: string | undefined) =>
205 account === undefined ? host : `${host}\u0000${account}`;
206
207 const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) {
208 const [host = key, choice] = key.split("\u0000");
209 // An environment token wins over a pinned account, exactly as it does in gh.
210 const fromEnv = environmentToken(host, environment);
211 // A pinned login gh no longer holds (logged out, expired) falls back to the active one,
212 // which is what discovery reports as the account in use.
213 const token =
214 fromEnv ??
215 (yield* fromGh(host, choice).pipe(
216 Effect.catchTags({
217 GitHubNotSignedInError: (error) =>
218 choice === undefined ? Effect.fail(error) : fromGh(host, undefined),
219 }),
220 ));
221 return {
222 host,
223 token: Redacted.make(token),
224 source: fromEnv !== null ? "env" : "gh",
225 fingerprint: yield* fingerprintOf(host, token),
226 } satisfies GitHubCredential;
227 });
228
229 const cache = yield* Cache.makeWith(lookup, {
230 capacity: 32,
231 // A transient gh failure (a timeout, a locked keyring) is asked again on the next read.
232 timeToLive: (exit) =>
233 Exit.isSuccess(exit)
234 ? TOKEN_TTL
235 : Exit.findErrorOption(exit).pipe(
236 Option.exists((error) => error._tag === "GitHubCliFailedError"),
237 )
238 ? Duration.zero
239 : MISSING_TTL,
240 });
241
242 return GitHubCredentials.of({
243 get: Effect.fn("GitHubCredentials.get")(function* (rawHost) {
244 const host = normalizeHost(rawHost);
245 const choice = yield* hostChoice(host);
246 if (choice?.enabled === false) {
247 return yield* new GitHubHostDisabledError({ host });
248 }
249 // A token saved in Settings is the most deliberate choice, so it comes before the
250 // environment and gh. It is read from the secret store each time, so it needs no cache.
251 const saved = yield* savedToken(host);
252 if (saved !== null) {
253 return {
254 host,
255 token: Redacted.make(saved),
256 source: "settings",
257 fingerprint: yield* fingerprintOf(host, saved),
258 } satisfies GitHubCredential;
259 }
260 return yield* Cache.get(cache, cacheKey(host, choice?.account));
261 }),
262 invalidate: (rawHost) => {
263 const host = normalizeHost(rawHost);
264 return hostChoice(host).pipe(
265 Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))),
266 );
267 },
268 });
269});
270
271export const layer = Layer.effect(GitHubCredentials, make);