From 701d7c7bd15614c2c6047c5565ae7a8ab57739e3 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:16:40 -0400 Subject: [PATCH] Add cross-platform source control tool maintenance --- .../SourceControlDiscovery.test.ts | 65 ++++ .../sourceControl/SourceControlDiscovery.ts | 41 ++- .../SourceControlToolMaintenance.test.ts | 118 ++++++- .../SourceControlToolMaintenance.ts | 126 ++++--- .../SourceControlToolPackages.ts | 130 +++++++ .../SourceControlToolVersionAdvisory.test.ts | 96 +++++ .../SourceControlToolVersionAdvisory.ts | 334 +++++++++++++++--- .../src/sourceControl/SourceControlWinGet.ts | 120 +++++++ apps/server/src/ws.ts | 9 +- .../settings/CompactVersionAdvisory.tsx | 60 +++- .../settings/SettingsPanels.browser.tsx | 132 +++++++ .../settings/SourceControlSettings.tsx | 3 +- .../src/lib/sourceControlDiscoveryState.ts | 5 +- apps/web/src/localApi.test.ts | 1 + packages/contracts/src/sourceControl.ts | 14 +- 15 files changed, 1131 insertions(+), 123 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlToolPackages.ts create mode 100644 apps/server/src/sourceControl/SourceControlWinGet.ts diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index c336eae3a..b954be18d 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -115,6 +115,8 @@ it.effect("reports implemented tools separately from locally available executabl platform: "win32", latestVersionResolver: (target) => Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"), + winGetVersionResolver: (target) => + Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"), }), ).pipe( Layer.provide( @@ -424,3 +426,66 @@ it.effect("skips unavailable discovery commands before spawning probes", () => { assert.deepStrictEqual(processCommands, []); }).pipe(Effect.provide(testLayer)); }); + +it.effect("offers allowlisted Homebrew installs for missing macOS tools", () => { + const hasOnlyHomebrew = (command: string) => command === "brew"; + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => + Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} should not be spawned`), + }), + ), + } satisfies Partial; + const testLayer = Layer.effect( + SourceControlDiscovery.SourceControlDiscovery, + SourceControlDiscovery.make({ + commandAvailable: hasOnlyHomebrew, + platform: "darwin", + latestVersionResolver: noLatestToolVersion, + }), + ).pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-brew-discovery-" }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + commandAvailable: hasOnlyHomebrew, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + const actions = [...result.versionControlSystems, ...result.sourceControlProviders].flatMap( + (item) => item.versionAdvisory?.actions.filter((action) => action.kind === "runUpdate") ?? [], + ); + + assert.deepStrictEqual( + actions.map((action) => + action.kind === "runUpdate" ? [action.target, action.operation] : null, + ), + [ + ["git", "install"], + ["github-cli", "install"], + ["gitlab-cli", "install"], + ["azure-cli", "install"], + ], + ); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 97453bcd9..398d995a3 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -15,7 +15,9 @@ import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { selectSourceControlToolPackageManager } from "./SourceControlToolPackages.ts"; import * as SourceControlToolVersionAdvisory from "./SourceControlToolVersionAdvisory.ts"; +import * as SourceControlWinGet from "./SourceControlWinGet.ts"; interface DiscoveryProbe { readonly label: string; @@ -68,6 +70,7 @@ export interface SourceControlDiscoveryShape { export interface SourceControlDiscoveryOptions { readonly commandAvailable?: SourceControlProviderDiscovery.CommandAvailability; readonly latestVersionResolver?: SourceControlToolVersionAdvisory.LatestVersionResolver; + readonly winGetVersionResolver?: SourceControlWinGet.LatestWinGetVersionResolver; readonly platform?: NodeJS.Platform; } @@ -104,8 +107,13 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( const platform = options?.platform ?? process.platform; const commandAvailable = options?.commandAvailable ?? ((command) => isCommandAvailable(command, { platform })); - const latestVersionResolver = options?.latestVersionResolver; - const canRunToolUpdate = platform === "win32" && commandAvailable("winget"); + const latestVersionResolver = + options?.latestVersionResolver ?? (() => Effect.succeed(null)); + const sourceControlToolPackageManager = selectSourceControlToolPackageManager({ + platform, + commandAvailable, + }); + const canRunToolUpdate = platform === "win32" && sourceControlToolPackageManager === "winget"; const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, @@ -167,14 +175,21 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( const withVersionAdvisory = ( item: Item, ): Effect.Effect => - latestVersionResolver - ? SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ - item, - platform, - latestVersionResolver, - canRunUpdate: canRunToolUpdate, - }) - : Effect.succeed(item); + SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ + item, + platform, + latestVersionResolver, + ...(options?.winGetVersionResolver + ? { winGetVersionResolver: options.winGetVersionResolver } + : {}), + canRunUpdate: canRunToolUpdate, + packageManager: sourceControlToolPackageManager, + canRunInstall: + sourceControlToolPackageManager !== null && + item.status !== "available" && + item.executable !== undefined && + !commandAvailable(item.executable), + }); return SourceControlDiscovery.of({ discover: Effect.all({ @@ -203,11 +218,17 @@ export const layer = Layer.effect( SourceControlDiscovery, Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; + const config = yield* ServerConfig; + const vcsProcess = yield* VcsProcess.VcsProcess; return yield* make({ latestVersionResolver: (target) => SourceControlToolVersionAdvisory.resolveLatestToolVersion(target).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), ), + winGetVersionResolver: SourceControlWinGet.makeLatestWinGetVersionResolver({ + cwd: config.cwd, + vcsProcess, + }), }); }), ); diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts index 982f5c4e6..469803633 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts @@ -6,10 +6,13 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError } from "@threadlines/contracts"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlToolMaintenance from "./SourceControlToolMaintenance.ts"; +import * as SourceControlToolPackages from "./SourceControlToolPackages.ts"; +import * as SourceControlWinGet from "./SourceControlWinGet.ts"; const processOutput: VcsProcess.VcsProcessOutput = { exitCode: ChildProcessSpawner.ExitCode(0), @@ -19,6 +22,40 @@ const processOutput: VcsProcess.VcsProcessOutput = { stderrTruncated: false, }; +it("parses and normalizes the latest versions reported by WinGet", () => { + assert.strictEqual( + SourceControlWinGet.parseLatestWinGetVersion( + "git", + "Found Git [Git.Git]\r\nVersion\r\n--------\r\n2.55.0.3\r\n2.55.0.2\r\n", + ), + "2.55.0.windows.3", + ); + assert.strictEqual( + SourceControlWinGet.parseLatestWinGetVersion( + "github-cli", + "Found GitHub CLI [GitHub.cli]\nVersion\n-------\n2.98.0\n2.97.0\n", + ), + "2.98.0", + ); +}); + +it("uses Linuxbrew without treating sudo package managers as one-click capable", () => { + assert.strictEqual( + SourceControlToolPackages.selectSourceControlToolPackageManager({ + platform: "linux", + commandAvailable: (command) => command === "brew" || command === "apt-get", + }), + "homebrew", + ); + assert.strictEqual( + SourceControlToolPackages.selectSourceControlToolPackageManager({ + platform: "linux", + commandAvailable: (command) => command === "apt-get", + }), + null, + ); +}); + it("verifies installed versions from raw discovery output without an advisory", () => { assert.strictEqual( SourceControlToolMaintenance.currentSourceControlToolVersion( @@ -94,13 +131,13 @@ it.effect("runs only the allowlisted source control WinGet update recipes", () = }).pipe(Effect.provide(layer)); }); -it.effect("refuses one-click updates outside the verified Windows WinGet path", () => { +it.effect("refuses one-click updates when no supported package manager is available", () => { let calls = 0; const layer = Layer.effect( SourceControlToolMaintenance.SourceControlToolMaintenance, SourceControlToolMaintenance.make({ platform: "linux", - commandAvailable: () => true, + commandAvailable: () => false, }), ).pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), @@ -124,6 +161,83 @@ it.effect("refuses one-click updates outside the verified Windows WinGet path", }).pipe(Effect.provide(layer)); }); +it.effect("runs allowlisted Homebrew install and update recipes on macOS", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "darwin", + commandAvailable: (command) => command === "brew" || command === "git", + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + calls.push(input); + return Effect.succeed(processOutput); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + yield* maintenance.update({ target: "git" }); + yield* maintenance.update({ target: "github-cli", operation: "install" }); + yield* maintenance.update({ target: "azure-cli", operation: "install" }); + + assert.deepStrictEqual( + calls.map((call) => [call.command, ...call.args]), + [ + ["brew", "upgrade", "git"], + ["brew", "install", "gh"], + ["brew", "install", "azure-cli"], + ["az", "extension", "add", "--name", "azure-devops"], + ], + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("explains when WinGet has no applicable package update", () => { + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "win32", + commandAvailable: (command) => command === "winget", + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: [input.command, ...input.args].join(" "), + cwd: input.cwd, + exitCode: 0x8a15002b, + detail: "No applicable update found", + }), + ), + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const result = yield* Effect.result(maintenance.update({ target: "git" })); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.match(result.failure.reason, /does not currently offer a newer compatible Git/i); + assert.match(result.failure.reason, /official release/i); + } + }).pipe(Effect.provide(layer)); +}); + it.effect("serializes all source control updates through one WinGet lock", () => Effect.gen(function* () { const started = yield* Deferred.make(); diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts index 3f8628ba0..f246c5f89 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts @@ -3,6 +3,7 @@ import { type SourceControlDiscoveryResult, type SourceControlToolUpdateInput, type SourceControlToolUpdateTarget, + type VcsError, } from "@threadlines/contracts"; import { isCommandAvailable } from "@threadlines/shared/shell"; import * as Context from "effect/Context"; @@ -13,26 +14,17 @@ import * as Ref from "effect/Ref"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + selectSourceControlToolPackageManager, + sourceControlToolLabel, + sourceControlToolPackageRecipe, +} from "./SourceControlToolPackages.ts"; import { parseGitHubCliVersion, parseGitVersion } from "./SourceControlToolVersionAdvisory.ts"; +import { isWinGetUpdateNotApplicable } from "./SourceControlWinGet.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; -const WINGET_PACKAGE_IDS = { - "github-cli": "GitHub.cli", - git: "Git.Git", -} as const satisfies Record; - -const WINGET_UPDATE_SUFFIX = [ - "--exact", - "--source", - "winget", - "--silent", - "--accept-source-agreements", - "--accept-package-agreements", - "--disable-interactivity", -] as const; - export interface SourceControlToolMaintenanceShape { readonly update: ( input: SourceControlToolUpdateInput, @@ -53,6 +45,25 @@ function updateError(target: SourceControlToolUpdateTarget, reason: string) { return new SourceControlToolUpdateError({ target, reason }); } +function packageManagerFailureReason(input: { + readonly target: SourceControlToolUpdateTarget; + readonly operation: "install" | "update"; + readonly manager: "homebrew" | "winget"; + readonly cause: VcsError; +}): string { + if ( + input.manager === "winget" && + input.operation === "update" && + isWinGetUpdateNotApplicable(input.cause) + ) { + const label = sourceControlToolLabel(input.target); + return `WinGet does not currently offer a newer compatible ${label} package. Its catalog may still be behind the latest official release; use the official release link or check again later.`; + } + + const managerLabel = input.manager === "winget" ? "WinGet" : "Homebrew"; + return `The verified ${managerLabel} ${input.operation} failed: ${input.cause.message || "unknown process error"}`; +} + export function currentSourceControlToolVersion( discovery: SourceControlDiscoveryResult, target: SourceControlToolUpdateTarget, @@ -60,13 +71,24 @@ export function currentSourceControlToolVersion( const item = target === "git" ? discovery.versionControlSystems.find((candidate) => candidate.kind === "git") - : discovery.sourceControlProviders.find((candidate) => candidate.kind === "github"); + : discovery.sourceControlProviders.find((candidate) => { + switch (target) { + case "github-cli": + return candidate.kind === "github"; + case "gitlab-cli": + return candidate.kind === "gitlab"; + case "azure-cli": + return candidate.kind === "azure-devops"; + } + }); if (!item) return null; const rawVersion = Option.getOrNull(item.version); const detectedVersion = rawVersion ? target === "git" ? parseGitVersion(rawVersion) - : parseGitHubCliVersion(rawVersion) + : target === "github-cli" + ? parseGitHubCliVersion(rawVersion) + : rawVersion : null; return detectedVersion ?? item.versionAdvisory?.currentVersion ?? null; } @@ -74,14 +96,27 @@ export function currentSourceControlToolVersion( export function hasVerifiedSourceControlToolUpdateAction( discovery: SourceControlDiscoveryResult, target: SourceControlToolUpdateTarget, + operation: NonNullable = "update", ): boolean { const item = target === "git" ? discovery.versionControlSystems.find((candidate) => candidate.kind === "git") - : discovery.sourceControlProviders.find((candidate) => candidate.kind === "github"); + : discovery.sourceControlProviders.find((candidate) => { + switch (target) { + case "github-cli": + return candidate.kind === "github"; + case "gitlab-cli": + return candidate.kind === "gitlab"; + case "azure-cli": + return candidate.kind === "azure-devops"; + } + }); return ( item?.versionAdvisory?.actions.some( - (action) => action.kind === "runUpdate" && action.target === target, + (action) => + action.kind === "runUpdate" && + action.target === target && + (action.operation ?? "update") === operation, ) === true ); } @@ -100,16 +135,23 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( "SourceControlToolMaintenance.update", )(function* (input) { const { target } = input; - if (platform !== "win32") { + const packageManager = selectSourceControlToolPackageManager({ platform, commandAvailable }); + if (packageManager === null) { return yield* updateError( target, - "One-click source control updates are currently available only for verified WinGet installations on Windows.", + "No supported package manager is available on this server for one-click source control tool maintenance.", ); } - if (!commandAvailable("winget")) { + const operation = input.operation ?? "update"; + const recipe = sourceControlToolPackageRecipe({ + manager: packageManager, + target, + operation, + }); + if (recipe === null) { return yield* updateError( target, - "WinGet is not available on this server, so Threadlines cannot run a verified update command.", + `${sourceControlToolLabel(target)} does not have a verified ${packageManager} package recipe yet.`, ); } @@ -119,26 +161,26 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( } return yield* Effect.gen(function* () { - const packageId = WINGET_PACKAGE_IDS[target]; - - yield* vcsProcess - .run({ - operation: "source-control.tool.update", - command: "winget", - args: ["upgrade", "--id", packageId, ...WINGET_UPDATE_SUFFIX], - cwd: config.cwd, - timeoutMs: UPDATE_TIMEOUT_MS, - maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, - appendTruncationMarker: true, - }) - .pipe( - Effect.mapError((cause) => - updateError( - target, - `The verified WinGet update failed: ${cause.message || "unknown process error"}`, + for (const step of recipe.steps) { + yield* vcsProcess + .run({ + operation: `source-control.tool.${operation}`, + command: step.command, + args: step.args, + cwd: config.cwd, + timeoutMs: UPDATE_TIMEOUT_MS, + maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, + appendTruncationMarker: true, + }) + .pipe( + Effect.mapError((cause) => + updateError( + target, + packageManagerFailureReason({ target, operation, manager: packageManager, cause }), + ), ), - ), - ); + ); + } }).pipe(Effect.ensuring(Ref.set(updateActive, false))); }); diff --git a/apps/server/src/sourceControl/SourceControlToolPackages.ts b/apps/server/src/sourceControl/SourceControlToolPackages.ts new file mode 100644 index 000000000..7e80607c9 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlToolPackages.ts @@ -0,0 +1,130 @@ +import type { + SourceControlToolUpdateOperation, + SourceControlToolUpdateTarget, +} from "@threadlines/contracts"; + +export type SourceControlToolPackageManager = "homebrew" | "winget"; + +export interface SourceControlToolPackageStep { + readonly command: string; + readonly args: ReadonlyArray; +} + +export interface SourceControlToolPackageRecipe { + readonly manager: SourceControlToolPackageManager; + readonly steps: ReadonlyArray; + readonly copyCommand: string; + readonly copyLabel: string; +} + +const TOOL_LABELS = { + "azure-cli": "Azure CLI", + git: "Git", + "github-cli": "GitHub CLI", + "gitlab-cli": "GitLab CLI", +} as const satisfies Record; + +const HOMEBREW_FORMULAE = { + "azure-cli": "azure-cli", + git: "git", + "github-cli": "gh", + "gitlab-cli": "glab", +} as const satisfies Record; + +const WINGET_PACKAGE_IDS = { + "azure-cli": "Microsoft.AzureCLI", + git: "Git.Git", + "github-cli": "GitHub.cli", + "gitlab-cli": "GLab.GLab", +} as const satisfies Record; + +export const TOOL_RELEASE_URLS = { + "azure-cli": "https://learn.microsoft.com/cli/azure/install-azure-cli", + git: "https://git-scm.com/downloads", + "github-cli": "https://cli.github.com/", + "gitlab-cli": "https://gitlab.com/gitlab-org/cli#installation", +} as const satisfies Record; + +export function sourceControlToolLabel(target: SourceControlToolUpdateTarget): string { + return TOOL_LABELS[target]; +} + +export function winGetPackageId(target: SourceControlToolUpdateTarget): string { + return WINGET_PACKAGE_IDS[target]; +} + +export function selectSourceControlToolPackageManager(input: { + readonly platform: NodeJS.Platform; + readonly commandAvailable: (command: string) => boolean; +}): SourceControlToolPackageManager | null { + if (input.platform === "win32" && input.commandAvailable("winget")) return "winget"; + if ( + (input.platform === "darwin" || input.platform === "linux") && + input.commandAvailable("brew") + ) { + return "homebrew"; + } + return null; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) return value; + return `'${value.replace(/'/gu, `'"'"'`)}'`; +} + +function commandLine(step: SourceControlToolPackageStep): string { + return [step.command, ...step.args].map(shellQuote).join(" "); +} + +function azureDevOpsExtensionStep(): SourceControlToolPackageStep { + return { + command: "az", + args: ["extension", "add", "--name", "azure-devops"], + }; +} + +export function sourceControlToolPackageRecipe(input: { + readonly manager: SourceControlToolPackageManager; + readonly target: SourceControlToolUpdateTarget; + readonly operation: SourceControlToolUpdateOperation; +}): SourceControlToolPackageRecipe | null { + if (input.operation === "update" && input.target !== "git" && input.target !== "github-cli") { + return null; + } + + const primaryStep: SourceControlToolPackageStep = + input.manager === "winget" + ? { + command: "winget", + args: [ + input.operation === "install" ? "install" : "upgrade", + "--id", + WINGET_PACKAGE_IDS[input.target], + "--exact", + "--source", + "winget", + "--silent", + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + ], + } + : { + command: "brew", + args: [ + input.operation === "install" ? "install" : "upgrade", + HOMEBREW_FORMULAE[input.target], + ], + }; + const steps = + input.operation === "install" && input.target === "azure-cli" + ? [primaryStep, azureDevOpsExtensionStep()] + : [primaryStep]; + + return { + manager: input.manager, + steps, + copyCommand: steps.map(commandLine).join(" && "), + copyLabel: input.manager === "winget" ? "Copy WinGet command" : "Copy Homebrew command", + }; +} diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts index 7f02afc46..ffea3d3fa 100644 --- a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts @@ -49,6 +49,7 @@ it.effect("recommends GitHub CLI updates for the Windows terminal flash range", platform: "win32", canRunUpdate: true, latestVersionResolver: () => Effect.succeed("2.98.0"), + winGetVersionResolver: () => Effect.succeed("2.98.0"), item, }); @@ -108,6 +109,7 @@ it.effect("recommends the Git for Windows security baseline without executing up platform: "win32", canRunUpdate: true, latestVersionResolver: () => Effect.succeed("2.55.0.windows.4"), + winGetVersionResolver: () => Effect.succeed("2.55.0.windows.4"), item, }); @@ -121,6 +123,61 @@ it.effect("recommends the Git for Windows security baseline without executing up }), ); +it.effect("does not offer a stale WinGet update after its catalog version is installed", () => + Effect.gen(function* () { + const item: VcsDiscoveryItem = { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.55.0.windows.3"), + installHint: "Install Git.", + detail: Option.none(), + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "win32", + canRunUpdate: true, + latestVersionResolver: () => Effect.succeed("2.55.0.windows.4"), + winGetVersionResolver: () => Effect.succeed("2.55.0.windows.3"), + item, + }); + + assert.strictEqual(enriched.versionAdvisory?.status, "recommended_update"); + assert.strictEqual(enriched.versionAdvisory?.latestVersion, "2.55.0.windows.4"); + assert.match(enriched.versionAdvisory?.message ?? "", /has not reached WinGet yet/i); + assert.ok(enriched.versionAdvisory?.actions.some((action) => action.kind === "openUrl")); + assert.ok(!enriched.versionAdvisory?.actions.some((action) => action.kind === "runUpdate")); + assert.ok(!enriched.versionAdvisory?.actions.some((action) => action.kind === "copyCommand")); + }), +); + +it.effect("offers the newer WinGet package while explaining when it trails upstream", () => + Effect.gen(function* () { + const item: VcsDiscoveryItem = { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.54.0.windows.1"), + installHint: "Install Git.", + detail: Option.none(), + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "win32", + canRunUpdate: true, + latestVersionResolver: () => Effect.succeed("2.55.0.windows.4"), + winGetVersionResolver: () => Effect.succeed("2.55.0.windows.3"), + item, + }); + + assert.match(enriched.versionAdvisory?.message ?? "", /currently offers 2\.55\.0\.windows\.3/i); + assert.ok(enriched.versionAdvisory?.actions.some((action) => action.kind === "runUpdate")); + assert.ok(enriched.versionAdvisory?.actions.some((action) => action.kind === "copyCommand")); + }), +); + it.effect("does not check Git for Windows latest releases off Windows", () => { let resolverCalls = 0; return Effect.gen(function* () { @@ -148,6 +205,45 @@ it.effect("does not check Git for Windows latest releases off Windows", () => { }); }); +it.effect("offers one-click Homebrew installs for missing macOS source control tools", () => + Effect.gen(function* () { + const item: SourceControlProviderDiscoveryItem = { + kind: "github", + label: "GitHub", + executable: "gh", + status: "missing", + version: Option.none(), + installHint: "Install GitHub CLI.", + detail: Option.some("gh was not found on the server PATH."), + auth: { + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "darwin", + packageManager: "homebrew", + canRunInstall: true, + latestVersionResolver: () => Effect.succeed(null), + item, + }); + + assert.strictEqual(enriched.versionAdvisory?.status, "install_available"); + assert.strictEqual(enriched.versionAdvisory?.currentVersion, null); + assert.deepStrictEqual(enriched.versionAdvisory?.actions.slice(0, 2), [ + { + label: "Install now", + kind: "runUpdate", + target: "github-cli", + operation: "install", + }, + { label: "Copy Homebrew command", kind: "copyCommand", value: "brew install gh" }, + ]); + }), +); + it.effect("caches successful latest-release lookups", () => { clearSourceControlToolVersionAdvisoryCacheForTests(); let requestCount = 0; diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts index 5e3f2cee1..dfb6b60e9 100644 --- a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts @@ -1,5 +1,6 @@ import type { SourceControlProviderDiscoveryItem, + SourceControlToolUpdateTarget, SourceControlToolVersionAdvisory, VcsDiscoveryItem, } from "@threadlines/contracts"; @@ -8,6 +9,14 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + sourceControlToolLabel, + sourceControlToolPackageRecipe, + TOOL_RELEASE_URLS, + type SourceControlToolPackageManager, +} from "./SourceControlToolPackages.ts"; +import type { LatestWinGetVersionResolver } from "./SourceControlWinGet.ts"; + const LATEST_VERSION_TIMEOUT_MS = 5_000; const LATEST_VERSION_CACHE_TTL_MS = 12 * 60 * 60_000; const LATEST_VERSION_FAILURE_CACHE_TTL_MS = 30 * 60_000; @@ -171,39 +180,199 @@ function advisory(input: { }; } +function windowsUpdateActions(input: { + readonly target: SourceControlToolUpdateTarget; + readonly currentVersion: string | null; + readonly winGetVersion: string | null; + readonly canRunUpdate: boolean; + readonly copyCommand: string; + readonly openLabel: string; + readonly openUrl: string; +}): SourceControlToolVersionAdvisory["actions"] { + const winGetUpdateAvailable = + input.currentVersion !== null && + input.winGetVersion !== null && + compareToolVersions(input.currentVersion, input.winGetVersion) < 0; + + return [ + ...(input.canRunUpdate && winGetUpdateAvailable + ? ([{ label: "Update now", kind: "runUpdate", target: input.target }] as const) + : []), + ...(winGetUpdateAvailable + ? ([{ label: "Copy WinGet command", kind: "copyCommand", value: input.copyCommand }] as const) + : []), + { label: input.openLabel, kind: "openUrl", value: input.openUrl }, + ]; +} + +function installActions(input: { + readonly target: SourceControlToolUpdateTarget; + readonly packageManager: SourceControlToolPackageManager | null; + readonly canRun: boolean; + readonly openLabel: string; + readonly openUrl: string; +}): SourceControlToolVersionAdvisory["actions"] { + const recipe = input.packageManager + ? sourceControlToolPackageRecipe({ + manager: input.packageManager, + target: input.target, + operation: "install", + }) + : null; + return [ + ...(recipe && input.canRun + ? ([ + { + label: "Install now", + kind: "runUpdate", + target: input.target, + operation: "install", + }, + ] as const) + : []), + ...(recipe + ? ([{ label: recipe.copyLabel, kind: "copyCommand", value: recipe.copyCommand }] as const) + : []), + { label: input.openLabel, kind: "openUrl", value: input.openUrl }, + ]; +} + +function updateActions(input: { + readonly target: SourceControlToolUpdateTarget; + readonly packageManager: SourceControlToolPackageManager | null; + readonly canRun: boolean; + readonly openLabel: string; + readonly openUrl: string; +}): SourceControlToolVersionAdvisory["actions"] { + const recipe = input.packageManager + ? sourceControlToolPackageRecipe({ + manager: input.packageManager, + target: input.target, + operation: "update", + }) + : null; + return [ + ...(recipe && input.canRun + ? ([ + { + label: "Update now", + kind: "runUpdate", + target: input.target, + operation: "update", + }, + ] as const) + : []), + ...(recipe + ? ([{ label: recipe.copyLabel, kind: "copyCommand", value: recipe.copyCommand }] as const) + : []), + { label: input.openLabel, kind: "openUrl", value: input.openUrl }, + ]; +} + +function sourceControlToolTargetForItem( + item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, +): SourceControlToolUpdateTarget | null { + if (!("auth" in item) && item.kind === "git") return "git"; + if ("auth" in item) { + switch (item.kind) { + case "github": + return "github-cli"; + case "gitlab": + return "gitlab-cli"; + case "azure-devops": + return "azure-cli"; + case "bitbucket": + case "unknown": + return null; + } + } + return null; +} + +function createMissingToolAdvisory(input: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; + readonly packageManager: SourceControlToolPackageManager | null; + readonly canRunInstall: boolean; + readonly checkedAt: string; +}): SourceControlToolVersionAdvisory | undefined { + const target = sourceControlToolTargetForItem(input.item); + if (target === null) return undefined; + + const actions = installActions({ + target, + packageManager: input.packageManager, + canRun: input.canRunInstall, + openLabel: "Open install guide", + openUrl: TOOL_RELEASE_URLS[target], + }); + + return advisory({ + status: "install_available", + severity: "info", + currentVersion: null, + latestVersion: null, + recommendedVersion: null, + checkedAt: input.checkedAt, + message: `Install ${sourceControlToolLabel(target)} to enable this source control integration.`, + notificationKey: null, + actions, + }); +} + +function winGetAvailabilityNote(input: { + readonly label: string; + readonly currentVersion: string | null; + readonly targetVersion: string | null; + readonly winGetVersion: string | null; +}): string | null { + if ( + input.currentVersion === null || + input.targetVersion === null || + compareToolVersions(input.currentVersion, input.targetVersion) >= 0 + ) { + return null; + } + + if (input.winGetVersion === null) { + return `Threadlines could not verify ${input.label} ${input.targetVersion} in WinGet; use the official release link or check again later.`; + } + if (compareToolVersions(input.winGetVersion, input.targetVersion) >= 0) { + return null; + } + if (compareToolVersions(input.currentVersion, input.winGetVersion) < 0) { + return `WinGet currently offers ${input.winGetVersion}, but ${input.label} ${input.targetVersion} has not reached WinGet yet.`; + } + return `${input.label} ${input.targetVersion} has not reached WinGet yet; use the official release link or check again later.`; +} + function createGitHubCliAdvisory(input: { readonly currentVersion: string | null; readonly latestVersion: string | null; + readonly winGetVersion: string | null; readonly platform: NodeJS.Platform; readonly checkedAt: string; readonly canRunUpdate: boolean; + readonly packageManager: SourceControlToolPackageManager | null | undefined; }): SourceControlToolVersionAdvisory | undefined { const actions: SourceControlToolVersionAdvisory["actions"] = input.platform === "win32" - ? [ - ...(input.canRunUpdate - ? ([ - { - label: "Update now", - kind: "runUpdate", - target: "github-cli", - }, - ] as const) - : []), - { - label: "Copy WinGet command", - kind: "copyCommand", - value: - "winget upgrade --id GitHub.cli --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity", - }, - { label: "Open releases", kind: "openUrl", value: GITHUB_CLI_RELEASES_URL }, - ] - : input.platform === "darwin" - ? [ - { label: "Copy Homebrew command", kind: "copyCommand", value: "brew upgrade gh" }, - { label: "Open releases", kind: "openUrl", value: GITHUB_CLI_RELEASES_URL }, - ] - : [{ label: "Open update instructions", kind: "openUrl", value: GITHUB_CLI_RELEASES_URL }]; + ? windowsUpdateActions({ + target: "github-cli", + currentVersion: input.currentVersion, + winGetVersion: input.winGetVersion, + canRunUpdate: input.canRunUpdate, + copyCommand: + "winget upgrade --id GitHub.cli --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity", + openLabel: "Open releases", + openUrl: GITHUB_CLI_RELEASES_URL, + }) + : updateActions({ + target: "github-cli", + packageManager: input.packageManager ?? null, + canRun: input.canRunUpdate, + openLabel: "Open releases", + openUrl: GITHUB_CLI_RELEASES_URL, + }); if ( input.currentVersion !== null && @@ -212,6 +381,18 @@ function createGitHubCliAdvisory(input: { const hasWindowsTerminalFlashRisk = input.platform === "win32" && compareToolVersions(input.currentVersion, GH_TERMINAL_FLASH_FIXED_VERSION) < 0; + const baseMessage = hasWindowsTerminalFlashRisk + ? "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release." + : "This GitHub CLI version is below the recommended security-fix release."; + const availabilityNote = + input.platform === "win32" + ? winGetAvailabilityNote({ + label: "GitHub CLI", + currentVersion: input.currentVersion, + targetVersion: GH_SECURITY_VERSION, + winGetVersion: input.winGetVersion, + }) + : null; return advisory({ status: "recommended_update", severity: "warning", @@ -219,9 +400,7 @@ function createGitHubCliAdvisory(input: { latestVersion: input.latestVersion, recommendedVersion: GH_SECURITY_VERSION, checkedAt: input.checkedAt, - message: hasWindowsTerminalFlashRisk - ? "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release." - : "This GitHub CLI version is below the recommended security-fix release.", + message: availabilityNote ? `${baseMessage} ${availabilityNote}` : baseMessage, notificationKey: `github-cli:security:${GH_SECURITY_VERSION}`, actions, }); @@ -232,6 +411,15 @@ function createGitHubCliAdvisory(input: { input.latestVersion !== null && compareToolVersions(input.currentVersion, input.latestVersion) < 0 ) { + const availabilityNote = + input.platform === "win32" + ? winGetAvailabilityNote({ + label: "GitHub CLI", + currentVersion: input.currentVersion, + targetVersion: input.latestVersion, + winGetVersion: input.winGetVersion, + }) + : null; return advisory({ status: "behind_latest", severity: "info", @@ -239,7 +427,7 @@ function createGitHubCliAdvisory(input: { latestVersion: input.latestVersion, recommendedVersion: input.latestVersion, checkedAt: input.checkedAt, - message: "A newer GitHub CLI version is available for this environment.", + message: availabilityNote ?? "A newer GitHub CLI version is available for this environment.", notificationKey: null, actions, }); @@ -265,37 +453,37 @@ function createGitHubCliAdvisory(input: { function createGitForWindowsAdvisory(input: { readonly currentVersion: string | null; readonly latestVersion: string | null; + readonly winGetVersion: string | null; readonly platform: NodeJS.Platform; readonly checkedAt: string; readonly canRunUpdate: boolean; + readonly packageManager: SourceControlToolPackageManager | null | undefined; }): SourceControlToolVersionAdvisory | undefined { if (input.platform !== "win32") { return undefined; } - const actions: SourceControlToolVersionAdvisory["actions"] = [ - ...(input.canRunUpdate - ? ([ - { - label: "Update now", - kind: "runUpdate", - target: "git", - }, - ] as const) - : []), - { - label: "Copy WinGet command", - kind: "copyCommand", - value: - "winget upgrade --id Git.Git --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity", - }, - { label: "Open official release", kind: "openUrl", value: GIT_FOR_WINDOWS_RELEASES_URL }, - ]; + const actions = windowsUpdateActions({ + target: "git", + currentVersion: input.currentVersion, + winGetVersion: input.winGetVersion, + canRunUpdate: input.canRunUpdate, + copyCommand: + "winget upgrade --id Git.Git --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity", + openLabel: "Open official release", + openUrl: GIT_FOR_WINDOWS_RELEASES_URL, + }); if ( input.currentVersion !== null && compareToolVersions(input.currentVersion, GIT_FOR_WINDOWS_SECURITY_VERSION) < 0 ) { + const availabilityNote = winGetAvailabilityNote({ + label: "Git for Windows", + currentVersion: input.currentVersion, + targetVersion: GIT_FOR_WINDOWS_SECURITY_VERSION, + winGetVersion: input.winGetVersion, + }); return advisory({ status: "recommended_update", severity: "warning", @@ -303,7 +491,12 @@ function createGitForWindowsAdvisory(input: { latestVersion: input.latestVersion, recommendedVersion: GIT_FOR_WINDOWS_SECURITY_VERSION, checkedAt: input.checkedAt, - message: "This Git for Windows version is below the recommended security-fix release.", + message: [ + "This Git for Windows version is below the recommended security-fix release.", + availabilityNote, + ] + .filter((part): part is string => part !== null) + .join(" "), notificationKey: `git-for-windows:security:${GIT_FOR_WINDOWS_SECURITY_VERSION}`, actions, }); @@ -314,6 +507,12 @@ function createGitForWindowsAdvisory(input: { input.latestVersion !== null && compareToolVersions(input.currentVersion, input.latestVersion) < 0 ) { + const availabilityNote = winGetAvailabilityNote({ + label: "Git for Windows", + currentVersion: input.currentVersion, + targetVersion: input.latestVersion, + winGetVersion: input.winGetVersion, + }); return advisory({ status: "behind_latest", severity: "info", @@ -321,7 +520,7 @@ function createGitForWindowsAdvisory(input: { latestVersion: input.latestVersion, recommendedVersion: input.latestVersion, checkedAt: input.checkedAt, - message: "A newer Git for Windows release is available.", + message: availabilityNote ?? "A newer Git for Windows release is available.", notificationKey: null, actions, }); @@ -350,26 +549,46 @@ export function withSourceControlToolVersionAdvisory< readonly item: Item; readonly platform: NodeJS.Platform; readonly latestVersionResolver: LatestVersionResolver; + readonly winGetVersionResolver?: LatestWinGetVersionResolver; readonly canRunUpdate?: boolean; + readonly packageManager?: SourceControlToolPackageManager | null; + readonly canRunInstall?: boolean; }): Effect.Effect { + const checkedAt = new Date().toISOString(); + if (input.item.status !== "available") { - return Effect.succeed(input.item); + const versionAdvisory = createMissingToolAdvisory({ + item: input.item, + packageManager: input.packageManager ?? null, + canRunInstall: input.canRunInstall === true, + checkedAt, + }); + return Effect.succeed( + versionAdvisory ? ({ ...input.item, versionAdvisory } as Item) : input.item, + ); } const resolver = input.latestVersionResolver; const versionLine = Option.getOrNull(input.item.version); - const checkedAt = new Date().toISOString(); if ("auth" in input.item && input.item.kind === "github") { const currentVersion = parseGitHubCliVersion(versionLine); - return resolver("github-cli").pipe( - Effect.map((latestVersion) => { + return Effect.all({ + latestVersion: resolver("github-cli"), + winGetVersion: + input.platform === "win32" && input.winGetVersionResolver + ? input.winGetVersionResolver("github-cli") + : Effect.succeed(null), + }).pipe( + Effect.map(({ latestVersion, winGetVersion }) => { const versionAdvisory = createGitHubCliAdvisory({ currentVersion, latestVersion, + winGetVersion, platform: input.platform, checkedAt, canRunUpdate: input.canRunUpdate === true, + packageManager: input.packageManager, }); return versionAdvisory ? ({ ...input.item, versionAdvisory } as Item) : input.item; }), @@ -383,14 +602,21 @@ export function withSourceControlToolVersionAdvisory< } const currentVersion = parseGitVersion(versionLine); - return resolver("git-for-windows").pipe( - Effect.map((latestVersion) => { + return Effect.all({ + latestVersion: resolver("git-for-windows"), + winGetVersion: input.winGetVersionResolver + ? input.winGetVersionResolver("git") + : Effect.succeed(null), + }).pipe( + Effect.map(({ latestVersion, winGetVersion }) => { const versionAdvisory = createGitForWindowsAdvisory({ currentVersion, latestVersion, + winGetVersion, platform: input.platform, checkedAt, canRunUpdate: input.canRunUpdate === true, + packageManager: input.packageManager, }); return versionAdvisory ? ({ ...input.item, versionAdvisory } as Item) : input.item; }), diff --git a/apps/server/src/sourceControl/SourceControlWinGet.ts b/apps/server/src/sourceControl/SourceControlWinGet.ts new file mode 100644 index 000000000..d5560190f --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlWinGet.ts @@ -0,0 +1,120 @@ +import type { SourceControlToolUpdateTarget, VcsError } from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; + +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { winGetPackageId } from "./SourceControlToolPackages.ts"; + +const VERSION_LOOKUP_TIMEOUT_MS = 15_000; +const VERSION_LOOKUP_OUTPUT_MAX_BYTES = 20_000; +const VERSION_CACHE_TTL_MS = 5 * 60_000; +const VERSION_FAILURE_CACHE_TTL_MS = 60_000; + +const WINGET_UPDATE_NOT_APPLICABLE_EXIT_CODES = new Set([0x8a15002b, -1_978_335_189]); + +interface CachedVersion { + readonly expiresAt: number; + readonly version: string | null; +} + +export type LatestWinGetVersionResolver = ( + target: SourceControlToolUpdateTarget, +) => Effect.Effect; + +export function winGetUpdateArgs(target: SourceControlToolUpdateTarget): ReadonlyArray { + return [ + "upgrade", + "--id", + winGetPackageId(target), + "--exact", + "--source", + "winget", + "--silent", + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + ]; +} + +export function winGetShowVersionsArgs( + target: SourceControlToolUpdateTarget, +): ReadonlyArray { + return [ + "show", + "--id", + winGetPackageId(target), + "--exact", + "--source", + "winget", + "--versions", + "--accept-source-agreements", + "--disable-interactivity", + ]; +} + +function normalizeWinGetVersion(target: SourceControlToolUpdateTarget, version: string): string { + if (target !== "git") return version; + + const gitForWindowsVersion = version.match(/^(\d+\.\d+\.\d+)(?:\.(\d+))?$/u); + if (!gitForWindowsVersion) return version; + return `${gitForWindowsVersion[1]}.windows.${gitForWindowsVersion[2] ?? "1"}`; +} + +export function parseLatestWinGetVersion( + target: SourceControlToolUpdateTarget, + output: string, +): string | null { + const lines = output.split(/\r?\n/u).map((line) => line.trim()); + const separatorIndex = lines.findIndex((line) => /^-{3,}$/u.test(line)); + if (separatorIndex < 0) return null; + + const version = lines + .slice(separatorIndex + 1) + .find((line) => /^v?\d+(?:\.[0-9A-Za-z]+)+(?:[-+][0-9A-Za-z.-]+)?$/u.test(line)); + return version ? normalizeWinGetVersion(target, version.replace(/^v/iu, "")) : null; +} + +export function isWinGetUpdateNotApplicable(cause: VcsError): boolean { + return ( + cause._tag === "VcsProcessExitError" && + WINGET_UPDATE_NOT_APPLICABLE_EXIT_CODES.has(cause.exitCode) + ); +} + +export function makeLatestWinGetVersionResolver(input: { + readonly cwd: string; + readonly vcsProcess: VcsProcess.VcsProcessShape; +}): LatestWinGetVersionResolver { + const cache = new Map(); + + return (target) => { + const now = Date.now(); + const cached = cache.get(target); + if (cached && cached.expiresAt > now) { + return Effect.succeed(cached.version); + } + + return input.vcsProcess + .run({ + operation: "source-control.tool.winget-version", + command: "winget", + args: winGetShowVersionsArgs(target), + cwd: input.cwd, + timeoutMs: VERSION_LOOKUP_TIMEOUT_MS, + maxOutputBytes: VERSION_LOOKUP_OUTPUT_MAX_BYTES, + appendTruncationMarker: true, + }) + .pipe( + Effect.map((result) => parseLatestWinGetVersion(target, result.stdout)), + Effect.catch(() => Effect.succeed(null)), + Effect.tap((version) => + Effect.sync(() => { + cache.set(target, { + expiresAt: + now + (version === null ? VERSION_FAILURE_CACHE_TTL_MS : VERSION_CACHE_TTL_MS), + version, + }); + }), + ), + ); + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 239eb90a5..c22b1d152 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1206,17 +1206,19 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect( WS_METHODS.serverUpdateSourceControlTool, Effect.gen(function* () { + const operation = input.operation ?? "update"; const before = yield* sourceControlDiscovery.discover; if ( !SourceControlToolMaintenance.hasVerifiedSourceControlToolUpdateAction( before, input.target, + operation, ) ) { return yield* new SourceControlToolUpdateError({ target: input.target, reason: - "Threadlines could not verify an available update for this tool. Rescan the server environment and try again.", + "Threadlines could not verify an available install or update action for this tool. Rescan the server environment and try again.", }); } const previousVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( @@ -1224,7 +1226,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => input.target, ); - yield* sourceControlToolMaintenance.update(input); + yield* sourceControlToolMaintenance.update({ ...input, operation }); const discovery = yield* sourceControlDiscovery.discover; const currentVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( @@ -1235,12 +1237,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => return yield* new SourceControlToolUpdateError({ target: input.target, reason: - "WinGet finished, but Threadlines could not verify the installed tool version afterward. Rescan after restarting the desktop app.", + "The package-manager command finished, but Threadlines could not verify the installed tool version afterward. Rescan after restarting the desktop app.", }); } return { target: input.target, + operation, status: previousVersion === currentVersion ? "unchanged" : "succeeded", previousVersion, currentVersion, diff --git a/apps/web/src/components/settings/CompactVersionAdvisory.tsx b/apps/web/src/components/settings/CompactVersionAdvisory.tsx index df0a519df..f70e4cde2 100644 --- a/apps/web/src/components/settings/CompactVersionAdvisory.tsx +++ b/apps/web/src/components/settings/CompactVersionAdvisory.tsx @@ -38,7 +38,14 @@ function openExternalUrl(url: string): void { } function advisoryTitle(advisory: SourceControlToolVersionAdvisory): string { - return advisory.status === "current" ? "Up to date" : "Update available"; + if (advisory.status === "current") return "Up to date"; + return advisory.status === "install_available" ? "Install available" : "Update available"; +} + +function packageManagerLabel(copyLabel: string | undefined): string { + if (copyLabel?.toLowerCase().includes("homebrew")) return "Homebrew"; + if (copyLabel?.toLowerCase().includes("winget")) return "WinGet"; + return "package manager"; } export function CompactVersionAdvisory({ @@ -76,22 +83,33 @@ export function CompactVersionAdvisory({ void updateSourceControlTool({ ...(environmentId === undefined ? {} : { environmentId }), target: updateAction.target, + ...(updateAction.operation ? { operation: updateAction.operation } : {}), }) .then((result) => { toastManager.add({ type: result.status === "succeeded" ? "success" : "info", - title: result.status === "succeeded" ? `${label} updated` : `${label} is unchanged`, + title: + result.status === "succeeded" + ? result.operation === "install" + ? `${label} installed` + : `${label} updated` + : `${label} is unchanged`, description: result.status === "succeeded" - ? `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}` - : "WinGet completed, but the detected version did not change.", + ? result.operation === "install" + ? result.currentVersion + ? `Installed ${result.currentVersion}` + : "Installed successfully." + : `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}` + : `${packageManagerLabel(copyAction?.label)} completed, but the detected version did not change.`, }); }) .catch((error: unknown) => { + const operation = updateAction.operation ?? "update"; toastManager.add( stackedThreadToast({ type: "error", - title: `Could not update ${label}`, + title: `Could not ${operation === "install" ? "install" : "update"} ${label}`, description: error instanceof Error ? error.message : "The verified update command failed.", }), @@ -100,6 +118,23 @@ export function CompactVersionAdvisory({ .finally(() => setIsUpdating(false)); }; + if (advisory.status === "install_available" && updateAction) { + return ( + + ); + } + return ( @@ -229,8 +264,17 @@ export function CompactVersionAdvisory({ > {updateAction - ? "Threadlines runs only the verified WinGet package shown above after you click Update now. Windows may ask for permission." - : "Threadlines cannot run this update automatically. Use the copied command on this environment's host."} + ? packageManagerLabel(copyAction?.label) === "WinGet" && + (updateAction.operation ?? "update") === "update" + ? "Threadlines runs only the verified WinGet package shown above after you click Update now. Windows may ask for permission." + : `Threadlines runs only the verified ${packageManagerLabel(copyAction?.label)} ${updateAction.operation === "install" ? "install" : "update"} recipe shown above after you click ${updateAction.label}.` + : copyAction + ? "Threadlines cannot run this automatically. Use the copied command on this environment's host." + : advisory.status === "current" + ? "This tool is up to date." + : advisory.status === "install_available" + ? "Threadlines cannot run this install automatically yet. Use the official install guide on this environment's host." + : "Threadlines cannot run this update automatically yet. Use the official release link or check again after WinGet publishes it."}

diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index c9fbaca2d..d77228a5e 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -2599,6 +2599,7 @@ describe("SourceControlSettingsPanel discovery states", () => { .fn() .mockResolvedValue({ target: "github-cli", + operation: "update", status: "succeeded", previousVersion: "2.92.0", currentVersion: "2.98.0", @@ -2650,6 +2651,137 @@ describe("SourceControlSettingsPanel discovery states", () => { ); }); + it("runs a verified Homebrew install only after Install now is clicked", async () => { + const discoveryResult: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "missing", + version: Option.none(), + installHint: "Install GitHub CLI.", + detail: Option.some("gh was not found on the server PATH."), + auth: { + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + versionAdvisory: { + status: "install_available", + severity: "info", + currentVersion: null, + latestVersion: null, + recommendedVersion: null, + checkedAt: "2026-08-14T00:00:00.000Z", + message: "Install GitHub CLI to enable this source control integration.", + notificationKey: null, + actions: [ + { + label: "Install now", + kind: "runUpdate", + target: "github-cli", + operation: "install", + }, + { + label: "Copy Homebrew command", + kind: "copyCommand", + value: "brew install gh", + }, + { + label: "Open install guide", + kind: "openUrl", + value: "https://cli.github.com/", + }, + ], + }, + }, + ], + }; + const updateSourceControlTool = vi + .fn() + .mockResolvedValue({ + target: "github-cli", + operation: "install", + status: "succeeded", + previousVersion: null, + currentVersion: "2.98.0", + discovery: discoveryResult, + }); + setSourceControlDiscoveryStub(async () => discoveryResult, updateSourceControlTool); + + mounted = await renderWithTestRouter( + + + , + ); + + expect(updateSourceControlTool).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Install GitHub" }).click(); + expect(updateSourceControlTool).toHaveBeenCalledWith({ + target: "github-cli", + operation: "install", + }); + }); + + it("shows the official release without Update now while WinGet trails upstream", async () => { + setSourceControlDiscoveryStub(async () => ({ + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.55.0.windows.3"), + installHint: "Install Git.", + detail: Option.none(), + versionAdvisory: { + status: "recommended_update", + severity: "warning", + currentVersion: "2.55.0.windows.3", + latestVersion: "2.55.0.windows.4", + recommendedVersion: "2.55.0.windows.4", + checkedAt: "2026-08-14T00:00:00.000Z", + message: + "This Git for Windows version is below the recommended security-fix release. Git for Windows 2.55.0.windows.4 has not reached WinGet yet; use the official release link or check again later.", + notificationKey: "git-for-windows:security:2.55.0.windows.4", + actions: [ + { + label: "Open official release", + kind: "openUrl", + value: "https://github.com/git-for-windows/git/releases/latest", + }, + ], + }, + }, + ], + sourceControlProviders: [], + })); + + mounted = await renderWithTestRouter( + + + , + ); + + await page.getByRole("button", { name: "Git update advisory" }).click(); + + await expect.element(page.getByRole("button", { name: "Update now" })).not.toBeInTheDocument(); + await expect.element(page.getByText(/has not reached WinGet yet/i)).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Open official release" })).toBeVisible(); + await expect + .element( + page.getByText( + "Threadlines cannot run this update automatically yet. Use the official release link or check again after WinGet publishes it.", + ), + ) + .toBeVisible(); + }); + it("shows unauthenticated source control providers as unavailable", async () => { setSourceControlDiscoveryStub(async () => ({ versionControlSystems: [], diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 34449a255..8c43dd537 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -271,8 +271,7 @@ function DiscoveryItemRow({ {item.label} {version ? {version} : null} - {item.versionAdvisory?.status === "behind_latest" || - item.versionAdvisory?.status === "recommended_update" ? ( + {item.versionAdvisory && item.versionAdvisory.actions.length > 0 ? ( { it("forwards typed source control tool updates to the RPC client", async () => { const result = { target: "github-cli" as const, + operation: "update" as const, status: "succeeded" as const, previousVersion: "2.92.0", currentVersion: "2.98.0", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index b3e65395b..c35e93c85 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -139,6 +139,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; export const SourceControlToolVersionAdvisoryStatus = Schema.Literals([ "unknown", + "install_available", "current", "behind_latest", "recommended_update", @@ -150,9 +151,17 @@ export const SourceControlToolVersionAdvisorySeverity = Schema.Literals(["info", export type SourceControlToolVersionAdvisorySeverity = typeof SourceControlToolVersionAdvisorySeverity.Type; -export const SourceControlToolUpdateTarget = Schema.Literals(["github-cli", "git"]); +export const SourceControlToolUpdateTarget = Schema.Literals([ + "github-cli", + "git", + "gitlab-cli", + "azure-cli", +]); export type SourceControlToolUpdateTarget = typeof SourceControlToolUpdateTarget.Type; +export const SourceControlToolUpdateOperation = Schema.Literals(["install", "update"]); +export type SourceControlToolUpdateOperation = typeof SourceControlToolUpdateOperation.Type; + export const SourceControlToolVersionAdvisoryAction = Schema.Union([ Schema.Struct({ label: TrimmedNonEmptyString, @@ -163,6 +172,7 @@ export const SourceControlToolVersionAdvisoryAction = Schema.Union([ label: TrimmedNonEmptyString, kind: Schema.Literal("runUpdate"), target: SourceControlToolUpdateTarget, + operation: Schema.optional(SourceControlToolUpdateOperation), }), ]); export type SourceControlToolVersionAdvisoryAction = @@ -213,11 +223,13 @@ export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.T export const SourceControlToolUpdateInput = Schema.Struct({ target: SourceControlToolUpdateTarget, + operation: Schema.optional(SourceControlToolUpdateOperation), }); export type SourceControlToolUpdateInput = typeof SourceControlToolUpdateInput.Type; export const SourceControlToolUpdateResult = Schema.Struct({ target: SourceControlToolUpdateTarget, + operation: SourceControlToolUpdateOperation, status: Schema.Literals(["succeeded", "unchanged"]), previousVersion: Schema.NullOr(TrimmedNonEmptyString), currentVersion: Schema.NullOr(TrimmedNonEmptyString),