diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts new file mode 100644 index 000000000000..9ca95ec930e0 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts @@ -0,0 +1,125 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import * as ClaudeAdapterV2 from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ModelManifest from "../ModelManifest.ts"; +import { installFakeMise } from "../testUtils/fakeMise.ts"; +import { ClaudeDriver } from "./ClaudeDriver.ts"; + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-claude-driver-maintenance-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(IdAllocator.layer), + // Resolving maintenance never opens a Claude session. + Layer.provideMerge(Layer.mock(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner)({})), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled Claude must not make an HTTP request")), + ), + ), +); + +// These write `#!/bin/sh` stubs and symlinks, which Windows cannot resolve. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +it.layer(testLayer)("ClaudeDriver", (it) => { + it.effect.each([ + { layout: "native", miseOnPath: false, expected: "claude-update" }, + { layout: "mise wrapper", miseOnPath: false, expected: "manual" }, + { layout: "mise wrapper", miseOnPath: true, expected: "mise-upgrade" }, + ] as const)( + "resolves updates for a $layout at ~/.local/bin/claude (mise on PATH: $miseOnPath)", + ({ layout, miseOnPath, expected }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-claude-update-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const binaryPath = path.join(home, ".local", "bin", "claude"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + const fake = installFakeMise(path.join(home, "mise-bin", "mise"), { + which: { claude: path.join(home, ".local/share/mise/installs/claude/2.1.5/bin/claude") }, + ls: { + claude: [ + { + version: "2.1.5", + install_path: path.join(home, ".local/share/mise/installs/claude/2.1.5"), + active: true, + }, + ], + }, + outdated: {}, + }); + if (layout === "native") { + const versionPath = path.join(home, ".local", "share", "claude", "versions", "2.1.0"); + yield* fs.makeDirectory(path.dirname(versionPath), { recursive: true }); + yield* fs.writeFileString(versionPath, "#!/bin/sh\n"); + yield* fs.chmod(versionPath, 0o755); + yield* fs.symlink(versionPath, binaryPath); + } else { + const miseBin = path.join(home, ".local/share/mise/installs/claude/2.1.5/bin/claude"); + yield* fs.makeDirectory(path.dirname(miseBin), { recursive: true }); + yield* fs.writeFileString(miseBin, "#!/bin/sh\n"); + yield* fs.writeFileString( + binaryPath, + '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g --quiet "claude" || exit 1\nexec mise x "claude" -- "claude" "$@"\n', + ); + yield* fs.chmod(binaryPath, 0o755); + } + + const instance = yield* ClaudeDriver.create({ + instanceId: ProviderInstanceId.make("claude-update"), + displayName: "Claude test", + enabled: false, + environment: [ + { name: "HOME", value: home, sensitive: false }, + { + name: "PATH", + value: miseOnPath ? path.dirname(fake.misePath) : "", + sensitive: false, + }, + ], + config: { ...ClaudeDriver.defaultConfig(), binaryPath }, + }); + + const update = (yield* instance.snapshot.resolveMaintenance()).update; + if (expected === "claude-update") { + expect(update).toMatchObject({ executable: binaryPath, args: ["update"] }); + } else if (expected === "mise-upgrade") { + expect(update).toMatchObject({ + executable: fake.misePath, + args: ["upgrade", "--no-prune", "claude"], + }); + } else { + // `claude update` would only report that a package manager owns it. + expect(update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 1673d3e6d639..b469a2f56853 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -75,12 +75,16 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +/** + * POSIX native launchers symlink into `~/.local/share/claude/`, so a plain + * `~/.local/bin/claude` is someone else's launcher (Omarchy's mise wrapper), + * where `claude update` reports a package manager and changes nothing. + * Windows copies `claude.exe` there instead of linking it. + */ function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( - normalized.endsWith("/.local/bin/claude") || - normalized.endsWith("/.local/bin/claude.exe") || - normalized.includes("/.local/share/claude/") + normalized.endsWith("/.local/bin/claude.exe") || normalized.includes("/.local/share/claude/") ); } diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 4d1a4ce3519f..f764c05b515a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -326,12 +326,12 @@ it.layer(testLayer)("CodexDriver", (it) => { for (const fixture of [ { - name: "leaves mise npm-backend installations manual-only", + name: "leaves mise npm-backend installations manual-only without a mise to confirm them", installSegments: ["mise", "installs", "npm-openai-codex", "0.110.0"], npmOwned: false, }, { - name: "leaves mise tool aliases backed by npm manual-only", + name: "leaves mise tool aliases backed by npm manual-only without a mise to confirm them", installSegments: ["mise", "installs", "codex", "0.110.0"], npmOwned: false, }, @@ -373,7 +373,8 @@ it.layer(testLayer)("CodexDriver", (it) => { instanceId: ProviderInstanceId.make("codex-installer"), displayName: "Codex installer test", enabled: false, - environment: [], + // No mise on PATH: these cases exercise the path rules alone. + environment: [{ name: "PATH", value: "", sensitive: false }], config: { ...CodexDriver.defaultConfig(), binaryPath, @@ -405,42 +406,44 @@ it.layer(testLayer)("CodexDriver", (it) => { } for (const layout of ["direct", "wrapper"] as const) { - it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs - .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) - .pipe(Effect.flatMap((directory) => fs.realPath(directory))); - const binaryPath = - layout === "direct" - ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") - : NodePath.join(tempDir, "omarchy", "bin", "codex"); - yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); - yield* fs.writeFileString( - binaryPath, - layout === "direct" - ? "#!/bin/sh\n" - : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', - ); - yield* fs.chmod(binaryPath, 0o755); - - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), - displayName: "Codex mise test", - enabled: false, - environment: [], - config: { - ...CodexDriver.defaultConfig(), + it.effect.skipIf(windowsHost)( + `leaves a mise ${layout} installation manual-only without a mise to confirm it`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const binaryPath = + layout === "direct" + ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") + : NodePath.join(tempDir, "omarchy", "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString( binaryPath, - homePath: NodePath.join(tempDir, "codex-home"), - }, - }); + layout === "direct" + ? "#!/bin/sh\n" + : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', + ); + yield* fs.chmod(binaryPath, 0o755); - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), - Effect.scoped, - ), + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), + displayName: "Codex mise test", + enabled: false, + environment: [{ name: "PATH", value: "", sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), ); } @@ -517,19 +520,26 @@ it.layer(testLayer)("CodexDriver", (it) => { NodePath.dirname(brewPath), ].join(NodePath.delimiter); const probes: Array> = []; + const miseProbes: Array> = []; const metadataSpawner = ChildProcessSpawner.make((command) => { - if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + if ( + !ChildProcess.isStandardCommand(command) || + (command.command !== brewPath && command.command !== misePath) + ) { return Effect.die("Provider resolution must not execute a provider or updater"); } - probes.push(command.args); - const stdout = - command.args[0] === "--prefix" + // This mise has no tool behind the shim, so it cannot own Codex. + const isMise = command.command === misePath; + (isMise ? miseProbes : probes).push(command.args); + const stdout = isMise + ? "" + : command.args[0] === "--prefix" ? brewPrefix : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(isMise ? 1 : 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -563,6 +573,7 @@ it.layer(testLayer)("CodexDriver", (it) => { ), ); expect(probes).toEqual([]); + expect(miseProbes).toEqual(fixture.nodeFirst ? [] : [["which", fixture.commandName]]); expect(latestVersion).toBe("0.153.4"); expect( createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts index a9d6791a3e50..2feb6b004de7 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -13,6 +13,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import { installFakeMise } from "../testUtils/fakeMise.ts"; import { GrokDriver } from "./GrokDriver.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; @@ -84,6 +85,47 @@ it.layer(testLayer)("GrokDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "upgrades a mise-managed Grok through mise, not `grok update`", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-grok-mise-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const toolDir = path.join(tempDir, "mise", "installs", "npm-xai-official-grok"); + const installPath = path.join(toolDir, "1.0.0"); + yield* fs.makeDirectory(path.join(installPath, "bin"), { recursive: true }); + yield* fs.writeFileString(path.join(installPath, "bin", "grok"), "#!/bin/sh\n"); + yield* fs.chmod(path.join(installPath, "bin", "grok"), 0o755); + yield* fs.symlink("1.0.0", path.join(toolDir, "latest")); + const binaryPath = path.join(toolDir, "latest", "bin", "grok"); + const tool = "npm:@xai-official/grok"; + const fake = installFakeMise(path.join(tempDir, "bin", "mise"), { + ls: { + [tool]: [{ version: "1.0.0", install_path: installPath, active: true }], + }, + outdated: { [tool]: { latest: "1.1.0" } }, + }); + + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-mise"), + displayName: "Grok test", + enabled: false, + environment: [{ name: "PATH", value: path.dirname(fake.misePath), sensitive: false }], + config: { ...GrokDriver.defaultConfig(), binaryPath }, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.update).toMatchObject({ + executable: fake.misePath, + args: ["upgrade", "--no-prune", tool], + }); + expect(capabilities.latestVersion).toBe("1.1.0"); + }).pipe(Effect.scoped), + ); + it.effect("stays manual-only when the configured executable does not exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index c056a740cfac..f377f354dc38 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -37,6 +37,8 @@ import { makeManualOnlyProviderMaintenanceCapabilities, makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, + type ProviderMaintenanceResolutionContext, + resolveMiseProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -51,27 +53,38 @@ const DRIVER_KIND = ProviderDriverKind.make("grok"); // by default, so the registry stays the source for "latest". const GROK_NPM_PACKAGE = "@xai-official/grok"; // `grok update` finds the installer that owns the binary itself, so the -// resolved executable is its own updater. It installs under `GROK_HOME`, so it -// runs with the instance's environment. No executable means nothing to update, -// not "whatever is on PATH". +// resolved executable is its own updater, unless mise owns it: mise pins the +// version, so only `mise upgrade` moves it. It installs under `GROK_HOME`, so +// it runs with the instance's environment. No executable means nothing to +// update, not "whatever is on PATH". const UPDATE: ProviderMaintenanceCapabilitiesResolver = { - resolve: (context) => - Effect.succeed( - context - ? makeProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: GROK_NPM_PACKAGE, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["update"], - updateLockKey: "grok", - platform: context.platform, - env: context.env, - }) - : makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: GROK_NPM_PACKAGE, - }), - ), + resolve: Effect.fn("resolveGrokMaintenance")(function* ( + context: ProviderMaintenanceResolutionContext | null, + ) { + if (!context) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + }); + } + const mise = yield* resolveMiseProviderMaintenance({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + context, + }); + if (mise.kind === "decided") { + return mise.capabilities; + } + return makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "grok", + platform: context.platform, + env: context.env, + }); + }), }; export type GrokDriverEnv = diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 332a5e931d29..253bd1ccf424 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2662,7 +2662,10 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { if (command._tag !== "StandardCommand") return spawner.spawn(command); - spawnedCommands.push(command.command); + // Other providers may probe their installers (mise, brew) on the host. + if (command.command === firstMissing || command.command === secondMissing) { + spawnedCommands.push(command.command); + } const beforeSpawn = command.command === secondMissing ? Deferred.succeed(secondProbeStarted, undefined).pipe( diff --git a/apps/server/src/provider/installerProbe.ts b/apps/server/src/provider/installerProbe.ts new file mode 100644 index 000000000000..75f706d0c8fb --- /dev/null +++ b/apps/server/src/provider/installerProbe.ts @@ -0,0 +1,60 @@ +import { causeErrorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; + +/** + * Run a read-only installer query (`brew info`, `mise ls`, …) and return its + * stdout, or null on a non-zero exit, timeout, or oversized output. + */ +export const runInstallerProbe = Effect.fn("runInstallerProbe")(function* (input: { + readonly executable: string; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly timeout: Duration.Input; + readonly maxBytes: number; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const collect = Effect.gen(function* () { + // Windows installers may be `.cmd` shims, which need the shell to launch. + const resolved = yield* resolveSpawnCommand(input.executable, input.args, { + env: input.env, + extendEnv: true, + }); + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { + shell: resolved.shell, + env: input.env, + extendEnv: true, + }), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // stderr is drained so a chatty installer cannot block on a full pipe. + const [stdout, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout, maxBytes: input.maxBytes }), + child.exitCode, + Stream.runDrain(child.stderr), + ], + { concurrency: "unbounded" }, + ); + return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text; + }); + return yield* collect.pipe( + Effect.scoped, + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrNull), + Effect.catchCause((cause) => + Effect.logWarning("Installer probe failed", { + executable: input.executable, + subcommand: input.args[0], + errorTag: causeErrorTag(cause), + }).pipe(Effect.as(null)), + ), + ); +}); diff --git a/apps/server/src/provider/miseOwnership.test.ts b/apps/server/src/provider/miseOwnership.test.ts new file mode 100644 index 000000000000..f58a0b93f4db --- /dev/null +++ b/apps/server/src/provider/miseOwnership.test.ts @@ -0,0 +1,829 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +import { + createProviderVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + resolvePackageManagedProviderMaintenance, + resolveProviderMaintenanceCapabilitiesEffect, +} from "./providerMaintenance.ts"; +import { installFakeMise } from "./testUtils/fakeMise.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const claudeUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: CLAUDE, + npmPackageName: "@anthropic-ai/claude-code", + nativeUpdate: null, +}); + +// Shims, wrappers, and the fake mise are POSIX scripts and symlinks. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +/** A sandbox whose paths contain spaces, as a user's home or data dir may. */ +function makeSandbox(prefix = "t3 mise ") { + const root = NodeFS.mkdtempSync(NodePath.join(NodeFS.realpathSync(NodeOS.tmpdir()), prefix)); + return { root, dataDir: NodePath.join(root, "mise data") }; +} + +function writeScript(path: string, content: string) { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content); + NodeFS.chmodSync(path, 0o755); +} + +/** + * Install `/installs///`, a script that prints its + * version, and point mise's `latest` link at it, as an install or upgrade does. + */ +function installTool(dataDir: string, directory: string, version: string, bin: string) { + const toolDir = NodePath.join(dataDir, "installs", directory); + writeScript(NodePath.join(toolDir, version, bin), `#!/bin/sh\necho ${version}\n`); + NodeFS.rmSync(NodePath.join(toolDir, "latest"), { force: true }); + NodeFS.symlinkSync(version, NodePath.join(toolDir, "latest")); + return { + toolDir, + installPath: NodePath.join(toolDir, version), + latestBin: NodePath.join(toolDir, "latest", bin), + }; +} + +function runLauncher(path: string, env: NodeJS.ProcessEnv = {}) { + return NodeChildProcess.spawnSync(path, [], { + env: { ...process.env, ...env }, + encoding: "utf8", + }).stdout.trim(); +} + +function miseListing( + tool: string, + installPath: string, + version: string, + options?: { readonly active?: boolean; readonly requested?: string }, +) { + return { + [tool]: [ + { + version, + install_path: installPath, + requested_version: options?.requested ?? "latest", + active: options?.active ?? true, + }, + ], + }; +} + +function miseOutdated(tool: string, latest: string) { + return { [tool]: { latest } }; +} + +it.layer(NodeServices.layer)("mise provider ownership", (it) => { + it.effect.each([ + { + name: "double-quoted alias", + execLine: 'exec mise x "claude" -- "claude" "$@"', + tool: "claude", + }, + { + name: "single-quoted npm backend", + execLine: "exec mise x 'npm:@anthropic-ai/claude-code' -- 'claude' \"$@\"", + tool: "npm:@anthropic-ai/claude-code", + }, + { + name: "bare words through mise exec", + execLine: 'exec mise exec claude -- claude "$@"', + tool: "claude", + }, + { + name: "a moving request that matches mise's config", + execLine: 'exec mise x "claude@latest" -- "claude" "$@"', + tool: "claude", + which: "which --tool claude@latest claude", + }, + ])( + "upgrades the tool an Omarchy wrapper runs: $name", + ({ execLine, tool, which = "which claude" }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "mise bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing(tool, claude.installPath, "2.1.0"), + outdated: miseOutdated(tool, "2.1.5"), + }); + const wrapperDir = NodePath.join(root, ".local", "bin"); + writeScript( + NodePath.join(wrapperDir, "claude"), + [ + "#!/bin/bash", + // The bug in #9225 read the first mise command anywhere in the file. + '# exec mise x "other-tool" -- "other-tool" "$@"', + "export MISE_MINIMUM_RELEASE_AGE=0", + 'mise use -g --quiet "other-tool" || exit 1', + execLine, + "", + ].join("\n"), + ); + const env = { + PATH: [wrapperDir, NodePath.dirname(fake.misePath)].join(NodePath.delimiter), + HOME: root, + MISE_DATA_DIR: dataDir, + }; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: "claude", + env, + }); + + // The wrapper's own export reaches mise exactly as it would at launch. + const launcherEnv = { ...env, MISE_MINIMUM_RELEASE_AGE: "0" }; + expect(capabilities.update).toEqual({ + command: `'${fake.misePath}' upgrade --no-prune ${tool}`, + executable: fake.misePath, + args: ["upgrade", "--no-prune", tool], + lockKey: "mise", + env: launcherEnv, + }); + expect(capabilities.latestVersion).toBe("2.1.5"); + expect(fake.calls()).toEqual([ + { dataDir, args: which }, + { dataDir, args: "ls --installed --json" }, + { dataDir, args: `outdated --json ${tool}` }, + ]); + + // The runner spawns the action over the server's environment. + const update = capabilities.update!; + const result = NodeChildProcess.spawnSync(update.executable, update.args, { + env: { ...process.env, ...update.env }, + }); + expect(result.status).toBe(0); + expect(fake.calls().at(-1)).toEqual({ dataDir, args: `upgrade --no-prune ${tool}` }); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "follows the patched Omarchy wrapper: conditional setup, then an unconditional exec of a shim", + () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + // Not on PATH: only the shim's link can lead resolution to this mise. + const fake = installFakeMise(NodePath.join(root, "opt", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: {}, + }); + NodeFS.mkdirSync(NodePath.join(dataDir, "shims"), { recursive: true }); + NodeFS.symlinkSync(fake.misePath, NodePath.join(dataDir, "shims", "claude")); + const binaryPath = NodePath.join(root, ".local", "bin", "claude"); + writeScript( + binaryPath, + [ + "#!/bin/bash", + 'shim="$HOME/mise data/shims/claude"', + "if [[ ! -x $shim ]]; then", + " export MISE_MINIMUM_RELEASE_AGE=0", + ' flock "$HOME/.config/mise/.wrapper.lock" mise use -g "claude" >/dev/null || exit 1', + " [[ -x $shim ]] || mise reshim >/dev/null 2>&1", + "fi", + 'exec "$shim" "$@"', + "", + ].join("\n"), + ); + const env = { PATH: "", HOME: root }; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath, + env, + }); + + expect(capabilities.update).toMatchObject({ + executable: fake.misePath, + args: ["upgrade", "--no-prune", "claude"], + }); + // The release-age export only runs on first install, so it is not carried. + expect(capabilities.update?.env).toEqual(env); + // Up to date within its request: the installed version is the target. + expect(capabilities.latestVersion).toBe("2.1.0"); + }), + ); + + it.effect.skipIf(windowsHost)( + "never credits a wrapper with an exec it skips, the review's conditional launcher", + () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: {}, + }); + const shim = NodePath.join(dataDir, "shims", "claude"); + NodeFS.mkdirSync(NodePath.dirname(shim), { recursive: true }); + NodeFS.symlinkSync(fake.misePath, shim); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript( + wrapper, + `#!/bin/sh\nif false; then\n exec '${shim}' "$@"\nfi\nprintf unrelated\n`, + ); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: "" }, + }); + + expect(runLauncher(wrapper)).toBe("unrelated"); + expect(capabilities.update).toBeNull(); + expect(fake.calls()).toEqual([]); + }), + ); + + it.effect.each([ + { + name: "only a commented-out line mentions mise", + script: '#!/bin/sh\n# exec mise x "claude" -- "claude" "$@"\nexec /bin/sh "$@"\n', + }, + { + name: "the exec sits inside a branch", + script: '#!/bin/sh\nif [ -n "$X" ]; then exec mise x claude -- claude "$@"; fi\n', + }, + { + name: "a branch may launch something else and exit first", + script: + '#!/bin/sh\nif [ -n "$X" ]; then\n other "$@"\n exit\nfi\nexec mise x claude -- claude "$@"\n', + }, + { + name: "an unconditional exit comes first", + script: '#!/bin/sh\nexit 0\nexec mise x claude -- claude "$@"\n', + }, + { + name: "the exec'd variable is only assigned on some runs", + script: + '#!/bin/sh\nshim=/usr/bin/true\nif [ -n "$X" ]; then\n shim="$HOME/shims/claude"\nfi\nexec "$shim" "$@"\n', + }, + { + name: "mise runs a script rather than the tool's own binary", + script: '#!/bin/sh\nexec mise x node -- node "$HOME/cli.js" "$@"\n', + }, + { + name: "the exec line uses shell features", + script: '#!/bin/sh\nexec mise x "$(pick-tool)" -- claude "$@"\n', + }, + { + name: "the launcher sources another file", + script: '#!/bin/sh\n. "$HOME/env"\nexec mise x claude -- claude "$@"\n', + }, + ])( + "never runs mise when $name", + ({ script }) => + Effect.gen(function* () { + const { root } = makeSandbox(); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), {}); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript(wrapper, script); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: NodePath.dirname(fake.misePath), HOME: root }, + }); + + expect(capabilities.update).toBeNull(); + expect(fake.calls()).toEqual([]); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "probes and upgrades the data root a wrapper selects, not the server's", + () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const wrapperRoot = NodePath.join(root, "wrapper root"); + const claude = installTool(wrapperRoot, "claude", "2.1.0", "bin/claude"); + // Only the PATH the wrapper sets leads to this mise. + const fake = installFakeMise(NodePath.join(root, "wrapper bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: miseOutdated("claude", "2.1.5"), + }); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript( + wrapper, + [ + "#!/bin/sh", + 'MISE_DATA_DIR="$HOME/wrapper root"', + "export MISE_DATA_DIR", + 'export PATH="$HOME/wrapper bin:$PATH"', + 'exec mise x claude -- claude "$@"', + "", + ].join("\n"), + ); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: "/usr/bin:/bin", HOME: root, MISE_DATA_DIR: dataDir }, + }); + + expect(capabilities.update).toMatchObject({ + executable: fake.misePath, + args: ["upgrade", "--no-prune", "claude"], + env: { MISE_DATA_DIR: wrapperRoot }, + }); + expect(fake.calls().map((call) => call.dataDir)).toEqual([ + wrapperRoot, + wrapperRoot, + wrapperRoot, + ]); + }), + ); + + it.effect.each([ + { + name: "changes the data root on only some runs", + setup: 'if [ -n "$X" ]; then\n export MISE_DATA_DIR=/elsewhere\nfi', + }, + { name: "changes directory", setup: 'cd "$HOME/project"' }, + { name: "sets the data root from a command", setup: "export MISE_DATA_DIR=$(pick-root)" }, + { name: "sets the config root to an unknown value", setup: 'export MISE_CONFIG_DIR="$NOPE"' }, + ])( + "stays manual-only without probing when the wrapper $name", + ({ setup }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: {}, + }); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript(wrapper, `#!/bin/sh\n${setup}\nexec mise x claude -- claude "$@"\n`); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: NodePath.dirname(fake.misePath), HOME: root, MISE_DATA_DIR: dataDir }, + }); + + expect(capabilities.update).toBeNull(); + expect(fake.calls()).toEqual([]); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "upgrades a wrapper's explicit @latest without touching the config's pin", + () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const pinned = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const explicit = installTool(dataDir, "claude", "2.1.5", "bin/claude"); + // mise lists the explicit selection's install as inactive: the config selects 2.1.0. + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: explicit.latestBin }, + ls: { + claude: [ + { + version: "2.1.0", + install_path: pinned.installPath, + requested_version: "2.1.0", + active: true, + }, + { version: "2.1.5", install_path: explicit.installPath, active: false }, + ], + }, + outdated: miseOutdated("claude", "2.2.0"), + }); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript(wrapper, '#!/bin/sh\nexec mise x "claude@latest" -- "claude" "$@"\n'); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: NodePath.dirname(fake.misePath), MISE_DATA_DIR: dataDir }, + }); + + expect(capabilities.update?.args).toEqual(["upgrade", "--no-prune", "claude@latest"]); + expect(capabilities.latestVersion).toBe("2.2.0"); + expect(fake.calls().map((call) => call.args)).toEqual([ + "which --tool claude@latest claude", + "ls --installed --json", + "outdated --json claude@latest", + ]); + }), + ); + + it.effect.each([ + { name: "a fixed version in the wrapper", spec: "claude@2.0.0", requested: "latest" }, + { name: "a request mise's config does not make", spec: "claude@2", requested: "latest" }, + { name: "a different tool", spec: "npm:other", requested: "latest" }, + ])( + "stays manual-only when the wrapper runs $name", + ({ spec, requested }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0", { requested }), + outdated: {}, + }); + const wrapper = NodePath.join(root, "wrapper", "claude"); + writeScript(wrapper, `#!/bin/sh\nexec mise x "${spec}" -- "claude" "$@"\n`); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: wrapper, + env: { PATH: NodePath.dirname(fake.misePath) }, + }); + + expect(capabilities.update).toBeNull(); + }), + { skip: windowsHost }, + ); + + it.effect.each([ + { name: "a link to mise's latest", link: "latest", target: "2.1.5", follows: true }, + { name: "a range link the upgrade stays in", link: "2.1", target: "2.1.5", follows: true }, + { name: "a range link the upgrade leaves", link: "2.1", target: "2.2.0", follows: false }, + { name: "a link straight into a version", link: "2.1.0", target: "2.1.5", follows: false }, + ])( + "offers an update through $name only when the launch follows it", + ({ link, target, follows }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + if (link === "2.1") NodeFS.symlinkSync("2.1.0", NodePath.join(claude.toolDir, "2.1")); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: miseOutdated("claude", target), + }); + const launchers = { + link: NodePath.join(root, "links", "claude"), + wrapper: NodePath.join(root, "wrapper", "claude"), + }; + NodeFS.mkdirSync(NodePath.dirname(launchers.link)); + NodeFS.symlinkSync(NodePath.join(claude.toolDir, link, "bin", "claude"), launchers.link); + writeScript(launchers.wrapper, `#!/bin/sh\nexec '${launchers.link}' "$@"\n`); + const env = { PATH: NodePath.dirname(fake.misePath), MISE_DATA_DIR: dataDir }; + + for (const launcher of Object.values(launchers)) { + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: launcher, + env, + }); + expect(capabilities.update?.args ?? null).toEqual( + follows ? ["upgrade", "--no-prune", "claude"] : null, + ); + } + + // What mise does on upgrade: install the target, move `latest` and the range link. + installTool(dataDir, "claude", target, "bin/claude"); + if (link === "2.1" && target.startsWith("2.1.")) { + NodeFS.rmSync(NodePath.join(claude.toolDir, "2.1")); + NodeFS.symlinkSync(target, NodePath.join(claude.toolDir, "2.1")); + } + for (const launcher of Object.values(launchers)) { + expect(runLauncher(launcher)).toBe(follows ? target : "2.1.0"); + } + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "treats an exact pin as current instead of advertising an unreachable update", + () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const tool = "npm:@anthropic-ai/claude-code"; + const claude = installTool( + dataDir, + "npm-anthropic-ai-claude-code", + "2.1.0", + "lib/node_modules/@anthropic-ai/claude-code/cli.js", + ); + // `mise outdated` omits a tool pinned to its installed version. + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + ls: miseListing(tool, claude.installPath, "2.1.0", { requested: "2.1.0" }), + outdated: {}, + }); + const binaryPath = NodePath.join(root, "links", "claude"); + NodeFS.mkdirSync(NodePath.dirname(binaryPath)); + NodeFS.symlinkSync(claude.latestBin, binaryPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath, + env: { PATH: NodePath.dirname(fake.misePath), MISE_DATA_DIR: dataDir }, + }); + + expect(capabilities.update?.args).toEqual(["upgrade", "--no-prune", tool]); + expect( + createProviderVersionAdvisory({ + driver: CLAUDE, + currentVersion: "2.1.0", + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ status: "current", canUpdate: true, canInstallVersion: false }); + }), + ); + + it.effect.each([ + { name: "no mise is on PATH", mise: "missing" }, + { name: "`mise ls` fails", mise: "failing" }, + { name: "`mise ls` prints invalid JSON", mise: "invalid" }, + ] as const)( + "keeps a mise npm backend in a custom data root from npm when $name", + ({ mise }) => + Effect.gen(function* () { + const { root } = makeSandbox("t3 tools "); + const dataDir = NodePath.join(root, "custom-tools"); + const claude = installTool( + dataDir, + "npm-anthropic-ai-claude-code", + "1.0.0", + "lib/node_modules/@anthropic-ai/claude-code/cli.js", + ); + const fake = installFakeMise( + NodePath.join(root, "bin", "mise"), + mise === "invalid" ? { ls: "not json" } : {}, + ); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: claude.latestBin, + env: { + PATH: mise === "missing" ? "" : NodePath.dirname(fake.misePath), + MISE_DATA_DIR: dataDir, + }, + }); + + expect(capabilities.update).toBeNull(); + }), + { skip: windowsHost }, + ); + + it.effect.each([ + { name: "through a shim, as mise reports it", via: "shim" }, + { name: "directly, when mise cannot list its installs", via: "path" }, + ] as const)( + "keeps npm updates for a global under mise's Node $name", + ({ via }) => + Effect.gen(function* () { + const { root } = makeSandbox("t3 tools "); + const dataDir = NodePath.join(root, "custom-tools"); + const node = NodePath.join(dataDir, "installs", "node", "24.0.0"); + const entry = NodePath.join( + node, + "lib", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js", + ); + writeScript(entry, "#!/bin/sh\n"); + NodeFS.mkdirSync(NodePath.join(node, "bin")); + NodeFS.symlinkSync(entry, NodePath.join(node, "bin", "claude")); + const fake = installFakeMise( + NodePath.join(root, "bin", "mise"), + via === "shim" + ? { + which: { claude: NodePath.join(node, "bin", "claude") }, + ls: miseListing("node", node, "24.0.0"), + } + : {}, + ); + const shimDir = NodePath.join(dataDir, "shims"); + NodeFS.mkdirSync(shimDir, { recursive: true }); + NodeFS.symlinkSync(fake.misePath, NodePath.join(shimDir, "claude")); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: "claude", + env: { + PATH: + via === "shim" + ? shimDir + : [NodePath.join(node, "bin"), NodePath.dirname(fake.misePath)].join( + NodePath.delimiter, + ), + MISE_DATA_DIR: dataDir, + }, + }); + + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", node, "@anthropic-ai/claude-code@latest"]), + }); + }), + { skip: windowsHost }, + ); + + it.effect.each( + (["standard", "custom"] as const).flatMap((root) => + ( + [ + { + name: "an alias for mise's Node", + tool: "node-lts", + backend: "core:node", + owner: "npm", + }, + { + name: "an alias for an npm backend", + tool: "claude", + backend: "npm:@anthropic-ai/claude-code", + owner: "mise", + }, + { + name: "an alias whose backend mise cannot report", + tool: "node-lts", + backend: null, + owner: "manual", + }, + ] as const + ).map((fixture) => ({ ...fixture, root })), + ), + )( + "decides a node_modules binary under $name in the $root data root by mise's backend", + ({ tool, owner, backend, root: rootKind }) => + Effect.gen(function* () { + // The standard root's `/mise/installs/` path is the one the npm guard watches. + const { root } = makeSandbox("t3 tools "); + const dataDir = + rootKind === "standard" + ? NodePath.join(root, ".local", "share", "mise") + : NodePath.join(root, "custom-tools"); + const install = NodePath.join(dataDir, "installs", tool, "24.0.0"); + const entry = NodePath.join( + install, + "lib", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js", + ); + writeScript(entry, "#!/bin/sh\n"); + NodeFS.mkdirSync(NodePath.join(install, "bin")); + NodeFS.symlinkSync(entry, NodePath.join(install, "bin", "claude")); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: NodePath.join(install, "bin", "claude") }, + ls: miseListing(tool, install, "24.0.0"), + // Node's own latest must never be advertised as the provider's. + outdated: miseOutdated(tool, "24.1.0"), + ...(backend === null ? {} : { backends: { [tool]: backend } }), + }); + const shimDir = NodePath.join(dataDir, "shims"); + NodeFS.mkdirSync(shimDir, { recursive: true }); + NodeFS.symlinkSync(fake.misePath, NodePath.join(shimDir, "claude")); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: "claude", + env: { + PATH: shimDir, + HOME: root, + ...(rootKind === "custom" ? { MISE_DATA_DIR: dataDir } : {}), + }, + }); + + expect(fake.calls().map((call) => call.args)).toContain(`tool --backend ${tool}`); + if (owner === "npm") { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", install, "@anthropic-ai/claude-code@latest"]), + }); + } else if (owner === "mise") { + expect(capabilities.update?.args).toEqual(["upgrade", "--no-prune", tool]); + expect(capabilities.latestVersion).toBe("24.1.0"); + } else { + expect(capabilities.update).toBeNull(); + } + }), + { skip: windowsHost }, + ); + + it.effect.each([ + { + name: "a version directory `mise activate` put on PATH", + launch: "version-directory", + active: true, + }, + { name: "a version mise's config does not select", launch: "latest-link", active: false }, + ] as const)( + "stays manual-only for $name", + ({ launch, active }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + ls: miseListing("claude", claude.installPath, "2.1.0", { active }), + outdated: miseOutdated("claude", "2.1.5"), + }); + const binDir = + launch === "version-directory" + ? NodePath.join(claude.installPath, "bin") + : NodePath.dirname(claude.latestBin); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: "claude", + env: { PATH: [binDir, NodePath.dirname(fake.misePath)].join(NodePath.delimiter) }, + }); + + expect(capabilities.update).toBeNull(); + expect(fake.calls().map((call) => call.args)).toEqual(["ls --installed --json"]); + }), + { skip: windowsHost }, + ); + + it.effect.each([ + { name: "mise has no tool for the shim", which: false, ls: {} }, + { name: "`mise ls` prints something unexpected", which: true, ls: "not json" }, + { name: "the bin is outside every install", which: true, ls: {} }, + ])( + "stays manual-only when $name", + ({ which, ls }) => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + ...(which ? { which: { claude: claude.latestBin } } : {}), + ls, + }); + const shim = NodePath.join(dataDir, "shims", "claude"); + NodeFS.mkdirSync(NodePath.dirname(shim), { recursive: true }); + NodeFS.symlinkSync(fake.misePath, shim); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: shim, + env: { PATH: "" }, + }); + + expect(capabilities.update).toBeNull(); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)("leaves installs mise does not report to other installers", () => + Effect.gen(function* () { + const { root } = makeSandbox(); + // asdf shares mise's `installs//` layout. + const prefix = NodePath.join(root, ".asdf", "installs", "nodejs", "24.0.0"); + const entry = NodePath.join( + prefix, + "lib", + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js", + ); + writeScript(entry, "#!/bin/sh\n"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { ls: {} }); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(claudeUpdate, { + binaryPath: entry, + env: { PATH: NodePath.dirname(fake.misePath), HOME: root }, + }); + + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", prefix]), + }); + }), + ); + + it.effect.skipIf(windowsHost)("recognizes a Windows shim beside mise's installs", () => + Effect.gen(function* () { + const { root, dataDir } = makeSandbox(); + const claude = installTool(dataDir, "claude", "2.1.0", "bin/claude.exe"); + const fake = installFakeMise(NodePath.join(root, "bin", "mise"), { + which: { claude: claude.latestBin }, + ls: miseListing("claude", claude.installPath, "2.1.0"), + outdated: miseOutdated("claude", "2.1.5"), + }); + const shim = NodePath.join(dataDir, "shims", "claude.cmd"); + writeScript(shim, "@echo off\r\n"); + const env = { PATH: NodePath.dirname(fake.misePath) }; + + const capabilities = yield* resolvePackageManagedProviderMaintenance( + { provider: CLAUDE, npmPackageName: "@anthropic-ai/claude-code", nativeUpdate: null }, + { + binaryPath: "claude", + resolvedCommandPath: shim, + realCommandPath: shim, + env, + platform: "win32", + }, + ); + + expect(capabilities.update).toMatchObject({ + executable: fake.misePath, + args: ["upgrade", "--no-prune", "claude"], + }); + expect(capabilities.latestVersion).toBe("2.1.5"); + }), + ); +}); diff --git a/apps/server/src/provider/miseOwnership.ts b/apps/server/src/provider/miseOwnership.ts new file mode 100644 index 000000000000..c5994a80f5a2 --- /dev/null +++ b/apps/server/src/provider/miseOwnership.ts @@ -0,0 +1,909 @@ +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { runInstallerProbe } from "./installerProbe.ts"; +import type { ProviderMaintenanceResolutionContext } from "./providerMaintenance.ts"; + +const MISE_PROBE_TIMEOUT = Duration.seconds(10); +// `outdated` asks each backend's registry for new versions. +const MISE_OUTDATED_TIMEOUT = Duration.seconds(30); +// `ls` prints every installed version of every tool. +const MISE_PROBE_MAX_BYTES = 4 * 1_024 * 1_024; +const LAUNCHER_SCRIPT_MAX_BYTES = 8_192; +const SYMLINK_HOPS_MAX = 40; + +export type MiseOwnership = + /** Nothing ties the executable to mise; the other installers decide. */ + | { readonly kind: "unrelated" } + /** Mise's Node runs a package that its own npm installed globally; npm owns it. */ + | { + readonly kind: "npm-global"; + readonly resolvedCommandPath: string; + readonly realCommandPath: string; + /** + * The Node install mise reported for an aliased Node such as `node-lts`; + * null when the literal `node` install directory is the only evidence. + */ + readonly provenNodePrefix: string | null; + } + /** Mise is involved, but `mise upgrade` cannot be shown to reach the executable. */ + | { readonly kind: "uncertain"; readonly reason: string } + | { + readonly kind: "tool"; + /** The mise that resolved ownership; the upgrade must run the same one. */ + readonly executable: string; + /** The environment the provider's launcher gives mise; the upgrade runs in it too. */ + readonly env: NodeJS.ProcessEnv; + /** + * What `mise upgrade` and `mise outdated` take: the tool as mise's config + * names it (alias or backend spec), or `@latest` for a launcher + * that runs that explicit selection instead of the configured one. + */ + readonly selection: string; + /** Newest version `mise upgrade` installs within the configured request. */ + readonly latestVersion: string | null; + }; + +/** How the provider executable reaches mise, read from the filesystem alone. */ +type MiseLaunch = + | { readonly kind: "none" } + /** A launcher reaches mise in a way T3 Code cannot follow. */ + | { readonly kind: "declined"; readonly reason: string } + /** The executable is a file inside a tool's install directory. */ + | { + readonly kind: "install-tree"; + readonly launchPath: string; + readonly realPath: string; + readonly env: NodeJS.ProcessEnv; + } + /** A mise shim or `mise exec` picks the binary named `bin` at run time. */ + | { + readonly kind: "bin"; + readonly bin: string; + /** The mise binary the launcher runs, when it names one; otherwise mise on PATH. */ + readonly executable: string | null; + /** The tool spec a `mise exec` wrapper requests. */ + readonly spec: string | null; + readonly env: NodeJS.ProcessEnv; + }; + +const UNRELATED: MiseOwnership = { kind: "unrelated" }; +const NO_LAUNCH: MiseLaunch = { kind: "none" }; + +/** + * Ask mise which tool owns the provider executable. Probes run with the + * environment the provider's launcher runs with (`MISE_DATA_DIR`, + * `MISE_CONFIG_DIR`, PATH) and the server's cwd, like the provider's own + * version probe, so they see the installation and config the provider runs + * with. Only paths that already point at mise start a probe. + */ +export const resolveMiseOwnership = Effect.fn("resolveMiseOwnership")(function* ( + context: ProviderMaintenanceResolutionContext, +) { + const launch = yield* findMiseLaunch(context); + switch (launch.kind) { + case "none": + return UNRELATED; + case "declined": + return uncertain(launch.reason); + case "install-tree": { + const executable = yield* findMiseOnPath(launch.env); + const owner = + executable === null + ? null + : yield* findOwningTool({ + executable, + env: launch.env, + platform: context.platform, + launchPath: launch.launchPath, + binPath: launch.launchPath, + realBinPath: launch.realPath, + spec: null, + }); + if (owner) return owner; + // asdf shares the `installs//` layout, so only this + // environment's own mise data root keeps the path from the npm rules. + const installRelativePath = yield* pathBelowMiseInstalls( + launch.realPath, + launch.env, + context.platform, + ); + if (installRelativePath === null) return UNRELATED; + if (/^node\/[^/]+\/(?:.*\/)?node_modules\//.test(installRelativePath)) { + return { + kind: "npm-global", + resolvedCommandPath: launch.launchPath, + realCommandPath: launch.realPath, + provenNodePrefix: null, + } satisfies MiseOwnership; + } + return uncertain( + `${launch.realPath} is inside mise's installs, but mise could not say which tool owns it.`, + ); + } + case "bin": { + const executable = launch.executable ?? (yield* findMiseOnPath(launch.env)); + if (!executable) { + return uncertain(`\`${launch.bin}\` runs through mise, but no mise executable is on PATH.`); + } + const spec = launch.spec === null ? null : splitToolSpec(launch.spec); + // `which --tool` resolves the launcher's own selection without + // installing it, which `mise exec` would do. + const binPath = (yield* runInstallerProbe({ + executable, + args: spec?.version ? ["which", "--tool", launch.spec!, launch.bin] : ["which", launch.bin], + env: launch.env, + timeout: MISE_PROBE_TIMEOUT, + maxBytes: MISE_PROBE_MAX_BYTES, + }))?.trim(); + if (!binPath) { + return uncertain(`mise has no active tool that provides \`${launch.bin}\`.`); + } + const fileSystem = yield* FileSystem.FileSystem; + const realBinPath = yield* fileSystem + .realPath(binPath) + .pipe(Effect.orElseSucceed(() => null)); + if (!realBinPath) { + return uncertain(`mise resolves \`${launch.bin}\` to ${binPath}, which does not exist.`); + } + const owner = yield* findOwningTool({ + executable, + env: launch.env, + platform: context.platform, + // Shims and `mise exec` pick the version at run time, so nothing pins it. + launchPath: null, + binPath, + realBinPath, + spec, + }); + return ( + owner ?? + uncertain(`mise could not attribute \`${launch.bin}\` (${binPath}) to an installed tool.`) + ); + } + } +}); + +function uncertain(reason: string): MiseOwnership { + return { kind: "uncertain", reason }; +} + +/** + * Recognize the ways a mise-managed provider shows up: a shim (on POSIX a + * symlink to the mise binary, which dispatches on the name it was run as), a + * path inside a tool's install directory, or a launcher script whose final + * `exec` runs a shim, an install path, or `mise exec`. Omarchy writes such + * launchers to `~/.local/bin/`. + */ +const findMiseLaunch = Effect.fn("findMiseLaunch")(function* ( + context: ProviderMaintenanceResolutionContext, +) { + const shim = yield* readMiseShim({ + launchPath: context.resolvedCommandPath, + realPath: context.realCommandPath, + platform: context.platform, + env: context.env, + }); + if (shim) return shim; + if (isInsideInstallTree(context.realCommandPath)) { + return { + kind: "install-tree", + launchPath: context.resolvedCommandPath, + realPath: context.realCommandPath, + env: context.env, + } satisfies MiseLaunch; + } + + const launcher = yield* readLauncher(context.realCommandPath, context.env); + if (!launcher) return NO_LAUNCH; + const target = yield* readLaunchTarget({ + argv: launcher.argv, + env: launcher.kind === "followed" ? launcher.env : context.env, + platform: context.platform, + }); + if (target.kind === "none" || launcher.kind === "followed") return target; + return { kind: "declined", reason: launcher.reason } satisfies MiseLaunch; +}); + +/** + * POSIX shims are symlinks to the mise binary. Windows shims are `.cmd` + * files or copies of `mise-shim.exe` in `/shims`, next to `installs`. + */ +const readMiseShim = Effect.fn("readMiseShim")(function* (input: { + readonly launchPath: string; + readonly realPath: string; + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; +}) { + const path = yield* Path.Path; + const bin = commandName(input.launchPath, path); + if (bin.toLowerCase() === "mise") return null; + const realName = commandName(input.realPath, path).toLowerCase(); + if (realName === "mise" || realName === "mise-shim") { + return { + kind: "bin", + bin, + executable: realName === "mise" ? input.realPath : null, + spec: null, + env: input.env, + } satisfies MiseLaunch; + } + if (input.platform !== "win32") return null; + const shimDir = path.dirname(input.launchPath); + if (path.basename(shimDir).toLowerCase() !== "shims") return null; + const fileSystem = yield* FileSystem.FileSystem; + const hasInstalls = yield* fileSystem + .exists(path.join(path.dirname(shimDir), "installs")) + .pipe(Effect.orElseSucceed(() => false)); + return hasInstalls + ? ({ kind: "bin", bin, executable: null, spec: null, env: input.env } satisfies MiseLaunch) + : null; +}); + +/** What a launcher's final `exec` runs: `mise exec`, a shim, or an install path. */ +const readLaunchTarget = Effect.fn("readLaunchTarget")(function* (input: { + readonly argv: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; +}) { + const path = yield* Path.Path; + const miseExec = readMiseExec(input.argv, input.env, path); + if (miseExec) return miseExec; + const target = input.argv[0]!; + if (!path.isAbsolute(target)) return NO_LAUNCH; + const fileSystem = yield* FileSystem.FileSystem; + const realTarget = yield* fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)); + if (!realTarget) return NO_LAUNCH; + const targetShim = yield* readMiseShim({ + launchPath: target, + realPath: realTarget, + platform: input.platform, + env: input.env, + }); + if (targetShim) return targetShim; + return isInsideInstallTree(realTarget) + ? ({ + kind: "install-tree", + launchPath: target, + realPath: realTarget, + env: input.env, + } satisfies MiseLaunch) + : NO_LAUNCH; +}); + +function commandName(commandPath: string, path: Path.Path): string { + return path.basename(commandPath).replace(/\.(?:exe|cmd|bat|ps1)$/i, ""); +} + +function isInsideInstallTree(realPath: string): boolean { + return realPath.replaceAll("\\", "/").includes("/installs/"); +} + +/** + * `[/path/to/]mise x|exec -- `, with no options before `--` and + * no arguments after ``: in `mise x node -- node cli.js` the provider is + * `cli.js`, not the node tool. + */ +function readMiseExec( + argv: ReadonlyArray, + env: NodeJS.ProcessEnv, + path: Path.Path, +): MiseLaunch | null { + if (argv.length !== 5) return null; + const [mise, subcommand, spec, separator, bin] = argv; + if (!mise || commandName(mise, path).toLowerCase() !== "mise") return null; + if (subcommand !== "x" && subcommand !== "exec") return null; + if (!spec || spec.startsWith("-") || separator !== "--" || !bin) return null; + if (mise !== "mise" && !path.isAbsolute(mise)) return null; + return { kind: "bin", bin, executable: mise === "mise" ? null : mise, spec, env }; +} + +/** + * `claude`, `npm:@openai/codex`, `claude@latest`, `npm:@openai/codex@0.1`. + * The `@` that opens an npm scope follows the backend's colon or starts the spec. + */ +function splitToolSpec(spec: string): ToolSpec { + const at = spec.lastIndexOf("@"); + return at > 0 && spec[at - 1] !== ":" + ? { tool: spec.slice(0, at), version: spec.slice(at + 1) } + : { tool: spec, version: null }; +} + +interface ToolSpec { + readonly tool: string; + readonly version: string | null; +} + +/** + * What a launcher script's final `exec` runs. `followed` carries the + * environment the script hands to it. `declined` means the script changes + * mise's environment or working directory in a way T3 Code does not follow, + * so ownership must not be proven against the original environment. + */ +type Launcher = + | { + readonly kind: "followed"; + readonly argv: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + } + | { readonly kind: "declined"; readonly argv: ReadonlyArray; readonly reason: string }; + +const readLauncher = Effect.fn("readLauncher")(function* ( + scriptPath: string, + env: NodeJS.ProcessEnv, +) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(scriptPath).pipe(Effect.orElseSucceed(() => null)); + if (!info || info.type !== "File" || Number(info.size) > LAUNCHER_SCRIPT_MAX_BYTES) return null; + const script = yield* fileSystem + .readFileString(scriptPath) + .pipe(Effect.orElseSucceed(() => null)); + return script?.startsWith("#!") ? readLauncherScript(script, env) : null; +}); + +// Mise settings that only shape which version an install picks, not which +// installation or config is in play. Omarchy's launchers set this one. +const VERSION_POLICY_VARIABLES = new Set(["MISE_MINIMUM_RELEASE_AGE"]); +// Commands whose effect on later lines T3 Code cannot follow. +const OPAQUE_COMMANDS = new Set([ + "source", + ".", + "eval", + "unset", + "read", + "mapfile", + "readarray", + "getopts", + "local", + "declare", + "typeset", + "readonly", + "alias", + "trap", +]); +const UNSUPPORTED_RESERVED_WORDS = new Set([ + "case", + "esac", + "for", + "select", + "while", + "until", + "do", + "done", + "function", + "{", + "}", + "time", + "coproc", +]); +const ASSIGNMENT_WORD = /^([A-Za-z_]\w*)=(.*)$/s; + +interface LauncherState { + /** Shell variables the script assigned; null when the value is unknown. */ + readonly variables: Map; + readonly env: NodeJS.ProcessEnv; + unsupported: string | null; +} + +/** + * Read a launcher script without running it. The only command T3 Code + * attributes to the launcher is an unconditional top-level `exec`: anything + * before it is setup, and `|| exit` may end the script early but cannot run + * anything else. An `exec` inside an `if`, a plain `exit`, or a construct this + * reader does not model (loops, `case`, functions, subshells, `$(…)`, + * `source`) means it cannot say what the launcher runs. Variables are only + * known when assigned unconditionally at the top level; a conditional + * assignment makes the variable unknown. Changes to mise's environment are + * carried into the probes, or declined when they are conditional or + * unevaluable, except version policy that does not choose the installation. + */ +function readLauncherScript(script: string, env: NodeJS.ProcessEnv): Launcher | null { + const state: LauncherState = { variables: new Map(), env: { ...env }, unsupported: null }; + let depth = 0; + for (const line of script.split(/\r?\n/)) { + if (line.trimEnd().endsWith("\\")) return null; + const segments = splitShellCommands(line); + if (!segments) return null; + for (const segment of segments) { + let words = segment.words; + if (words[0] === "if") { + depth += 1; + words = words.slice(1); + } else if (words[0] === "then" || words[0] === "else" || words[0] === "elif") { + if (depth === 0) return null; + words = words.slice(1); + } else if (words[0] === "fi") { + if (depth === 0 || words.length > 1) return null; + depth -= 1; + continue; + } + if (words[0] === "!") words = words.slice(1); + if (words.length === 0) continue; + if (UNSUPPORTED_RESERVED_WORDS.has(words[0]!)) return null; + + const conditional = depth > 0 || segment.before === "&&" || segment.before === "||"; + const inPipeline = segment.before === "|" || segment.after === "|"; + const assignmentCount = words.findIndex((word) => !ASSIGNMENT_WORD.test(word)); + if (assignmentCount === -1) { + // Pipeline members run in subshells; their assignments do not stick. + if (inPipeline) return null; + for (const word of words) + recordLauncherWrite(state, word, { exported: false, conditional }); + continue; + } + const [command, ...args] = words.slice(assignmentCount); + switch (command) { + case "exec": { + if (depth > 0 || segment.before !== null || segment.after !== null) return null; + if (assignmentCount > 0) return null; + const argv = readExecArgv(args, state); + if (!argv) return null; + return state.unsupported === null + ? { kind: "followed", argv, env: state.env } + : { kind: "declined", argv, reason: state.unsupported }; + } + case "exit": + case "return": + case "logout": + if (segment.before !== "||") return null; + continue; + case "export": + if (inPipeline) return null; + for (const arg of args) { + if (arg.startsWith("-")) return null; + if (ASSIGNMENT_WORD.test(arg)) { + recordLauncherWrite(state, arg, { exported: true, conditional }); + } else { + recordLauncherExport(state, arg, conditional); + } + } + continue; + case "cd": + case "pushd": + case "popd": + state.unsupported ??= + "The launcher changes directory before it runs the tool, so mise would read a different config."; + continue; + case "set": + if (args.some((arg) => /^-\w*a/.test(arg) || arg === "allexport")) return null; + continue; + default: + if (command && OPAQUE_COMMANDS.has(command)) return null; + } + } + } + return null; +} + +/** Words of one line's simple commands, with the list operator on each side. */ +function splitShellCommands(line: string): Array<{ + readonly words: ReadonlyArray; + readonly before: string | null; + readonly after: string | null; +}> | null { + const segments: Array<{ words: Array; before: string | null; after: string | null }> = []; + let current: { words: Array; before: string | null; after: string | null } = { + words: [], + before: null, + after: null, + }; + const tokenPattern = + /\s*(?:(#.*)|(\|\||&&|;;|[;|&()])|((?:\d*[<>]+&?|[^\s'"`;&|()<>]|'[^']*'|"(?:[^"\\`]|\\.)*")+))/y; + let position = 0; + for (;;) { + tokenPattern.lastIndex = position; + const match = tokenPattern.exec(line); + if (!match) break; + position = tokenPattern.lastIndex; + if (match[1] !== undefined) break; + const operator = match[2]; + if (operator === undefined) { + // A heredoc's body would follow on lines read as commands. + if (/^\d*< 0) segments.push(current); + current = { words: [], before: operator === ";" ? null : operator, after: null }; + } + if (current.words.length > 0) segments.push(current); + // An unterminated quote leaves text the token pattern could not consume. + return line.slice(position).trim() === "" ? segments : null; +} + +/** The exec'd argv, expanded, with a trailing `"$@"` dropped. */ +function readExecArgv( + words: ReadonlyArray, + state: LauncherState, +): ReadonlyArray | null { + const expanded: Array = []; + for (const word of words) { + const value = expandShellWord(word, state); + if (value === null) return null; + expanded.push(value); + } + const argv = expanded.at(-1) === "$@" ? expanded.slice(0, -1) : expanded; + if (argv.length === 0 || argv[0]!.startsWith("-")) return null; + return argv.some((word) => word.includes("$@")) ? null : argv; +} + +function steersMise(name: string): boolean { + return /^(?:MISE_|XDG_)/.test(name) || name === "PATH" || name === "HOME"; +} + +function recordLauncherWrite( + state: LauncherState, + word: string, + options: { readonly exported: boolean; readonly conditional: boolean }, +) { + const [, name, raw] = ASSIGNMENT_WORD.exec(word)!; + if (options.conditional) { + state.variables.set(name!, null); + if (steersMise(name!) && !VERSION_POLICY_VARIABLES.has(name!)) { + state.unsupported ??= `The launcher changes ${name} on only some runs.`; + } + return; + } + const value = expandShellWord(raw!, state); + // A plain assignment to a variable already in the environment changes what + // the exec'd process inherits, like `export` does. + const variableName: string = name!; + const reachesChild = options.exported || variableName in state.env; + state.variables.set(name!, value); + if (!reachesChild || !steersMise(name!)) return; + if (value === null) { + state.unsupported ??= `The launcher sets ${name} to a value T3 Code cannot evaluate.`; + return; + } + state.env[name!] = value; +} + +function recordLauncherExport(state: LauncherState, name: string, conditional: boolean) { + if (!steersMise(name) || !state.variables.has(name)) return; + const value = state.variables.get(name); + if (conditional || value === null || value === undefined) { + if (!VERSION_POLICY_VARIABLES.has(name)) { + state.unsupported ??= `The launcher exports ${name} with a value T3 Code cannot follow.`; + } + return; + } + state.env[name] = value; +} + +/** + * Expand one shell word: bare text, `'…'`, and `"…"`, with `~`, `$NAME`, and + * `${NAME}` taken from the launcher's variables or environment. `$@` stays + * literal. Escapes, substitutions, globs, and unknown variables yield null. + */ +function expandShellWord(raw: string, state: LauncherState): string | null { + let word = ""; + for (const [segment] of raw.matchAll(/'[^']*'|"[^"]*"|[^'"]+/g)) { + if (segment.startsWith("'")) { + word += segment.slice(1, -1); + continue; + } + const quoted = segment.startsWith('"'); + const text = quoted ? segment.slice(1, -1) : segment; + if (/[\\`]/.test(text) || (!quoted && /[;&|<>()*?[\]]/.test(text))) return null; + let homeExpanded = text; + if (!quoted && word === "" && /^~(?:\/|$)/.test(text)) { + const home = lookupLauncherVariable(state, "HOME"); + if (home === undefined) return null; + homeExpanded = `${home}${text.slice(1)}`; + } + const expanded = expandShellVariables(homeExpanded, state); + if (expanded === null) return null; + word += expanded; + } + return word; +} + +function expandShellVariables(text: string, state: LauncherState): string | null { + let unknown = false; + const expanded = text.replace( + /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*)|(@))/g, + (_, braced: string | undefined, bare: string | undefined, all: string | undefined) => { + if (all) return "$@"; + const value = lookupLauncherVariable(state, (braced ?? bare)!); + if (value === undefined) unknown = true; + return value ?? ""; + }, + ); + // Any `$` left over is an expansion we do not evaluate. + return unknown || expanded.replaceAll("$@", "").includes("$") ? null : expanded; +} + +function lookupLauncherVariable(state: LauncherState, name: string): string | undefined { + return state.variables.has(name) ? (state.variables.get(name) ?? undefined) : state.env[name]; +} + +const findMiseOnPath = (env: NodeJS.ProcessEnv) => + resolveCommandPath("mise", { env }).pipe( + Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), + ); + +/** + * Where `realPath` sits below this environment's mise `installs/`, or null. + * Mirrors mise's data dir: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise` or + * `~/.local/share/mise`, and `%LOCALAPPDATA%\mise` on Windows. + */ +const pathBelowMiseInstalls = Effect.fn("pathBelowMiseInstalls")(function* ( + realPath: string, + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const defaultRoot = + platform === "win32" + ? env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "mise") + : env.XDG_DATA_HOME + ? path.join(env.XDG_DATA_HOME, "mise") + : env.HOME && path.join(env.HOME, ".local", "share", "mise"); + for (const root of [env.MISE_DATA_DIR, defaultRoot]) { + if (!root) continue; + const installs = path.join(root, "installs"); + const realInstalls = yield* fileSystem + .realPath(installs) + .pipe(Effect.orElseSucceed(() => installs)); + const below = + pathBelow(installs, realPath, platform) ?? pathBelow(realInstalls, realPath, platform); + if (below !== null) return below; + } + return null; +}); + +const MiseInstalledTools = Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Array( + Schema.Struct({ + version: Schema.String, + install_path: Schema.String, + requested_version: Schema.optional(Schema.String), + active: Schema.optional(Schema.Boolean), + }), + ), + ), +); +const decodeMiseInstalledTools = Schema.decodeUnknownOption(MiseInstalledTools); + +const MiseOutdatedTools = Schema.fromJsonString( + Schema.Record(Schema.String, Schema.Struct({ latest: Schema.String })), +); +const decodeMiseOutdatedTools = Schema.decodeUnknownOption(MiseOutdatedTools); + +/** + * Match the real binary against every install mise reports, so the tool name + * is mise's own (alias, backend spec, custom data dir) rather than a guess + * from directory names. Null when mise cannot list its installs or none + * contains the binary. + */ +const findOwningTool = Effect.fn("findOwningMiseTool")(function* (input: { + readonly executable: string; + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + /** The path T3 Code launches when it names an install path; null when mise picks at run time. */ + readonly launchPath: string | null; + /** The provider binary inside the install, before and after following symlinks. */ + readonly binPath: string; + readonly realBinPath: string; + /** The tool a `mise exec` launcher requests. */ + readonly spec: ToolSpec | null; +}) { + const listing = yield* runInstallerProbe({ + executable: input.executable, + args: ["ls", "--installed", "--json"], + env: input.env, + timeout: MISE_PROBE_TIMEOUT, + maxBytes: MISE_PROBE_MAX_BYTES, + }); + if (listing === null) return null; + const tools = decodeMiseInstalledTools(listing); + if (Option.isNone(tools)) { + yield* Effect.logWarning("mise ls --json printed an unexpected shape", { + executable: input.executable, + }); + return null; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [tool, installs] of Object.entries(tools.value)) { + for (const install of installs) { + const installPath = yield* fileSystem + .realPath(install.install_path) + .pipe(Effect.orElseSucceed(() => install.install_path)); + const relativeBinPath = pathBelow(installPath, input.realBinPath, input.platform); + if (relativeBinPath === null) continue; + + // A binary inside node_modules is either the package an npm backend + // installed or an npm global under a Node install, whatever the tool's + // alias. Only mise's own backend name tells them apart. + if (relativeBinPath.includes("node_modules/") && !tool.startsWith("npm:")) { + const backend = isNodeBackend(tool) + ? tool + : (yield* runInstallerProbe({ + executable: input.executable, + args: ["tool", "--backend", tool], + env: input.env, + timeout: MISE_PROBE_TIMEOUT, + maxBytes: MISE_PROBE_MAX_BYTES, + }))?.trim(); + if (backend && isNodeBackend(backend)) { + return { + kind: "npm-global", + resolvedCommandPath: input.binPath, + realCommandPath: input.realBinPath, + provenNodePrefix: installPath, + } satisfies MiseOwnership; + } + if (!backend?.startsWith("npm:")) { + return uncertain( + `${tool} holds the binary inside node_modules, but mise did not show that its backend (${backend || "unknown"}) is the provider's npm package rather than a Node install.`, + ); + } + } + if (input.spec && input.spec.tool !== tool) { + return uncertain( + `The launcher runs ${input.spec.tool}, but mise resolves the binary from ${tool}.`, + ); + } + // A launcher's `tool@latest` is its own selection: `mise upgrade + // tool@latest` moves it and leaves the config's request alone. Any other + // request is only what `mise upgrade` moves when the config makes it. + const requestedVersion = input.spec?.version ?? null; + const explicitLatest = + requestedVersion === "latest" && install.requested_version !== "latest"; + if ( + requestedVersion !== null && + !explicitLatest && + requestedVersion !== install.requested_version + ) { + return uncertain( + `The launcher runs ${tool}@${requestedVersion}, but mise's config requests ${tool}@${install.requested_version ?? "nothing"}.`, + ); + } + if (!explicitLatest && !install.active) { + return uncertain( + `${tool}@${install.version} is installed by mise, but mise's config does not select it, so \`mise upgrade\` would not change it.`, + ); + } + const selection = explicitLatest ? `${tool}@latest` : tool; + const entry = + input.launchPath === null + ? null + : yield* findInstallEntry({ + launchPath: input.launchPath, + toolDirs: [path.dirname(install.install_path), path.dirname(installPath)], + platform: input.platform, + }); + if (input.launchPath !== null && entry?.kind !== "selector") { + return uncertain( + `T3 Code launches ${tool} through ${entry?.path ?? input.launchPath}, which names mise's fixed ${entry?.name ?? install.version} install. \`mise upgrade\` installs beside it, so point the provider at the mise shim or the \`latest\` link.`, + ); + } + + const outdated = yield* runInstallerProbe({ + executable: input.executable, + args: ["outdated", "--json", selection], + env: input.env, + timeout: MISE_OUTDATED_TIMEOUT, + maxBytes: MISE_PROBE_MAX_BYTES, + }); + // `outdated` lists only tools behind their request, keyed by tool, so a + // missing entry means the installed version is all `mise upgrade` reaches. + // That is what keeps an exact pin from advertising an unreachable update. + const latestVersion = + outdated === null + ? null + : Option.match(decodeMiseOutdatedTools(outdated), { + onNone: () => null, + onSome: (outdatedTools) => outdatedTools[tool]?.latest ?? install.version, + }); + // A range link such as `2.1` keeps pointing inside its range. + if ( + entry !== null && + entry.name !== "latest" && + latestVersion !== null && + latestVersion !== entry.name && + !latestVersion.startsWith(`${entry.name}.`) + ) { + return uncertain( + `T3 Code launches ${tool} through mise's \`${entry.name}\` link, which an upgrade to ${latestVersion} would not move.`, + ); + } + return { + kind: "tool", + executable: input.executable, + env: input.env, + selection, + latestVersion, + } satisfies MiseOwnership; + } + } + return null; +}); + +/** + * Which entry of a tool's install directory the launch path first passes + * through while its symlinks resolve: a selector link mise moves on upgrade + * (`latest`, `2`, `2.1`) or a fixed version directory. A symlink from + * elsewhere straight into `2.1.0/` stays on 2.1.0 forever. + */ +const findInstallEntry = Effect.fn("findInstallEntry")(function* (input: { + readonly launchPath: string; + readonly toolDirs: ReadonlyArray; + readonly platform: NodeJS.Platform; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const launchPath of yield* listSymlinkPaths(input.launchPath)) { + for (const toolDir of input.toolDirs) { + const below = pathBelow(toolDir, launchPath, input.platform); + if (below === null) continue; + const name = below.split("/", 1)[0]!; + const isLink = yield* fileSystem.readLink(path.join(toolDir, name)).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return { kind: isLink ? "selector" : "version", name, path: launchPath } as const; + } + } + return null; +}); + +/** `node`, `core:node`, or a plugin backend for Node such as `asdf:nodejs`. */ +function isNodeBackend(backend: string): boolean { + return /(?:^|[:/])node(?:js)?$/.test(backend); +} + +/** + * Every spelling of `start` while its symlinks resolve, one link at a time + * from the root down, ending at the real path. + */ +const listSymlinkPaths = Effect.fn("listSymlinkPaths")(function* (start: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spellings = [path.resolve(start)]; + for (let hop = 0; hop < SYMLINK_HOPS_MAX; hop += 1) { + const current = spellings.at(-1)!; + const { root } = path.parse(current); + const components = current.slice(root.length).split(path.sep).filter(Boolean); + let prefix = root; + let next: string | null = null; + for (const [index, component] of components.entries()) { + const candidate = path.join(prefix, component); + const target = yield* fileSystem.readLink(candidate).pipe(Effect.option); + if (Option.isSome(target)) { + next = path.resolve(prefix, target.value, ...components.slice(index + 1)); + break; + } + prefix = candidate; + } + if (next === null) return spellings; + spellings.push(next); + } + return spellings; +}); + +/** The part of `child` below `parent` with forward slashes, or null when it is not below. */ +function pathBelow(parent: string, child: string, platform: NodeJS.Platform): string | null { + const prefix = `${comparablePath(parent, platform)}/`; + const candidate = comparablePath(child, platform); + return candidate.startsWith(prefix) ? candidate.slice(prefix.length) : null; +} + +function comparablePath(value: string, platform: NodeJS.Platform): string { + const slashed = value.replaceAll("\\", "/").replace(/\/+$/, ""); + return platform === "win32" ? slashed.toLowerCase() : slashed; +} diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index ace445b03060..3a576cdc46bd 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,7 +5,6 @@ import { } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -16,15 +15,15 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { runInstallerProbe } from "./installerProbe.ts"; +import { resolveMiseOwnership } from "./miseOwnership.ts"; const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; -const HOMEBREW_INFO_TIMEOUT_MS = 10_000; +const HOMEBREW_INFO_TIMEOUT = Duration.seconds(10); const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; @@ -259,6 +258,8 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean { export function npmGlobalPrefixFromCommandPath( realCommandPath: string, packageName: string, + /** A Node install mise itself identified, which may carry an alias like `node-lts`. */ + provenNodePrefix: string | null = null, ): string | null { const slashPath = realCommandPath.replaceAll("\\", "/"); const normalized = slashPath.toLowerCase(); @@ -269,11 +270,15 @@ export function npmGlobalPrefixFromCommandPath( } // Mise's npm backend uses a global-looking layout inside a tool version. // Globals under its Node installation still belong to npm. + const prefix = packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1]; - if (miseTool && miseTool !== "node") { + const isProvenNode = + provenNodePrefix !== null && + normalizeCommandPath(provenNodePrefix) === normalizeCommandPath(prefix); + if (miseTool && miseTool !== "node" && !isProvenNode) { return null; } - return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); + return prefix; } // `/Cellar///…` or `/Caskroom///…`. @@ -335,40 +340,6 @@ export function parseHomebrewLatestVersion( return nonEmptyString(raw); } -/** Run `brew ` and return stdout, or null on failure, timeout, or oversized output. */ -const runHomebrew = Effect.fn("runHomebrew")(function* ( - brewPath: string, - args: ReadonlyArray, - env: NodeJS.ProcessEnv, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const collect = Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(brewPath, args, { env, extendEnv: true })); - yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); - // stderr is drained so a chatty brew cannot block on a full pipe. - const [stdout, exitCode] = yield* Effect.all( - [ - collectUint8StreamText({ stream: child.stdout, maxBytes: HOMEBREW_INFO_MAX_BYTES }), - child.exitCode, - Stream.runDrain(child.stderr), - ], - { concurrency: "unbounded" }, - ); - return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text; - }); - return yield* collect.pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(HOMEBREW_INFO_TIMEOUT_MS)), - Effect.map(Option.getOrNull), - Effect.catchCause((cause) => - Effect.logWarning("Homebrew probe failed", { - subcommand: args[0], - errorTag: causeErrorTag(cause), - }).pipe(Effect.as(null)), - ), - ); -}); - /** * Derive update capabilities from where the executable actually lives. Every * branch that yields a one-click command has evidence that the named tool @@ -403,7 +374,18 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), }); } - if (commandPaths.some(isVitePlusGlobalCommandPath)) { + + const mise = yield* resolveMiseProviderMaintenance({ + provider: definition.provider, + packageName, + context, + }); + if (mise.kind === "decided") { + return mise.capabilities; + } + const installerContext = mise.context; + const installerPaths = [installerContext.resolvedCommandPath, installerContext.realCommandPath]; + if (installerPaths.some(isVitePlusGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -412,7 +394,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateLockKey: "vite-plus-global", }); } - if (commandPaths.some(isBunGlobalCommandPath)) { + if (installerPaths.some(isBunGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -421,7 +403,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateLockKey: "bun-global", }); } - if (commandPaths.some(isPnpmGlobalCommandPath)) { + if (installerPaths.some(isPnpmGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -434,7 +416,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // npm proof names the package, so it outranks a keg the path merely passes // through: a Homebrew-installed Node keeps its globals under // `Cellar/node//lib/node_modules/`, and that is npm's install, not brew's. - const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName); + const npmPrefix = yield* resolveNpmGlobalPrefix( + installerContext, + packageName, + mise.provenNodePrefix, + ); if (npmPrefix) { // npm 12 blocks install scripts by default (empty allow-scripts allowlist) // and still exits 0, so a package whose postinstall finishes the install @@ -457,12 +443,8 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } - const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); + const homebrew = homebrewOwnershipFromCommandPath(installerContext.realCommandPath); if (homebrew) { - // Mise shims resolve to the version manager, not the provider. - if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { - return manual; - } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); @@ -472,7 +454,15 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // A keg-shaped path is only Homebrew's if it sits under the prefix of the // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. const fileSystem = yield* FileSystem.FileSystem; - const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); + const brewPrefix = nonEmptyString( + yield* runInstallerProbe({ + executable: brewPath, + args: ["--prefix"], + env: context.env, + timeout: HOMEBREW_INFO_TIMEOUT, + maxBytes: HOMEBREW_INFO_MAX_BYTES, + }), + ); const realBrewPrefix = brewPrefix ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) : null; @@ -486,7 +476,13 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; // Homebrew lags npm by hours on every release, so compare against what // `brew upgrade` can actually deliver. - const info = yield* runHomebrew(brewPath, ["info", "--json=v2", homebrew.name], context.env); + const info = yield* runInstallerProbe({ + executable: brewPath, + args: ["info", "--json=v2", homebrew.name], + env: context.env, + timeout: HOMEBREW_INFO_TIMEOUT, + maxBytes: HOMEBREW_INFO_MAX_BYTES, + }); return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -501,6 +497,66 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( return manual; }); +/** + * Mise's verdict on an executable: an upgrade of the tool that owns it, + * manual-only when mise is involved but cannot be shown to reach it, or the + * context the remaining installers should judge. A package that npm installed + * under mise's Node is judged from the package path mise resolved. + */ +export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMaintenance")( + function* (input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly context: ProviderMaintenanceResolutionContext; + }) { + const ownership = yield* resolveMiseOwnership(input.context); + switch (ownership.kind) { + case "unrelated": + return { kind: "undecided", context: input.context, provenNodePrefix: null } as const; + case "npm-global": + return { + kind: "undecided", + context: { + ...input.context, + resolvedCommandPath: ownership.resolvedCommandPath, + realCommandPath: ownership.realCommandPath, + }, + provenNodePrefix: ownership.provenNodePrefix, + } as const; + case "uncertain": + yield* Effect.logInfo("Provider update is manual-only: mise ownership is uncertain", { + provider: input.provider, + reason: ownership.reason, + }); + return { + kind: "decided", + capabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: input.provider, + packageName: input.packageName, + }), + } as const; + case "tool": + return { + kind: "decided", + capabilities: makeProviderMaintenanceCapabilities({ + provider: input.provider, + packageName: input.packageName, + updateExecutable: ownership.executable, + // No `--bump`: mise stays within the version the user's config + // requests and never rewrites a project or global pin. + // `--no-prune`: a running provider or another link may still use + // the old install, so it must not be removed or scheduled for it. + updateArgs: ["upgrade", "--no-prune", ownership.selection], + updateLockKey: "mise", + platform: input.context.platform, + env: ownership.env, + latestVersion: ownership.latestVersion, + }), + } as const; + } + }, +); + /** * POSIX npm links `/bin/` into the package, so the real path is * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof @@ -509,8 +565,13 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( context: ProviderMaintenanceResolutionContext, packageName: string, + provenNodePrefix: string | null, ) { - const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName); + const fromRealPath = npmGlobalPrefixFromCommandPath( + context.realCommandPath, + packageName, + provenNodePrefix, + ); if (fromRealPath) { return fromRealPath; } diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 64554ce263d3..278073f4809a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -330,6 +330,84 @@ describe("providerMaintenanceRunner", () => { }, ); + it.effect.each([ + { + name: "stays on the old version once mise's latest is unavailable", + versionAfter: "1.0.0", + latestAfter: null, + status: "unchanged", + }, + { + name: "stays on the old version once mise stops reporting the target", + versionAfter: "1.0.0", + latestAfter: "1.0.0", + status: "unchanged", + }, + { + name: "reaches the target though mise's latest is then unavailable", + versionAfter: "1.1.0", + latestAfter: null, + status: "succeeded", + }, + { + name: "cannot read its version to prove the target", + versionAfter: null, + latestAfter: "1.1.0", + status: "unchanged", + }, + ] as const)( + "judges a mise update that $name as $status", + ({ versionAfter, latestAfter, status }) => { + const calls: Array> = []; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ + ...baseProvider, + version: "1.0.0", + }); + // Cached and fresh pre-update reads know the target; the post-update read may not. + const latestReads = ["1.1.0", "1.1.0", latestAfter]; + let read = 0; + const updater = yield* makeTestRunner({ + ...registry, + refreshInstance: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, version: versionAfter })), + ), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: "@openai/codex", + updateExecutable: "/usr/bin/mise", + updateArgs: ["upgrade", "--no-prune", "codex"], + updateLockKey: "mise", + latestVersion: latestReads[Math.min(read++, latestReads.length - 1)] ?? null, + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [["upgrade", "--no-prune", "codex"]]); + assert.strictEqual(result.providers[0]?.updateState?.status, status); + if (status === "unchanged") { + assert.notStrictEqual(result.providers[0]?.updateState?.message, "Provider updated."); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + // mise reports its own target, so the npm registry must not decide. + latestVersionHttpClient("9.9.9"), + mockSpawnerLayer((_command, args) => { + calls.push(args); + return { stdout: "" }; + }), + ), + ), + ); + }, + ); + it.effect("spawns the updater with the environment its capabilities declare", () => { const seen: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 918e9ce3454e..eb93129896dd 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -7,6 +7,7 @@ import { type ServerProviderUpdatedPayload, type ServerProviderUpdateState, } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -420,6 +421,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } + // An installer that reports its own target (mise, Homebrew) must + // be seen to reach it. Its metadata can vanish after the command, + // and a missing "latest" would otherwise read as current. + const installerTarget = + targetVersion === undefined && fresh.latestVersion !== undefined + ? fresh.latestVersion + : null; const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { @@ -448,18 +456,26 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // "Succeeded" needs the provider to still be installed: an // installer that exits 0 and leaves the binary missing is not a // success. A missing version alone is not held against it, since - // Cursor's `about` probe can fail transiently on a healthy binary. + // Cursor's `about` probe can fail transiently on a healthy binary, + // unless a known installer target has to be proven. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some( (verifiedProvider) => !isStillInstalled(verifiedProvider) || (targetVersion !== undefined && - verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + verifiedProvider.version?.replace(/^v/, "") !== targetVersion) || + (installerTarget !== null && !verifiedProvider.version), ); const stillOutdated = targetVersion === undefined && - verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); + verifiedProviders.some( + (verifiedProvider) => + isOutdatedProvider(verifiedProvider) || + (installerTarget !== null && + verifiedProvider.version !== null && + compareSemverVersions(verifiedProvider.version, installerTarget) < 0), + ); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/server/src/provider/testUtils/fakeMise.ts b/apps/server/src/provider/testUtils/fakeMise.ts new file mode 100644 index 000000000000..6e2c83c00c72 --- /dev/null +++ b/apps/server/src/provider/testUtils/fakeMise.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +interface FakeMiseInstall { + readonly version: string; + readonly install_path: string; + readonly requested_version?: string; + readonly active: boolean; +} + +export interface FakeMiseCall { + readonly dataDir: string; + readonly args: string; +} + +/** + * A POSIX `mise` stand-in for maintenance tests. It answers `which `, + * `ls`, `outdated`, and `tool --backend` with the given JSON, or raw text when given a string + * (a missing answer exits 1), and + * records each call with the `MISE_DATA_DIR` it ran with, so a test proves + * which mise ran, with which arguments, in which environment. + */ +export function installFakeMise( + misePath: string, + answers: { + readonly which?: Readonly>; + readonly ls?: string | Readonly>>; + readonly outdated?: string | Readonly>; + /** `mise tool --backend ` answers, by tool name. */ + readonly backends?: Readonly>; + }, +) { + const stateDir = `${misePath}.state`; + NodeFS.mkdirSync(NodePath.dirname(misePath), { recursive: true }); + NodeFS.mkdirSync(stateDir, { recursive: true }); + for (const [bin, path] of Object.entries(answers.which ?? {})) { + NodeFS.writeFileSync(NodePath.join(stateDir, `which-${bin}`), `${path}\n`); + } + for (const [tool, backend] of Object.entries(answers.backends ?? {})) { + NodeFS.writeFileSync(NodePath.join(stateDir, `backend-${tool}`), `${backend}\n`); + } + for (const [name, answer] of [ + ["ls", answers.ls], + ["outdated", answers.outdated], + ] as const) { + if (answer === undefined) continue; + NodeFS.writeFileSync( + NodePath.join(stateDir, name), + typeof answer === "string" ? answer : JSON.stringify(answer), + ); + } + NodeFS.writeFileSync( + misePath, + [ + "#!/bin/sh", + // Tests may run it with an empty PATH. + "PATH=/usr/bin:/bin", + `state='${stateDir}'`, + `printf '%s\\t%s\\n' "\${MISE_DATA_DIR:-}" "$*" >> "$state/calls"`, + 'case "$1" in', + // `which [--tool ] `: the bin is the last argument. + ' which) for bin; do :; done; answer="$state/which-$bin" ;;', + ' ls) answer="$state/ls" ;;', + ' tool) for tool; do :; done; answer="$state/backend-$tool" ;;', + ' outdated) answer="$state/outdated" ;;', + " upgrade) exit 0 ;;", + " *) exit 2 ;;", + "esac", + '[ -f "$answer" ] || exit 1', + 'cat "$answer"', + "", + ].join("\n"), + ); + NodeFS.chmodSync(misePath, 0o755); + return { + misePath, + calls: (): ReadonlyArray => { + const log = NodePath.join(stateDir, "calls"); + if (!NodeFS.existsSync(log)) return []; + return NodeFS.readFileSync(log, "utf8") + .trimEnd() + .split("\n") + .map((line) => { + const [dataDir = "", args = ""] = line.split("\t"); + return { dataDir, args }; + }); + }, + }; +} diff --git a/docs/internals/providers.md b/docs/internals/providers.md index c1def5f3528a..480cc3119366 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -78,9 +78,22 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione `brew --prefix`, or `/lib/node_modules//` (Windows: the shim beside `node_modules`). Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either the resolved path or its real target, since those installers place real files or their own symlinks -there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its -own installer, so any resolved executable runs ` update`. Anything unproven stays -manual-only but still reports the version gap. npm updates pin +there. Mise is proven by mise itself: a shim, an `installs/` path, or a launcher script leads to +`mise which` and `mise ls --json`, and the install containing the real binary names the tool. A +launcher (Omarchy's `~/.local/bin` wrappers) counts only through its unconditional top-level +`exec`; probes and the upgrade run with the environment it exports, and a launcher that changes +directory or changes mise's environment on only some runs stays manual-only. The update is +`mise upgrade --no-prune `: no `--bump`, so it stays within the configured version request, +and no pruning, so a running provider keeps its install. `mise outdated` supplies the newest +version that request reaches, so an exact pin reads as current. A launcher's `mise x @latest` +is its own selection: it is resolved with `mise which --tool` and upgraded as `@latest`, +leaving the config's request alone; other explicit requests must match the config's. A launch path that reaches a fixed +version directory instead of a selector link such as `latest` stays manual-only, since an upgrade +installs beside it. A path inside this environment's mise installs that mise cannot attribute stays +manual-only rather than falling to npm, except globals under mise's Node. +Grok's only updater is the CLI itself, which detects its own installer, so any resolved executable +that mise does not own runs ` update`. Anything unproven stays manual-only but still +reports the version gap. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version @@ -90,7 +103,9 @@ See the [resolver](../../apps/server/src/provider/providerMaintenance.ts). Ownership is cached per instance and re-read immediately before an update runs. The [runner](../../apps/server/src/provider/providerMaintenanceRunner.ts) refuses when the lock key changed since the advisory, and reports success only when the refreshed provider is still installed -with a readable, current version. +with a readable, current version. When the installer reported its own target (mise, Homebrew), the +refreshed version must reach the target read just before the command; installer metadata that +drops out afterwards cannot turn an unchanged version into success. ## Protocol traps diff --git a/docs/user/updating.md b/docs/user/updating.md index c0f05fc89351..c99bda8b5de7 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -78,6 +78,10 @@ update can roll back to the previous version. If the update still fails: at once. Hover it to see which providers it will update. Providers that only offer a manual update command are not included. +Providers installed with mise update through `mise upgrade`, within the version +your mise config requests. A provider pinned to an exact version shows as +current; change the pin in your mise config to move it. + ## Mobile updates To update an environment from your phone, open **Settings → Environments** and