diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index b96f7f68b..9e4111e3e 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -220,6 +220,7 @@ describe("DesktopShellEnvironment", () => { PATH: "C:\\Windows\\System32", APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + ProgramFiles: "C:\\Program Files", USERPROFILE: "C:\\Users\\testuser", }; const commands: ChildProcess.Command[] = []; @@ -246,7 +247,12 @@ describe("DesktopShellEnvironment", () => { [ "C:\\Profile\\Node", "C:\\Windows\\System32", + "C:\\Program Files\\Git\\cmd", + "C:\\Program Files\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index e3f2f0a74..281d3e7a0 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { hideWindowsConsole } from "@threadlines/shared/childProcess"; +import { resolveKnownWindowsCliDirs } from "@threadlines/shared/shell"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -105,27 +106,6 @@ const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray return candidates; }; -const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ - ...trimNonEmpty(env.APPDATA).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\npm`], - }), - ), - ...trimNonEmpty(env.LOCALAPPDATA).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], - }), - ), - ...trimNonEmpty(env.USERPROFILE).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], - }), - ), -]; - const startMarker = (name: string) => `__THREADLINES_ENV_${name}_START__`; const endMarker = (name: string) => `__THREADLINES_ENV_${name}_END__`; @@ -268,7 +248,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin }); const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), - trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")), trimNonEmpty(noProfile.PATH), readEnvPath(config.env), ]); diff --git a/apps/server/src/git/GitAuthRemediationService.test.ts b/apps/server/src/git/GitAuthRemediationService.test.ts index 92b6f0a58..999b162d3 100644 --- a/apps/server/src/git/GitAuthRemediationService.test.ts +++ b/apps/server/src/git/GitAuthRemediationService.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitManagerError, VcsProcessSpawnError } from "@threadlines/contracts"; import { ServerConfig } from "../config.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "../sourceControl/GitHubCliEnvironment.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitAuthRemediationService from "./GitAuthRemediationService.ts"; @@ -31,13 +32,14 @@ type FakeGhBehavior = "authed" | "unauthenticated" | "missing"; interface RecordedGhCall { readonly command: string; readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; } const makeFakeVcsProcess = (behavior: FakeGhBehavior, calls: RecordedGhCall[]) => VcsProcess.VcsProcess.of({ run: (input) => Effect.suspend(() => { - calls.push({ command: input.command, args: input.args }); + calls.push({ command: input.command, args: input.args, env: input.env }); if (behavior === "missing") { return Effect.fail( new VcsProcessSpawnError({ @@ -133,6 +135,7 @@ it.layer(TestLayer)("GitAuthRemediationService", (it) => { { command: "gh", args: ["auth", "status", "--hostname", UNREACHABLE_HOST], + env: THREADLINES_GITHUB_CLI_ENV, }, ]); @@ -241,6 +244,7 @@ it.layer(TestLayer)("GitAuthRemediationService", (it) => { { command: "gh", args: ["auth", "setup-git", "--hostname", UNREACHABLE_HOST], + env: THREADLINES_GITHUB_CLI_ENV, }, ]); }), diff --git a/apps/server/src/git/GitAuthRemediationService.ts b/apps/server/src/git/GitAuthRemediationService.ts index fc87c406e..889671c7c 100644 --- a/apps/server/src/git/GitAuthRemediationService.ts +++ b/apps/server/src/git/GitAuthRemediationService.ts @@ -18,6 +18,7 @@ import { } from "@threadlines/shared/git"; import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "../sourceControl/GitHubCliEnvironment.ts"; export interface GitAuthRemediationServiceShape { readonly plan: ( @@ -119,6 +120,7 @@ export const make = Effect.fn("makeGitAuthRemediationService")(function* () { command: "gh", args: ["auth", "status", "--hostname", host], cwd, + env: THREADLINES_GITHUB_CLI_ENV, allowNonZeroExit: true, timeoutMs: GH_PROBE_TIMEOUT_MS, }) @@ -257,6 +259,7 @@ export const make = Effect.fn("makeGitAuthRemediationService")(function* () { command: "gh", args: ["auth", "setup-git", "--hostname", host], cwd, + env: THREADLINES_GITHUB_CLI_ENV, allowNonZeroExit: true, timeoutMs: APPLY_TIMEOUT_MS, }) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 423453104..26c2155dd 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -16,6 +16,10 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ }); const mockRun = vi.fn(); +const GITHUB_CLI_BACKGROUND_ENV = { + GH_NO_UPDATE_NOTIFIER: "1", + GH_TELEMETRY: "0", +} as const; const layer = GitHubCli.layer.pipe( Layer.provide( @@ -84,6 +88,7 @@ describe("GitHubCli.layer", () => { "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, timeoutMs: 30_000, }); }).pipe(Effect.provide(layer)), @@ -255,6 +260,7 @@ describe("GitHubCli.layer", () => { "nameWithOwner,url,sshUrl", ], cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, timeoutMs: 30_000, }); }).pipe(Effect.provide(layer)), @@ -302,6 +308,7 @@ describe("GitHubCli.layer", () => { "platform", ], cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, timeoutMs: 30_000, }); expect(mockRun).toHaveBeenNthCalledWith(2, { @@ -309,6 +316,7 @@ describe("GitHubCli.layer", () => { command: "gh", args: ["api", "repos/octocat/example-app", "--jq", ".default_branch"], cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, timeoutMs: 30_000, }); }).pipe(Effect.provide(layer)), @@ -346,6 +354,7 @@ describe("GitHubCli.layer", () => { "/tmp/body.md", ], cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, timeoutMs: 30_000, }); }).pipe(Effect.provide(layer)), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 519645c5e..5ecce50b7 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -12,6 +12,7 @@ import { } from "@threadlines/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts"; import * as GitHubPullRequests from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -263,6 +264,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { command: "gh", args: input.args, cwd: input.cwd, + env: THREADLINES_GITHUB_CLI_ENV, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); diff --git a/apps/server/src/sourceControl/GitHubCliEnvironment.ts b/apps/server/src/sourceControl/GitHubCliEnvironment.ts new file mode 100644 index 000000000..ed6fc774c --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCliEnvironment.ts @@ -0,0 +1,4 @@ +export const THREADLINES_GITHUB_CLI_ENV = { + GH_NO_UPDATE_NOTIFIER: "1", + GH_TELEMETRY: "0", +} as const satisfies NodeJS.ProcessEnv; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 05e123758..1dfd6d767 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -12,6 +12,7 @@ import { import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@threadlines/shared/git"; import * as GitHubCli from "./GitHubCli.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubPullRequests from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -139,6 +140,7 @@ export const discovery = { executable: "gh", versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], + env: THREADLINES_GITHUB_CLI_ENV, parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 506f81b8c..c336eae3a 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -12,13 +12,16 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -const hasGitAndGhCommand = (command: string) => command === "git" || command === "gh"; +const hasGitGhAndWingetCommand = (command: string) => + command === "git" || command === "gh" || command === "winget"; const hasAllCommandsExceptJj = (command: string) => command !== "jj"; const noCommandsAvailable = () => false; +const noLatestToolVersion = () => Effect.succeed(null); const sourceControlProviderRegistryTestLayer = (input: { readonly bitbucket: Partial; @@ -65,6 +68,9 @@ it.effect("reports implemented tools separately from locally available executabl const processMock = { run: (input: VcsProcess.VcsProcessInput) => { processCommands.push(input.command); + if (input.command === "gh") { + assert.deepStrictEqual(input.env, THREADLINES_GITHUB_CLI_ENV); + } if (input.command === "git") { return Effect.succeed(processOutput("git version 2.51.0\n")); } @@ -104,7 +110,12 @@ it.effect("reports implemented tools separately from locally available executabl } satisfies Partial; const testLayer = Layer.effect( SourceControlDiscovery.SourceControlDiscovery, - SourceControlDiscovery.make({ commandAvailable: hasGitAndGhCommand }), + SourceControlDiscovery.make({ + commandAvailable: hasGitGhAndWingetCommand, + platform: "win32", + latestVersionResolver: (target) => + Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"), + }), ).pipe( Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), @@ -113,7 +124,7 @@ it.effect("reports implemented tools separately from locally available executabl Layer.provide( sourceControlProviderRegistryTestLayer({ process: processMock, - commandAvailable: hasGitAndGhCommand, + commandAvailable: hasGitGhAndWingetCommand, bitbucket: { probeAuth: Effect.succeed({ status: "unauthenticated", @@ -181,6 +192,37 @@ it.effect("reports implemented tools separately from locally available executabl const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); assert.strictEqual(bitbucket.executable, undefined); + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.deepStrictEqual(github.versionAdvisory, { + status: "recommended_update", + severity: "warning", + currentVersion: "2.83.0", + latestVersion: "2.98.0", + recommendedVersion: "2.97.0", + checkedAt: github.versionAdvisory?.checkedAt ?? null, + message: + "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.", + notificationKey: "github-cli:security:2.97.0", + actions: [ + { + label: "Update now", + kind: "runUpdate", + target: "github-cli", + }, + { + 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: "https://github.com/cli/cli/releases/latest", + }, + ], + }); assert.deepStrictEqual( processCommands.filter( (command) => command === "jj" || command === "glab" || command === "az", @@ -242,7 +284,10 @@ Logged in to gitlab.com as gitlab-user } satisfies Partial; const testLayer = Layer.effect( SourceControlDiscovery.SourceControlDiscovery, - SourceControlDiscovery.make({ commandAvailable: hasAllCommandsExceptJj }), + SourceControlDiscovery.make({ + commandAvailable: hasAllCommandsExceptJj, + latestVersionResolver: noLatestToolVersion, + }), ).pipe( Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), @@ -323,7 +368,10 @@ it.effect("skips unavailable discovery commands before spawning probes", () => { } satisfies Partial; const testLayer = Layer.effect( SourceControlDiscovery.SourceControlDiscovery, - SourceControlDiscovery.make({ commandAvailable: noCommandsAvailable }), + SourceControlDiscovery.make({ + commandAvailable: noCommandsAvailable, + latestVersionResolver: noLatestToolVersion, + }), ).pipe( Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-skip-discovery-" }), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 439fe1339..97453bcd9 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -1,5 +1,6 @@ import { type SourceControlDiscoveryResult, + type SourceControlProviderDiscoveryItem, type VcsDiscoveryItem, type VcsDriverKind, } from "@threadlines/contracts"; @@ -8,11 +9,13 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; 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 * as SourceControlToolVersionAdvisory from "./SourceControlToolVersionAdvisory.ts"; interface DiscoveryProbe { readonly label: string; @@ -64,6 +67,8 @@ export interface SourceControlDiscoveryShape { export interface SourceControlDiscoveryOptions { readonly commandAvailable?: SourceControlProviderDiscovery.CommandAvailability; + readonly latestVersionResolver?: SourceControlToolVersionAdvisory.LatestVersionResolver; + readonly platform?: NodeJS.Platform; } export class SourceControlDiscovery extends Context.Service< @@ -94,9 +99,13 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( options?: SourceControlDiscoveryOptions, ) { const config = yield* ServerConfig; - const process = yield* VcsProcess.VcsProcess; + const vcsProcess = yield* VcsProcess.VcsProcess; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const commandAvailable = options?.commandAvailable ?? ((command) => isCommandAvailable(command)); + 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 probe = ( input: DiscoveryProbe & { readonly kind: Kind }, @@ -117,7 +126,7 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( ); } - return process + return vcsProcess .run({ operation: "source-control.discovery.probe", command: executable, @@ -155,15 +164,50 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( ); }; + const withVersionAdvisory = ( + item: Item, + ): Effect.Effect => + latestVersionResolver + ? SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ + item, + platform, + latestVersionResolver, + canRunUpdate: canRunToolUpdate, + }) + : Effect.succeed(item); + return SourceControlDiscovery.of({ discover: Effect.all({ versionControlSystems: Effect.all( VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, { concurrency: "unbounded" }, + ).pipe( + Effect.flatMap((items) => + Effect.forEach(items, withVersionAdvisory, { + concurrency: "unbounded", + }), + ), + ), + sourceControlProviders: sourceControlProviders.discover.pipe( + Effect.flatMap((items) => + Effect.forEach(items, withVersionAdvisory, { + concurrency: "unbounded", + }), + ), ), - sourceControlProviders: sourceControlProviders.discover, }), }); }); -export const layer = Layer.effect(SourceControlDiscovery, make()); +export const layer = Layer.effect( + SourceControlDiscovery, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return yield* make({ + latestVersionResolver: (target) => + SourceControlToolVersionAdvisory.resolveLatestToolVersion(target).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 3c2030645..c202e0d22 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -35,6 +35,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -179,6 +180,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, + ...(input.spec.env !== undefined ? { env: input.spec.env } : {}), timeoutMs: 5_000, maxOutputBytes: 8_000, appendTruncationMarker: true, @@ -258,6 +260,7 @@ export function probeSourceControlProvider(input: { command: spec.executable, args: spec.authArgs, cwd: input.cwd, + ...(spec.env !== undefined ? { env: spec.env } : {}), allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 8_000, @@ -301,6 +304,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide command: spec.executable, args: spec.authArgs, cwd: input.cwd, + ...(spec.env !== undefined ? { env: spec.env } : {}), allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 8_000, diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts new file mode 100644 index 000000000..982f5c4e6 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts @@ -0,0 +1,167 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +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 { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlToolMaintenance from "./SourceControlToolMaintenance.ts"; + +const processOutput: VcsProcess.VcsProcessOutput = { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "Successfully installed", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}; + +it("verifies installed versions from raw discovery output without an advisory", () => { + assert.strictEqual( + SourceControlToolMaintenance.currentSourceControlToolVersion( + { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.55.0.windows.4"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + }, + "git", + ), + "2.55.0.windows.4", + ); +}); + +it.effect("runs only the allowlisted source control WinGet update recipes", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + 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) => { + calls.push(input); + return Effect.succeed(processOutput); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + yield* maintenance.update({ target: "github-cli" }); + yield* maintenance.update({ target: "git" }); + + assert.strictEqual(calls.length, 2); + assert.deepStrictEqual(calls[0], { + operation: "source-control.tool.update", + command: "winget", + args: [ + "upgrade", + "--id", + "GitHub.cli", + "--exact", + "--source", + "winget", + "--silent", + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity", + ], + cwd: process.cwd(), + timeoutMs: 300_000, + maxOutputBytes: 10_000, + appendTruncationMarker: true, + }); + assert.deepStrictEqual(calls[1]?.args.slice(0, 3), ["upgrade", "--id", "Git.Git"]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("refuses one-click updates outside the verified Windows WinGet path", () => { + let calls = 0; + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "linux", + commandAvailable: () => true, + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => { + calls += 1; + return Effect.succeed(processOutput); + }, + }), + ), + 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"); + assert.strictEqual(calls, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("serializes all source control updates through one WinGet lock", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + 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: () => { + calls += 1; + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(processOutput), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const first = yield* maintenance.update({ target: "github-cli" }).pipe(Effect.forkScoped); + yield* Deferred.await(started); + + const second = yield* Effect.result(maintenance.update({ target: "git" })); + assert.strictEqual(second._tag, "Failure"); + assert.strictEqual(calls, 1); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts new file mode 100644 index 000000000..3f8628ba0 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts @@ -0,0 +1,148 @@ +import { + SourceControlToolUpdateError, + type SourceControlDiscoveryResult, + type SourceControlToolUpdateInput, + type SourceControlToolUpdateTarget, +} from "@threadlines/contracts"; +import { isCommandAvailable } from "@threadlines/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { parseGitHubCliVersion, parseGitVersion } from "./SourceControlToolVersionAdvisory.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, + ) => Effect.Effect; +} + +export interface SourceControlToolMaintenanceOptions { + readonly commandAvailable?: (command: string) => boolean; + readonly platform?: NodeJS.Platform; +} + +export class SourceControlToolMaintenance extends Context.Service< + SourceControlToolMaintenance, + SourceControlToolMaintenanceShape +>()("threadlines/source-control/SourceControlToolMaintenance") {} + +function updateError(target: SourceControlToolUpdateTarget, reason: string) { + return new SourceControlToolUpdateError({ target, reason }); +} + +export function currentSourceControlToolVersion( + discovery: SourceControlDiscoveryResult, + target: SourceControlToolUpdateTarget, +): string | null { + const item = + target === "git" + ? discovery.versionControlSystems.find((candidate) => candidate.kind === "git") + : discovery.sourceControlProviders.find((candidate) => candidate.kind === "github"); + if (!item) return null; + const rawVersion = Option.getOrNull(item.version); + const detectedVersion = rawVersion + ? target === "git" + ? parseGitVersion(rawVersion) + : parseGitHubCliVersion(rawVersion) + : null; + return detectedVersion ?? item.versionAdvisory?.currentVersion ?? null; +} + +export function hasVerifiedSourceControlToolUpdateAction( + discovery: SourceControlDiscoveryResult, + target: SourceControlToolUpdateTarget, +): boolean { + const item = + target === "git" + ? discovery.versionControlSystems.find((candidate) => candidate.kind === "git") + : discovery.sourceControlProviders.find((candidate) => candidate.kind === "github"); + return ( + item?.versionAdvisory?.actions.some( + (action) => action.kind === "runUpdate" && action.target === target, + ) === true + ); +} + +export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( + options?: SourceControlToolMaintenanceOptions, +) { + const config = yield* ServerConfig; + const vcsProcess = yield* VcsProcess.VcsProcess; + const platform = options?.platform ?? process.platform; + const commandAvailable = + options?.commandAvailable ?? ((command: string) => isCommandAvailable(command, { platform })); + const updateActive = yield* Ref.make(false); + + const update: SourceControlToolMaintenanceShape["update"] = Effect.fn( + "SourceControlToolMaintenance.update", + )(function* (input) { + const { target } = input; + if (platform !== "win32") { + return yield* updateError( + target, + "One-click source control updates are currently available only for verified WinGet installations on Windows.", + ); + } + if (!commandAvailable("winget")) { + return yield* updateError( + target, + "WinGet is not available on this server, so Threadlines cannot run a verified update command.", + ); + } + + const acquired = yield* Ref.modify(updateActive, (active) => [!active, true] as const); + if (!acquired) { + return yield* updateError(target, "Another source control tool update is already running."); + } + + 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"}`, + ), + ), + ); + }).pipe(Effect.ensuring(Ref.set(updateActive, false))); + }); + + return SourceControlToolMaintenance.of({ update }); +}); + +export const layer = Layer.effect(SourceControlToolMaintenance, make()); diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts new file mode 100644 index 000000000..7f02afc46 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts @@ -0,0 +1,199 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import type { SourceControlProviderDiscoveryItem, VcsDiscoveryItem } from "@threadlines/contracts"; + +import { + clearSourceControlToolVersionAdvisoryCacheForTests, + compareToolVersions, + parseGitHubCliVersion, + parseGitVersion, + resolveLatestToolVersion, + withSourceControlToolVersionAdvisory, +} from "./SourceControlToolVersionAdvisory.ts"; + +it("parses common Git and GitHub CLI version output", () => { + assert.strictEqual(parseGitVersion("git version 2.55.0.windows.4"), "2.55.0.windows.4"); + assert.strictEqual(parseGitHubCliVersion("gh version 2.92.0 (2026-08-01)"), "2.92.0"); + assert.strictEqual(parseGitVersion("unexpected output"), null); + assert.strictEqual(parseGitHubCliVersion("unexpected output"), null); +}); + +it("compares multi-digit and Git for Windows version segments", () => { + assert.ok(compareToolVersions("2.10.0", "2.9.9") > 0); + assert.ok(compareToolVersions("2.55.0.windows.3", "2.55.0.windows.4") < 0); + assert.ok(compareToolVersions("2.55.0.windows.10", "2.55.0.windows.4") > 0); + assert.ok(compareToolVersions("v2.56.0.windows.1", "2.55.0.windows.4") > 0); +}); + +it.effect("recommends GitHub CLI updates for the Windows terminal flash range", () => + Effect.gen(function* () { + const item: SourceControlProviderDiscoveryItem = { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("gh version 2.92.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("octocat"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "win32", + canRunUpdate: true, + latestVersionResolver: () => Effect.succeed("2.98.0"), + item, + }); + + assert.strictEqual(enriched.versionAdvisory?.status, "recommended_update"); + assert.strictEqual(enriched.versionAdvisory?.recommendedVersion, "2.97.0"); + assert.match(enriched.versionAdvisory?.message ?? "", /terminal windows/i); + assert.ok(enriched.versionAdvisory?.actions.some((action) => action.kind === "copyCommand")); + assert.deepStrictEqual( + enriched.versionAdvisory?.actions.find((action) => action.kind === "runUpdate"), + { label: "Update now", kind: "runUpdate", target: "github-cli" }, + ); + }), +); + +it.effect("recommends GitHub CLI security floor across platforms", () => + Effect.gen(function* () { + const item: SourceControlProviderDiscoveryItem = { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("gh version 2.96.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("octocat"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "linux", + latestVersionResolver: () => Effect.succeed("2.98.0"), + item, + }); + + assert.strictEqual(enriched.versionAdvisory?.status, "recommended_update"); + assert.strictEqual(enriched.versionAdvisory?.recommendedVersion, "2.97.0"); + assert.match(enriched.versionAdvisory?.message ?? "", /security/i); + }), +); + +it.effect("recommends the Git for Windows security baseline without executing updates", () => + 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"), + item, + }); + + assert.strictEqual(enriched.versionAdvisory?.status, "recommended_update"); + assert.strictEqual(enriched.versionAdvisory?.recommendedVersion, "2.55.0.windows.4"); + assert.ok(enriched.versionAdvisory?.actions.some((action) => action.kind === "copyCommand")); + assert.deepStrictEqual( + enriched.versionAdvisory?.actions.find((action) => action.kind === "runUpdate"), + { label: "Update now", kind: "runUpdate", target: "git" }, + ); + }), +); + +it.effect("does not check Git for Windows latest releases off Windows", () => { + let resolverCalls = 0; + return Effect.gen(function* () { + const item: VcsDiscoveryItem = { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.54.0"), + installHint: "Install Git.", + detail: Option.none(), + }; + const enriched = yield* withSourceControlToolVersionAdvisory({ + platform: "linux", + latestVersionResolver: () => { + resolverCalls += 1; + return Effect.succeed("2.55.0.windows.4"); + }, + item, + }); + + assert.strictEqual(enriched.versionAdvisory, undefined); + assert.strictEqual(resolverCalls, 0); + }); +}); + +it.effect("caches successful latest-release lookups", () => { + clearSourceControlToolVersionAdvisoryCacheForTests(); + let requestCount = 0; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requestCount += 1; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { tag_name: "v2.99.0" }, + { headers: { "content-type": "application/json" } }, + ), + ), + ); + }), + ); + + return Effect.gen(function* () { + const first = yield* resolveLatestToolVersion("github-cli"); + const second = yield* resolveLatestToolVersion("github-cli"); + + assert.strictEqual(first, "2.99.0"); + assert.strictEqual(second, "2.99.0"); + assert.strictEqual(requestCount, 1); + }).pipe(Effect.provide(httpLayer)); +}); + +it.effect("turns latest-release fetch failures into a null version", () => { + clearSourceControlToolVersionAdvisoryCacheForTests(); + let requestCount = 0; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requestCount += 1; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 503 }))); + }), + ); + + return Effect.gen(function* () { + const first = yield* resolveLatestToolVersion("github-cli"); + const second = yield* resolveLatestToolVersion("github-cli"); + + assert.strictEqual(first, null); + assert.strictEqual(second, null); + assert.strictEqual(requestCount, 1); + }).pipe(Effect.provide(httpLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts new file mode 100644 index 000000000..5e3f2cee1 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts @@ -0,0 +1,402 @@ +import type { + SourceControlProviderDiscoveryItem, + SourceControlToolVersionAdvisory, + VcsDiscoveryItem, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +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; + +// v2.93.0 first shipped cli/cli#13353, which suppresses the Windows tzutil console flash. +const GH_TERMINAL_FLASH_FIXED_VERSION = "2.93.0"; +// v2.97.0 fixed four upstream security advisories and explicitly asked users to update promptly. +const GH_SECURITY_VERSION = "2.97.0"; +// v2.55.0.windows.4 is the Git for Windows CVE-2026-62960 security-fix release. +const GIT_FOR_WINDOWS_SECURITY_VERSION = "2.55.0.windows.4"; + +const GITHUB_CLI_RELEASES_URL = "https://github.com/cli/cli/releases/latest"; +const GIT_FOR_WINDOWS_RELEASES_URL = "https://github.com/git-for-windows/git/releases/latest"; + +type SourceControlToolVersionTarget = "github-cli" | "git-for-windows"; + +export type LatestVersionResolver = ( + target: SourceControlToolVersionTarget, +) => Effect.Effect; + +interface CachedLatestVersion { + readonly expiresAt: number; + readonly version: string | null; +} + +const latestVersionCache = new Map(); + +export function clearSourceControlToolVersionAdvisoryCacheForTests(): void { + latestVersionCache.clear(); +} + +const LatestGitHubReleaseResponse = Schema.Struct({ + tag_name: Schema.String, +}); + +function nonEmpty(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function stripVersionPrefix(value: string): string { + return value.trim().replace(/^v/iu, ""); +} + +export function parseGitHubCliVersion(versionLine: string | null): string | null { + const value = nonEmpty(versionLine); + if (!value) return null; + return nonEmpty(value.match(/\bgh\s+version\s+([^\s]+)/iu)?.[1]); +} + +export function parseGitVersion(versionLine: string | null): string | null { + const value = nonEmpty(versionLine); + if (!value) return null; + return nonEmpty(value.match(/\bgit\s+version\s+([^\s]+)/iu)?.[1]); +} + +function parseVersionSegments(value: string): ReadonlyArray { + return stripVersionPrefix(value) + .split(/[\s.+_-]+/u) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map((segment) => (/^\d+$/u.test(segment) ? Number.parseInt(segment, 10) : segment)); +} + +export function compareToolVersions(left: string, right: string): number { + const leftSegments = parseVersionSegments(left); + const rightSegments = parseVersionSegments(right); + const length = Math.max(leftSegments.length, rightSegments.length); + + for (let index = 0; index < length; index += 1) { + const leftSegment = leftSegments[index] ?? 0; + const rightSegment = rightSegments[index] ?? 0; + if (leftSegment === rightSegment) continue; + + if (typeof leftSegment === "number" && typeof rightSegment === "number") { + return leftSegment - rightSegment; + } + if (typeof leftSegment === "number") return 1; + if (typeof rightSegment === "number") return -1; + + const comparison = leftSegment.localeCompare(rightSegment); + if (comparison !== 0) return comparison; + } + + return 0; +} + +function githubLatestReleaseApiUrl(target: SourceControlToolVersionTarget): string { + switch (target) { + case "github-cli": + return "https://api.github.com/repos/cli/cli/releases/latest"; + case "git-for-windows": + return "https://api.github.com/repos/git-for-windows/git/releases/latest"; + } +} + +export const resolveLatestToolVersion = Effect.fn("resolveLatestSourceControlToolVersion")( + function* (target: SourceControlToolVersionTarget) { + const now = Date.now(); + const cached = latestVersionCache.get(target); + if (cached && cached.expiresAt > now) { + return cached.version; + } + + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(githubLatestReleaseApiUrl(target)).pipe( + HttpClientRequest.setHeaders({ + accept: "application/vnd.github+json", + "user-agent": "threadlines-source-control-advisory", + }), + ); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none())), + ); + + if (Option.isNone(response) || response.value.status < 200 || response.value.status >= 300) { + latestVersionCache.set(target, { + expiresAt: now + LATEST_VERSION_FAILURE_CACHE_TTL_MS, + version: null, + }); + return null; + } + + const payload = yield* response.value.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(LatestGitHubReleaseResponse)), + Effect.catch(() => Effect.succeed(null)), + ); + const version = payload ? nonEmpty(stripVersionPrefix(payload.tag_name)) : null; + latestVersionCache.set(target, { + expiresAt: + now + + (version === null ? LATEST_VERSION_FAILURE_CACHE_TTL_MS : LATEST_VERSION_CACHE_TTL_MS), + version, + }); + return version; + }, +); + +function advisory(input: { + readonly status: SourceControlToolVersionAdvisory["status"]; + readonly severity: SourceControlToolVersionAdvisory["severity"]; + readonly currentVersion: string | null; + readonly latestVersion: string | null; + readonly recommendedVersion: string | null; + readonly checkedAt: string | null; + readonly message: string | null; + readonly notificationKey: string | null; + readonly actions: SourceControlToolVersionAdvisory["actions"]; +}): SourceControlToolVersionAdvisory { + return { + status: input.status, + severity: input.severity, + currentVersion: input.currentVersion, + latestVersion: input.latestVersion, + recommendedVersion: input.recommendedVersion, + checkedAt: input.checkedAt, + message: input.message, + notificationKey: input.notificationKey, + actions: input.actions, + }; +} + +function createGitHubCliAdvisory(input: { + readonly currentVersion: string | null; + readonly latestVersion: string | null; + readonly platform: NodeJS.Platform; + readonly checkedAt: string; + readonly canRunUpdate: boolean; +}): 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 }]; + + if ( + input.currentVersion !== null && + compareToolVersions(input.currentVersion, GH_SECURITY_VERSION) < 0 + ) { + const hasWindowsTerminalFlashRisk = + input.platform === "win32" && + compareToolVersions(input.currentVersion, GH_TERMINAL_FLASH_FIXED_VERSION) < 0; + return advisory({ + status: "recommended_update", + severity: "warning", + currentVersion: input.currentVersion, + 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.", + notificationKey: `github-cli:security:${GH_SECURITY_VERSION}`, + actions, + }); + } + + if ( + input.currentVersion !== null && + input.latestVersion !== null && + compareToolVersions(input.currentVersion, input.latestVersion) < 0 + ) { + return advisory({ + status: "behind_latest", + severity: "info", + currentVersion: input.currentVersion, + latestVersion: input.latestVersion, + recommendedVersion: input.latestVersion, + checkedAt: input.checkedAt, + message: "A newer GitHub CLI version is available for this environment.", + notificationKey: null, + actions, + }); + } + + if (input.currentVersion !== null && input.latestVersion !== null) { + return advisory({ + status: "current", + severity: "info", + currentVersion: input.currentVersion, + latestVersion: input.latestVersion, + recommendedVersion: null, + checkedAt: input.checkedAt, + message: null, + notificationKey: null, + actions: [], + }); + } + + return undefined; +} + +function createGitForWindowsAdvisory(input: { + readonly currentVersion: string | null; + readonly latestVersion: string | null; + readonly platform: NodeJS.Platform; + readonly checkedAt: string; + readonly canRunUpdate: boolean; +}): 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 }, + ]; + + if ( + input.currentVersion !== null && + compareToolVersions(input.currentVersion, GIT_FOR_WINDOWS_SECURITY_VERSION) < 0 + ) { + return advisory({ + status: "recommended_update", + severity: "warning", + currentVersion: input.currentVersion, + 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.", + notificationKey: `git-for-windows:security:${GIT_FOR_WINDOWS_SECURITY_VERSION}`, + actions, + }); + } + + if ( + input.currentVersion !== null && + input.latestVersion !== null && + compareToolVersions(input.currentVersion, input.latestVersion) < 0 + ) { + return advisory({ + status: "behind_latest", + severity: "info", + currentVersion: input.currentVersion, + latestVersion: input.latestVersion, + recommendedVersion: input.latestVersion, + checkedAt: input.checkedAt, + message: "A newer Git for Windows release is available.", + notificationKey: null, + actions, + }); + } + + if (input.currentVersion !== null && input.latestVersion !== null) { + return advisory({ + status: "current", + severity: "info", + currentVersion: input.currentVersion, + latestVersion: input.latestVersion, + recommendedVersion: null, + checkedAt: input.checkedAt, + message: null, + notificationKey: null, + actions: [], + }); + } + + return undefined; +} + +export function withSourceControlToolVersionAdvisory< + Item extends VcsDiscoveryItem | SourceControlProviderDiscoveryItem, +>(input: { + readonly item: Item; + readonly platform: NodeJS.Platform; + readonly latestVersionResolver: LatestVersionResolver; + readonly canRunUpdate?: boolean; +}): Effect.Effect { + if (input.item.status !== "available") { + return Effect.succeed(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) => { + const versionAdvisory = createGitHubCliAdvisory({ + currentVersion, + latestVersion, + platform: input.platform, + checkedAt, + canRunUpdate: input.canRunUpdate === true, + }); + return versionAdvisory ? ({ ...input.item, versionAdvisory } as Item) : input.item; + }), + Effect.catch(() => Effect.succeed(input.item)), + ); + } + + if (!("auth" in input.item) && input.item.kind === "git") { + if (input.platform !== "win32") { + return Effect.succeed(input.item); + } + + const currentVersion = parseGitVersion(versionLine); + return resolver("git-for-windows").pipe( + Effect.map((latestVersion) => { + const versionAdvisory = createGitForWindowsAdvisory({ + currentVersion, + latestVersion, + platform: input.platform, + checkedAt, + canRunUpdate: input.canRunUpdate === true, + }); + return versionAdvisory ? ({ ...input.item, versionAdvisory } as Item) : input.item; + }), + Effect.catch(() => Effect.succeed(input.item)), + ); + } + + return Effect.succeed(input.item); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 154f3f264..239eb90a5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -48,6 +48,7 @@ import { ProviderSubagentTranscriptError, ThreadId, type TerminalEvent, + SourceControlToolUpdateError, WS_METHODS, WsRpcGroup, } from "@threadlines/contracts"; @@ -125,6 +126,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlToolMaintenance from "./sourceControl/SourceControlToolMaintenance.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -263,6 +265,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const sourceControlToolMaintenance = + yield* SourceControlToolMaintenance.SourceControlToolMaintenance; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -1198,6 +1202,55 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateSourceControlTool]: (input) => + observeRpcEffect( + WS_METHODS.serverUpdateSourceControlTool, + Effect.gen(function* () { + const before = yield* sourceControlDiscovery.discover; + if ( + !SourceControlToolMaintenance.hasVerifiedSourceControlToolUpdateAction( + before, + input.target, + ) + ) { + 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.", + }); + } + const previousVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( + before, + input.target, + ); + + yield* sourceControlToolMaintenance.update(input); + + const discovery = yield* sourceControlDiscovery.discover; + const currentVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( + discovery, + input.target, + ); + if (currentVersion === null) { + 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.", + }); + } + + return { + target: input.target, + status: previousVersion === currentVersion ? "unchanged" : "succeeded", + previousVersion, + currentVersion, + discovery, + } as const; + }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverGetTraceDiagnostics]: (_input) => observeRpcEffect( WS_METHODS.serverGetTraceDiagnostics, @@ -2129,6 +2182,9 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide( + SourceControlToolMaintenance.layer.pipe(Layer.provide(VcsProcess.layer)), + ), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts new file mode 100644 index 000000000..7fd4b2ac0 --- /dev/null +++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts @@ -0,0 +1,42 @@ +import type { + SourceControlDiscoveryResult, + SourceControlToolVersionAdvisory, +} from "@threadlines/contracts"; + +import { sourceControlToolAdvisoryDismissalKey } from "../sourceControlToolAdvisoryDismissal"; + +export interface SourceControlToolUpdateWarning { + readonly label: string; + readonly advisory: SourceControlToolVersionAdvisory; + readonly dismissalKey: string; +} + +export function collectSourceControlToolUpdateWarnings(input: { + readonly discovery: SourceControlDiscoveryResult; + readonly environmentKey: string; +}): ReadonlyArray { + return [...input.discovery.versionControlSystems, ...input.discovery.sourceControlProviders] + .flatMap((item) => { + const advisory = item.versionAdvisory; + if ( + advisory?.status !== "recommended_update" || + advisory.severity !== "warning" || + advisory.notificationKey === null + ) { + return []; + } + + const dismissalKey = sourceControlToolAdvisoryDismissalKey({ + environmentKey: input.environmentKey, + notificationKey: advisory.notificationKey, + }); + return dismissalKey ? [{ label: item.label, advisory, dismissalKey }] : []; + }) + .sort((left, right) => left.dismissalKey.localeCompare(right.dismissalKey)); +} + +export function sourceControlToolUpdateWarningSetKey( + warnings: ReadonlyArray, +): string | null { + return warnings.length > 0 ? warnings.map((warning) => warning.dismissalKey).join("|") : null; +} diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx new file mode 100644 index 000000000..cb1ebdc38 --- /dev/null +++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx @@ -0,0 +1,104 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef } from "react"; + +import { useSourceControlDiscovery } from "../lib/sourceControlDiscoveryState"; +import { useDismissedSourceControlToolAdvisoryKeys } from "../sourceControlToolAdvisoryDismissal"; +import { useStore } from "../store"; +import { useActiveEnvironmentFirstRunSetupPending } from "./chat/firstRunSetupState"; +import { + collectSourceControlToolUpdateWarnings, + sourceControlToolUpdateWarningSetKey, +} from "./SourceControlToolUpdateLaunchNotification.logic"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +const seenSourceControlToolWarningSetKeys = new Set(); +type SourceControlToolWarningToastId = ReturnType; + +interface ActiveSourceControlToolWarningToast { + readonly key: string; + readonly toastId: SourceControlToolWarningToastId; +} + +export function SourceControlToolUpdateLaunchNotification() { + const navigate = useNavigate(); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); + const discovery = useSourceControlDiscovery({ environmentId: activeEnvironmentId }); + const firstRunSetupPending = useActiveEnvironmentFirstRunSetupPending(); + const activeToastRef = useRef(null); + const { dismissedNotificationKeys, dismissNotificationKeys } = + useDismissedSourceControlToolAdvisoryKeys(); + + const warnings = useMemo(() => { + if (!activeEnvironmentId || !discovery.data) { + return []; + } + return collectSourceControlToolUpdateWarnings({ + discovery: discovery.data, + environmentKey: `environment:${activeEnvironmentId}`, + }).filter((warning) => !dismissedNotificationKeys.has(warning.dismissalKey)); + }, [activeEnvironmentId, discovery.data, dismissedNotificationKeys]); + const warningSetKey = useMemo(() => sourceControlToolUpdateWarningSetKey(warnings), [warnings]); + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast && activeToast.key !== warningSetKey) { + toastManager.close(activeToast.toastId); + activeToastRef.current = null; + } + + if ( + warningSetKey === null || + firstRunSetupPending || + activeToastRef.current !== null || + seenSourceControlToolWarningSetKeys.has(warningSetKey) + ) { + return; + } + + seenSourceControlToolWarningSetKeys.add(warningSetKey); + const dismissalKeys = warnings.map((warning) => warning.dismissalKey); + const labels = warnings.map((warning) => warning.label).join(" and "); + const title = + warnings.length === 1 + ? `${warnings[0]!.label} update recommended` + : `${warnings.length} source control updates recommended`; + const description = + warnings.length === 1 + ? (warnings[0]!.advisory.message ?? "A source control tool update is recommended.") + : `${labels} should be updated for a known security or reliability issue.`; + + let toastId!: SourceControlToolWarningToastId; + const dismiss = () => { + dismissNotificationKeys(dismissalKeys); + if (activeToastRef.current?.toastId === toastId) { + activeToastRef.current = null; + } + }; + const openSettings = () => { + dismiss(); + toastManager.close(toastId); + void navigate({ to: "/settings/source-control" }); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "warning", + title, + description, + timeout: 0, + actionProps: { + children: "Settings", + onClick: openSettings, + }, + actionVariant: "outline", + data: { + hideCopyButton: true, + onClose: dismiss, + }, + }), + ); + activeToastRef.current = { key: warningSetKey, toastId }; + }, [dismissNotificationKeys, firstRunSetupPending, navigate, warningSetKey, warnings]); + + return null; +} diff --git a/apps/web/src/components/settings/CompactVersionAdvisory.tsx b/apps/web/src/components/settings/CompactVersionAdvisory.tsx new file mode 100644 index 000000000..df0a519df --- /dev/null +++ b/apps/web/src/components/settings/CompactVersionAdvisory.tsx @@ -0,0 +1,239 @@ +import type { EnvironmentId, SourceControlToolVersionAdvisory } from "@threadlines/contracts"; +import { + AlertCircleIcon, + ArrowUpCircleIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + LoaderIcon, +} from "lucide-react"; +import { useState } from "react"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { readLocalApi } from "../../localApi"; +import { cn } from "../../lib/utils"; +import { updateSourceControlTool } from "../../lib/sourceControlDiscoveryState"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +interface CompactVersionAdvisoryProps { + readonly advisory: SourceControlToolVersionAdvisory; + readonly environmentId: EnvironmentId | null | undefined; + readonly label: string; +} + +function openExternalUrl(url: string): void { + const api = readLocalApi(); + if (!api) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + + void api.shell.openExternal(url).catch(() => { + window.open(url, "_blank", "noopener,noreferrer"); + }); +} + +function advisoryTitle(advisory: SourceControlToolVersionAdvisory): string { + return advisory.status === "current" ? "Up to date" : "Update available"; +} + +export function CompactVersionAdvisory({ + advisory, + environmentId, + label, +}: CompactVersionAdvisoryProps) { + const [isUpdating, setIsUpdating] = useState(false); + const updateAction = advisory.actions.find((action) => action.kind === "runUpdate"); + const copyActionCandidate = advisory.actions.find((action) => action.kind === "copyCommand"); + const copyAction = copyActionCandidate?.kind === "copyCommand" ? copyActionCandidate : undefined; + const openActionCandidate = advisory.actions.find((action) => action.kind === "openUrl"); + const openAction = openActionCandidate?.kind === "openUrl" ? openActionCandidate : undefined; + const { copyToClipboard } = useCopyToClipboard<{ readonly label: string }>({ + onCopy: ({ label: actionLabel }) => { + toastManager.add({ + type: "success", + title: "Command copied", + description: actionLabel, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy command", + description: error.message, + }), + ); + }, + }); + const runUpdate = () => { + if (!updateAction || isUpdating) return; + setIsUpdating(true); + void updateSourceControlTool({ + ...(environmentId === undefined ? {} : { environmentId }), + target: updateAction.target, + }) + .then((result) => { + toastManager.add({ + type: result.status === "succeeded" ? "success" : "info", + title: result.status === "succeeded" ? `${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.", + }); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${label}`, + description: + error instanceof Error ? error.message : "The verified update command failed.", + }), + ); + }) + .finally(() => setIsUpdating(false)); + }; + + return ( + + + + + } + /> + +
+
+

+ {advisoryTitle(advisory)} +

+
+ {advisory.currentVersion ? ( +

+ Current + {advisory.currentVersion} +

+ ) : null} + {advisory.latestVersion ? ( +

+ Latest + {advisory.latestVersion} +

+ ) : null} + {advisory.recommendedVersion && + advisory.recommendedVersion !== advisory.latestVersion ? ( +

+ Security baseline + {advisory.recommendedVersion} +

+ ) : null} +
+ {advisory.message ? ( +

+ {advisory.message} +

+ ) : null} +
+ + {updateAction ? ( + + ) : null} + + {copyAction ? ( +
+ + + {copyAction.value} + + + + copyToClipboard(copyAction.value, { label: copyAction.label })} + aria-label={`Copy ${label} update command`} + > + + + } + /> + Copy command + +
+ ) : null} + + {openAction ? ( + + ) : null} + +

+ + {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."} +

+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 3436816ce..c9fbaca2d 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -36,6 +36,10 @@ import { __resetLocalApiForTests } from "../../localApi"; import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { useUiStateStore } from "../../uiStateStore"; +import { + collectSourceControlToolUpdateWarnings, + sourceControlToolUpdateWarningSetKey, +} from "../SourceControlToolUpdateLaunchNotification.logic"; import { ConnectionsSettings } from "./ConnectionsSettings"; import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; @@ -2442,10 +2446,12 @@ describe("SourceControlSettingsPanel discovery states", () => { function setSourceControlDiscoveryStub( discoverSourceControl: () => Promise, + updateSourceControlTool?: LocalApi["server"]["updateSourceControlTool"], ) { window.nativeApi = { server: { discoverSourceControl, + ...(updateSourceControlTool ? { updateSourceControlTool } : {}), }, } as LocalApi; } @@ -2517,6 +2523,133 @@ describe("SourceControlSettingsPanel discovery states", () => { await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); }); + it("runs a verified source control tool update only after Update now is clicked", async () => { + const discoveryResult: SourceControlDiscoveryResult = { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.55.0.windows.4"), + installHint: "Install Git.", + detail: Option.none(), + versionAdvisory: { + status: "behind_latest", + severity: "info", + currentVersion: "2.55.0.windows.4", + latestVersion: "2.56.0.windows.1", + recommendedVersion: "2.56.0.windows.1", + checkedAt: "2026-08-14T00:00:00.000Z", + message: "A newer Git for Windows release is available.", + notificationKey: null, + actions: [], + }, + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("gh version 2.92.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("octocat"), + host: Option.some("github.com"), + detail: Option.none(), + }, + versionAdvisory: { + status: "recommended_update", + severity: "warning", + currentVersion: "2.92.0", + latestVersion: "2.98.0", + recommendedVersion: "2.97.0", + checkedAt: "2026-08-14T00:00:00.000Z", + message: + "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.", + notificationKey: "github-cli:security:2.97.0", + actions: [ + { + label: "Update now", + kind: "runUpdate", + target: "github-cli", + }, + { + 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: "https://github.com/cli/cli/releases/latest", + }, + ], + }, + }, + ], + }; + const updateSourceControlTool = vi + .fn() + .mockResolvedValue({ + target: "github-cli", + status: "succeeded", + previousVersion: "2.92.0", + currentVersion: "2.98.0", + discovery: discoveryResult, + }); + setSourceControlDiscoveryStub(async () => discoveryResult, updateSourceControlTool); + + mounted = await renderWithTestRouter( + + + , + ); + + const advisoryButton = page.getByRole("button", { name: "GitHub update advisory" }); + await expect.element(advisoryButton).toBeInTheDocument(); + + await advisoryButton.click(); + + await expect.element(page.getByText("Update available")).toBeVisible(); + await expect.element(page.getByText("Latest")).toBeVisible(); + await expect + .element( + page.getByText( + "winget upgrade --id GitHub.cli --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity", + ), + ) + .toBeVisible(); + await expect.element(page.getByRole("button", { name: "Update now" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Open releases" })).toBeVisible(); + await expect + .element( + page.getByText( + "Threadlines runs only the verified WinGet package shown above after you click Update now. Windows may ask for permission.", + ), + ) + .toBeVisible(); + expect(updateSourceControlTool).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Update now" }).click(); + expect(updateSourceControlTool).toHaveBeenCalledWith({ target: "github-cli" }); + + const warnings = collectSourceControlToolUpdateWarnings({ + discovery: discoveryResult, + environmentKey: "environment:test-host", + }); + expect(warnings).toHaveLength(1); + expect(sourceControlToolUpdateWarningSetKey(warnings)).toBe( + "environment:test-host:github-cli:security:2.97.0", + ); + }); + 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 b87a864ac..34449a255 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useState, type ReactNode } from "react"; import type { + EnvironmentId, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, @@ -26,6 +27,7 @@ import { refreshSourceControlDiscovery, useSourceControlDiscovery, } from "../../lib/sourceControlDiscoveryState"; +import { useStore } from "../../store"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -69,6 +71,7 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; +import { CompactVersionAdvisory } from "./CompactVersionAdvisory"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], @@ -237,9 +240,11 @@ function itemSummary({ function DiscoveryItemRow({ item, + environmentId, children, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; + readonly environmentId?: EnvironmentId | null; readonly children?: ReactNode; }) { const version = optionLabel(item.version); @@ -266,6 +271,14 @@ function DiscoveryItemRow({ {item.label} {version ? {version} : null} + {item.versionAdvisory?.status === "behind_latest" || + item.versionAdvisory?.status === "recommended_update" ? ( + + ) : null} {isVcsNotReady(item) ? ( Coming Soon @@ -679,13 +692,14 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const discovery = useSourceControlDiscovery(); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); + const discovery = useSourceControlDiscovery({ environmentId: activeEnvironmentId }); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; const isInitialScanPending = discovery.isPending && discovery.data === null; const handleScan = () => { - void refreshSourceControlDiscovery(); + void refreshSourceControlDiscovery({ environmentId: activeEnvironmentId }); }; const scanButton = ( @@ -720,7 +734,11 @@ export function SourceControlSettingsPanel() { {result.versionControlSystems.length > 0 ? ( {result.versionControlSystems.map((item) => ( - + {item.kind === "git" ? : undefined} ))} @@ -733,7 +751,11 @@ export function SourceControlSettingsPanel() { headerAction={result.versionControlSystems.length === 0 ? scanButton : null} > {result.sourceControlProviders.map((item) => ( - + ))} ) : null} diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts index a13b249d1..38095ae00 100644 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -6,7 +6,13 @@ import { getSourceControlDiscoveryTargetKey, sourceControlDiscoveryStateAtom, } from "@threadlines/client-runtime"; -import { EnvironmentId, type SourceControlDiscoveryResult } from "@threadlines/contracts"; +import { + EnvironmentId, + type LocalApi, + type SourceControlDiscoveryResult, + type SourceControlToolUpdateInput, + type SourceControlToolUpdateResult, +} from "@threadlines/contracts"; import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; @@ -23,6 +29,28 @@ interface SourceControlDiscoveryTargetInput { readonly environmentId?: EnvironmentId | null; } +function readSourceControlServer( + input?: SourceControlDiscoveryTargetInput, +): LocalApi["server"] | null { + const target = sourceControlDiscoveryTarget(input); + if (target.key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { + const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; + const primaryConnection = primaryEnvironmentId + ? readEnvironmentConnection(primaryEnvironmentId) + : null; + if (primaryConnection) return primaryConnection.client.server; + try { + return readLocalApi()?.server ?? null; + } catch { + return null; + } + } + + return target.key + ? (readEnvironmentConnection(EnvironmentId.make(target.key))?.client.server ?? null) + : null; +} + function sourceControlDiscoveryTarget( input?: SourceControlDiscoveryTargetInput, ): SourceControlDiscoveryTarget { @@ -37,28 +65,12 @@ function sourceControlDiscoveryTarget( export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ getRegistry: () => appAtomRegistry, - getClient: (key) => { - if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { - const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; - const primaryConnection = primaryEnvironmentId - ? readEnvironmentConnection(primaryEnvironmentId) - : null; - if (primaryConnection) { - return primaryConnection.client.server; - } - try { - return readLocalApi()?.server ?? null; - } catch { - return null; - } - } - const environmentId = EnvironmentId.make(key); - const connection = readEnvironmentConnection(environmentId); - if (connection) { - return connection.client.server; - } - return null; - }, + getClient: (key) => + readSourceControlServer( + key === SOURCE_CONTROL_DISCOVERY_TARGET.key + ? undefined + : { environmentId: EnvironmentId.make(key) }, + ), }); const sourceControlDiscoveryAutoRefreshAtom = Atom.family((targetKey: string) => @@ -80,6 +92,27 @@ export function refreshSourceControlDiscovery( return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); } +export function refreshSourceControlDiscoveryAfterReconnect( + input?: SourceControlDiscoveryTargetInput, +): Promise { + return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input), undefined, { + force: true, + }); +} + +export async function updateSourceControlTool( + input: SourceControlDiscoveryTargetInput & SourceControlToolUpdateInput, +): Promise { + const server = readSourceControlServer(input); + if (!server) { + throw new Error("The selected server environment is not connected."); + } + + const result = await server.updateSourceControlTool({ target: input.target }); + sourceControlDiscoveryManager.storeResult(sourceControlDiscoveryTarget(input), result.discovery); + return result; +} + export function getSourceControlDiscoverySnapshot( input?: SourceControlDiscoveryTargetInput, ): SourceControlDiscoveryState { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 2f7b7be7b..abed0ea11 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -117,6 +117,8 @@ const rpcClientMock = { upsertKeybinding: vi.fn(), getSettings: vi.fn(), updateSettings: vi.fn(), + discoverSourceControl: vi.fn(), + updateSourceControlTool: vi.fn(), subscribeConfig: vi.fn(), subscribeLifecycle: vi.fn(), subscribeAuthAccess: vi.fn(), @@ -704,6 +706,27 @@ describe("wsApi", () => { }); }); + it("forwards typed source control tool updates to the RPC client", async () => { + const result = { + target: "github-cli" as const, + status: "succeeded" as const, + previousVersion: "2.92.0", + currentVersion: "2.98.0", + discovery: { versionControlSystems: [], sourceControlProviders: [] }, + }; + rpcClientMock.server.updateSourceControlTool.mockResolvedValue(result); + const { createLocalApi } = await import("./localApi"); + + const api = createLocalApi(rpcClientMock as never); + + await expect(api.server.updateSourceControlTool({ target: "github-cli" })).resolves.toEqual( + result, + ); + expect(rpcClientMock.server.updateSourceControlTool).toHaveBeenCalledWith({ + target: "github-cli", + }); + }); + it("forwards context menu metadata to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2a3d233d4..3d078cf75 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -141,6 +141,8 @@ function createBrowserLocalApi(resolveRpcClient?: () => WsRpcClient | null): Loc getSettings: () => withServer((server) => server.getSettings()), updateSettings: (patch) => withServer((server) => server.updateSettings(patch)), discoverSourceControl: () => withServer((server) => server.discoverSourceControl()), + updateSourceControlTool: (input) => + withServer((server) => server.updateSourceControlTool(input)), getTraceDiagnostics: () => withServer((server) => server.getTraceDiagnostics()), getProcessDiagnostics: () => withServer((server) => server.getProcessDiagnostics()), getProcessResourceHistory: (input) => diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 57b8fb05d..d4701d53f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { SourceControlToolUpdateLaunchNotification } from "../components/SourceControlToolUpdateLaunchNotification"; import { SavedEnvironmentConnectionOverlay } from "../components/SavedEnvironmentConnectionOverlay"; import { WebSocketConnectionCoordinator, @@ -33,6 +34,7 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { refreshSourceControlDiscoveryAfterReconnect } from "../lib/sourceControlDiscoveryState"; import { useSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -150,6 +152,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? ( {appShell} @@ -490,6 +493,9 @@ function EventRouter() { updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); + void refreshSourceControlDiscoveryAfterReconnect({ + environmentId: payload.environment.environmentId, + }); void (async () => { await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); if (disposedRef.current) { diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b6788c5c1..d81442f1e 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -212,6 +212,9 @@ export interface WsRpcClient { readonly discoverSourceControl: RpcUnaryNoArgMethod< typeof WS_METHODS.serverDiscoverSourceControl >; + readonly updateSourceControlTool: RpcUnaryMethod< + typeof WS_METHODS.serverUpdateSourceControlTool + >; readonly getTraceDiagnostics: RpcUnaryNoArgMethod; readonly getProcessDiagnostics: RpcUnaryNoArgMethod< typeof WS_METHODS.serverGetProcessDiagnostics @@ -581,6 +584,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), discoverSourceControl: () => transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), + updateSourceControlTool: (input) => + transport.request((client) => client[WS_METHODS.serverUpdateSourceControlTool](input)), getTraceDiagnostics: () => transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({}).pipe(Effect.withTracerEnabled(false)), diff --git a/apps/web/src/sourceControlToolAdvisoryDismissal.ts b/apps/web/src/sourceControlToolAdvisoryDismissal.ts new file mode 100644 index 000000000..70b2d3ed0 --- /dev/null +++ b/apps/web/src/sourceControlToolAdvisoryDismissal.ts @@ -0,0 +1,108 @@ +import { useCallback, useMemo } from "react"; +import * as Schema from "effect/Schema"; + +import { + getLocalStorageItemWithLegacyKeys, + setLocalStorageItem, + useLocalStorage, +} from "./hooks/useLocalStorage"; + +export const SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEY = + "threadlines:source-control-tool-advisory-dismissals:v1"; +const LEGACY_SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEYS = [] as const; + +const SourceControlToolAdvisoryDismissalsSchema = Schema.Struct({ + keys: Schema.Array(Schema.String), +}); + +type SourceControlToolAdvisoryDismissals = typeof SourceControlToolAdvisoryDismissalsSchema.Type; + +function readDismissals(): SourceControlToolAdvisoryDismissals { + try { + return ( + getLocalStorageItemWithLegacyKeys( + SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEY, + LEGACY_SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEYS, + SourceControlToolAdvisoryDismissalsSchema, + ) ?? { keys: [] } + ); + } catch { + return { keys: [] }; + } +} + +function writeDismissals(document: SourceControlToolAdvisoryDismissals): void { + try { + setLocalStorageItem( + SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEY, + document, + SourceControlToolAdvisoryDismissalsSchema, + ); + } catch { + // Best-effort UI state; storage failure should not block advisory display. + } +} + +export function sourceControlToolAdvisoryDismissalKey(input: { + readonly environmentKey: string; + readonly notificationKey: string | null | undefined; +}): string | null { + const notificationKey = input.notificationKey?.trim(); + const environmentKey = input.environmentKey.trim(); + if (!notificationKey || !environmentKey) { + return null; + } + return `${environmentKey}:${notificationKey}`; +} + +export function isSourceControlToolAdvisoryDismissed( + dismissalKey: string | null | undefined, +): boolean { + if (!dismissalKey) return false; + return readDismissals().keys.includes(dismissalKey); +} + +export function dismissSourceControlToolAdvisory(dismissalKey: string | null | undefined): void { + const trimmedKey = dismissalKey?.trim(); + if (!trimmedKey) return; + const document = readDismissals(); + if (document.keys.includes(trimmedKey)) return; + writeDismissals({ keys: [...document.keys, trimmedKey] }); +} + +export function useDismissedSourceControlToolAdvisoryKeys() { + const [dismissals, setDismissals] = useLocalStorage( + SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEY, + { keys: [] }, + SourceControlToolAdvisoryDismissalsSchema, + { legacyKeys: LEGACY_SOURCE_CONTROL_TOOL_ADVISORY_DISMISSALS_STORAGE_KEYS }, + ); + const dismissedKeys = dismissals.keys; + const dismissedKeySet = useMemo(() => new Set(dismissedKeys), [dismissedKeys]); + + const dismissNotificationKeys = useCallback( + (keys: ReadonlyArray) => { + const newKeys = keys + .map((key) => key?.trim()) + .filter( + (key): key is string => key !== undefined && key.length > 0 && !dismissedKeySet.has(key), + ); + if (newKeys.length === 0) { + return; + } + setDismissals({ keys: [...new Set([...dismissedKeys, ...newKeys])] }); + }, + [dismissedKeySet, dismissedKeys, setDismissals], + ); + + const dismissNotificationKey = useCallback( + (key: string | null | undefined) => dismissNotificationKeys([key]), + [dismissNotificationKeys], + ); + + return { + dismissedNotificationKeys: dismissedKeySet, + dismissNotificationKey, + dismissNotificationKeys, + }; +} diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts index 872a6edd2..28b698d2d 100644 --- a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts +++ b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts @@ -1,5 +1,6 @@ import { assert, beforeEach, it } from "vite-plus/test"; import type { SourceControlDiscoveryResult } from "@threadlines/contracts"; +import * as Option from "effect/Option"; import { AtomRegistry } from "effect/unstable/reactivity"; import { @@ -12,6 +13,22 @@ const EMPTY_RESULT: SourceControlDiscoveryResult = { sourceControlProviders: [], }; +const GIT_AVAILABLE_RESULT: SourceControlDiscoveryResult = { + versionControlSystems: [ + { + 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(), + }, + ], + sourceControlProviders: [], +}; + function unresolvedDiscovery() { throw new Error("Discovery resolver was not initialized."); } @@ -43,6 +60,21 @@ it("stores refreshed discovery data in an atom snapshot", async () => { }); }); +it("stores discovery returned by a related server operation", () => { + const manager = createSourceControlDiscoveryManager({ + getRegistry: () => registry, + getClient: () => null, + }); + + manager.storeResult({ key: "primary" }, EMPTY_RESULT); + + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { + data: EMPTY_RESULT, + error: null, + isPending: false, + }); +}); + it("deduplicates in-flight discovery refreshes by target key", async () => { let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; let calls = 0; @@ -79,6 +111,32 @@ it("deduplicates in-flight discovery refreshes by target key", async () => { }); }); +it("forces a fresh probe after reconnect without letting the old result overwrite it", async () => { + const resolvers: Array<(result: SourceControlDiscoveryResult) => void> = []; + const manager = createSourceControlDiscoveryManager({ + getRegistry: () => registry, + getClient: () => ({ + discoverSourceControl: () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + }), + }); + + const beforeReconnect = manager.refresh({ key: "primary" }); + const afterReconnect = manager.refresh({ key: "primary" }, undefined, { force: true }); + + assert.notStrictEqual(beforeReconnect, afterReconnect); + assert.strictEqual(resolvers.length, 2); + + resolvers[1]!(GIT_AVAILABLE_RESULT); + await afterReconnect; + resolvers[0]!(EMPTY_RESULT); + await beforeReconnect; + + assert.strictEqual(manager.getSnapshot({ key: "primary" }).data, GIT_AVAILABLE_RESULT); +}); + it("keeps the previous snapshot when refresh fails", async () => { let shouldFail = false; const manager = createSourceControlDiscoveryManager({ diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.ts b/packages/client-runtime/src/sourceControlDiscoveryState.ts index 2b4636bc0..546ae3720 100644 --- a/packages/client-runtime/src/sourceControlDiscoveryState.ts +++ b/packages/client-runtime/src/sourceControlDiscoveryState.ts @@ -73,8 +73,14 @@ export interface SourceControlDiscoveryManagerConfig { readonly getClient: (key: string) => SourceControlDiscoveryClient | null; } +export interface SourceControlDiscoveryRefreshOptions { + /** Start a new probe even if an older request for this target is still pending. */ + readonly force?: boolean; +} + export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) { const refreshInFlight = new Map>(); + const refreshGeneration = new Map(); /* -- Atom helpers -------------------------------------------------- */ @@ -137,6 +143,7 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove function refresh( target: SourceControlDiscoveryTarget, client?: SourceControlDiscoveryClient, + options?: SourceControlDiscoveryRefreshOptions, ): Promise { const targetKey = getSourceControlDiscoveryTargetKey(target); if (targetKey === null) { @@ -144,10 +151,16 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove } const existing = refreshInFlight.get(targetKey); - if (existing) { + if (existing && options?.force !== true) { return existing; } + if (options?.force === true) { + refreshGeneration.set(targetKey, (refreshGeneration.get(targetKey) ?? 0) + 1); + refreshInFlight.delete(targetKey); + } + const generation = refreshGeneration.get(targetKey) ?? 0; + const resolvedClient = client ?? config.getClient(targetKey); if (!resolvedClient) { const error = new Error("Source control discovery client is unavailable."); @@ -158,15 +171,23 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove markPending(targetKey); const promise = resolvedClient.discoverSourceControl().then( (result) => { - setData(targetKey, result); + if ((refreshGeneration.get(targetKey) ?? 0) === generation) { + setData(targetKey, result); + } return result; }, (error: unknown) => { - setError(targetKey, error); + if ((refreshGeneration.get(targetKey) ?? 0) === generation) { + setError(targetKey, error); + } return getSnapshot(target).data; }, ); - const tracked = promise.finally(() => refreshInFlight.delete(targetKey)); + const tracked = promise.finally(() => { + if (refreshInFlight.get(targetKey) === tracked) { + refreshInFlight.delete(targetKey); + } + }); refreshInFlight.set(targetKey, tracked); return tracked; } @@ -186,6 +207,18 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); } + /** Store a discovery result already returned by another server operation. */ + function storeResult( + target: SourceControlDiscoveryTarget, + result: SourceControlDiscoveryResult, + ): void { + const targetKey = getSourceControlDiscoveryTargetKey(target); + if (targetKey === null) return; + refreshGeneration.set(targetKey, (refreshGeneration.get(targetKey) ?? 0) + 1); + refreshInFlight.delete(targetKey); + setData(targetKey, result); + } + /** * Clear in-flight refresh tracking and reset every known discovery atom. * Primarily used by tests and runtime teardown. @@ -193,6 +226,7 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove function reset(): void { refreshInFlight.clear(); for (const key of knownSourceControlDiscoveryKeys) { + refreshGeneration.set(key, (refreshGeneration.get(key) ?? 0) + 1); setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); } knownSourceControlDiscoveryKeys.clear(); @@ -201,6 +235,7 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove return { refresh, getSnapshot, + storeResult, reset, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3ed6eae82..363d5b2fa 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -182,6 +182,8 @@ import type { SourceControlPublishRepositoryResult, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlToolUpdateInput, + SourceControlToolUpdateResult, } from "./sourceControl.ts"; export interface ContextMenuItem { @@ -1054,6 +1056,9 @@ export interface LocalApi { getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; discoverSourceControl: () => Promise; + updateSourceControlTool: ( + input: SourceControlToolUpdateInput, + ) => Promise; getTraceDiagnostics: () => Promise; getProcessDiagnostics: () => Promise; getProcessResourceHistory: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dcbb4034b..6eacb1fe7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -228,6 +228,9 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlToolUpdateError, + SourceControlToolUpdateInput, + SourceControlToolUpdateResult, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -320,6 +323,7 @@ export const WS_METHODS = { serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", + serverUpdateSourceControlTool: "server.updateSourceControlTool", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", @@ -467,6 +471,15 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov success: SourceControlDiscoveryResult, }); +export const WsServerUpdateSourceControlToolRpc = Rpc.make( + WS_METHODS.serverUpdateSourceControlTool, + { + payload: SourceControlToolUpdateInput, + success: SourceControlToolUpdateResult, + error: SourceControlToolUpdateError, + }, +); + export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { payload: Schema.Struct({}), success: ServerTraceDiagnosticsResult, @@ -1130,6 +1143,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, + WsServerUpdateSourceControlToolRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 6ff6c5d0d..b3e65395b 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { IsoDateTime, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ @@ -137,6 +137,50 @@ export const SourceControlProviderAuth = Schema.Struct({ }); export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; +export const SourceControlToolVersionAdvisoryStatus = Schema.Literals([ + "unknown", + "current", + "behind_latest", + "recommended_update", +]); +export type SourceControlToolVersionAdvisoryStatus = + typeof SourceControlToolVersionAdvisoryStatus.Type; + +export const SourceControlToolVersionAdvisorySeverity = Schema.Literals(["info", "warning"]); +export type SourceControlToolVersionAdvisorySeverity = + typeof SourceControlToolVersionAdvisorySeverity.Type; + +export const SourceControlToolUpdateTarget = Schema.Literals(["github-cli", "git"]); +export type SourceControlToolUpdateTarget = typeof SourceControlToolUpdateTarget.Type; + +export const SourceControlToolVersionAdvisoryAction = Schema.Union([ + Schema.Struct({ + label: TrimmedNonEmptyString, + kind: Schema.Literals(["copyCommand", "openUrl"]), + value: TrimmedNonEmptyString, + }), + Schema.Struct({ + label: TrimmedNonEmptyString, + kind: Schema.Literal("runUpdate"), + target: SourceControlToolUpdateTarget, + }), +]); +export type SourceControlToolVersionAdvisoryAction = + typeof SourceControlToolVersionAdvisoryAction.Type; + +export const SourceControlToolVersionAdvisory = Schema.Struct({ + status: SourceControlToolVersionAdvisoryStatus, + severity: SourceControlToolVersionAdvisorySeverity, + currentVersion: Schema.NullOr(TrimmedNonEmptyString), + latestVersion: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.NullOr(TrimmedNonEmptyString), + checkedAt: Schema.NullOr(IsoDateTime), + message: Schema.NullOr(TrimmedNonEmptyString), + notificationKey: Schema.NullOr(TrimmedNonEmptyString), + actions: Schema.Array(SourceControlToolVersionAdvisoryAction), +}); +export type SourceControlToolVersionAdvisory = typeof SourceControlToolVersionAdvisory.Type; + const SourceControlDiscoverySharedFields = { label: TrimmedNonEmptyString, executable: Schema.optional(TrimmedNonEmptyString), @@ -144,6 +188,7 @@ const SourceControlDiscoverySharedFields = { version: Schema.Option(TrimmedNonEmptyString), installHint: TrimmedNonEmptyString, detail: Schema.Option(TrimmedNonEmptyString), + versionAdvisory: Schema.optionalKey(SourceControlToolVersionAdvisory), } as const; export const VcsDiscoveryItem = Schema.Struct({ @@ -166,6 +211,32 @@ export const SourceControlDiscoveryResult = Schema.Struct({ }); export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.Type; +export const SourceControlToolUpdateInput = Schema.Struct({ + target: SourceControlToolUpdateTarget, +}); +export type SourceControlToolUpdateInput = typeof SourceControlToolUpdateInput.Type; + +export const SourceControlToolUpdateResult = Schema.Struct({ + target: SourceControlToolUpdateTarget, + status: Schema.Literals(["succeeded", "unchanged"]), + previousVersion: Schema.NullOr(TrimmedNonEmptyString), + currentVersion: Schema.NullOr(TrimmedNonEmptyString), + discovery: SourceControlDiscoveryResult, +}); +export type SourceControlToolUpdateResult = typeof SourceControlToolUpdateResult.Type; + +export class SourceControlToolUpdateError extends Schema.TaggedErrorClass()( + "SourceControlToolUpdateError", + { + target: SourceControlToolUpdateTarget, + reason: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return this.reason; + } +} + export class SourceControlProviderError extends Schema.TaggedErrorClass()( "SourceControlProviderError", { diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 922e985b0..99ac1b01e 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { constants } from "node:fs"; import { extractPathFromShellOutput, @@ -313,10 +314,19 @@ describe("resolveKnownWindowsCliDirs", () => { resolveKnownWindowsCliDirs({ APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + ProgramFiles: "C:\\Program Files", + "ProgramFiles(x86)": "C:\\Program Files (x86)", USERPROFILE: "C:\\Users\\testuser", }), ).toEqual([ + "C:\\Program Files\\Git\\cmd", + "C:\\Program Files\\GitHub CLI", + "C:\\Program Files (x86)\\Git\\cmd", + "C:\\Program Files (x86)\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", @@ -346,6 +356,21 @@ describe("resolveCommandPath", () => { }), ).toBeNull(); }); + + it("recognizes a launchable Windows App Execution Alias when stat is denied", () => { + expect( + resolveCommandPath("winget", { + platform: "win32", + env: { PATH: "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps" }, + fileSystem: { + isFile: () => { + throw new Error("EACCES"); + }, + canAccess: (filePath, mode) => filePath.endsWith("winget.EXE") && mode === constants.F_OK, + }, + }), + ).toBe("C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps\\winget.EXE"); + }); }); describe("resolveWindowsEnvironment", () => { @@ -374,6 +399,9 @@ describe("resolveWindowsEnvironment", () => { ).toEqual({ PATH: [ "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", @@ -424,6 +452,9 @@ describe("resolveWindowsEnvironment", () => { "C:\\Profile\\Node", "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 22b877a89..3077f0818 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -2,7 +2,7 @@ import * as NodeOS from "node:os"; import { execFileSync } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { join as joinHostPath, posix as PosixPath, win32 as WindowsPath } from "node:path"; const PATH_CAPTURE_START = "__THREADLINES_PATH_START__"; const PATH_CAPTURE_END = "__THREADLINES_PATH_END__"; @@ -20,8 +20,22 @@ type ExecFileSyncLike = ( export interface CommandAvailabilityOptions { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; + readonly fileSystem?: CommandFileSystem; } +export interface CommandFileSystem { + readonly isFile: (filePath: string) => boolean; + readonly canAccess: (filePath: string, mode: number) => boolean; +} + +const defaultCommandFileSystem: CommandFileSystem = { + isFile: (filePath) => statSync(filePath).isFile(), + canAccess: (filePath, mode) => { + accessSync(filePath, mode); + return true; + }, +}; + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -337,13 +351,17 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray 0 ? Array.from(new Set(parsed)) : fallback; } +function pathForPlatform(platform: NodeJS.Platform) { + return platform === "win32" ? WindowsPath : PosixPath; +} + function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, ): ReadonlyArray { if (platform !== "win32") return [command]; - const extension = extname(command); + const extension = pathForPlatform(platform).extname(command); const normalizedExtension = extension.toUpperCase(); if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { @@ -359,8 +377,8 @@ function resolveCommandCandidates( const candidates: string[] = []; for (const candidateExtension of windowsPathExtensions) { - candidates.push(`${command}${candidateExtension}`); candidates.push(`${command}${candidateExtension.toLowerCase()}`); + candidates.push(`${command}${candidateExtension}`); } return Array.from(new Set(candidates)); } @@ -369,19 +387,31 @@ function isExecutableFile( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, + fileSystem: CommandFileSystem, ): boolean { try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; + if (!fileSystem.isFile(filePath)) return false; if (platform === "win32") { - const extension = extname(filePath); + const extension = pathForPlatform(platform).extname(filePath); if (extension.length === 0) return false; return windowsPathExtensions.includes(extension.toUpperCase()); } - accessSync(filePath, constants.X_OK); - return true; + return fileSystem.canAccess(filePath, constants.X_OK); } catch { - return false; + if (platform !== "win32") return false; + + // Windows App Execution Aliases (including winget.exe) are launchable reparse points, + // but Node's stat call can fail with EACCES. F_OK still reflects whether Windows can + // resolve the alias, so accept that narrower fallback for executable extensions. + const extension = pathForPlatform(platform).extname(filePath); + if (extension.length === 0 || !windowsPathExtensions.includes(extension.toUpperCase())) { + return false; + } + try { + return fileSystem.canAccess(filePath, constants.F_OK); + } catch { + return false; + } } } @@ -391,12 +421,18 @@ export function resolveCommandPath( ): string | null { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; + const fileSystem = options.fileSystem ?? defaultCommandFileSystem; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + // The default filesystem always uses the host's path syntax. Platform overrides are + // also used by cross-platform callers and tests against real host temp directories. + // An injected filesystem models the target platform instead (for example, Windows + // App Execution Alias probing), so its candidate paths use the target path syntax. + const joinPath = options.fileSystem ? pathForPlatform(platform).join : joinHostPath; if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { - if (isExecutableFile(candidate, platform, windowsPathExtensions)) { + if (isExecutableFile(candidate, platform, windowsPathExtensions, fileSystem)) { return candidate; } } @@ -412,8 +448,8 @@ export function resolveCommandPath( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - const candidatePath = join(pathEntry, candidate); - if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + const candidatePath = joinPath(pathEntry, candidate); + if (isExecutableFile(candidatePath, platform, windowsPathExtensions, fileSystem)) { return candidatePath; } } @@ -431,11 +467,23 @@ export function isCommandAvailable( export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); const localAppData = env.LOCALAPPDATA?.trim(); + const programFiles = env.ProgramFiles?.trim(); + const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); const userProfile = env.USERPROFILE?.trim(); return [ + ...(programFiles ? [`${programFiles}\\Git\\cmd`, `${programFiles}\\GitHub CLI`] : []), + ...(programFilesX86 ? [`${programFilesX86}\\Git\\cmd`, `${programFilesX86}\\GitHub CLI`] : []), ...(appData ? [`${appData}\\npm`] : []), - ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData + ? [ + `${localAppData}\\Microsoft\\WindowsApps`, + `${localAppData}\\Programs\\Git\\cmd`, + `${localAppData}\\Programs\\GitHub CLI`, + `${localAppData}\\Programs\\nodejs`, + `${localAppData}\\Volta\\bin`, + ] + : []), ...(localAppData ? [`${localAppData}\\pnpm`] : []), ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), ];