From 291a514f10fec93cebe2156c8de90180cc74c573 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:32:45 -0400 Subject: [PATCH 1/5] fix(setup): complete Windows first-run setup --- .../src/shell/DesktopShellEnvironment.test.ts | 3 + .../src/shell/DesktopShellEnvironment.ts | 20 +- .../src/provider/Drivers/ClaudeDriver.ts | 27 ++- .../src/provider/Drivers/ClaudeHome.test.ts | 8 + .../server/src/provider/Drivers/ClaudeHome.ts | 13 ++ .../src/provider/Drivers/CodexDriver.ts | 41 +++- .../ProviderInstanceEnvironment.test.ts | 23 +- .../provider/ProviderInstanceEnvironment.ts | 8 + .../makeManagedServerProvider.test.ts | 26 +++ .../src/provider/makeManagedServerProvider.ts | 4 +- .../src/provider/providerMaintenance.test.ts | 66 ++++++ .../src/provider/providerMaintenance.ts | 60 +++-- .../providerMaintenanceCommandCoordinator.ts | 30 +-- .../providerMaintenanceRunner.test.ts | 25 ++- .../src/provider/providerMaintenanceRunner.ts | 13 +- apps/server/src/server.ts | 11 +- .../src/sourceControl/GitHubAuth.test.ts | 212 ++++++++++++++++++ apps/server/src/sourceControl/GitHubAuth.ts | 198 ++++++++++++++++ .../src/sourceControl/GitHubCli.test.ts | 117 ++++++---- apps/server/src/sourceControl/GitHubCli.ts | 60 +++-- .../SourceControlDiscovery.test.ts | 90 ++++++++ .../sourceControl/SourceControlDiscovery.ts | 53 ++--- .../SourceControlProviderDiscovery.ts | 92 ++++---- .../SourceControlToolMaintenance.test.ts | 91 +++++--- .../SourceControlToolMaintenance.ts | 184 ++++++++++----- apps/server/src/ws.ts | 81 +++++-- .../chat/FirstRunSetupCard.browser.tsx | 143 +++++++++++- .../src/components/chat/FirstRunSetupCard.tsx | 112 ++++++++- .../settings/CompactVersionAdvisory.tsx | 89 ++++++-- .../settings/GitHubSignInAction.tsx | 104 +++++++++ .../settings/SettingsPanels.browser.tsx | 104 ++++++++- .../settings/SourceControlSettings.tsx | 23 +- .../src/lib/sourceControlDiscoveryState.ts | 74 +++++- apps/web/src/localApi.ts | 3 + apps/web/src/rpc/wsRpcClient.ts | 11 + packages/client-runtime/src/index.ts | 1 + .../src/sourceControlSetupState.test.ts | 90 ++++++++ .../src/sourceControlSetupState.ts | 154 +++++++++++++ packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 23 ++ packages/contracts/src/sourceControl.ts | 22 ++ packages/shared/src/shell.test.ts | 54 +++++ packages/shared/src/shell.ts | 47 +++- 43 files changed, 2280 insertions(+), 335 deletions(-) create mode 100644 apps/server/src/sourceControl/GitHubAuth.test.ts create mode 100644 apps/server/src/sourceControl/GitHubAuth.ts create mode 100644 apps/web/src/components/settings/GitHubSignInAction.tsx create mode 100644 packages/client-runtime/src/sourceControlSetupState.test.ts create mode 100644 packages/client-runtime/src/sourceControlSetupState.ts diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9e4111e3e..84f5cce89 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -249,13 +249,16 @@ describe("DesktopShellEnvironment", () => { "C:\\Windows\\System32", "C:\\Program Files\\Git\\cmd", "C:\\Program Files\\GitHub CLI", + "C:\\Program Files\\nodejs", "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\\Programs\\OpenAI\\Codex\\bin", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 281d3e7a0..7e1c58c33 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -5,7 +5,10 @@ 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 { + buildWindowsEnvironmentCaptureCommand, + resolveKnownWindowsCliDirs, +} from "@threadlines/shared/shell"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -120,19 +123,6 @@ const capturePosixEnvironmentCommand = (names: ReadonlyArray) => }) .join("; "); -const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => - [ - "$ErrorActionPreference = 'Stop'", - ...names.flatMap((name) => { - return [ - `Write-Output '${startMarker(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, - "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", - `Write-Output '${endMarker(name)}'`, - ]; - }), - ].join("; "); - const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { const environment: EnvironmentPatch = {}; @@ -219,7 +209,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), "-NonInteractive", "-Command", - captureWindowsEnvironmentCommand(names), + buildWindowsEnvironmentCaptureCommand(names), ]; for (const command of WINDOWS_SHELL_CANDIDATES) { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 6fc79c0d0..661434a3a 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -51,10 +51,14 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, + makeWindowsNativeInstaller, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; @@ -253,6 +257,12 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@anthropic-ai/claude-code", homebrewFormula: "claude-code", + nativeInstall: { + win32: makeWindowsNativeInstaller({ + url: "https://claude.ai/install.ps1", + lockKey: "claude-native-verified-win32", + }), + }, nativeUpdate: { executable: "claude", args: ["update"], @@ -322,7 +332,7 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + let maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, platform: process.platform, @@ -380,8 +390,17 @@ export const ClaudeDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), + get maintenanceCapabilities() { + return maintenanceCapabilities; + }, + getSettings: Effect.gen(function* () { + refreshProviderInstanceEnvironment(environment, processEnv); + maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + return effectiveConfig; + }), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index bb9587a71..66a527ba6 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -14,6 +14,14 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { + it.effect("uses refreshed PATH when a retained environment is passed to a new process", () => + Effect.gen(function* () { + const baseEnv = { PATH: "/old/bin" }; + const environment = yield* makeClaudeEnvironment({ homePath: "" }, baseEnv); + baseEnv.PATH = "/new/bin"; + expect({ ...environment }.PATH).toBe("/new/bin"); + }), + ); it.effect("uses the process home when no Claude home override is configured", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 4372582d0..f3e10bc7d 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -41,6 +41,19 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function baseEnv: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const environment: NodeJS.ProcessEnv = { ...baseEnv }; + // Adapters retain this environment across turns. Read the driver's refreshed + // PATH at spawn time so a newly installed CLI works without rebuilding it. + if (process.platform === "win32") { + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "PATH") delete environment[key]; + } + } + Object.defineProperty(environment, "PATH", { + enumerable: true, + configurable: true, + get: () => + process.platform === "win32" ? (baseEnv.PATH ?? baseEnv.Path ?? baseEnv.path) : baseEnv.PATH, + }); // The CLI re-runs a turn it considers interrupted when the session is // resumed. The orchestration core already records that turn as // interrupted, and a silent re-run would land its output (and repeat its diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index f5d04221a..eb8a41b05 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -47,10 +47,15 @@ import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, + makeWindowsNativeInstaller, + normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -62,11 +67,27 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const WINDOWS_NATIVE_INSTALL = makeWindowsNativeInstaller({ + url: "https://chatgpt.com/codex/install.ps1", + lockKey: "codex-native-win32", + environmentPatch: { CODEX_NON_INTERACTIVE: "1" }, +}); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", homebrewFormula: "codex", - nativeUpdate: null, + nativeInstall: { win32: WINDOWS_NATIVE_INSTALL }, + nativeUpdate: { + ...WINDOWS_NATIVE_INSTALL, + isCommandPath: (commandPath) => { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/programs/openai/codex/bin/codex.exe") || + normalized.includes("/packages/standalone/") + ); + }, + unsupportedOneClickPlatforms: ["darwin", "linux"], + }, }); /** @@ -115,6 +136,7 @@ export const CodexDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -142,7 +164,7 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + let maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); @@ -164,8 +186,17 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), + get maintenanceCapabilities() { + return maintenanceCapabilities; + }, + getSettings: Effect.gen(function* () { + refreshProviderInstanceEnvironment(environment, processEnv); + maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + return effectiveConfig; + }), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f28..9f91720d0 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,27 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it("refreshes inherited PATH for existing runtimes while retaining an instance override", () => { + const inherited = { PATH: "/old/bin" }; + const overridden = { PATH: "/custom/bin" }; + vi.stubEnv("PATH", "/new/bin"); + try { + refreshProviderInstanceEnvironment(undefined, inherited); + refreshProviderInstanceEnvironment( + [{ name: "PATH", value: "/custom/bin", sensitive: false }], + overridden, + ); + expect(inherited.PATH).toBe("/new/bin"); + expect(overridden.PATH).toBe("/custom/bin"); + } finally { + vi.unstubAllEnvs(); + } + }); it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e81f0c11a..8e3a99409 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,13 @@ import type { ProviderInstanceEnvironment } from "@threadlines/contracts"; +/** Refresh inherited PATH in the environment already held by a driver's runtimes. */ +export function refreshProviderInstanceEnvironment( + environment: ProviderInstanceEnvironment | undefined, + target: NodeJS.ProcessEnv, +): void { + Object.assign(target, mergeProviderInstanceEnvironment(environment)); +} + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 892881cd8..e9e691bf2 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const fastModeCapabilities = createModelCapabilities({ @@ -116,6 +117,31 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("exposes the installation manager discovered during refresh", () => + Effect.scoped( + Effect.gen(function* () { + let capabilities: ProviderMaintenanceCapabilities = maintenanceCapabilities; + const provider = yield* makeManagedServerProvider({ + get maintenanceCapabilities() { + return capabilities; + }, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.sync(() => { + capabilities = { + ...maintenanceCapabilities, + update: { ...maintenanceCapabilities.update, command: "native update" }, + }; + return refreshedSnapshot; + }), + }); + yield* provider.refresh; + assert.strictEqual(provider.maintenanceCapabilities.update?.command, "native update"); + }), + ), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index cbbc540e2..46421c641 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -218,7 +218,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); return { - maintenanceCapabilities: input.maintenanceCapabilities, + get maintenanceCapabilities() { + return input.maintenanceCapabilities; + }, // Reads the cached snapshot without probing or queueing behind the // refresh semaphore — startup paths must never wait on a slow probe. getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index f517c5955..ced1cfe52 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -13,6 +13,7 @@ import { makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, + makeWindowsNativeInstaller, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -33,6 +34,21 @@ const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { }); const WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"; +const nativeWindowsInstall = makeWindowsNativeInstaller({ + url: "https://example.test/install.ps1", + lockKey: "package-tool-native", + environmentPatch: { PACKAGE_TOOL_NON_INTERACTIVE: "1" }, +}); +const nativeWindowsTool = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + homebrewFormula: null, + nativeInstall: { win32: nativeWindowsInstall }, + nativeUpdate: { + ...nativeWindowsInstall, + isCommandPath: (value) => value.endsWith("package-tool.exe"), + }, +}); /** * Put an executable named `name` in `dir` for the platform the test is @@ -137,6 +153,56 @@ afterEach(() => { }); describe("providerMaintenance", () => { + it("installs a missing Windows provider without npm and keeps its native updater", () => { + const install = nativeWindowsTool.resolve({ + binaryPath: "missing-package-tool", + platform: "win32", + env: { PATH: "" }, + }).install; + expect(install).toMatchObject({ + executable: "powershell.exe", + lockKey: "package-tool-native", + environmentPatch: { PACKAGE_TOOL_NON_INTERACTIVE: "1" }, + }); + const encoded = install?.args.at(-1); + expect(Buffer.from(encoded ?? "", "base64").toString("utf16le")).toContain( + "irm 'https://example.test/install.ps1' | iex", + ); + expect( + nativeWindowsTool.resolve({ + binaryPath: "C:\\Users\\alice\\.local\\bin\\package-tool.exe", + platform: "win32", + env: { PATH: "" }, + }).update, + ).toEqual(install); + }); + + it.effect("preserves an explicit npm install prefix even when a native installer exists", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-install-prefix"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(path.join(tempDir, "npm.cmd"), "@echo off\r\n"); + const install = nativeWindowsTool.resolve({ + binaryPath: "missing-package-tool", + platform: "win32", + env: { PATH: tempDir, PATHEXT: WINDOWS_PATHEXT, NPM_CONFIG_PREFIX: tempDir }, + }).install; + expect(install).toMatchObject({ + executable: "npm", + environmentPatch: { NPM_CONFIG_PREFIX: tempDir }, + }); + }), + ); + + it("does not offer a default install for an explicit custom binary path", () => { + expect( + nativeWindowsTool.resolve({ + binaryPath: "C:\\custom\\missing.exe", + platform: "win32", + env: { PATH: "" }, + }).install, + ).toBeNull(); + }); it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index ad44774c6..20e288e84 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -24,9 +24,8 @@ export interface ProviderMaintenanceCapabilities { readonly update: ProviderMaintenanceCommandAction | null; /** * How Threadlines would put this provider's CLI on the machine when it is - * missing. There is no installed binary to inspect in that state, so this - * is only ever the default manager (npm global) and only when `npm` itself - * resolves. `null` means the UI falls back to the provider's install guide. + * missing. Uses the platform installer when available, otherwise npm. + * `null` means the UI falls back to the provider's install guide. */ readonly install: ProviderMaintenanceCommandAction | null; readonly manualUpdateCommand: string | null; @@ -47,6 +46,7 @@ export interface ProviderMaintenanceCommandDefinition { readonly lockKey: string; readonly displayCommand?: string | null | undefined; readonly advisoryMessage?: string | null | undefined; + readonly environmentPatch?: Readonly>; } export interface ProviderMaintenanceCapabilityResolutionOptions { @@ -66,6 +66,7 @@ export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; readonly homebrewFormula: string | null; + readonly nativeInstall?: Partial>; readonly nativeUpdate: | (ProviderMaintenanceCommandDefinition & { readonly isCommandPath: (commandPath: string) => boolean; @@ -170,19 +171,51 @@ function makeNpmGlobalCommandAction(input: { }; } -/** - * The install command for a provider whose CLI could not be located. There is - * no binary whose origin we could inspect, so the manager is the default one - * (npm global) and the only question is whether `npm` is on the server's PATH. - * A configured `NPM_CONFIG_PREFIX` is carried into the command's environment - * patch so the install lands in the same prefix the rest of the process uses. - */ -function resolveNpmGlobalInstallAction( +/** Runs a provider's official Windows installer without interpolating shell arguments. */ +export function makeWindowsNativeInstaller(input: { + readonly url: string; + readonly lockKey: string; + readonly environmentPatch?: Readonly>; +}): ProviderMaintenanceCommandDefinition { + const command = `irm '${input.url.replaceAll("'", "''")}' | iex`; + const script = `$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; ${command}`; + return { + executable: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + lockKey: input.lockKey, + displayCommand: `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${command}"`, + ...(input.environmentPatch ? { environmentPatch: input.environmentPatch } : {}), + }; +} + +/** Prefer a native platform installer, preserving an explicitly configured npm prefix. */ +function resolveDefaultInstallAction( definition: PackageManagedProviderMaintenanceDefinition, options?: ProviderMaintenanceCapabilityResolutionOptions, ): ProviderMaintenanceCommandAction | null { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; + const nativeInstall = definition.nativeInstall?.[platform]; + // An explicit npm prefix is a user's installation choice. + if (nativeInstall && !nonEmptyString(env.NPM_CONFIG_PREFIX)) { + return { + executable: nativeInstall.executable, + args: nativeInstall.args, + lockKey: nativeInstall.lockKey, + command: + nativeInstall.displayCommand ?? [nativeInstall.executable, ...nativeInstall.args].join(" "), + ...(nativeInstall.environmentPatch + ? { environmentPatch: nativeInstall.environmentPatch } + : {}), + }; + } if (!resolveCommandPath("npm", { platform, env })) { return null; } @@ -287,6 +320,7 @@ function makeNativeProviderMaintenanceCapabilities( updateArgs: update.args, updateLockKey: update.lockKey, updateDisplayCommand: update.displayCommand, + ...(update.environmentPatch ? { updateEnvironmentPatch: update.environmentPatch } : {}), advisoryMessage: update.advisoryMessage ?? definition.nativeUpdate.advisoryMessage, }); } @@ -403,7 +437,7 @@ export function resolvePackageManagedProviderMaintenance( const platform = options?.platform ?? process.platform; if (!binaryPath) { return makeNpmGlobalProviderMaintenanceCapabilities(definition, { - install: resolveNpmGlobalInstallAction(definition, options), + install: resolveDefaultInstallAction(definition, options), }); } @@ -473,7 +507,7 @@ export function resolvePackageManagedProviderMaintenance( if (!hasPathSeparator(binaryPath)) { return makeNpmGlobalProviderMaintenanceCapabilities(definition, { install: - resolvedCommandPath === null ? resolveNpmGlobalInstallAction(definition, options) : null, + resolvedCommandPath === null ? resolveDefaultInstallAction(definition, options) : null, }); } diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts index 7c456c3c4..0033b79ac 100644 --- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -59,20 +59,24 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( onQueued, run, }) => - Effect.gen(function* () { - const acquired = yield* acquireTarget(targetKey); - if (!acquired) { - return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); - } - - return yield* Effect.gen(function* () { - const lock = yield* getLock(lockKey); - if (onQueued) { - yield* onQueued; + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const acquired = yield* acquireTarget(targetKey); + if (!acquired) { + return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); } - return yield* lock.withPermits(1)(run); - }).pipe(Effect.ensuring(releaseTarget(targetKey))); - }); + + return yield* restore( + Effect.gen(function* () { + const lock = yield* getLock(lockKey); + if (onQueued) { + yield* onQueued; + } + return yield* lock.withPermits(1)(run); + }), + ).pipe(Effect.ensuring(releaseTarget(targetKey))); + }), + ); return { withCommandLock, diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 23010b772..b1c426986 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { afterEach, beforeEach, describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -289,16 +289,18 @@ function claudeWindowsUpdateCapabilities(): ProviderMaintenanceCapabilities { } const makeTestRunner = (registry: ProviderRegistryShape) => - Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( - Effect.provide( - ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), - ), - ), - ); + ProviderMaintenanceRunner.make().pipe(Effect.provideService(ProviderRegistry, registry)); describe("providerMaintenanceRunner", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + beforeEach(() => { + // Generic command assertions use POSIX argv. Windows cases below opt in explicitly. + Object.defineProperty(process, "platform", { value: "linux" }); + }); afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } clearLatestProviderVersionCacheForTests(); }); @@ -945,7 +947,7 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("prevents concurrent updates for the same provider", () => { + it.effect("keeps an update running and locked after its requesting client disconnects", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; const started = new Promise((resolve) => { @@ -960,6 +962,7 @@ describe("providerMaintenanceRunner", () => { const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); yield* Effect.promise(() => started); + yield* Fiber.interrupt(first); const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); assert.strictEqual(Exit.isFailure(second), true); @@ -972,7 +975,9 @@ describe("providerMaintenanceRunner", () => { } releaseLatch.resolve(); - yield* Fiber.join(first); + while ((yield* registry.getProviders)[0]?.updateState?.status !== "succeeded") { + yield* Effect.yieldNow; + } }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 0aba47b05..506d67b85 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -10,12 +10,14 @@ import { type ServerProviderUpdateState, } from "@threadlines/contracts"; import { hideWindowsConsole } from "@threadlines/shared/childProcess"; +import { refreshWindowsPath } from "@threadlines/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; 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 * as Ref from "effect/Ref"; @@ -479,6 +481,7 @@ function makeUpdateState(input: { } export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { + const scope = yield* Effect.scope; const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; @@ -723,6 +726,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* Effect.sync(() => refreshWindowsPath()); const { verifiedProviders } = yield* verifyRefreshedProvider( provider, capabilities, @@ -876,7 +880,14 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }); return ProviderMaintenanceRunner.of({ - updateProvider, + updateProvider: (target) => + Effect.uninterruptibleMask((restore) => + updateProvider(target).pipe( + Effect.interruptible, + Effect.forkIn(scope), + Effect.flatMap((fiber) => restore(Fiber.join(fiber))), + ), + ), resolveUpdateBlockers, }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 86459f6b4..8fbc8d0af 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -64,6 +64,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as SourceControlToolMaintenance from "./sourceControl/SourceControlToolMaintenance.ts"; +import * as GitHubAuth from "./sourceControl/GitHubAuth.ts"; +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import { AutomaticGitFetchSupervisorLive } from "./vcs/AutomaticGitFetchSupervisor.ts"; @@ -406,7 +409,13 @@ export const makeRoutesLayer = Layer.mergeAll( serverEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, -).pipe(Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(browserApiCorsLayer), + // Build setup services once for the HTTP server, not once per WebSocket. + Layer.provide(SourceControlToolMaintenance.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide(GitHubAuth.layer), + Layer.provide(ProviderMaintenanceRunner.layer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubAuth.test.ts b/apps/server/src/sourceControl/GitHubAuth.test.ts new file mode 100644 index 000000000..25c55133a --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAuth.test.ts @@ -0,0 +1,212 @@ +import { assert, it } from "@effect/vitest"; +import type { GitHubAuthState } from "@threadlines/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { make, type GitHubAuthShape } from "./GitHubAuth.ts"; + +const encoder = new TextEncoder(); + +function handle( + input: { + readonly chunks?: ReadonlyArray; + readonly exitCode?: Effect.Effect; + readonly kill?: () => Effect.Effect; + } = {}, +) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(true), + kill: input.kill ?? (() => Effect.void), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.fromIterable((input.chunks ?? []).map((chunk) => encoder.encode(chunk))), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const waitForState = (auth: GitHubAuthShape, predicate: (state: GitHubAuthState) => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 100; attempt += 1) { + const state = yield* auth.getState; + if (predicate(state)) return state; + yield* Effect.yieldNow; + } + assert.fail("GitHub sign-in did not reach the expected state."); + }); + +it.effect("keeps the device code available until login and credential verification finish", () => + Effect.gen(function* () { + const loginExit = yield* Deferred.make(); + const calls: ReadonlyArray[] = []; + const spawner = ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + const standard = command as ChildProcess.StandardCommand; + calls.push(standard.args); + return Effect.succeed( + calls.length === 1 + ? handle({ + chunks: [ + "! First copy your one-time co", + "de: ABCD-1234\nOpen this URL in your web browser: https://github.com/login/device\n", + "private auth transcript should not appear in state", + ], + exitCode: Deferred.await(loginExit), + }) + : handle(), + ); + }); + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.strictEqual((yield* auth.start).status, "running"); + const prompt = yield* waitForState(auth, (state) => state.userCode !== null); + assert.strictEqual(prompt?.userCode, "ABCD-1234"); + assert.strictEqual(prompt?.verificationUrl, "https://github.com/login/device"); + assert.strictEqual((yield* auth.start).userCode, "ABCD-1234"); + assert.strictEqual(calls.length, 1); + + yield* Deferred.succeed(loginExit, ChildProcessSpawner.ExitCode(0)); + const completed = yield* waitForState(auth, (state) => state.status === "succeeded"); + assert.deepStrictEqual(completed, { + status: "succeeded", + userCode: null, + verificationUrl: null, + message: "Signed in to GitHub.", + }); + assert.deepStrictEqual(calls, [ + ["auth", "login", "--hostname", "github.com", "--web"], + ["api", "--hostname", "github.com", "user", "--silent"], + ["auth", "setup-git", "--hostname", "github.com"], + ]); + }).pipe(Effect.scoped), +); + +it.effect("cancels only its login process and can start a fresh sign-in", () => + Effect.gen(function* () { + let killed = 0; + let spawned = 0; + const spawner = ChildProcessSpawner.make(() => { + spawned += 1; + return Effect.succeed( + handle({ + chunks: ["First copy your one-time code: ABCD-1234"], + exitCode: Effect.never, + kill: () => + Effect.sync(() => { + killed += 1; + }), + }), + ); + }); + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + yield* auth.cancel; + assert.strictEqual(killed, 1); + assert.deepStrictEqual(yield* auth.getState, { + status: "cancelled", + userCode: null, + verificationUrl: null, + message: "GitHub sign-in cancelled.", + }); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + assert.strictEqual(spawned, 2); + yield* auth.cancel; + }).pipe(Effect.scoped), +); + +it.effect("rejects credential overrides before starting and never exposes their values", () => + Effect.gen(function* () { + let spawned = false; + const auth = yield* make({ + commandAvailable: () => true, + environment: () => ({ GH_TOKEN: "private-token-value" }), + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawned = true; + return Effect.succeed(handle()); + }), + ), + ); + const result = yield* Effect.result(auth.start); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.match(result.failure.detail, /GH_TOKEN/); + assert.notMatch(result.failure.detail, /private-token-value/); + } + assert.strictEqual(spawned, false); + assert.strictEqual((yield* auth.getState).status, "idle"); + }).pipe(Effect.scoped), +); + +it.effect("clears the device code after timeout and stops the login process", () => + Effect.gen(function* () { + let killed = 0; + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + handle({ + chunks: ["First copy your one-time code: ABCD-1234"], + exitCode: Effect.never, + kill: () => + Effect.sync(() => { + killed += 1; + }), + }), + ), + ), + ), + ); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + yield* TestClock.adjust("15 minutes"); + const failed = yield* waitForState(auth, (state) => state.status === "failed"); + assert.strictEqual(failed?.userCode, null); + assert.match(failed?.message ?? "", /timed out/); + assert.strictEqual(killed, 1); + }).pipe(Effect.scoped), +); + +it.effect( + "does not report success or configure Git when the saved credential fails verification", + () => + Effect.gen(function* () { + let spawned = 0; + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawned += 1; + return Effect.succeed( + handle({ + chunks: ["private-token-value"], + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(spawned === 1 ? 0 : 1)), + }), + ); + }), + ), + ); + yield* auth.start; + const failed = yield* waitForState(auth, (state) => state.status === "failed"); + assert.strictEqual(spawned, 2); + assert.strictEqual(failed?.userCode, null); + assert.match(failed?.message ?? "", /could not verify/); + assert.notMatch(failed?.message ?? "", /private-token-value/); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/sourceControl/GitHubAuth.ts b/apps/server/src/sourceControl/GitHubAuth.ts new file mode 100644 index 000000000..09cb7a1d9 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAuth.ts @@ -0,0 +1,198 @@ +import { SourceControlProviderError, type GitHubAuthState } from "@threadlines/contracts"; +import { hideWindowsConsole } from "@threadlines/shared/childProcess"; +import { isCommandAvailable } from "@threadlines/shared/shell"; +import * as Context from "effect/Context"; +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 * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { planCliSpawn } from "../cliSpawn.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts"; + +const DEVICE_URL = "https://github.com/login/device"; +const AUTH_OUTPUT_LIMIT = 8_192; + +export interface GitHubAuthShape { + readonly getState: Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: Effect.Effect; +} + +export class GitHubAuth extends Context.Service()( + "threadlines/source-control/GitHubAuth", +) {} + +interface GitHubAuthOptions { + readonly commandAvailable?: (command: string) => boolean; + readonly environment?: () => NodeJS.ProcessEnv; +} + +const authError = (detail: string) => + new SourceControlProviderError({ provider: "github", operation: "signIn", detail }); + +const authState = (status: GitHubAuthState["status"], message: string | null): GitHubAuthState => ({ + status, + verificationUrl: null, + userCode: null, + message, +}); + +/** The server owns the sign-in process, so reconnecting clients can resume its device prompt. */ +export const make = Effect.fn("makeGitHubAuth")(function* (options: GitHubAuthOptions = {}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + const state = yield* Ref.make(authState("idle", null)); + const lock = yield* Semaphore.make(1); + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const environment = options.environment ?? (() => process.env); + let activeFiber: Fiber.Fiber | null = null; + + const runCommand = Effect.fn("GitHubAuth.runCommand")(function* ( + args: ReadonlyArray, + onStderr?: (chunk: string) => Effect.Effect, + ) { + const env = { + ...environment(), + ...THREADLINES_GITHUB_CLI_ENV, + GH_PROMPT_DISABLED: "1", + NO_COLOR: "1", + }; + const plan = planCliSpawn("gh", args, env); + const child = yield* spawner.spawn( + ChildProcess.make( + plan.command, + [...plan.args], + hideWindowsConsole({ + ...plan.options, + env, + extendEnv: false, + stdin: "ignore", + forceKillAfter: "5 seconds", + }), + ), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [, , exitCode] = yield* Effect.all( + [ + Stream.runDrain(child.stdout), + child.stderr.pipe(Stream.decodeText(), Stream.runForEach(onStderr ?? (() => Effect.void))), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + return Number(exitCode); + }); + + const run = Effect.gen(function* () { + let output = ""; + const exitCode = yield* runCommand( + ["auth", "login", "--hostname", "github.com", "--web"], + (chunk) => + Effect.gen(function* () { + output = `${output}${chunk}`.slice(-AUTH_OUTPUT_LIMIT); + const userCode = /one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})\b/i.exec(output)?.[1]; + if (userCode) { + yield* Ref.update(state, (current) => + current.status === "running" + ? { + ...current, + userCode: userCode.toUpperCase(), + verificationUrl: DEVICE_URL, + message: "Enter this code on GitHub to finish signing in.", + } + : current, + ); + } + }), + ).pipe(Effect.scoped); + if (exitCode !== 0) { + return yield* authError( + "GitHub sign-in did not finish. Try again and complete the browser step.", + ); + } + + // Check the active credential directly, without printing account data or tokens. + const verified = yield* runCommand([ + "api", + "--hostname", + "github.com", + "user", + "--silent", + ]).pipe(Effect.scoped); + if (verified !== 0) { + return yield* authError( + "GitHub could not verify the sign-in. Check your connection and try again.", + ); + } + let message = "Signed in to GitHub."; + if (commandAvailable("git")) { + const configured = yield* runCommand(["auth", "setup-git", "--hostname", "github.com"]).pipe( + Effect.scoped, + Effect.catch(() => Effect.succeed(-1)), + ); + if (configured !== 0) { + message = + "Signed in to GitHub. Run `gh auth setup-git` to enable Git access with this account."; + } + } + yield* Ref.update(state, (current) => + current.status === "running" ? authState("succeeded", message) : current, + ); + }).pipe( + Effect.timeoutOption("15 minutes"), + Effect.flatMap((result) => + Option.isNone(result) + ? Effect.fail(authError("GitHub sign-in timed out. Start again to get a new code.")) + : Effect.void, + ), + Effect.catch((error) => + Ref.update(state, (current) => + current.status === "running" + ? authState( + "failed", + error instanceof SourceControlProviderError + ? error.detail + : "GitHub sign-in could not run. Check that GitHub CLI is installed and try again.", + ) + : current, + ), + ), + ); + + return GitHubAuth.of({ + getState: Ref.get(state), + start: Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.status === "running") return current; + const env = environment(); + if (env.GH_TOKEN || env.GITHUB_TOKEN) { + return yield* authError( + "GitHub credentials are set through GH_TOKEN or GITHUB_TOKEN on this server. Update those credentials, or remove the override and restart Threadlines before signing in here.", + ); + } + if (!commandAvailable("gh")) { + return yield* authError("Install GitHub CLI before signing in."); + } + const next = authState("running", "Starting GitHub sign-in..."); + yield* Ref.set(state, next); + activeFiber = yield* run.pipe(Effect.interruptible, Effect.forkIn(scope)); + return next; + }).pipe(lock.withPermits(1), Effect.uninterruptible), + cancel: Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.status !== "running") return; + yield* Ref.set(state, authState("cancelled", "GitHub sign-in cancelled.")); + if (activeFiber) { + yield* Fiber.interrupt(activeFiber); + activeFiber = null; + } + }).pipe(lock.withPermits(1), Effect.uninterruptible), + }); +}); + +export const layer = Layer.effect(GitHubAuth, make()); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 26c2155dd..227fdf5a4 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -220,49 +220,88 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); - it.effect("lists authenticated user repositories", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - nameWithOwner: "octocat/example-app", - url: "https://github.com/octocat/example-app", - sshUrl: "git@github.com:octocat/example-app.git", - }, - ]), + it.effect( + "lists personal, organization, and collaborator repositories across archived entries", + () => + Effect.gen(function* () { + const repository = (nameWithOwner: string, archived = false) => ({ + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, + archived, + }); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + repository("octocat/example-app"), + repository("octocat/archived", true), + repository("example-org/team-app"), + ]), + ), ), - ), - ); + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + repository("collaborator/shared-app"), + repository("octocat/beyond-limit"), + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listRepositories({ cwd: "/repo", limit: 3 }); + + assert.deepStrictEqual(result, [ + { + nameWithOwner: "octocat/example-app", + url: "https://github.com/octocat/example-app", + sshUrl: "git@github.com:octocat/example-app.git", + }, + { + nameWithOwner: "example-org/team-app", + url: "https://github.com/example-org/team-app", + sshUrl: "git@github.com:example-org/team-app.git", + }, + { + nameWithOwner: "collaborator/shared-app", + url: "https://github.com/collaborator/shared-app", + sshUrl: "git@github.com:collaborator/shared-app.git", + }, + ]); + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "api", + "user/repos?affiliation=owner,collaborator,organization_member&sort=updated&direction=desc&per_page=3&page=1", + "--jq", + "map({nameWithOwner: .full_name, url: .html_url, sshUrl: .ssh_url, archived: .archived})", + ], + cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, + timeoutMs: 30_000, + }); + assert.match(mockRun.mock.calls[1]?.[0].args[1] ?? "", /per_page=3&page=2$/); + assert.strictEqual(mockRun.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + + it.effect("stops listing when the accessible repositories run out", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.listRepositories({ cwd: "/repo", limit: 25 }); + const result = yield* gh.listRepositories({ cwd: "/repo", limit: 150 }); - assert.deepStrictEqual(result, [ - { - nameWithOwner: "octocat/example-app", - url: "https://github.com/octocat/example-app", - sshUrl: "git@github.com:octocat/example-app.git", - }, - ]); - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "GitHubCli.execute", - command: "gh", - args: [ - "repo", - "list", - "--no-archived", - "--limit", - "25", - "--json", - "nameWithOwner,url,sshUrl", - ], - cwd: "/repo", - env: GITHUB_CLI_BACKGROUND_ENV, - timeoutMs: 30_000, - }); + assert.deepStrictEqual(result, []); + assert.strictEqual(mockRun.mock.calls.length, 1); + assert.match(mockRun.mock.calls[0]?.[0].args[1] ?? "", /per_page=100&page=1$/); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 1f3e49899..f57bdaa22 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -183,6 +183,13 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubRepositoryListSchema = Schema.Array( + Schema.Struct({ + ...RawGitHubRepositoryCloneUrlsSchema.fields, + archived: Schema.Boolean, + }), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -371,29 +378,42 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.map(normalizeRepositoryCloneUrls), ), listRepositories: (input) => - execute({ - cwd: input.cwd, - args: [ - "repo", - "list", - "--no-archived", - "--limit", - String(input.limit ?? 50), - "--json", - "nameWithOwner,url,sshUrl", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitHubJson( + Effect.gen(function* () { + const limit = input.limit ?? 50; + const pageSize = Math.min(limit, 100); + const repositories: GitHubRepositoryCloneUrls[] = []; + + // The authenticated-user endpoint includes organization and collaborator + // access. `gh repo list` only lists repositories the account owns. + for (let page = 1; repositories.length < limit; page += 1) { + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + `user/repos?affiliation=owner,collaborator,organization_member&sort=updated&direction=desc&per_page=${pageSize}&page=${page}`, + "--jq", + "map({nameWithOwner: .full_name, url: .html_url, sshUrl: .ssh_url, archived: .archived})", + ], + }); + const raw = result.stdout.trim(); + const entries = yield* decodeGitHubJson( raw.length === 0 ? "[]" : raw, - Schema.Array(RawGitHubRepositoryCloneUrlsSchema), + RawGitHubRepositoryListSchema, "listRepositories", "GitHub CLI returned invalid repository list JSON.", - ), - ), - Effect.map((repositories) => repositories.map(normalizeRepositoryCloneUrls)), - ), + ); + for (const entry of entries) { + if (!entry.archived && repositories.length < limit) { + repositories.push(normalizeRepositoryCloneUrls(entry)); + } + } + if (entries.length < pageSize) { + break; + } + } + + return repositories; + }), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b954be18d..6cf8394cd 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -63,6 +63,96 @@ const processOutput = ( stderrTruncated: false, }); +it.effect( + "discovers tools installed or removed after startup and refreshes install actions", + () => { + const commands = new Set(); + const commandAvailable = (command: string) => commands.has(command); + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => + Effect.succeed( + processOutput( + input.command === "git" + ? "git version 2.55.0.windows.4" + : input.args[0] === "--version" + ? "gh version 2.98.0" + : '{"hosts":{}}', + ), + ), + } satisfies Partial; + const testLayer = Layer.effect( + SourceControlDiscovery.SourceControlDiscovery, + SourceControlDiscovery.make({ + commandAvailable, + platform: "win32", + latestVersionResolver: noLatestToolVersion, + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-rescan-" })), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + commandAvailable, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const initial = yield* discovery.discover; + assert.equal( + initial.versionControlSystems.find((item) => item.kind === "git")?.status, + "missing", + ); + commands.add("winget"); + const installable = yield* discovery.discover; + assert.ok( + installable.versionControlSystems + .find((item) => item.kind === "git") + ?.versionAdvisory?.actions.some( + (action) => action.kind === "runUpdate" && action.operation === "install", + ), + ); + commands.add("git"); + commands.add("gh"); + const installed = yield* discovery.discover; + assert.equal( + installed.versionControlSystems.find((item) => item.kind === "git")?.status, + "available", + ); + assert.equal( + installed.sourceControlProviders.find((item) => item.kind === "github")?.status, + "available", + ); + commands.clear(); + const removed = yield* discovery.discover; + assert.equal( + removed.versionControlSystems.find((item) => item.kind === "git")?.status, + "missing", + ); + assert.equal( + removed.sourceControlProviders.find((item) => item.kind === "github")?.status, + "missing", + ); + assert.equal( + removed.versionControlSystems + .find((item) => item.kind === "git") + ?.versionAdvisory?.actions.some((action) => action.kind === "runUpdate") ?? false, + false, + ); + }).pipe(Effect.provide(testLayer)); + }, +); + it.effect("reports implemented tools separately from locally available executables", () => { const processCommands: Array = []; const processMock = { diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index d9fbe6398..ca18687d9 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -117,10 +117,8 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( options?.commandAvailable ?? ((command) => isCommandAvailable(command, { platform })); const latestVersionResolver = options?.latestVersionResolver ?? (() => Effect.succeed(null)); - const sourceControlToolPackageManager = selectSourceControlToolPackageManager({ - platform, - commandAvailable, - }); + const packageManager = () => + selectSourceControlToolPackageManager({ platform, commandAvailable }); const homebrewManagedExecutable = options?.homebrewManagedExecutable ?? ((executable: string): boolean => { @@ -147,8 +145,9 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, ): boolean => { if (platform === "win32" && item.status === "available" && item.kind === "git") return true; - if (sourceControlToolPackageManager === "winget") return true; - if (sourceControlToolPackageManager !== "homebrew") return false; + const manager = packageManager(); + if (manager === "winget") return true; + if (manager !== "homebrew") return false; return item.executable !== undefined && homebrewManagedExecutable(item.executable); }; @@ -211,8 +210,9 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( const withVersionAdvisory = ( item: Item, - ): Effect.Effect => - SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ + ): Effect.Effect => { + const sourceControlToolPackageManager = packageManager(); + return SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ item, platform, latestVersionResolver, @@ -227,27 +227,30 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( item.executable !== undefined && !commandAvailable(item.executable), }); + }; 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", - }), + discover: Effect.suspend(() => + 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.pipe( + Effect.flatMap((items) => + Effect.forEach(items, withVersionAdvisory, { + concurrency: "unbounded", + }), + ), ), - ), - }), + }), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index c202e0d22..0db44952f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -162,56 +162,58 @@ function probeCli(input: { readonly cwd: string; readonly commandAvailable: CommandAvailability; }): Effect.Effect { - if (!input.commandAvailable(input.spec.executable)) { - return Effect.succeed({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "missing" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.some(`${input.spec.executable} was not found on the server PATH.`), - } satisfies DiscoveryProbeResult); - } + return Effect.suspend(() => { + if (!input.commandAvailable(input.spec.executable)) { + return Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.some(`${input.spec.executable} was not found on the server PATH.`), + } satisfies DiscoveryProbeResult); + } - return input.process - .run({ - operation: "source-control.discovery.probe", - 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, - }) - .pipe( - Effect.map( - (result) => - ({ + return input.process + .run({ + operation: "source-control.discovery.probe", + 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, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ kind: input.spec.kind, label: input.spec.label, executable: input.spec.executable, - status: "available" as const, - version: Option.orElse(firstNonEmptyLine(result.stdout), () => - firstNonEmptyLine(result.stderr), - ), + status: "missing" as const, + version: Option.none(), installHint: input.spec.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "missing" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); + }); } export function probeSourceControlProvider(input: { diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts index 6ee80c251..dbdbff50f 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts @@ -372,44 +372,63 @@ it.effect("explains when Homebrew does not manage the tool it was asked to upgra }).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" || command === "git", - }), - ).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), - ); - }, +it.effect( + "queues different tools, rejects duplicates, and keeps installing after the caller leaves", + () => + 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" || command === "git", }), - ), - Layer.provideMerge(NodeServices.layer), - ); + ).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); + 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); + const duplicate = yield* Effect.result(maintenance.update({ target: "github-cli" })); + assert.strictEqual(duplicate._tag, "Failure"); + const second = yield* maintenance.update({ target: "git" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + assert.deepStrictEqual( + (yield* maintenance.getState).map((state) => [state.target, state.status]), + [ + ["github-cli", "running"], + ["git", "queued"], + ], + ); + assert.strictEqual(calls, 1); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(first); - }).pipe(Effect.provide(layer)); - }), + yield* Fiber.interrupt(first); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(second); + assert.strictEqual(calls, 2); + assert.deepStrictEqual( + (yield* maintenance.getState).map((state) => state.status), + ["succeeded", "succeeded"], + ); + }).pipe(Effect.provide(layer)); + }), ); diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts index d3d3c17ea..75ef9e7f4 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts @@ -3,14 +3,18 @@ import { type SourceControlDiscoveryResult, type SourceControlToolUpdateInput, type SourceControlToolUpdateTarget, + type SourceControlToolMaintenanceState, type VcsError, } from "@threadlines/contracts"; -import { isCommandAvailable } from "@threadlines/shared/shell"; +import { isCommandAvailable, refreshWindowsPath } from "@threadlines/shared/shell"; import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; 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 * as Fiber from "effect/Fiber"; +import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -27,8 +31,10 @@ const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; export interface SourceControlToolMaintenanceShape { + readonly getState: Effect.Effect>; readonly update: ( input: SourceControlToolUpdateInput, + verify?: () => Effect.Effect, ) => Effect.Effect; } @@ -151,11 +157,17 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( const platform = options?.platform ?? process.platform; const commandAvailable = options?.commandAvailable ?? ((command: string) => isCommandAvailable(command, { platform })); - const updateActive = yield* Ref.make(false); + const scope = yield* Effect.scope; + const installerLock = yield* Semaphore.make(1); + const states = yield* Ref.make< + ReadonlyMap + >(new Map()); + const setState = (state: SourceControlToolMaintenanceState) => + Ref.update(states, (current) => new Map(current).set(state.target, state)); const update: SourceControlToolMaintenanceShape["update"] = Effect.fn( "SourceControlToolMaintenance.update", - )(function* (input) { + )(function* (input, verify) { const { target } = input; const operation = input.operation ?? "update"; const useGitForWindowsUpdater = @@ -189,60 +201,128 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( ); } - 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* () { - let updaterStarted = false; - for (const step of recipe.steps) { - const output = yield* vcsProcess - .run({ - operation: `source-control.tool.${operation}`, - command: step.command, - args: step.args, - cwd: config.cwd, - timeoutMs: UPDATE_TIMEOUT_MS, - maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, - appendTruncationMarker: true, - ...(useGitForWindowsUpdater ? { allowNonZeroExit: true } : {}), - }) - .pipe( - Effect.mapError((cause) => - useGitForWindowsUpdater - ? updateError( - target, - `The official Git for Windows updater failed: ${cause.message || "unknown process error"}`, - ) - : updateError( - target, - packageManagerFailureReason({ - target, - operation, - manager: packageManager!, - cause, - }), - ), - ), + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const acquired = yield* Ref.modify(states, (current) => { + const existing = current.get(target); + if (existing && ["queued", "running", "checking"].includes(existing.status)) + return [false, current] as const; + return [ + true, + new Map(current).set(target, { + target, + operation, + status: "queued", + message: "Waiting for another install to finish.", + } satisfies SourceControlToolMaintenanceState), + ] as const; + }); + if (!acquired) { + return yield* updateError( + target, + "This tool already has an install or update in progress.", ); + } - if (useGitForWindowsUpdater) { - if (output.exitCode !== 0 && output.exitCode !== 2) { - const detail = output.stderr.trim() || output.stdout.trim(); - return yield* updateError( - target, - `The official Git for Windows updater exited with code ${output.exitCode}${detail ? `: ${detail}` : "."}`, - ); + const run = Effect.gen(function* () { + yield* setState({ + target, + operation, + status: "running", + message: `${operation === "install" ? "Installing." : "Updating."}${platform === "win32" ? " Check for a Windows permission prompt." : ""}`, + }); + let updaterStarted = false; + for (const step of recipe.steps) { + const output = yield* vcsProcess + .run({ + operation: `source-control.tool.${operation}`, + command: step.command, + args: step.args, + cwd: config.cwd, + timeoutMs: UPDATE_TIMEOUT_MS, + maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, + appendTruncationMarker: true, + ...(useGitForWindowsUpdater ? { allowNonZeroExit: true } : {}), + }) + .pipe( + Effect.mapError((cause) => + useGitForWindowsUpdater + ? updateError( + target, + `The official Git for Windows updater failed: ${cause.message || "unknown process error"}`, + ) + : updateError( + target, + packageManagerFailureReason({ + target, + operation, + manager: packageManager!, + cause, + }), + ), + ), + ); + + if (useGitForWindowsUpdater) { + if (output.exitCode !== 0 && output.exitCode !== 2) { + const detail = output.stderr.trim() || output.stdout.trim(); + return yield* updateError( + target, + `The official Git for Windows updater exited with code ${output.exitCode}${detail ? `: ${detail}` : "."}`, + ); + } + updaterStarted ||= output.exitCode === 2; + } } - updaterStarted ||= output.exitCode === 2; - } - } - return { status: updaterStarted ? "started" : "completed" } as const; - }).pipe(Effect.ensuring(Ref.set(updateActive, false))); + yield* Effect.sync(() => refreshWindowsPath({ platform })); + yield* setState({ + target, + operation, + status: "checking", + message: "Checking installation.", + }); + if (verify) yield* verify(); + yield* setState({ + target, + operation, + status: updaterStarted ? "started" : "succeeded", + message: updaterStarted + ? "Finish the Windows installer, then rescan." + : operation === "install" + ? "Installed." + : "Update finished.", + }); + return { status: updaterStarted ? "started" : "completed" } as const; + }); + // The environment owns this job. Closing Settings or reconnecting only + // detaches the caller; it does not interrupt the installer. + return yield* installerLock + .withPermits(1)(run) + .pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause); + return setState({ + target, + operation, + status: "failed", + message: + error instanceof SourceControlToolUpdateError + ? error.reason + : "The installer stopped unexpectedly. Try again.", + }).pipe(Effect.andThen(Effect.failCause(cause))); + }), + Effect.interruptible, + Effect.forkIn(scope), + Effect.flatMap((fiber) => restore(Fiber.join(fiber))), + ); + }), + ); }); - return SourceControlToolMaintenance.of({ update }); + return SourceControlToolMaintenance.of({ + update, + getState: Ref.get(states).pipe(Effect.map((current) => [...current.values()])), + }); }); export const layer = Layer.effect(SourceControlToolMaintenance, make()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca4cffe2f..dd30b9b66 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -132,6 +132,8 @@ 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 * as GitHubAuth from "./sourceControl/GitHubAuth.ts"; +import { refreshWindowsPath } from "@threadlines/shared/shell"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -273,6 +275,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const sourceControlToolMaintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const githubAuth = yield* GitHubAuth.GitHubAuth; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -1083,10 +1086,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe(Effect.map((providers) => ({ providers }))), + Effect.sync(() => refreshWindowsPath()).pipe( + Effect.andThen( + input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(), + ), + Effect.map((providers) => ({ providers })), + ), { "rpc.aggregate": "server" }, ), [WS_METHODS.serverStartProviderReview]: (input) => @@ -1238,11 +1245,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, - sourceControlDiscovery.discover, + Effect.sync(() => refreshWindowsPath()).pipe( + Effect.andThen(sourceControlDiscovery.discover), + ), { "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetSourceControlSetup]: () => + Effect.all({ + tools: sourceControlToolMaintenance.getState, + githubAuth: githubAuth.getState, + }), + [WS_METHODS.serverStartGitHubAuth]: () => githubAuth.start, + [WS_METHODS.serverCancelGitHubAuth]: () => githubAuth.cancel, [WS_METHODS.serverUpdateSourceControlTool]: (input) => observeRpcEffect( WS_METHODS.serverUpdateSourceControlTool, @@ -1267,10 +1283,29 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => input.target, ); - const maintenanceResult = yield* sourceControlToolMaintenance.update({ - ...input, - operation, - }); + const maintenanceResult = yield* sourceControlToolMaintenance.update( + { + ...input, + operation, + }, + () => + sourceControlDiscovery.discover.pipe( + Effect.flatMap((after) => + SourceControlToolMaintenance.currentSourceControlToolVersion( + after, + input.target, + ) !== null + ? Effect.void + : Effect.fail( + new SourceControlToolUpdateError({ + target: input.target, + reason: + "The installer finished, but the tool could not be found. Check the installer, then rescan or retry.", + }), + ), + ), + ), + ); const discovery = yield* sourceControlDiscovery.discover; const currentVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( @@ -1281,7 +1316,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => return yield* new SourceControlToolUpdateError({ target: input.target, reason: - "The package-manager command finished, but Threadlines could not verify the installed tool version afterward. Rescan after restarting the desktop app.", + "The installer finished, but the tool could not be found. Check the installer, then rescan or retry.", }); } @@ -2323,8 +2358,11 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const providerMaintenance = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const githubSignIn = yield* GitHubAuth.GitHubAuth; + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -2337,11 +2375,20 @@ export const websocketRpcRouteLayer = Layer.unwrap( }).pipe( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( - Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( - SourceControlToolMaintenance.layer.pipe(Layer.provide(VcsProcess.layer)), + Layer.succeed( + SourceControlToolMaintenance.SourceControlToolMaintenance, + maintenance, + ), ), + Layer.provide( + Layer.succeed( + ProviderMaintenanceRunner.ProviderMaintenanceRunner, + providerMaintenance, + ), + ), + Layer.provide(Layer.succeed(GitHubAuth.GitHubAuth, githubSignIn)), + Layer.provideMerge(RpcSerialization.layerJson), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( Layer.provide( @@ -2395,6 +2442,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( () => sessions.markDisconnected(session.sessionId), ); }).pipe(Effect.scoped, Effect.catchTag("AuthError", respondToAuthError)), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index cbf149b2b..0b61df200 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -3,6 +3,8 @@ import "../../index.css"; import { ProviderDriverKind, ProviderInstanceId, + EnvironmentId, + type SourceControlDiscoveryResult, type ServerProvider, } from "@threadlines/contracts"; import { @@ -15,6 +17,9 @@ import { import { page } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; +import * as Option from "effect/Option"; +import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; +import { resetSourceControlDiscoveryStateForTests } from "../../lib/sourceControlDiscoveryState"; /** * The sign-in row drives the real server-side auth flow, so the card needs a @@ -42,12 +47,28 @@ const providerAuthHarness = vi.hoisted(() => { }>(); const startCalls: Array<{ instanceId: string; flow: string }> = []; const installCalls: Array<{ provider: string; instanceId?: string; action?: string }> = []; + let discovery: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [], + }; + const externalUrls: string[] = []; + const remoteDiscovery = new Map(); return { startCalls, installCalls, + externalUrls, + remoteDiscovery, + setDiscovery(value: SourceControlDiscoveryResult) { + discovery = value; + }, // The install row calls the same server RPC the Update button uses. server: { + discoverSourceControl: async () => discovery, + getSourceControlSetup: async () => ({ + tools: [], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }), updateProvider: (input: { provider: string; instanceId?: string; action?: string }) => { installCalls.push(input); return Promise.resolve({ providers: [] }); @@ -57,6 +78,9 @@ const providerAuthHarness = vi.hoisted(() => { listeners.clear(); startCalls.length = 0; installCalls.length = 0; + externalUrls.length = 0; + remoteDiscovery.clear(); + discovery = { versionControlSystems: [], sourceControlProviders: [] }; }, emit(event: AuthEvent) { for (const entry of listeners) { @@ -84,6 +108,11 @@ const providerAuthHarness = vi.hoisted(() => { }; }); +vi.mock("../../lib/externalLinks", () => ({ + openExternalUrl: (url: string) => providerAuthHarness.externalUrls.push(url), +})); +vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); + vi.mock("../../environments/runtime", () => { const primaryConnection = { client: { providerAuth: providerAuthHarness.client, server: providerAuthHarness.server }, @@ -113,7 +142,19 @@ vi.mock("../../environments/runtime", () => { getPrimaryEnvironmentConnection: () => primaryConnection, markRelaySavedEnvironmentLinkExpired: notUsed, readBackendEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, + readEnvironmentConnection: (environmentId: string) => + providerAuthHarness.remoteDiscovery.has(environmentId) + ? ({ + client: { + providerAuth: providerAuthHarness.client, + server: { + ...providerAuthHarness.server, + discoverSourceControl: async () => + providerAuthHarness.remoteDiscovery.get(environmentId)!, + }, + }, + } as never) + : primaryConnection, reconnectSavedEnvironment: notUsed, RELAY_LINK_EXPIRED_MESSAGE: "", removeSavedEnvironment: notUsed, @@ -216,6 +257,8 @@ const SIGNED_IN_CLAUDE = buildProvider({ * throwaway memory router rather than special-casing one test. */ function renderCard(props: { + readonly setupEnvironmentId?: EnvironmentId; + readonly projectEnvironmentId?: EnvironmentId; readonly providers: ReadonlyArray; readonly projectName: string | null; readonly onChooseProject?: () => void; @@ -225,10 +268,11 @@ function renderCard(props: { const rootRoute = createRootRoute({ component: () => ( ); + return render( + + + , + ); } function rowStates(): Record { @@ -260,13 +308,102 @@ function rowStates(): Record { describe("FirstRunSetupCard", () => { beforeEach(() => { + resetSourceControlDiscoveryStateForTests(); + resetAppAtomRegistryForTests(); providerAuthHarness.reset(); }); afterEach(() => { + resetSourceControlDiscoveryStateForTests(); document.body.innerHTML = ""; }); + it("offers Git setup and keeps GitHub optional when an agent is ready", async () => { + providerAuthHarness.setDiscovery({ + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "missing", + version: Option.none(), + detail: Option.none(), + installHint: "Install Git.", + versionAdvisory: { + status: "install_available", + severity: "info", + currentVersion: null, + latestVersion: null, + recommendedVersion: null, + checkedAt: null, + message: null, + notificationKey: null, + actions: [{ kind: "runUpdate", target: "git", operation: "install", label: "Install" }], + }, + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("2.98.0"), + detail: Option.none(), + installHint: "Install GitHub CLI.", + auth: { + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + }, + ], + }); + await renderCard({ providers: [SIGNED_IN_CLAUDE], projectName: "B-git-project" }); + await expect + .element(page.getByRole("button", { name: "Install Git", exact: true })) + .toBeVisible(); + await expect.element(page.getByRole("button", { name: "Sign in to GitHub" })).toBeVisible(); + await expect + .element(page.getByText("Optional: browse your GitHub repositories and pull requests.")) + .toBeVisible(); + await expect.element(page.getByTestId("first-run-setup-start")).toBeEnabled(); + const card = document.querySelector("[data-testid='first-run-setup-card']")!; + card.style.width = "320px"; + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth); + }); + + it("checks tools in the setup environment when the chosen project lives elsewhere", async () => { + providerAuthHarness.remoteDiscovery.set("setup-environment", { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("2.55.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + }); + providerAuthHarness.remoteDiscovery.set("project-environment", { + versionControlSystems: [], + sourceControlProviders: [], + }); + await renderCard({ + providers: [SIGNED_IN_CLAUDE], + projectName: "B-git-project", + setupEnvironmentId: EnvironmentId.make("setup-environment"), + projectEnvironmentId: EnvironmentId.make("project-environment"), + }); + await expect.poll(() => rowStates().git).toBe("available"); + }); + it("gives every provider state its own dot and action, and holds the start button back", async () => { const screen = await renderCard({ providers: [SIGNED_OUT_CODEX, MISSING_CLAUDE], diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index 5edc6f814..c54de9909 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -31,9 +31,19 @@ import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../../commandPaletteStore"; import { cn } from "../../lib/utils"; +import { + useSourceControlDiscovery, + useSourceControlSetup, +} from "../../lib/sourceControlDiscoveryState"; +import { openExternalUrl } from "../../lib/externalLinks"; import { selectWorkspaceProjectsAcrossEnvironments, useStore } from "../../store"; import { ProjectFavicon } from "../ProjectFavicon"; import { ProviderInstallAction } from "../settings/ProviderInstallAction"; +import { + CompactVersionAdvisory, + SourceControlToolProgress, +} from "../settings/CompactVersionAdvisory"; +import { GitHubSignInAction, GitHubSignInStatus } from "../settings/GitHubSignInAction"; import { useProviderConnectFlow } from "../settings/useProviderConnectFlow"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { Button } from "../ui/button"; @@ -74,7 +84,7 @@ function SetupRow({ }) { return (
  • ) : null} {description} - {action} + + {action} +
  • ); } @@ -144,7 +156,96 @@ function providerRowAction(row: FirstRunProviderRow): ReactNode { ); } +function SourceControlSetupRows({ + environmentId, +}: { + readonly environmentId: EnvironmentId | null; +}) { + const { data } = useSourceControlDiscovery({ environmentId }); + useSourceControlSetup({ environmentId }); + const git = data?.versionControlSystems.find((item) => item.kind === "git"); + const github = data?.sourceControlProviders.find((item) => item.kind === "github"); + return ( + <> + {git ? ( + + ) : git.versionAdvisory ? ( + + ) : ( + + ) + } + /> + ) : null} + {github ? ( + + ) : ( + + ) + ) : ( + <> + + {github.auth.status === "authenticated" ? ( + + ) : null} + {github.auth.status !== "authenticated" ? ( + + ) : null} + + ) + } + /> + ) : null} + + ); +} + export interface FirstRunSetupCardProps { + readonly setupEnvironmentId?: EnvironmentId | null; /** Enabled and disabled instances alike; disabled ones are filtered out. */ readonly providers: ReadonlyArray; readonly projectName: string | null; @@ -159,6 +260,7 @@ export interface FirstRunSetupCardProps { } export function FirstRunSetupCard({ + setupEnvironmentId, providers, projectName, projectCwd, @@ -225,7 +327,9 @@ export function FirstRunSetupCard({ className="flex w-full max-w-140 flex-col items-center pb-10" data-testid="first-run-setup-card" > - +
    + +

    ) : null} + {projectRowLeads ? null : projectSetupRow} @@ -365,6 +470,7 @@ export function useFirstRunSetupCard(input: UseFirstRunSetupCardInput): FirstRun } return ( tool.target === target); + return job && + (isSourceControlToolBusy(job.status) || job.status === "failed" || job.status === "started") ? ( + + {job.message} + + ) : null; +} + interface CompactVersionAdvisoryProps { readonly advisory: SourceControlToolVersionAdvisory; readonly environmentId: EnvironmentId | null | undefined; @@ -42,8 +70,19 @@ export function CompactVersionAdvisory({ environmentId, label, }: CompactVersionAdvisoryProps) { - const [isUpdating, setIsUpdating] = useState(false); + const [requestPending, setIsUpdating] = useState(false); + const setup = useSourceControlSetup({ environmentId }); const updateAction = advisory.actions.find((action) => action.kind === "runUpdate"); + const job = setup.tools.find((tool) => tool.target === updateAction?.target); + const isUpdating = requestPending || (job !== undefined && isSourceControlToolBusy(job.status)); + const busyLabel = + job?.status === "queued" + ? "Queued" + : job?.status === "checking" + ? "Checking" + : updateAction?.operation === "install" + ? "Installing" + : "Updating"; 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"); @@ -113,18 +152,28 @@ export function CompactVersionAdvisory({ if (advisory.status === "install_available" && updateAction) { return ( - + + + {job && (isUpdating || job.status === "failed" || job.status === "started") ? ( + + {job.message} + + ) : null} + ); } @@ -198,9 +247,17 @@ export function CompactVersionAdvisory({ ) : ( )} - {isUpdating ? "Updating" : updateAction.label} + {isUpdating ? busyLabel : updateAction.label} ) : null} + {job && (isUpdating || job.status === "failed" || job.status === "started") ? ( +

    + {job.message} +

    + ) : null} {copyAction ? (
    diff --git a/apps/web/src/components/settings/GitHubSignInAction.tsx b/apps/web/src/components/settings/GitHubSignInAction.tsx new file mode 100644 index 000000000..eacf80adf --- /dev/null +++ b/apps/web/src/components/settings/GitHubSignInAction.tsx @@ -0,0 +1,104 @@ +import type { EnvironmentId } from "@threadlines/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { openExternalUrl } from "../../lib/externalLinks"; +import { + cancelGitHubSignIn, + startGitHubSignIn, + useSourceControlSetup, +} from "../../lib/sourceControlDiscoveryState"; +import { Button } from "../ui/button"; + +const GITHUB_DEVICE_URL = "https://github.com/login/device"; + +export function GitHubSignInStatus({ + environmentId, +}: { + readonly environmentId?: EnvironmentId | null | undefined; +}) { + const { githubAuth } = useSourceControlSetup({ environmentId }); + return githubAuth.status === "succeeded" && githubAuth.message ? ( + + {githubAuth.message} + + ) : null; +} + +export function GitHubSignInAction({ + environmentId, +}: { + readonly environmentId?: EnvironmentId | null | undefined; +}) { + const { githubAuth } = useSourceControlSetup({ environmentId }); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const openWhenReady = useRef(false); + const running = pending || githubAuth.status === "running"; + const canOpen = githubAuth.verificationUrl === GITHUB_DEVICE_URL; + + useEffect(() => { + if (openWhenReady.current && canOpen && githubAuth.userCode) { + openWhenReady.current = false; + openExternalUrl(GITHUB_DEVICE_URL); + } + }, [canOpen, githubAuth.userCode]); + + const start = () => { + setPending(true); + setError(null); + openWhenReady.current = true; + void startGitHubSignIn({ environmentId }) + .catch((cause: unknown) => { + openWhenReady.current = false; + setError(cause instanceof Error ? cause.message : "Could not start GitHub sign-in."); + }) + .finally(() => setPending(false)); + }; + + return ( +
    + {running ? ( + <> + + {githubAuth.userCode ? ( + <> + Enter code{" "} + {githubAuth.userCode}{" "} + on GitHub. + + ) : ( + "Starting GitHub sign-in…" + )} + + {canOpen ? ( + + ) : null} + + + ) : ( + + )} + {error || githubAuth.status === "failed" ? ( + + {error ?? githubAuth.message} + + ) : null} +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 763502e09..4a51386e6 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -16,6 +16,7 @@ import { type ServerProcessResourceHistoryResult, type ServerProvider, type SourceControlDiscoveryResult, + type SourceControlSetupState, } from "@threadlines/contracts"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as DateTime from "effect/DateTime"; @@ -44,6 +45,7 @@ import { ConnectionsSettings } from "./ConnectionsSettings"; import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; import { SourceControlSettingsPanel } from "./SourceControlSettings"; +import { resetSourceControlDiscoveryStateForTests } from "../../lib/sourceControlDiscoveryState"; /** * The app-wide providers these panels are always mounted under. Settings rows @@ -2447,15 +2449,115 @@ describe("SourceControlSettingsPanel discovery states", () => { function setSourceControlDiscoveryStub( discoverSourceControl: () => Promise, updateSourceControlTool?: LocalApi["server"]["updateSourceControlTool"], + setup: Partial< + Pick + > = {}, ) { + resetSourceControlDiscoveryStateForTests(); window.nativeApi = { server: { discoverSourceControl, + getSourceControlSetup: async () => ({ + tools: [], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }), + ...setup, ...(updateSourceControlTool ? { updateSourceControlTool } : {}), }, - } as LocalApi; + shell: { openExternal: vi.fn(async () => {}) }, + } as unknown as LocalApi; } + it("restores an installation check and supports GitHub browser sign-in, cancellation, and safe links", async () => { + const discovery: SourceControlDiscoveryResult = { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("2.55.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("2.98.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + }, + ], + }; + let state: SourceControlSetupState = { + tools: [ + { + target: "git", + operation: "install", + status: "checking", + message: "Checking the installed Git version…", + }, + ], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }; + let verificationUrl = "https://github.com/login/device"; + const cancel = vi.fn(async () => { + state = { + ...state, + githubAuth: { status: "cancelled", verificationUrl: null, userCode: null, message: null }, + }; + }); + setSourceControlDiscoveryStub(async () => discovery, undefined, { + getSourceControlSetup: async () => state, + startGitHubAuth: async () => { + state = { + ...state, + githubAuth: { status: "running", verificationUrl, userCode: "ABCD-1234", message: null }, + }; + return state.githubAuth; + }, + cancelGitHubAuth: cancel, + }); + mounted = await renderWithTestRouter( + + + , + ); + await expect.element(page.getByText("Checking the installed Git version…")).toBeVisible(); + await page.getByRole("button", { name: "Sign in to GitHub" }).click(); + await expect.element(page.getByText("ABCD-1234", { exact: true })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open GitHub", exact: true })) + .toBeVisible(); + await expect + .poll(() => vi.mocked(window.nativeApi!.shell.openExternal).mock.calls.length) + .toBe(1); + expect(window.nativeApi!.shell.openExternal).toHaveBeenCalledWith( + "https://github.com/login/device", + ); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Sign in to GitHub" })).toBeVisible(); + expect(cancel).toHaveBeenCalledOnce(); + verificationUrl = "https://github.com.evil.example/login/device"; + await page.getByRole("button", { name: "Sign in to GitHub" }).click(); + await expect.element(page.getByText("ABCD-1234", { exact: true })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open GitHub", exact: true })) + .not.toBeInTheDocument(); + expect(window.nativeApi!.shell.openExternal).toHaveBeenCalledTimes(1); + }); + it("shows skeleton sections while the first source control scan is pending", async () => { setSourceControlDiscoveryStub(() => new Promise(() => {})); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 24038df9d..01fc4ce49 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -26,6 +26,7 @@ import { cn } from "../../lib/utils"; import { refreshSourceControlDiscovery, useSourceControlDiscovery, + useSourceControlSetup, } from "../../lib/sourceControlDiscoveryState"; import { useStore } from "../../store"; import { Badge } from "../ui/badge"; @@ -71,7 +72,8 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { CompactVersionAdvisory } from "./CompactVersionAdvisory"; +import { CompactVersionAdvisory, SourceControlToolProgress } from "./CompactVersionAdvisory"; +import { GitHubSignInAction, GitHubSignInStatus } from "./GitHubSignInAction"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], @@ -220,6 +222,8 @@ function itemSummary({ } if (auth.status === "unauthenticated") { + if (item.kind === "github") + return Sign in to browse your repositories and use pull requests.; return ( {item.label} is not authenticated on this server. Sign in or configure credentials using @@ -277,6 +281,11 @@ function DiscoveryItemRow({ environmentId={environmentId} label={item.label} /> + ) : item.kind === "git" || item.kind === "github" ? ( + ) : null} {isVcsNotReady(item) ? ( @@ -294,6 +303,17 @@ function DiscoveryItemRow({

    + {isProviderDiscoveryItem(item) && + item.kind === "github" && + item.auth.status === "authenticated" ? ( + + ) : null} + {isProviderDiscoveryItem(item) && + item.kind === "github" && + item.status === "available" && + item.auth.status !== "authenticated" ? ( + + ) : null} {hasDetails ? (