1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
13import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
14
15import * as GitCafeClient from "../client/definition.ts";
16
17/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */
18const TOKEN_TTL = Duration.minutes(5);
19/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */
20const MISSING_TTL = Duration.seconds(10);
21
22export interface GitCafeCredential {
23 readonly host: string;
24 readonly token: Redacted.Redacted<string>;
25 readonly source: "settings" | "env" | "cafe";
26}
27
28/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */
29export class GitCafeCliMissingError extends Schema.TaggedError<GitCafeCliMissingError>()(
30 "GitCafeCliMissingError",
31 { host: Schema.String },
32) {
33 override get message(): string {
34 return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`;
35 }
36}
37
38/** `cafe` is installed but holds no login for the host. */
39export class GitCafeNotSignedInError extends Schema.TaggedError<GitCafeNotSignedInError>()(
40 "GitCafeNotSignedInError",
41 { host: Schema.String },
42) {
43 override get message(): string {
44 return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`;
45 }
46}
47
48/** `cafe` timed out or failed for a reason other than having no login. */
49export class GitCafeCliFailedError extends Schema.TaggedError<GitCafeCliFailedError>()(
50 "GitCafeCliFailedError",
51 { host: Schema.String, cause: Schema.Defect() },
52) {
53 override get message(): string {
54 return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`;
55 }
56}
57
58export type GitCafeCredentialUnavailableError =
59 | GitCafeCliMissingError
60 | GitCafeNotSignedInError
61 | GitCafeCliFailedError;
62
63/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */
64export class GitCafeCredentials extends Context.Service<
65 GitCafeCredentials,
66 {
67 readonly get: (
68 host: string,
69 ) => Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError>;
70 /** Drops the held token after GitCafe refused it, so the next read asks its source again. */
71 readonly invalidate: (host: string) => Effect.Effect<void>;
72 /**
73 * Environment for a `cafe` run against the host: the token saved in Settings as `CAFE_TOKEN`,
74 * which `cafe` uses before its own login, or nothing when none is saved.
75 */
76 readonly cliEnv: (host: string) => Effect.Effect<Readonly<Record<string, string>>>;
77 }
78>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {}
79
80const normalizeHost = (host: string) => host.trim().toLowerCase();
81/** The host `cafe` and a token saved in Settings target unless told otherwise. */
82const DEFAULT_HOST = "git.cafe";
83
84/**
85 * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by
86 * default): a remote URL picks the host, and must not steer a production token to staging.
87 */
88export function environmentToken(
89 host: string,
90 env: Readonly<Record<string, string | undefined>>,
91): string | null {
92 const configured = env.CAFE_HOST?.trim();
93 const configuredHost = configured
94 ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host
95 : DEFAULT_HOST;
96 if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null;
97 return env.CAFE_TOKEN?.trim() || null;
98}
99
100/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */
101function credentialPassword(output: string): string | null {
102 for (const line of output.split(/\r?\n/u)) {
103 if (line.startsWith("password=")) return line.slice("password=".length).trim() || null;
104 }
105 return null;
106}
107
108/** @public Service construction is part of the canonical Effect module API. */
109export const make = Effect.gen(function* () {
110 const sourceControlHost = yield* SourceControlHost.SourceControlHost;
111 const { process } = sourceControlHost;
112 const environment = yield* HostProcess.Environment;
113 const workingDirectory = yield* HostProcess.WorkingDirectory;
114
115 /**
116 * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand
117 * the stored login to another program, and answers over stdin/stdout so the token never
118 * reaches an argument list or a log.
119 */
120 const fromCafe = (host: string) =>
121 process
122 .run({
123 operation: "GitCafeCredentials.get",
124 command: "cafe",
125 args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"],
126 cwd: workingDirectory,
127 stdin: `protocol=https\nhost=${host}\n\n`,
128 env: { CAFE_NO_UPDATE_CHECK: "1" },
129 timeoutMs: 10_000,
130 })
131 .pipe(
132 Effect.mapError((error) =>
133 error._tag === "VcsProcessSpawnError" &&
134 error.cause instanceof PlatformError.PlatformError &&
135 error.cause.reason._tag === "NotFound"
136 ? new GitCafeCliMissingError({ host })
137 : error._tag === "VcsProcessExitError"
138 ? new GitCafeNotSignedInError({ host })
139 : new GitCafeCliFailedError({ host, cause: error }),
140 ),
141 Effect.map((output) => credentialPassword(output.stdout)),
142 Effect.filterOrFail(
143 (token): token is string => token !== null,
144 () => new GitCafeNotSignedInError({ host }),
145 ),
146 );
147
148 /** A token saved in Settings, for production git.cafe only; read fresh on every lookup. */
149 const fromSettings = (host: string) =>
150 normalizeHost(host) !== DEFAULT_HOST
151 ? Effect.succeed(null)
152 : sourceControlHost.settings.get.pipe(
153 Effect.map(
154 (settings) =>
155 readSourceControlHostSettings(
156 GitCafeClient.settings,
157 settings.sourceControlHosts[GitCafeClient.definition.kind],
158 ).token || null,
159 ),
160 Effect.orElseSucceed(() => null),
161 );
162
163 const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (
164 host: string,
165 ): Effect.fn.Return<GitCafeCredential, GitCafeCredentialUnavailableError> {
166 const fromEnv = environmentToken(host, environment);
167 const token = fromEnv ?? (yield* fromCafe(host));
168 return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "cafe" };
169 });
170
171 const cache = yield* Cache.makeWith(lookup, {
172 capacity: 4,
173 // A transient failure (a timeout, a locked keyring) is asked again on the next read.
174 timeToLive: (exit) =>
175 Exit.isSuccess(exit)
176 ? TOKEN_TTL
177 : Exit.findErrorOption(exit).pipe(
178 Option.exists((error) => error._tag === "GitCafeCliFailedError"),
179 )
180 ? Duration.zero
181 : MISSING_TTL,
182 });
183
184 return GitCafeCredentials.of({
185 // A saved token is read fresh, so saving or removing one applies at once; the cache only
186 // spares asking `cafe` again.
187 get: (host) =>
188 fromSettings(host).pipe(
189 Effect.flatMap(
190 (saved): Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError> =>
191 saved === null
192 ? Cache.get(cache, normalizeHost(host))
193 : Effect.succeed({
194 host: normalizeHost(host),
195 token: Redacted.make(saved),
196 source: "settings",
197 }),
198 ),
199 ),
200 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
201 cliEnv: (host) =>
202 fromSettings(host).pipe(Effect.map((saved) => (saved === null ? {} : { CAFE_TOKEN: saved }))),
203 });
204});
205
206export const layer = Layer.effect(GitCafeCredentials, make);