From d0fe921fb14a8183666a30fe06eb7df9e67f0bf1 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:08:49 -0400 Subject: [PATCH 1/2] Install a missing provider CLI with one click A provider whose CLI is absent now carries an install capability when npm resolves on the server: `npm install -g @latest`, the same command family updates use, run through the same maintenance runner (shared npm-global lock, output capture, updateState progress, post-command re-probe). Verification differs by intent: an install must leave the provider detectable, an update must leave it current; either shortfall reports as unchanged with a plain explanation. The settings card shows Install in its header action group with running and failure states; the first-run setup card upgrades its Install guide action to Install when the capability exists. Both render from streamed snapshots. A configured NPM_CONFIG_PREFIX is honored end to end (baked into the command's environment patch and display string), and with npm absent everything falls back to the linkified guide. Contracts: versionAdvisory gains installCommand/canInstall with decoding defaults for older snapshot caches; server.updateProvider gains an action field instead of a new RPC. --- .../src/provider/Layers/ProviderRegistry.ts | 34 +-- .../src/provider/Services/ProviderRegistry.ts | 10 +- .../makeManagedServerProvider.test.ts | 1 + .../src/provider/providerMaintenance.test.ts | 128 +++++++++-- .../src/provider/providerMaintenance.ts | 96 +++++++-- .../providerMaintenanceRunner.test.ts | 198 +++++++++++++++++- .../src/provider/providerMaintenanceRunner.ts | 110 ++++++++-- ...iderUpdateLaunchNotification.logic.test.ts | 27 ++- .../ProviderUpdateLaunchNotification.logic.ts | 20 +- .../chat/FirstRunSetupCard.browser.tsx | 81 ++++++- .../src/components/chat/FirstRunSetupCard.tsx | 17 ++ .../src/components/chat/firstRunSetup.test.ts | 49 +++++ apps/web/src/components/chat/firstRunSetup.ts | 27 +-- .../settings/ProviderInstallAction.tsx | 119 +++++++++++ .../settings/ProviderInstanceCard.tsx | 24 ++- .../settings/SettingsPanels.browser.tsx | 151 +++++++++++++ .../components/settings/providerInstall.ts | 101 +++++++++ .../src/components/settings/providerStatus.ts | 16 ++ packages/contracts/src/server.ts | 21 ++ 19 files changed, 1138 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/components/settings/ProviderInstallAction.tsx create mode 100644 apps/web/src/components/settings/providerInstall.ts diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 26e53648e..ef8bb1a3f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -44,7 +44,11 @@ import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import { + ProviderRegistry, + type ProviderMaintenanceActionKind, + type ProviderRegistryShape, +} from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -424,7 +428,13 @@ export const ProviderRegistryLive = Layer.effect( ); const providersRef = yield* Ref.make>(cachedProviders); const maintenanceActionStatesRef = yield* Ref.make< - ReadonlyMap + ReadonlyMap< + ProviderInstanceId, + { + readonly action: ProviderMaintenanceActionKind; + readonly state: ServerProviderUpdateState; + } + > >(new Map()); // Live-source registry — the dynamic counterpart to the boot-time @@ -467,7 +477,7 @@ export const ProviderRegistryLive = Layer.effect( provider: ServerProvider, ) { const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); - const updateState = maintenanceActionStates.get(provider.instanceId)?.update; + const updateState = maintenanceActionStates.get(provider.instanceId)?.state; if (!updateState) { const { updateState: _updateState, ...providerWithoutUpdateState } = provider; return providerWithoutUpdateState; @@ -547,23 +557,23 @@ export const ProviderRegistryLive = Layer.effect( const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( function* (input: { readonly instanceId: ProviderInstanceId; - readonly action: "update"; + readonly action: ProviderMaintenanceActionKind; readonly state: ServerProviderUpdateState | null; }) { yield* Ref.update(maintenanceActionStatesRef, (previous) => { - const previousActions = previous.get(input.instanceId); - const nextActions = { ...previousActions }; - if (input.state === null || input.state.status === "idle") { - delete nextActions[input.action]; - } else { - nextActions[input.action] = input.state; + const isCleared = input.state === null || input.state.status === "idle"; + const current = previous.get(input.instanceId); + // Clearing only retires this action's own state; a different action + // that has since taken over the instance keeps reporting. + if (isCleared && current?.action !== input.action) { + return previous; } const next = new Map(previous); - if (Object.keys(nextActions).length === 0) { + if (isCleared || input.state === null) { next.delete(input.instanceId); } else { - next.set(input.instanceId, nextActions); + next.set(input.instanceId, { action: input.action, state: input.state }); } return next; }); diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b9f52069b..3fe2fa522 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -20,7 +20,7 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -export type ProviderMaintenanceActionKind = "update"; +export type ProviderMaintenanceActionKind = "update" | "install"; export interface ProviderRegistryShape { /** @@ -73,9 +73,11 @@ export interface ProviderRegistryShape { /** * Apply volatile maintenance-action state to one configured instance. - * This state is never persisted to disk. Today only update actions are - * projected onto `ServerProvider.updateState`; install/auth actions can - * extend this action map without adding driver-scoped APIs. + * This state is never persisted to disk. An instance runs at most one + * maintenance action at a time (they share the runner's per-instance lock), + * so the latest action's state is the one projected onto + * `ServerProvider.updateState`; a later action replaces an earlier one's + * state rather than competing with it. */ readonly setProviderMaintenanceActionState: (input: { readonly instanceId: ProviderInstanceId; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index cabdbbb5b..a9403c882 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -41,6 +41,7 @@ const maintenanceCapabilities = { lockKey: "npm-global", }, + install: null, manualUpdateCommand: null, advisoryMessage: null, } as const; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 78285ca43..f517c5955 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -18,7 +18,8 @@ import { } from "./providerMaintenance.ts"; const driver = (value: string) => ProviderDriverKind.make(value); -const noManualUpdate = { +const noInstallOrManualUpdate = { + install: null, manualUpdateCommand: null, advisoryMessage: null, } as const; @@ -31,6 +32,25 @@ const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { return path.join(os.tmpdir(), `${name}-${id}`); }); +const WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"; + +/** + * Put an executable named `name` in `dir` for the platform the test is + * actually running on, so PATH lookups behave the way they would in + * production. Returns the directory, for use as a PATH entry. + */ +function writeCommandShim(dir: string, name: string): string { + mkdirSync(dir, { recursive: true }); + if (process.platform === "win32") { + writeFileSync(path.join(dir, `${name}.cmd`), "@echo off\r\n"); + return dir; + } + const commandPath = path.join(dir, name); + writeFileSync(commandPath, "#!/bin/sh\n"); + chmodSync(commandPath, 0o755); + return dir; +} + function linkPackageCommand(input: { readonly packageBinDir: string; readonly packageBinPath: string; @@ -177,7 +197,7 @@ describe("providerMaintenance", () => { lockKey: "static-tool", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -212,7 +232,7 @@ describe("providerMaintenance", () => { lockKey: "vite-plus-global", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -247,7 +267,7 @@ describe("providerMaintenance", () => { lockKey: "bun-global", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -283,7 +303,7 @@ describe("providerMaintenance", () => { lockKey: "pnpm-global", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -316,7 +336,7 @@ describe("providerMaintenance", () => { environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix }, }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -363,7 +383,7 @@ describe("providerMaintenance", () => { lockKey: "homebrew", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -398,7 +418,7 @@ describe("providerMaintenance", () => { lockKey: "native-package-tool-native", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -431,6 +451,7 @@ describe("providerMaintenance", () => { lockKey: "native-package-tool-installer-win32", }, + install: null, manualUpdateCommand: null, advisoryMessage: "Run the native-package-tool Windows installer instead of native-package-tool update.", @@ -456,7 +477,7 @@ describe("providerMaintenance", () => { lockKey: "native-package-tool-native", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -517,11 +538,88 @@ describe("providerMaintenance", () => { lockKey: "scoped-package-tool-native", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); + it.effect("derives an npm global install for a provider CLI that resolves nowhere on PATH", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-install-capabilities"); + const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm"); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: process.platform, + env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT }, + }).install, + ).toEqual({ + command: "npm install -g @example/package-tool@latest", + executable: "npm", + args: ["install", "-g", "@example/package-tool@latest"], + lockKey: "npm-global", + }); + }), + ); + + it.effect("offers no install capability when npm itself is missing", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-install-capabilities-missing"); + const emptyBinDir = writeCommandShim(path.join(tempDir, "empty-bin"), "unrelated-tool"); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: process.platform, + env: { PATH: emptyBinDir, PATHEXT: WINDOWS_PATHEXT }, + }).install, + ).toBeNull(); + }), + ); + + it.effect("scopes the derived install to the configured npm prefix", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-install-capabilities-prefix"); + const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm"); + const npmPrefix = path.join(tempDir, "npm prefix"); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: process.platform, + env: { + PATH: npmBinDir, + PATHEXT: WINDOWS_PATHEXT, + NPM_CONFIG_PREFIX: npmPrefix, + }, + }).install, + ).toEqual({ + command: `npm --prefix "${npmPrefix}" install -g @example/package-tool@latest`, + executable: "npm", + args: ["install", "-g", "@example/package-tool@latest"], + lockKey: "npm-global", + environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix }, + }); + }), + ); + + it.effect("keeps an installed provider free of an install capability", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-install-capabilities-installed"); + const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm"); + writeCommandShim(npmBinDir, "package-tool"); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: process.platform, + env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT }, + }).install, + ).toBeNull(); + }), + ); + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { expect( nativePackageToolUpdate.resolve({ @@ -543,7 +641,7 @@ describe("providerMaintenance", () => { lockKey: "homebrew", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -568,7 +666,7 @@ describe("providerMaintenance", () => { lockKey: "homebrew", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); @@ -612,7 +710,7 @@ describe("providerMaintenance", () => { lockKey: "npm-global", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -661,7 +759,7 @@ describe("providerMaintenance", () => { lockKey: "pnpm-global", }, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }), ); @@ -680,7 +778,7 @@ describe("providerMaintenance", () => { provider: driver("packageTool"), packageName: "@example/package-tool", update: null, - ...noManualUpdate, + ...noInstallOrManualUpdate, }); }); }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index f021d1ee9..ad44774c6 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -22,6 +22,13 @@ export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; 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. + */ + readonly install: ProviderMaintenanceCommandAction | null; readonly manualUpdateCommand: string | null; readonly advisoryMessage: string | null; } @@ -98,6 +105,7 @@ export function makeProviderMaintenanceCapabilities(input: { readonly updateLockKey: string | null; readonly updateDisplayCommand?: string | null | undefined; readonly updateEnvironmentPatch?: Readonly> | undefined; + readonly install?: ProviderMaintenanceCommandAction | null | undefined; readonly manualUpdateCommand?: string | null | undefined; readonly advisoryMessage?: string | null | undefined; }): ProviderMaintenanceCapabilities { @@ -118,6 +126,7 @@ export function makeProviderMaintenanceCapabilities(input: { provider: input.provider, packageName: input.packageName, update, + install: input.install ?? null, manualUpdateCommand: input.manualUpdateCommand ?? null, advisoryMessage: input.advisoryMessage ?? null, }; @@ -140,22 +149,70 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { }); } +/** + * `npm install -g @latest`. Installing and updating an npm-managed CLI + * are the same command, so both capabilities are built from here and share + * the `npm-global` lock key that serializes them against each other. + */ +function makeNpmGlobalCommandAction(input: { + readonly packageName: string; + readonly prefix?: string | undefined; +}): ProviderMaintenanceCommandAction { + const args = ["install", "-g", `${input.packageName}@latest`]; + return { + command: input.prefix + ? `npm --prefix "${input.prefix}" install -g ${input.packageName}@latest` + : ["npm", ...args].join(" "), + executable: "npm", + args, + lockKey: "npm-global", + ...(input.prefix ? { environmentPatch: { NPM_CONFIG_PREFIX: input.prefix } } : {}), + }; +} + +/** + * 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( + definition: PackageManagedProviderMaintenanceDefinition, + options?: ProviderMaintenanceCapabilityResolutionOptions, +): ProviderMaintenanceCommandAction | null { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + if (!resolveCommandPath("npm", { platform, env })) { + return null; + } + const prefix = nonEmptyString(env.NPM_CONFIG_PREFIX); + return makeNpmGlobalCommandAction({ + packageName: definition.npmPackageName, + ...(prefix ? { prefix } : {}), + }); +} + function makeNpmGlobalProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, - prefix?: string, + options?: { + readonly prefix?: string | undefined; + readonly install?: ProviderMaintenanceCommandAction | null | undefined; + }, ): ProviderMaintenanceCapabilities { + const update = makeNpmGlobalCommandAction({ + packageName: definition.npmPackageName, + ...(options?.prefix ? { prefix: options.prefix } : {}), + }); return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, - updateExecutable: "npm", - updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], - updateLockKey: "npm-global", - ...(prefix - ? { - updateDisplayCommand: `npm --prefix "${prefix}" install -g ${definition.npmPackageName}@latest`, - updateEnvironmentPatch: { NPM_CONFIG_PREFIX: prefix }, - } - : {}), + updateExecutable: update.executable, + updateArgs: update.args, + updateLockKey: update.lockKey, + updateDisplayCommand: update.command, + ...(update.environmentPatch ? { updateEnvironmentPatch: update.environmentPatch } : {}), + ...(options?.install ? { install: options.install } : {}), }); } @@ -345,7 +402,9 @@ export function resolvePackageManagedProviderMaintenance( const binaryPath = nonEmptyString(options?.binaryPath); const platform = options?.platform ?? process.platform; if (!binaryPath) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, { + install: resolveNpmGlobalInstallAction(definition, options), + }); } const resolvedCommandPath = @@ -395,7 +454,9 @@ export function resolvePackageManagedProviderMaintenance( platform, }); if (windowsNpmPrefix) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition, windowsNpmPrefix); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, { + prefix: windowsNpmPrefix, + }); } if (commandPaths.some(isNpmGlobalCommandPath)) { return makeNpmGlobalProviderMaintenanceCapabilities(definition); @@ -405,8 +466,15 @@ export function resolvePackageManagedProviderMaintenance( } } + // A bare command name that resolved nowhere: the CLI is not installed, so + // this is the one place an install capability is derived. A bare name that + // did resolve but matched no known manager falls through here too, and + // there is nothing to install for it. if (!hasPathSeparator(binaryPath)) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, { + install: + resolvedCommandPath === null ? resolveNpmGlobalInstallAction(definition, options) : null, + }); } return makeManualOnlyProviderMaintenanceCapabilities({ @@ -514,6 +582,8 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? capabilities.manualUpdateCommand, canUpdate: capabilities.update !== null, + installCommand: capabilities.install?.command ?? null, + canInstall: capabilities.install !== null, checkedAt: input.checkedAt ?? null, message: advisoryMessage, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index b6aaf3128..23010b772 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -18,7 +18,11 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import { + ProviderRegistry, + type ProviderMaintenanceActionKind, + type ProviderRegistryShape, +} from "./Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { clearLatestProviderVersionCacheForTests, @@ -176,6 +180,16 @@ function withProcessPlatform( function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, + options?: { + /** + * Stands in for a real re-probe: the runner refreshes the instance after + * its command finishes, and what that probe now reports is what decides + * whether the run succeeded. + */ + readonly onRefreshInstance?: ( + providers: ReadonlyArray, + ) => ReadonlyArray; + }, ) { return Effect.gen(function* () { const providersRef = yield* Ref.make>( @@ -187,7 +201,7 @@ function makeRegistry( "providerMaintenanceRunner.test.setProviderMaintenanceActionState", )(function* (input: { readonly instanceId: ProviderInstanceId; - readonly action: "update"; + readonly action: ProviderMaintenanceActionKind; readonly state: ServerProviderUpdateState | null; }) { const updateState = input.state; @@ -214,7 +228,10 @@ function makeRegistry( const registry: ProviderRegistryShape = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), - refreshInstance: () => Ref.get(providersRef), + refreshInstance: () => + options?.onRefreshInstance + ? Ref.updateAndGet(providersRef, options.onRefreshInstance) + : Ref.get(providersRef), consumeRateLimitResetCredit: () => Ref.get(providersRef).pipe( Effect.map((providers) => ({ outcome: "nothingToReset" as const, providers })), @@ -232,6 +249,35 @@ function makeRegistry( }); } +const NPM_PREFIX = "C:\\Users\\Alice Smith\\AppData\\Roaming\\npm"; +const missingCodexProvider: ServerProvider = { + ...baseProvider, + installed: false, + version: null, + status: "error", +}; + +/** + * What the resolver hands back for a provider whose CLI is missing: the same + * npm command for both actions, differing only in why it runs. + */ +function codexInstallCapabilities(): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + install: { + command: `npm --prefix "${NPM_PREFIX}" install -g @openai/codex@latest`, + executable: "npm", + args: ["install", "-g", "@openai/codex@latest"], + lockKey: "npm-global", + environmentPatch: { NPM_CONFIG_PREFIX: NPM_PREFIX }, + }, + }); +} + function claudeWindowsUpdateCapabilities(): ProviderMaintenanceCapabilities { return makeProviderMaintenanceCapabilities({ provider: CLAUDE_DRIVER, @@ -298,6 +344,8 @@ describe("providerMaintenanceRunner", () => { latestVersion: "2.1.123", updateCommand: "bun i -g @anthropic-ai/claude-code@latest", canUpdate: true, + installCommand: null, + canInstall: false, checkedAt: "2026-04-30T12:00:00.000Z", message: "Update available.", }, @@ -753,6 +801,150 @@ describe("providerMaintenanceRunner", () => { ), ); + it.effect("installs a missing provider in the npm prefix and records success", () => { + const calls: Array<{ + environment: ChildProcess.CommandOptions["env"]; + extendEnv: ChildProcess.CommandOptions["extendEnv"]; + }> = []; + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(missingCodexProvider, { + onRefreshInstance: (providers) => + providers.map((provider) => + provider.instanceId === CODEX_INSTANCE_ID + ? { ...provider, installed: true, version: "1.0.0", status: "ready" as const } + : provider, + ), + }); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed(codexInstallCapabilities()), + }); + + const result = yield* runner.updateProvider({ + provider: CODEX_DRIVER, + action: "install", + }); + + assert.deepStrictEqual(calls, [ + { + environment: { NPM_CONFIG_PREFIX: NPM_PREFIX }, + extendEnv: true, + }, + ]); + assert.deepStrictEqual( + (yield* Ref.get(updateStatesRef)).map((state) => state.status), + ["queued", "running", "succeeded"], + ); + assert.strictEqual(result.providers[0]?.installed, true); + assert.strictEqual(result.providers[0]?.updateState?.message, "Provider installed."); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("1.0.0"), + mockSpawnerLayer((_command, _args, options) => { + calls.push({ environment: options.env, extendEnv: options.extendEnv }); + return { stdout: "added 1 package" }; + }), + ), + ), + ); + }); + + it.effect("reports an install that left the provider CLI missing as unchanged", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(missingCodexProvider); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed(codexInstallCapabilities()), + }); + + const result = yield* runner.updateProvider({ + provider: CODEX_DRIVER, + action: "install", + }); + + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.include(result.providers[0]?.updateState?.message ?? "", "still cannot find"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("1.0.0"), + mockSpawnerLayer(() => ({ stdout: "added 1 package" })), + ), + ), + ), + ); + + it.effect("queues an install behind an update holding the same package-manager lock", () => { + const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; + const firstStarted = new Promise((resolve) => { + firstStartedLatch.resolve = resolve; + }); + const releaseFirst = new Promise((resolve) => { + releaseFirstLatch.resolve = resolve; + }); + let commandCount = 0; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry([missingCodexProvider, baseOpenCodeProvider]); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + provider === OPENCODE_DRIVER ? lifecycleFor(provider) : codexInstallCapabilities(), + ), + }); + + const update = yield* runner.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => firstStarted); + + const install = yield* runner + .updateProvider({ provider: CODEX_DRIVER, action: "install" }) + .pipe(Effect.forkScoped); + let codexUpdateStatus: string | undefined; + for (let attempt = 0; attempt < 20; attempt += 1) { + codexUpdateStatus = (yield* registry.getProviders).find( + (provider) => provider.instanceId === CODEX_INSTANCE_ID, + )?.updateState?.status; + if (codexUpdateStatus === "queued") { + break; + } + yield* Effect.yieldNow; + } + + // The install waits on the update's `npm-global` lock instead of racing + // it: one package-manager command runs at a time. + assert.strictEqual(codexUpdateStatus, "queued"); + assert.strictEqual(commandCount, 1); + + releaseFirstLatch.resolve(); + yield* Fiber.join(update); + yield* Fiber.join(install); + assert.strictEqual(commandCount, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => { + commandCount += 1; + if (commandCount === 1) { + firstStartedLatch.resolve(); + return { + stdout: "updated", + exitCode: Effect.promise(() => releaseFirst).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; + } + return { stdout: "added 1 package" }; + }), + ), + ), + ); + }); + it.effect("prevents concurrent updates for the same provider", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 1b8f08607..0420084e6 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -4,6 +4,7 @@ import { ServerProviderUpdateError, type ProviderInstanceId, type ServerProvider, + type ServerProviderMaintenanceAction, type ServerProviderUpdateBlockerResolutionResult, type ServerProviderUpdatedPayload, type ServerProviderUpdateState, @@ -70,12 +71,20 @@ export interface ProviderMaintenanceCommandResult { } export interface ProviderMaintenanceRunnerShape { + /** + * Run one package-manager command for the target instance. `action` + * selects which capability supplies the command: `"update"` (default) + * refreshes an installed CLI, `"install"` puts a missing one on the + * machine. Both share this path so they also share the lock, the captured + * output, the progress state, and the post-command re-probe. + */ readonly updateProvider: ( target: | ProviderDriverKind | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly action?: ServerProviderMaintenanceAction | undefined; }, ) => Effect.Effect; readonly resolveUpdateBlockers: ( @@ -151,7 +160,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ - message: `Failed to run update command ${input.command}: ${cause.message}`, + message: `Failed to run ${input.command}: ${cause.message}`, cause, }), ), @@ -175,7 +184,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR Effect.mapError( (cause) => new ProviderMaintenanceCommandError({ - message: cause instanceof Error ? cause.message : "Update command failed to run.", + message: cause instanceof Error ? cause.message : "Provider command failed to run.", cause, }), ), @@ -366,6 +375,60 @@ function isWindowsExecutableReplaceFailure(result: ProviderMaintenanceCommandRes ); } +/** + * Install and update run the same command through the same machinery, so the + * only thing that varies is what we tell the user we are doing. + */ +interface ProviderMaintenanceActionCopy { + readonly running: string; + readonly succeeded: string; + readonly unverified: string; + readonly incomplete: string; + readonly timedOut: string; + readonly failed: string; + readonly unsupported: string; + readonly exitCode: (exitCode: number) => string; +} + +const PROVIDER_MAINTENANCE_ACTION_COPY: Record< + ServerProviderMaintenanceAction, + ProviderMaintenanceActionCopy +> = { + update: { + running: "Updating provider.", + succeeded: "Provider updated.", + unverified: "Update command completed, but Threadlines could not verify the provider version.", + incomplete: + "Update command completed, but Threadlines still detects an outdated provider version.", + timedOut: "Update timed out.", + failed: "Update command failed.", + unsupported: "This provider does not support one-click updates.", + exitCode: (exitCode) => `Update command exited with code ${exitCode}.`, + }, + install: { + running: "Installing provider.", + succeeded: "Provider installed.", + unverified: "Install command completed, but Threadlines could not verify the provider.", + incomplete: "Install command completed, but Threadlines still cannot find the provider CLI.", + timedOut: "Install timed out.", + failed: "Install command failed.", + unsupported: "Threadlines cannot install this provider for you.", + exitCode: (exitCode) => `Install command exited with code ${exitCode}.`, + }, +}; + +/** + * Did the command leave the provider in the state the user asked for? An + * update has to move off the outdated version; an install has to produce a + * CLI the probe can find. + */ +function isProviderMaintenanceIncomplete( + action: ServerProviderMaintenanceAction, + provider: ServerProvider, +): boolean { + return action === "install" ? !provider.installed : isOutdatedProvider(provider); +} + function shouldPrepareSessionsForUpdate(input: { readonly provider: ProviderDriverKind; readonly update: ProviderMaintenanceCapabilities["update"]; @@ -380,17 +443,19 @@ function shouldPrepareSessionsForUpdate(input: { function failureMessage( provider: ProviderDriverKind, result: ProviderMaintenanceCommandResult, + action: ServerProviderMaintenanceAction = "update", ): string { + const copy = PROVIDER_MAINTENANCE_ACTION_COPY[action]; if (result.timedOut) { - return "Update timed out."; + return copy.timedOut; } if (isWindowsExecutableReplaceFailure(result)) { return windowsClaudeProcessLockMessage(provider, 0); } if (result.exitCode !== null && result.exitCode !== 0) { - return `Update command exited with code ${result.exitCode}.`; + return copy.exitCode(result.exitCode); } - return "Update command failed."; + return copy.failed; } function isOutdatedProvider(provider: ServerProvider | undefined): boolean { @@ -432,7 +497,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { makeAlreadyRunningError: () => new ServerProviderUpdateError({ provider: ProviderDriverKind.make("unknown"), - reason: "An update is already running for this provider.", + reason: "Threadlines is already running a command for this provider.", }), }); @@ -574,22 +639,25 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); const targetKey = `instance:${instanceId}`; + const action: ServerProviderMaintenanceAction = + typeof target === "string" ? "update" : (target.action ?? "update"); + const copy = PROVIDER_MAINTENANCE_ACTION_COPY[action]; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, ); - const update = capabilities.update; + const update = action === "install" ? capabilities.install : capabilities.update; if (!update) { return yield* new ServerProviderUpdateError({ provider, - reason: "This provider does not support one-click updates.", + reason: copy.unsupported, }); } const setUpdateState = (state: ServerProviderUpdateState | null) => providerRegistry.setProviderMaintenanceActionState({ instanceId, - action: "update", + action, state, }); const setQueuedState = setUpdateState( @@ -597,7 +665,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "queued", startedAt: null, finishedAt: null, - message: "Waiting for another provider update to finish.", + message: "Waiting for another provider command to finish.", }), ).pipe(Effect.asVoid); @@ -616,7 +684,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "running", startedAt, finishedAt: null, - message: "Updating provider.", + message: copy.running, }), ); @@ -651,7 +719,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "failed", startedAt, finishedAt, - message: failureMessage(provider, result), + message: failureMessage(provider, result, action), output: commandOutput(result), }), ); @@ -663,19 +731,21 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { instanceId, ); const couldNotVerify = verifiedProviders.length === 0; - const stillOutdated = + const isIncomplete = couldNotVerify || - verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); + verifiedProviders.some((verifiedProvider) => + isProviderMaintenanceIncomplete(action, verifiedProvider), + ); return yield* finish( makeUpdateState({ - status: stillOutdated ? "unchanged" : "succeeded", + status: isIncomplete ? "unchanged" : "succeeded", startedAt, finishedAt, message: couldNotVerify - ? "Update command completed, but Threadlines could not verify the provider version." - : stillOutdated - ? "Update command completed, but Threadlines still detects an outdated provider version." - : "Provider updated.", + ? copy.unverified + : isIncomplete + ? copy.incomplete + : copy.succeeded, output: commandOutput(result), }), ); @@ -691,7 +761,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "failed", startedAt, finishedAt: yield* nowIso, - message: failure instanceof Error ? failure.message : "Update command failed.", + message: failure instanceof Error ? failure.message : copy.failed, output: null, }), ); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 7eaa27846..61c4317d6 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -38,13 +38,14 @@ function provider(input: { readonly canUpdate?: boolean; readonly updateCommand?: string | null; readonly updateState?: ServerProvider["updateState"]; + readonly installed?: boolean; readonly advisoryStatus?: NonNullable["status"]; }): ServerProvider { const result: ServerProvider = { instanceId: input.instanceId ?? instanceId(String(input.driver)), driver: input.driver, enabled: input.enabled ?? true, - installed: true, + installed: input.installed ?? true, version: input.version ?? "1.0.0", status: "ready", auth: { status: "authenticated" }, @@ -58,6 +59,8 @@ function provider(input: { latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", canUpdate: input.canUpdate ?? true, + installCommand: null, + canInstall: false, checkedAt, message: "Update available.", }, @@ -509,6 +512,28 @@ describe("provider update launch notification logic", () => { ).toEqual([cursor]); }); + it("calls a run against a missing CLI an install, not an update", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ + driver: driver("claudeAgent"), + installed: false, + version: null, + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Installing provider.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + statusChipLabel: "Installing", + description: "Claude install in progress.", + }); + }); + it("summarizes active provider updates for the sidebar pill", () => { const view = getProviderUpdateSidebarPillView([ provider({ diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 572b1836d..4777472ba 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -464,21 +464,31 @@ function getProviderUpdateSidebarItemTone( } } +/** + * A provider with no CLI on the machine has no update to run, so a + * maintenance command against it is an install. The runner writes both to the + * same state, and this is where the sidebar tells them apart. + */ +function isProviderInstallState(provider: Pick): boolean { + return !provider.installed; +} + function getProviderUpdateSidebarStatusLabel( - provider: Pick, + provider: Pick, status: ProviderUpdateSidebarPillItemStatus, ): string { + const isInstalling = isProviderInstallState(provider); switch (status) { case "queued": return "Queued"; case "running": - return "Updating"; + return isInstalling ? "Installing" : "Updating"; case "succeeded": return provider.version ? formatVersion(provider.version) : "Updated"; case "failed": return "Failed"; case "unchanged": - return "Needs update"; + return isInstalling ? "Not installed" : "Needs update"; } } @@ -582,7 +592,9 @@ export function getProviderUpdateSidebarPillView( description: items.length > 1 ? formatProviderUpdateSidebarItemDescription(items) - : `${formatProviderList(activeProviders)} update in progress.`, + : `${formatProviderList(activeProviders)} ${ + activeProviders.every(isProviderInstallState) ? "install" : "update" + } in progress.`, items, }; } diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index 9a6f32a77..3d61ac1fa 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -41,12 +41,22 @@ const providerAuthHarness = vi.hoisted(() => { readonly listener: (event: AuthEvent) => void; }>(); const startCalls: Array<{ instanceId: string; flow: string }> = []; + const installCalls: Array<{ provider: string; instanceId?: string; action?: string }> = []; return { startCalls, + installCalls, + // The install row calls the same server RPC the Update button uses. + server: { + updateProvider: (input: { provider: string; instanceId?: string; action?: string }) => { + installCalls.push(input); + return Promise.resolve({ providers: [] }); + }, + }, reset() { listeners.clear(); startCalls.length = 0; + installCalls.length = 0; }, emit(event: AuthEvent) { for (const entry of listeners) { @@ -75,7 +85,9 @@ const providerAuthHarness = vi.hoisted(() => { }); vi.mock("../../environments/runtime", () => { - const primaryConnection = { client: { providerAuth: providerAuthHarness.client } } as never; + const primaryConnection = { + client: { providerAuth: providerAuthHarness.client, server: providerAuthHarness.server }, + } as never; const notUsed = () => undefined as never; return { environmentUsesRelayTransport: () => false, @@ -165,6 +177,31 @@ const MISSING_CLAUDE = buildProvider({ auth: { status: "unknown" }, }); +/** Same missing CLI, but the server derived an install command for it. */ +function withInstallCapability( + provider: FirstRunSetupProvider, + updateState?: ServerProvider["updateState"], +): FirstRunSetupProvider { + return { + ...provider, + snapshot: { + ...provider.snapshot, + versionAdvisory: { + status: "unknown", + currentVersion: null, + latestVersion: null, + updateCommand: null, + canUpdate: false, + installCommand: "npm install -g @anthropic-ai/claude-code@latest", + canInstall: true, + checkedAt: null, + message: null, + }, + ...(updateState ? { updateState } : {}), + }, + }; +} + const SIGNED_IN_CLAUDE = buildProvider({ instanceId: "claudeAgent", driver: "claudeAgent", @@ -327,6 +364,48 @@ describe("FirstRunSetupCard", () => { await screen.unmount(); }); + it("installs a missing agent from the row instead of pointing at a guide", async () => { + const screen = await renderCard({ + providers: [withInstallCapability(MISSING_CLAUDE)], + projectName: "B-git-project", + }); + + // Never both: an install the row can run replaces the guide link. + expect(document.querySelector('a[href="/settings/providers"]')).toBeNull(); + + await page.getByRole("button", { name: "Install Claude" }).click(); + + await vi.waitFor(() => { + expect(providerAuthHarness.installCalls).toEqual([ + { provider: "claudeAgent", instanceId: "claudeAgent", action: "install" }, + ]); + }); + + await screen.unmount(); + }); + + it("reports a live install on the row it belongs to", async () => { + const screen = await renderCard({ + providers: [ + withInstallCapability(MISSING_CLAUDE, { + status: "running", + startedAt: "2026-08-06T00:00:00.000Z", + finishedAt: null, + message: "Installing provider.", + output: "added 1 package", + }), + ], + projectName: "B-git-project", + }); + + await expect.element(page.getByText("Installing… added 1 package")).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Install Claude" })) + .not.toBeInTheDocument(); + + await screen.unmount(); + }); + it("enables the start action once one agent is signed in and a folder exists", async () => { const onStart = vi.fn(); const onSkip = vi.fn(); diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index 6b60fd277..198f43d65 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -12,6 +12,11 @@ * session the Providers settings panel runs and reports it in place; only a * run that stalls long enough to need a real terminal hands off to settings. * + * A missing CLI works the same way when the server can install it: the row + * runs the install and reports progress in place, then re-derives from the + * snapshot into the sign-in state. Rows fall back to the install guide only + * when there is no install to run. + * * No container: typography, spacing, and hairline dividers on the empty * canvas, matching the rest of the app. * @@ -23,6 +28,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { cn } from "../../lib/utils"; import { ProjectFavicon } from "../ProjectFavicon"; +import { ProviderInstallAction } from "../settings/ProviderInstallAction"; import { useProviderConnectFlow } from "../settings/useProviderConnectFlow"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { Button } from "../ui/button"; @@ -146,6 +152,17 @@ function providerRowAction(row: FirstRunProviderRow): ReactNode { if (row.state === "needsSignIn" && row.signInCommand) { return ; } + if (row.install) { + return ( + + ); + } return ( + )} + + ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 9329adf97..b5931c1d9 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -68,12 +68,15 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderUsageDashboard } from "../ProviderUsageDashboard"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + firstSentenceOf, getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, getProviderSummary, getProviderVersionLabel, type ProviderStatusKey, } from "./providerStatus"; +import { deriveProviderInstallView } from "./providerInstall"; +import { ProviderInstallAction } from "./ProviderInstallAction"; const PROVIDER_ACCENT_SWATCHES = ["#00347D", "#16a34a", "#ea580c", "#dc2626", "#7c3aed"] as const; const PROVIDER_UPDATE_OUTPUT_PREVIEW_CHARS = 700; @@ -1216,6 +1219,12 @@ export function ProviderInstanceCard({ : null; const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); + const providerInstallView = deriveProviderInstallView(liveProvider); + // With an Install button on the row, the detail keeps its diagnosis and + // drops the manual install recipe: never both at once. + const summaryDetail = providerInstallView + ? firstSentenceOf(summary.detail) + : (summary.detail ?? null); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; const providerUpdateState = liveProvider?.updateState ?? null; @@ -1478,9 +1487,9 @@ export function ProviderInstanceCard({ )} - {summary.detail ? ( + {summaryDetail ? ( - - + - ) : null}

@@ -1660,7 +1669,16 @@ export function ProviderInstanceCard({ /> ) : null} -
+
+ {providerInstallView && driverKind ? ( + + ) : null}