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),