1/**
2 * Discovery specs: how each provider reports its CLI or API, and the shared probe that runs them.
3 *
4 * @module source-control-core/server/discovery
5 */
6import * as NodeUtil from "node:util";
7import type {
8 SourceControlProviderAuth,
9 SourceControlProviderDiscoveryItem,
10 SourceControlProviderInfo,
11 SourceControlProviderKind,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15
16import type * as SourceControlProvider from "./SourceControlProvider.ts";
17import type * as SourceControlHost from "./SourceControlHost.ts";
18
19export interface SourceControlAuthProbeInput {
20 readonly stdout: string;
21 readonly stderr: string;
22 readonly exitCode: SourceControlHost.SourceControlProcessOutput["exitCode"];
23}
24
25export interface SourceControlUnknownRemoteRefinementInput {
26 readonly cwd: string;
27 readonly context: SourceControlProvider.SourceControlProviderContext;
28 readonly auth: SourceControlAuthProbeInput;
29}
30
31interface SourceControlDiscoverySpecBase {
32 readonly kind: SourceControlProviderKind;
33 readonly label: string;
34 readonly installHint: string;
35}
36
37export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
38 readonly type: "cli";
39 readonly executable: string;
40 readonly versionArgs: ReadonlyArray<string>;
41 readonly authArgs: ReadonlyArray<string>;
42 readonly remoteRefinementArgs?: ReadonlyArray<string>;
43 readonly probeTimeoutMs?: number;
44 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
45 readonly refineUnknownRemote?: (
46 input: SourceControlUnknownRemoteRefinementInput,
47 ) => SourceControlProviderInfo | null;
48};
49
50export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
51 readonly type: "api";
52 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
53};
54
55export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & {
56 readonly type: "managed-cli";
57 readonly probe: (cwd: string) => Effect.Effect<SourceControlProviderDiscoveryItem>;
58 readonly refineUnknownRemote: (input: {
59 readonly cwd: string;
60 readonly context: SourceControlProvider.SourceControlProviderContext;
61 }) => Effect.Effect<SourceControlProviderInfo | null>;
62};
63
64export type SourceControlProviderDiscoverySpec =
65 | SourceControlCliDiscoverySpec
66 | SourceControlManagedCliDiscoverySpec
67 | SourceControlApiDiscoverySpec;
68
69type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
70 readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>;
71};
72
73// Most provider CLIs answer `--version` in well under a second, so a short budget keeps
74// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs.
75const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
76
77function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number {
78 return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
79}
80
81interface DiscoveryProbeResult {
82 readonly kind: SourceControlProviderKind;
83 readonly label: string;
84 readonly executable: string;
85 readonly status: "available" | "missing";
86 readonly version: Option.Option<string>;
87 readonly installHint: string;
88 readonly detail: Option.Option<string>;
89}
90
91export function firstNonEmptyLine(text: string): Option.Option<string> {
92 const line = NodeUtil.stripVTControlCharacters(text)
93 .split(/\r?\n/)
94 .map((entry) => entry.trim())
95 .find((entry) => entry.length > 0);
96 return line === undefined ? Option.none() : Option.some(line);
97}
98
99export function detailFromCause(cause: unknown): Option.Option<string> {
100 if (cause instanceof Error && cause.message.trim().length > 0) {
101 return Option.some(cause.message.trim());
102 }
103 return Option.none();
104}
105
106function authAccount(account: string | undefined): Option.Option<string> {
107 const trimmed = account?.trim();
108 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
109}
110
111function authHost(host: string | undefined): Option.Option<string> {
112 const trimmed = host?.trim();
113 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
114}
115
116function authDetail(detail: string | undefined): Option.Option<string> {
117 const trimmed = detail?.trim();
118 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
119}
120
121export function providerAuth(input: {
122 readonly status: SourceControlProviderAuth["status"];
123 readonly account?: string | undefined;
124 readonly host?: string | undefined;
125 readonly detail?: string | undefined;
126}): SourceControlProviderAuth {
127 return {
128 status: input.status,
129 account: authAccount(input.account),
130 host: authHost(input.host),
131 detail: authDetail(input.detail),
132 };
133}
134
135function unknownAuth(detail?: string): SourceControlProviderAuth {
136 return providerAuth({ status: "unknown", detail });
137}
138
139export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
140 const parts: string[] = [];
141 for (const entry of [input.stdout, input.stderr]) {
142 if (entry.trim().length > 0) {
143 parts.push(entry);
144 }
145 }
146 return parts.join("\n");
147}
148
149function sanitizedAuthLines(text: string): ReadonlyArray<string> {
150 const lines: string[] = [];
151 for (const entry of text.split(/\r?\n/)) {
152 const line = entry.trim();
153 if (line.length === 0) continue;
154 if (/^[-\s]*token(?:\s+scopes?)?:/iu.test(line)) continue;
155 lines.push(line);
156 }
157 return lines;
158}
159
160export function firstSafeAuthLine(text: string): string | undefined {
161 return sanitizedAuthLines(text)[0];
162}
163
164export function parseCliHost(text: string): string | undefined {
165 return sanitizedAuthLines(text)
166 .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
167 .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
168}
169
170export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
171 for (const pattern of patterns) {
172 const match = pattern.exec(text);
173 const value = match?.[1]?.trim();
174 if (value && value.length > 0) return value;
175 }
176 return undefined;
177}
178
179function isCliRemoteRefinementSpec(
180 spec: SourceControlProviderDiscoverySpec,
181): spec is SourceControlCliRemoteRefinementSpec {
182 return spec.type === "cli" && spec.refineUnknownRemote !== undefined;
183}
184
185function probeCli(input: {
186 readonly spec: SourceControlCliDiscoverySpec;
187 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
188 readonly cwd: string;
189}): Effect.Effect<DiscoveryProbeResult> {
190 return input.process
191 .run({
192 operation: "source-control.discovery.probe",
193 command: input.spec.executable,
194 args: input.spec.versionArgs,
195 cwd: input.cwd,
196 timeoutMs: probeTimeoutMs(input.spec),
197 maxOutputBytes: 8_000,
198 appendTruncationMarker: true,
199 })
200 .pipe(
201 Effect.map(
202 (result) =>
203 ({
204 kind: input.spec.kind,
205 label: input.spec.label,
206 executable: input.spec.executable,
207 status: "available" as const,
208 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
209 firstNonEmptyLine(result.stderr),
210 ),
211 installHint: input.spec.installHint,
212 detail: Option.none<string>(),
213 }) satisfies DiscoveryProbeResult,
214 ),
215 Effect.catch((cause) =>
216 Effect.succeed({
217 kind: input.spec.kind,
218 label: input.spec.label,
219 executable: input.spec.executable,
220 status: "missing" as const,
221 version: Option.none<string>(),
222 installHint: input.spec.installHint,
223 detail: detailFromCause(cause),
224 } satisfies DiscoveryProbeResult),
225 ),
226 );
227}
228
229export function probeSourceControlProvider(input: {
230 readonly spec: SourceControlProviderDiscoverySpec;
231 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
232 readonly cwd: string;
233}): Effect.Effect<SourceControlProviderDiscoveryItem> {
234 if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd);
235 if (input.spec.type === "api") {
236 return input.spec.probeAuth.pipe(
237 Effect.map(
238 (auth) =>
239 ({
240 kind: input.spec.kind,
241 label: input.spec.label,
242 status: "available" as const,
243 version: Option.none<string>(),
244 installHint: input.spec.installHint,
245 detail: Option.none<string>(),
246 auth,
247 }) satisfies SourceControlProviderDiscoveryItem,
248 ),
249 );
250 }
251
252 const spec = input.spec;
253
254 return probeCli({
255 spec,
256 process: input.process,
257 cwd: input.cwd,
258 }).pipe(
259 Effect.flatMap((item) => {
260 if (item.status !== "available") {
261 return Effect.succeed({
262 ...item,
263 auth: unknownAuth("Hosting integration command was not found on the server PATH."),
264 } satisfies SourceControlProviderDiscoveryItem);
265 }
266
267 return input.process
268 .run({
269 operation: "source-control.discovery.auth",
270 command: spec.executable,
271 args: spec.authArgs,
272 cwd: input.cwd,
273 allowNonZeroExit: true,
274 timeoutMs: probeTimeoutMs(spec),
275 maxOutputBytes: 8_000,
276 appendTruncationMarker: true,
277 })
278 .pipe(
279 Effect.map(
280 (result) =>
281 ({
282 ...item,
283 auth: spec.parseAuth(result),
284 }) satisfies SourceControlProviderDiscoveryItem,
285 ),
286 Effect.catch((cause) =>
287 Effect.succeed({
288 ...item,
289 auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
290 } satisfies SourceControlProviderDiscoveryItem),
291 ),
292 );
293 }),
294 );
295}
296
297export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
298 function* (input: {
299 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
300 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
301 readonly cwd: string;
302 readonly context: SourceControlProvider.SourceControlProviderContext | null;
303 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {
304 if (input.context === null || input.context.provider.kind !== "unknown") {
305 return input.context;
306 }
307 const context = input.context;
308
309 const providers = yield* Effect.forEach(input.specs, (spec) => {
310 if (spec.type === "managed-cli") {
311 return spec.refineUnknownRemote({ cwd: input.cwd, context });
312 }
313 if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null);
314 return input.process
315 .run({
316 operation: "source-control.discovery.refine-unknown-remote",
317 command: spec.executable,
318 args: spec.remoteRefinementArgs ?? spec.authArgs,
319 cwd: input.cwd,
320 allowNonZeroExit: true,
321 timeoutMs: probeTimeoutMs(spec),
322 maxOutputBytes: 8_000,
323 appendTruncationMarker: true,
324 })
325 .pipe(
326 Effect.map((auth) =>
327 spec.refineUnknownRemote({
328 cwd: input.cwd,
329 context,
330 auth,
331 }),
332 ),
333 Effect.orElseSucceed(() => null),
334 );
335 });
336 const provider = providers.find((candidate) => candidate !== null);
337
338 return provider ? { ...context, provider } : context;
339 },
340);