Showing the changes to apps/server/src/sourceControl/SourceControlDiscovery.ts (1 of 14 files). Show all
Unified · Split
| @@ -0,0 +1,178 @@ | |||
| 1 | import { | ||
| 2 | type SourceControlDiscoveryResult, | ||
| 3 | type SourceControlProviderDiscoveryItem, | ||
| 4 | type SourceControlProviderKind, | ||
| 5 | type VcsDiscoveryItem, | ||
| 6 | type VcsDriverKind, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { Context, Effect, Layer, Option } from "effect"; | ||
| 9 | |||
| 10 | import { ServerConfig } from "../config.ts"; | ||
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 12 | |||
| 13 | interface DiscoveryProbe { | ||
| 14 | readonly label: string; | ||
| 15 | readonly executable: string; | ||
| 16 | readonly versionArgs: ReadonlyArray<string>; | ||
| 17 | readonly implemented: boolean; | ||
| 18 | readonly installHint: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | type VcsProbe = DiscoveryProbe & { | ||
| 22 | readonly kind: VcsDriverKind; | ||
| 23 | }; | ||
| 24 | |||
| 25 | type ProviderProbe = DiscoveryProbe & { | ||
| 26 | readonly kind: SourceControlProviderKind; | ||
| 27 | }; | ||
| 28 | |||
| 29 | const VCS_PROBES: ReadonlyArray<VcsProbe> = [ | ||
| 30 | { | ||
| 31 | kind: "git", | ||
| 32 | label: "Git", | ||
| 33 | executable: "git", | ||
| 34 | versionArgs: ["--version"], | ||
| 35 | implemented: true, | ||
| 36 | installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", | ||
| 37 | }, | ||
| 38 | { | ||
| 39 | kind: "jj", | ||
| 40 | label: "Jujutsu", | ||
| 41 | executable: "jj", | ||
| 42 | versionArgs: ["--version"], | ||
| 43 | implemented: false, | ||
| 44 | installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | kind: "sapling", | ||
| 48 | label: "Sapling", | ||
| 49 | executable: "sl", | ||
| 50 | versionArgs: ["--version"], | ||
| 51 | implemented: false, | ||
| 52 | installHint: "Install Sapling (`sl`) from https://sapling-scm.com/.", | ||
| 53 | }, | ||
| 54 | ]; | ||
| 55 | |||
| 56 | const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ | ||
| 57 | { | ||
| 58 | kind: "github", | ||
| 59 | label: "GitHub", | ||
| 60 | executable: "gh", | ||
| 61 | versionArgs: ["--version"], | ||
| 62 | implemented: true, | ||
| 63 | installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | kind: "gitlab", | ||
| 67 | label: "GitLab", | ||
| 68 | executable: "glab", | ||
| 69 | versionArgs: ["--version"], | ||
| 70 | implemented: false, | ||
| 71 | installHint: | ||
| 72 | "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", | ||
| 73 | }, | ||
| 74 | { | ||
| 75 | kind: "azure-devops", | ||
| 76 | label: "Azure DevOps", | ||
| 77 | executable: "az", | ||
| 78 | versionArgs: ["--version"], | ||
| 79 | implemented: false, | ||
| 80 | installHint: | ||
| 81 | "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | kind: "bitbucket", | ||
| 85 | label: "Bitbucket", | ||
| 86 | executable: "bb", | ||
| 87 | versionArgs: ["--version"], | ||
| 88 | implemented: false, | ||
| 89 | installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", | ||
| 90 | }, | ||
| 91 | ]; | ||
| 92 | |||
| 93 | function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 94 | const line = text | ||
| 95 | .split(/\r?\n/) | ||
| 96 | .map((entry) => entry.trim()) | ||
| 97 | .find((entry) => entry.length > 0); | ||
| 98 | return line === undefined ? Option.none() : Option.some(line); | ||
| 99 | } | ||
| 100 | |||
| 101 | function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 102 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 103 | return Option.some(cause.message.trim()); | ||
| 104 | } | ||
| 105 | return Option.none(); | ||
| 106 | } | ||
| 107 | |||
| 108 | export interface SourceControlDiscoveryShape { | ||
| 109 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; | ||
| 110 | } | ||
| 111 | |||
| 112 | export class SourceControlDiscovery extends Context.Service< | ||
| 113 | SourceControlDiscovery, | ||
| 114 | SourceControlDiscoveryShape | ||
| 115 | >()("t3/source-control/SourceControlDiscovery") {} | ||
| 116 | |||
| 117 | export const layer = Layer.effect( | ||
| 118 | SourceControlDiscovery, | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const config = yield* ServerConfig; | ||
| 121 | const process = yield* VcsProcess.VcsProcess; | ||
| 122 | |||
| 123 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( | ||
| 124 | input: DiscoveryProbe & { readonly kind: Kind }, | ||
| 125 | ) => | ||
| 126 | process | ||
| 127 | .run({ | ||
| 128 | operation: "source-control.discovery.probe", | ||
| 129 | command: input.executable, | ||
| 130 | args: input.versionArgs, | ||
| 131 | cwd: config.cwd, | ||
| 132 | timeoutMs: 5_000, | ||
| 133 | maxOutputBytes: 8_000, | ||
| 134 | truncateOutputAtMaxBytes: true, | ||
| 135 | }) | ||
| 136 | .pipe( | ||
| 137 | Effect.map((result) => ({ | ||
| 138 | kind: input.kind, | ||
| 139 | label: input.label, | ||
| 140 | executable: input.executable, | ||
| 141 | implemented: input.implemented, | ||
| 142 | status: "available" as const, | ||
| 143 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | ||
| 144 | firstNonEmptyLine(result.stderr), | ||
| 145 | ), | ||
| 146 | installHint: input.installHint, | ||
| 147 | detail: Option.none<string>(), | ||
| 148 | })), | ||
| 149 | Effect.catch((cause) => | ||
| 150 | Effect.succeed({ | ||
| 151 | kind: input.kind, | ||
| 152 | label: input.label, | ||
| 153 | executable: input.executable, | ||
| 154 | implemented: input.implemented, | ||
| 155 | status: "missing" as const, | ||
| 156 | version: Option.none<string>(), | ||
| 157 | installHint: input.installHint, | ||
| 158 | detail: detailFromCause(cause), | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | |||
| 163 | return SourceControlDiscovery.of({ | ||
| 164 | discover: Effect.all({ | ||
| 165 | versionControlSystems: Effect.all( | ||
| 166 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, | ||
| 167 | { concurrency: "unbounded" }, | ||
| 168 | ), | ||
| 169 | sourceControlProviders: Effect.all( | ||
| 170 | SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probe(entry)) as ReadonlyArray< | ||
| 171 | Effect.Effect<SourceControlProviderDiscoveryItem> | ||
| 172 | >, | ||
| 173 | { concurrency: "unbounded" }, | ||
| 174 | ), | ||
| 175 | }), | ||
| 176 | }); | ||
| 177 | }), | ||
| 178 | ); | ||