1import * as Effect from "effect/Effect";
2import * as Match from "effect/Match";
3import * as Redacted from "effect/Redacted";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import * as ChildProcess from "effect/process/ChildProcess";
7import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
8import {
9 AuthError,
10 AuthProviderLayer,
11 NeedsReauth,
12 refreshHint,
13 type ConfigureField,
14 type ConfigureMethod,
15 type ProviderDetails,
16} from "../Auth/AuthProvider.ts";
17import { displayRedacted } from "../Auth/Credentials.ts";
18import { getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts";
19import {
20 storedSecret,
21 storedValueText,
22 validateFieldValues,
23} from "../Auth/StoredAuthProvider.ts";
24import * as Interaction from "../Interaction.ts";
25import {
26 githubHostname,
27 normalizeGitHubBaseUrl,
28 resolveGitHubBaseUrlFromEnv,
29} from "./BaseUrl.ts";
30
31const options: Array<{
32 value: GitHubAuthConfig["method"];
33 label: string;
34 description?: string;
35}> = [
36 {
37 value: "gh-cli",
38 label: "GitHub CLI",
39 description: "delegate to `gh auth token` (run `gh auth login` first)",
40 },
41 {
42 value: "stored",
43 label: "Personal Access Token",
44 description: "enter PAT interactively and store it in the profile",
45 },
46];
47
48/** Typed values stored in a GitHub provider profile document. */
49export const GitHubAuthConfigSchema = Schema.Union([
50 Schema.Struct({
51 method: Schema.Literal("stored"),
52 token: Schema.String,
53 baseUrl: Schema.optionalKey(Schema.String),
54 }),
55 Schema.Struct({
56 method: Schema.Literal("gh-cli"),
57 // v0 delegated to `gh auth token` and therefore had no inline token.
58 // Keep that migration state readable; the first credential resolution
59 // captures the token inline through updateConfig.
60 token: Schema.optionalKey(Schema.String),
61 baseUrl: Schema.optionalKey(Schema.String),
62 }),
63]);
64export type GitHubAuthConfig = typeof GitHubAuthConfigSchema.Type;
65
66export interface GitHubResolvedCredentials {
67 type: "token";
68 token: Redacted.Redacted<string>;
69 /**
70 * Normalized REST API base URL for GitHub Enterprise (e.g.
71 * `https://github.example.com/api/v3` or `https://api.acme.ghe.com`).
72 * `undefined` means github.com.
73 */
74 baseUrl?: string;
75 source: { type: GitHubAuthConfig["method"] | "env"; details?: string };
76}
77
78export const GITHUB_AUTH_PROVIDER_NAME = "GitHub";
79
80class GhCliError extends Error {
81 readonly _tag = "GhCliError";
82}
83
84const readEnvTokenFor = (
85 baseUrl: string | undefined,
86): Effect.Effect<GitHubResolvedCredentials, AuthError> =>
87 Effect.gen(function* () {
88 const candidates =
89 baseUrl !== undefined
90 ? [
91 "GH_ENTERPRISE_TOKEN",
92 "GITHUB_ENTERPRISE_TOKEN",
93 "GITHUB_ACCESS_TOKEN",
94 "GITHUB_TOKEN",
95 ]
96 : ["GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN"];
97 for (const key of candidates) {
98 const token = yield* getEnvRedacted(key);
99 if (token) {
100 return {
101 type: "token" as const,
102 token,
103 baseUrl,
104 source: { type: "env" as const, details: key },
105 };
106 }
107 }
108 return yield* new AuthError({
109 message: `GitHub env credentials not found. Set ${candidates.join(", ")}.`,
110 });
111 });
112
113/** Resolve standalone GitHub credentials from environment variables. */
114export const readEnvCredentials = (
115 configBaseUrl?: string,
116): Effect.Effect<GitHubResolvedCredentials, AuthError> =>
117 Effect.gen(function* () {
118 const baseUrl = configBaseUrl ?? (yield* resolveGitHubBaseUrlFromEnv);
119 return yield* readEnvTokenFor(baseUrl);
120 });
121
122/**
123 * Build the Layer that registers the GitHub {@link AuthProvider} into the
124 * {@link AuthProviders} registry. Included in the GitHub `providers()` layer
125 * so the alchemy CLI can discover it.
126 *
127 * Supported methods:
128 * - `gh-cli`: shells out to `gh auth token` (recommended).
129 * - `stored`: prompts for a PAT and stores it inline in the provider file.
130 *
131 * GitHub Enterprise (Server or Cloud with data residency) is supported by
132 * every method: `alchemy profile edit --reconfigure GitHub` prompts for the host, or set
133 * `GITHUB_BASE_URL` / `GITHUB_API_URL` / `GH_HOST` in the environment. The
134 * host is normalized into the REST API base URL passed to Octokit, and
135 * `gh auth token` is invoked with `--hostname` so the CLI returns the token
136 * for the right host.
137 *
138 * Browser/device OAuth is intentionally not implemented: GitHub's
139 * OAuth App flow requires a `client_secret` we cannot ship, and
140 * device flow is exactly what `gh auth login` already does.
141 */
142export interface GitHubAuthOptions {
143 /**
144 * Hard-code the GitHub host or API base URL (e.g. `github.example.com`
145 * or `https://github.example.com/api/v3`). When set, it takes precedence
146 * over the profile's configured host and the environment for every auth
147 * method, the configure flow stops prompting for a host, and the `gh` CLI
148 * method authenticates against this host. `GitHub.providers({ baseUrl })`
149 * threads its option here.
150 */
151 readonly baseUrl?: string;
152}
153
154export const makeGitHubAuth = (authOptions?: GitHubAuthOptions) =>
155 AuthProviderLayer<GitHubAuthConfig, GitHubResolvedCredentials>()(
156 GITHUB_AUTH_PROVIDER_NAME,
157 Effect.gen(function* () {
158 // Deferred accessors, NOT `yield* Interaction.Interaction`: resolving the service
159 // at impl build would put the terminal in the registration layer's
160 // requirements, and child processes (which only ever call `read`) build
161 // this layer with no interaction services at all.
162 const interaction = Interaction.accessors;
163 const cp = yield* ChildProcessSpawner;
164
165 // Hard-coded host from `providers({ baseUrl })`, resolved once at layer
166 // build. Kept as an object so "no option" (undefined) is distinct from
167 // "option normalized to the github.com default" ({ baseUrl: undefined })
168 // — both matter: the latter still pins the host and mutes the prompt.
169 const fixed =
170 authOptions?.baseUrl !== undefined
171 ? {
172 baseUrl: yield* normalizeGitHubBaseUrl(authOptions.baseUrl).pipe(
173 Effect.orDie,
174 ),
175 }
176 : undefined;
177
178 // The host every method authenticates against: the hard-coded value
179 // wins, then the profile's configured host, then the environment.
180 const effectiveBaseUrl = (
181 config: GitHubAuthConfig,
182 ): Effect.Effect<string | undefined, AuthError> =>
183 fixed !== undefined
184 ? Effect.succeed(fixed.baseUrl)
185 : config.baseUrl !== undefined
186 ? Effect.succeed(config.baseUrl)
187 : resolveGitHubBaseUrlFromEnv;
188
189 const ghCliToken = (
190 hostname?: string,
191 ): Effect.Effect<string, AuthError> =>
192 Effect.gen(function* () {
193 const handle = yield* cp.spawn(
194 ChildProcess.make(
195 "gh",
196 [
197 "auth",
198 "token",
199 ...(hostname !== undefined ? ["--hostname", hostname] : []),
200 ],
201 { shell: false },
202 ),
203 );
204 const [exitCode, stdout, stderr] = yield* Effect.all(
205 [
206 handle.exitCode,
207 Stream.mkString(Stream.decodeText(handle.stdout)),
208 Stream.mkString(Stream.decodeText(handle.stderr)),
209 ],
210 { concurrency: 3 },
211 );
212 if (exitCode !== 0) {
213 return yield* Effect.fail(
214 new GhCliError(
215 `gh auth token exited with ${exitCode}: ${stderr.trim() || stdout.trim()}`,
216 ),
217 );
218 }
219 const token = stdout.trim();
220 if (!token) {
221 return yield* Effect.fail(
222 new GhCliError("gh auth token returned empty output"),
223 );
224 }
225 return token;
226 }).pipe(
227 Effect.scoped,
228 Effect.mapError((e) =>
229 e instanceof GhCliError
230 ? new AuthError({ message: e.message, cause: e })
231 : new AuthError({
232 message:
233 "Could not invoke `gh`. Install GitHub CLI from https://cli.github.com/ and run `gh auth login`.",
234 cause: e,
235 }),
236 ),
237 );
238
239 const loginStored = Effect.fn(function* (baseUrl?: string) {
240 const token = yield* interaction.prompt
241 .password({
242 message: "GitHub Personal Access Token",
243 description:
244 "Requires `repo` scope and `workflow` for GitHub Actions.",
245 validate: (v) => (v.length === 0 ? "Required" : undefined),
246 })
247 .pipe(mapPromptCancellation);
248 yield* interaction.output.success("GitHub: credentials saved.");
249 return { method: "stored" as const, token, baseUrl };
250 });
251
252 // Optional GitHub Enterprise host. Blank means github.com; anything else
253 // is normalized into the REST API base URL (GHES gets `/api/v3`
254 // appended, data-residency hosts get the `api.` prefix).
255 const promptBaseUrl = interaction.prompt
256 .text({
257 message: "GitHub host",
258 description:
259 "Leave blank for github.com; use a hostname such as github.example.com for GitHub Enterprise.",
260 placeholder: "github.com",
261 defaultValue: "",
262 })
263 .pipe(
264 mapPromptCancellation,
265 Effect.flatMap((input) => {
266 const trimmed = (input ?? "").trim();
267 return trimmed === ""
268 ? Effect.succeed(undefined)
269 : normalizeGitHubBaseUrl(trimmed);
270 }),
271 );
272
273 const configureInteractive = (_profileName: string) =>
274 Effect.gen(function* () {
275 const method = yield* interaction.prompt.select({
276 message: "GitHub authentication method",
277 options,
278 });
279 // The host prompt is skipped when providers({ baseUrl }) pinned it —
280 // nothing is stored in the profile config; `read` re-applies the
281 // pinned value from code on every resolution.
282 const baseUrl =
283 fixed !== undefined ? undefined : yield* promptBaseUrl;
284 const verifyHost = fixed !== undefined ? fixed.baseUrl : baseUrl;
285 return yield* Match.value(method).pipe(
286 Match.when("gh-cli", () =>
287 ghCliToken(
288 verifyHost !== undefined
289 ? githubHostname(verifyHost)
290 : undefined,
291 ).pipe(
292 Effect.map((token) => ({
293 method: "gh-cli" as const,
294 token,
295 baseUrl,
296 })),
297 Effect.mapError(
298 (e) =>
299 new AuthError({
300 message: `gh CLI not available: ${e.message}`,
301 cause: e,
302 }),
303 ),
304 ),
305 ),
306 Match.when("stored", () => loginStored(baseUrl)),
307 Match.exhaustive,
308 );
309 });
310
311 const configureCredentials = (profileName: string) =>
312 configureInteractive(profileName).pipe(
313 Effect.mapError(
314 (e) =>
315 new AuthError({
316 message: "failed to configure credentials",
317 cause: e,
318 }),
319 ),
320 );
321
322 const resolveCredentials = (
323 profileName: string,
324 config: GitHubAuthConfig,
325 updateConfig?: (
326 config: GitHubAuthConfig,
327 ) => Effect.Effect<void, AuthError>,
328 ): Effect.Effect<GitHubResolvedCredentials, AuthError | NeedsReauth> =>
329 Match.value(config).pipe(
330 Match.when(
331 { method: "stored" },
332 Effect.fn(function* (c) {
333 const baseUrl = yield* effectiveBaseUrl(c);
334 return {
335 type: "token" as const,
336 token: Redacted.make(c.token),
337 baseUrl,
338 source: { type: "stored" as const },
339 };
340 }),
341 ),
342 Match.when(
343 { method: "gh-cli" },
344 Effect.fn(function* (c) {
345 const baseUrl = yield* effectiveBaseUrl(c);
346 const token =
347 c.token ??
348 (yield* ghCliToken(
349 baseUrl !== undefined ? githubHostname(baseUrl) : undefined,
350 ).pipe(
351 Effect.mapError(
352 (cause) =>
353 new NeedsReauth({
354 provider: GITHUB_AUTH_PROVIDER_NAME,
355 profile: profileName,
356 message: `GitHub CLI credentials need to be refreshed. ${refreshHint(GITHUB_AUTH_PROVIDER_NAME, profileName)}`,
357 cause,
358 }),
359 ),
360 ));
361 if (c.token === undefined && updateConfig !== undefined) {
362 yield* updateConfig({ ...c, token });
363 }
364 return {
365 type: "token" as const,
366 token: Redacted.make(token),
367 baseUrl,
368 source: { type: "gh-cli" as const },
369 };
370 }),
371 ),
372 Match.exhaustive,
373 );
374
375 const logout = (_profileName: string, config: GitHubAuthConfig) =>
376 Match.value(config).pipe(
377 Match.when({ method: "gh-cli" }, () => Effect.void),
378 Match.when({ method: "stored" }, () => Effect.void),
379 Match.exhaustive,
380 );
381
382 const login = (_profileName: string, config: GitHubAuthConfig) =>
383 Match.value(config)
384 .pipe(
385 Match.when({ method: "gh-cli" }, (c) =>
386 effectiveBaseUrl(c).pipe(
387 Effect.flatMap((baseUrl) =>
388 ghCliToken(
389 baseUrl !== undefined ? githubHostname(baseUrl) : undefined,
390 ),
391 ),
392 Effect.tap(() =>
393 interaction.output.success(
394 "GitHub: gh CLI authentication available.",
395 ),
396 ),
397 Effect.map((token) => ({ ...c, token })),
398 ),
399 ),
400 Match.when({ method: "stored" }, (c) => Effect.succeed(c)),
401 Match.exhaustive,
402 )
403 .pipe(
404 Effect.mapError(
405 (e) => new AuthError({ message: "login failed", cause: e }),
406 ),
407 );
408
409 const details = (
410 profileName: string,
411 config: GitHubAuthConfig,
412 updateConfig?: (
413 config: GitHubAuthConfig,
414 ) => Effect.Effect<void, AuthError>,
415 ): Effect.Effect<ProviderDetails, AuthError | NeedsReauth> =>
416 resolveCredentials(profileName, config, updateConfig).pipe(
417 Effect.map((creds) => {
418 const sourceStr = creds.source.details
419 ? `${creds.source.type} - ${creds.source.details}`
420 : creds.source.type;
421 return {
422 lines: [
423 { key: "token", value: displayRedacted(creds.token, 6) },
424 { key: "source", value: sourceStr },
425 ...(creds.baseUrl !== undefined
426 ? [{ key: "baseUrl", value: creds.baseUrl }]
427 : []),
428 ],
429 };
430 }),
431 );
432
433 // Flag-driven configuration covers the "stored" (PAT) method only;
434 // "gh-cli" requires an interactive `gh auth login` session and is
435 // deliberately absent from configureMethods.
436 const storedFields: ReadonlyArray<ConfigureField> = [
437 {
438 name: "token",
439 label: "GitHub Personal Access Token",
440 description:
441 "Requires `repo` scope and `workflow` for GitHub Actions.",
442 secret: true,
443 },
444 {
445 name: "baseUrl",
446 label: "GitHub host",
447 description:
448 "Leave blank for github.com; use a hostname such as github.example.com for GitHub Enterprise.",
449 optional: true,
450 },
451 ];
452
453 const configureMethods: ReadonlyArray<ConfigureMethod> = [
454 { method: "stored", fields: storedFields },
455 ];
456
457 const configureWith = (
458 _profileName: string,
459 input: {
460 readonly method: string;
461 readonly values: Record<string, string>;
462 },
463 ): Effect.Effect<GitHubAuthConfig, AuthError, Interaction.Interaction> =>
464 input.method === "stored"
465 ? validateFieldValues(
466 GITHUB_AUTH_PROVIDER_NAME,
467 storedFields,
468 input.values,
469 ).pipe(
470 Effect.flatMap(
471 Effect.fn(function* (values) {
472 // A hard-coded providers({ baseUrl }) pins the host; nothing
473 // is stored in the profile config in that case (mirrors the
474 // interactive configure flow).
475 const baseUrl =
476 fixed !== undefined
477 ? undefined
478 : values.baseUrl !== undefined
479 ? yield* normalizeGitHubBaseUrl(
480 storedValueText(values.baseUrl) ?? "",
481 )
482 : undefined;
483 const token = Redacted.value(
484 storedSecret(values.token) ?? Redacted.make(""),
485 );
486 yield* interaction.output.success(
487 "GitHub: credentials saved.",
488 );
489 return { method: "stored" as const, token, baseUrl };
490 }),
491 ),
492 )
493 : Effect.fail(
494 new AuthError({
495 message: `GitHub: unknown method '${input.method}'. Valid methods: stored. (gh-cli is interactive-only.)`,
496 }),
497 );
498
499 return {
500 configSchema: GitHubAuthConfigSchema,
501 configure: configureCredentials,
502 configureWith,
503 configureMethods,
504 logout,
505 login,
506 details,
507 read: resolveCredentials,
508 readEnvironment: readEnvCredentials(
509 fixed !== undefined ? fixed.baseUrl : undefined,
510 ),
511 environment: [
512 {
513 name: "GITHUB_ACCESS_TOKEN",
514 required: true,
515 secret: true,
516 alternatives: [
517 "GITHUB_TOKEN",
518 "GH_ENTERPRISE_TOKEN",
519 "GITHUB_ENTERPRISE_TOKEN",
520 ],
521 description:
522 "Personal access token. The enterprise variants are only consulted when a GitHub Enterprise host is configured.",
523 },
524 {
525 name: "GITHUB_BASE_URL",
526 required: false,
527 alternatives: ["GITHUB_API_URL", "GH_HOST"],
528 description: "GitHub Enterprise host or REST API base URL.",
529 },
530 ],
531 };
532 }),
533 );
534
535/**
536 * The default GitHub AuthProvider layer — {@link makeGitHubAuth} with no
537 * hard-coded host. Use `GitHub.providers({ baseUrl })` (or
538 * `makeGitHubAuth({ baseUrl })` directly) to pin a GitHub Enterprise host.
539 */
540export const GitHubAuth = makeGitHubAuth();