723 lines · 25.6 KB · TypeScript
RawDownload
import {  ProviderDriverKind,  type ServerProvider,  type ServerProviderVersionAdvisory,} 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";import * as DateTime from "effect/DateTime";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 * as Stream from "effect/Stream";import { HttpClient, HttpClientRequest } from "effect/unstable/http";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.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_MAX_BYTES = 256 * 1_024;const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; /** * Ownership is re-derived from the executable this often. Installs do not * move on their own, so this mostly bounds how stale a Homebrew "latest" can * get; the npm registry check keeps its own cache. */const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>  Object.fromEntries(    Object.entries(input).flatMap(([key, value]) =>      Option.match(value, {        onNone: () => [],        onSome: (resolved) => [[key, resolved]],      }),    ),  ); const CommandLookupEnvConfig = Config.all({  PATH: Config.string("PATH").pipe(Config.option),  Path: Config.string("Path").pipe(Config.option),  path: Config.string("path").pipe(Config.option),  PATHEXT: Config.string("PATHEXT").pipe(Config.option),}).pipe(Config.map(compactEnv)); const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); export interface ProviderMaintenanceCapabilities {  readonly provider: ProviderDriverKind;  readonly packageName: string | null;  readonly update: ProviderMaintenanceCommandAction | null;  /**   * Latest version reported by the installer that owns the executable.   * `undefined` means the installer has no channel of its own and the npm   * registry entry for `packageName` is authoritative; `null` means the   * installer was asked and did not know.   */  readonly latestVersion?: string | null;} export interface ProviderMaintenanceCommandAction {  readonly command: string;  readonly executable: string;  readonly args: ReadonlyArray<string>;  readonly lockKey: string;  /**   * Extra environment for the spawned updater, on top of the server's own.   * A native updater finds its install through the same variables the   * provider runs with (e.g. `CODEX_HOME`), so an instance with a custom home   * must update that home and not the default one.   */  readonly env?: NodeJS.ProcessEnv;} /** Where the provider executable was found; every path is absolute. */export interface ProviderMaintenanceResolutionContext {  readonly binaryPath: string;  readonly resolvedCommandPath: string;  readonly realCommandPath: string;  readonly env: NodeJS.ProcessEnv;  /** Host platform; decides how the copyable command quotes the executable. */  readonly platform: NodeJS.Platform;} export type ProviderMaintenanceResolverServices =  | FileSystem.FileSystem  | Path.Path  | ChildProcessSpawner.ChildProcessSpawner; export interface ProviderMaintenanceCapabilitiesResolver {  readonly resolve: (    context: ProviderMaintenanceResolutionContext | null,  ) => Effect.Effect<ProviderMaintenanceCapabilities, never, ProviderMaintenanceResolverServices>;} export interface PackageManagedProviderMaintenanceDefinition {  readonly provider: ProviderDriverKind;  readonly npmPackageName: string;  readonly nativeUpdate: {    readonly args: ReadonlyArray<string>;    readonly isCommandPath: (commandPath: string) => boolean;    /** Environment the native updater needs to target this instance's install. */    readonly env?: NodeJS.ProcessEnv;  } | null;} export interface ProviderVersionCacheEntry {  readonly expiresAt: number;  readonly version: string | null;} export const ProviderVersionCache = Context.Reference<Map<string, ProviderVersionCacheEntry>>(  "@t3tools/server/providerMaintenance/ProviderVersionCache",  {    defaultValue: () => new Map(),  },);const NpmLatestVersionResponse = Schema.Struct({  version: Schema.optional(Schema.String),}); function nonEmptyString(value: unknown): string | null {  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;} /** * The copyable command must paste into a shell as-is, so an executable path * with spaces or quotes is quoted for the host's default shell. */function quoteShellWord(word: string, platform: NodeJS.Platform): string {  const safeWord = platform === "win32" ? /^[\w./:\\@=-]+$/ : /^[\w./:@=-]+$/;  if (safeWord.test(word)) return word;  return platform === "win32"    ? `'${word.replace(/['\u2018\u2019]/g, "$&$&")}'`    : `'${word.replaceAll("'", "'\\''")}'`;} function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {  const quoted = quoteShellWord(executable, platform);  // Windows terminals default to PowerShell, where a quoted executable needs &.  return platform === "win32" && quoted !== executable ? `& ${quoted}` : quoted;} export function makeProviderMaintenanceCapabilities(input: {  readonly provider: ProviderDriverKind;  readonly packageName: string | null;  readonly updateExecutable: string | null;  readonly updateArgs: ReadonlyArray<string>;  readonly updateLockKey: string | null;  /** Shown to the user instead of `<executable> <args>`; use for a bare tool name like `brew`. */  readonly updateCommand?: string;  readonly platform?: NodeJS.Platform;  readonly env?: NodeJS.ProcessEnv;  readonly latestVersion?: string | null;}): ProviderMaintenanceCapabilities {  const platform = input.platform ?? HostProcessPlatform.defaultValue();  const update =    input.updateExecutable === null || input.updateLockKey === null      ? null      : {          command:            input.updateCommand ??            [              quoteUpdateExecutable(input.updateExecutable, platform),              ...input.updateArgs.map((arg) => quoteShellWord(arg, platform)),            ].join(" "),          executable: input.updateExecutable,          args: input.updateArgs,          lockKey: input.updateLockKey,          ...(input.env ? { env: input.env } : {}),        };  return {    provider: input.provider,    packageName: input.packageName,    update,    ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}),  };} export function makeManualOnlyProviderMaintenanceCapabilities(input: {  readonly provider: ProviderDriverKind;  readonly packageName: string | null;}): ProviderMaintenanceCapabilities {  return makeProviderMaintenanceCapabilities({    provider: input.provider,    packageName: input.packageName,    updateExecutable: null,    updateArgs: [],    updateLockKey: null,  });} export function normalizeCommandPath(commandPath: string): string {  return commandPath.replaceAll("\\", "/").toLowerCase();} function isVitePlusGlobalCommandPath(commandPath: string): boolean {  return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");} function isBunGlobalCommandPath(commandPath: string): boolean {  return normalizeCommandPath(commandPath).includes("/.bun/bin/");} function isPnpmGlobalCommandPath(commandPath: string): boolean {  const normalized = normalizeCommandPath(commandPath);  return (    normalized.includes("/.local/share/pnpm/") ||    normalized.includes("/library/pnpm/") ||    normalized.includes("/local/share/pnpm/") ||    normalized.includes("/appdata/local/pnpm/") ||    normalized.includes("/pnpm/global/")  );} /** * The npm global prefix that owns a package, derived from the real path of * its entry point: `<prefix>/lib/node_modules/<pkg>/…`. Windows global * installs have no `lib` segment and are proven by the shim instead (see * `resolveNpmGlobalPrefix`). A project-local `node_modules` is not a global * install and yields null. */export function npmGlobalPrefixFromCommandPath(  realCommandPath: string,  packageName: string,): string | null {  const slashPath = realCommandPath.replaceAll("\\", "/");  const normalized = slashPath.toLowerCase();  const packageSegment = `/lib/node_modules/${packageName.toLowerCase()}/`;  const packageIndex = normalized.lastIndexOf(packageSegment);  if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) {    return null;  }  // Mise's npm backend uses a global-looking layout inside a tool version.  // Globals under its Node installation still belong to npm.  const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1];  if (miseTool && miseTool !== "node") {    return null;  }  return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex);} // `<prefix>/Cellar/<name>/<version>/…` or `<prefix>/Caskroom/<name>/<version>/…`.// Homebrew always nests a version directory under the keg.const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i; export interface HomebrewOwnership {  readonly kind: "formula" | "cask";  readonly name: string;  /** The Homebrew prefix the keg sits under; must match `brew --prefix`. */  readonly prefix: string;} /** * Homebrew looks like the owner when the real path runs through a versioned * keg or cask. It is only proven once the prefix matches the `brew` that will * run the upgrade (see `resolvePackageManagedProviderMaintenance`). */export function homebrewOwnershipFromCommandPath(  realCommandPath: string,): HomebrewOwnership | null {  const match = HOMEBREW_KEG_PATTERN.exec(realCommandPath.replaceAll("\\", "/"));  if (!match) {    return null;  }  return {    kind: match[2]!.toLowerCase() === "cellar" ? "formula" : "cask",    name: match[3]!,    prefix: match[1]!,  };} const HomebrewInfoResponse = Schema.Struct({  formulae: Schema.optional(    Schema.Array(      Schema.Struct({        versions: Schema.optional(Schema.Struct({ stable: Schema.optional(Schema.String) })),      }),    ),  ),  casks: Schema.optional(Schema.Array(Schema.Struct({ version: Schema.optional(Schema.String) }))),}); const decodeHomebrewInfo = Schema.decodeUnknownOption(Schema.fromJsonString(HomebrewInfoResponse)); /** Cask versions may carry a build suffix after a comma (`1.2.3,456`). */export function parseHomebrewLatestVersion(  infoJson: string,  ownership: HomebrewOwnership,): string | null {  const decoded = decodeHomebrewInfo(infoJson);  if (Option.isNone(decoded)) {    return null;  }  const raw =    ownership.kind === "formula"      ? decoded.value.formulae?.[0]?.versions?.stable      : decoded.value.casks?.[0]?.version?.split(",", 1)[0];  return nonEmptyString(raw);} /** Run `brew <args>` and return stdout, or null on failure, timeout, or oversized output. */const runHomebrew = Effect.fn("runHomebrew")(function* (  brewPath: string,  args: ReadonlyArray<string>,  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 * owns that path; anything unproven stays manual-only so T3 Code never runs * a package manager against an install it did not create. */export const resolvePackageManagedProviderMaintenance = Effect.fn(  "resolvePackageManagedProviderMaintenance",)(function* (  definition: PackageManagedProviderMaintenanceDefinition,  context: ProviderMaintenanceResolutionContext | null,) {  const manual = makeManualOnlyProviderMaintenanceCapabilities({    provider: definition.provider,    packageName: definition.npmPackageName,  });  if (!context) {    return manual;  }  const commandPaths = [context.resolvedCommandPath, context.realCommandPath];  const packageName = definition.npmPackageName;   const nativeUpdate = definition.nativeUpdate;  if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) {    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: context.resolvedCommandPath,      updateArgs: nativeUpdate.args,      updateLockKey: `${definition.provider}-native`,      platform: context.platform,      ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}),    });  }  if (commandPaths.some(isVitePlusGlobalCommandPath)) {    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: "vp",      updateArgs: ["i", "-g", packageName],      updateLockKey: "vite-plus-global",    });  }  if (commandPaths.some(isBunGlobalCommandPath)) {    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: "bun",      updateArgs: ["i", "-g", `${packageName}@latest`],      updateLockKey: "bun-global",    });  }  if (commandPaths.some(isPnpmGlobalCommandPath)) {    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: "pnpm",      updateArgs: ["add", "-g", `${packageName}@latest`],      updateLockKey: "pnpm-global",    });  }   // 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/<ver>/lib/node_modules/`, and that is npm's install, not brew's.  const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);  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    // (claude copies its native binary over a placeholder stub) is left broken    // while the update reports success. Allow this one package's scripts.    // Older npm warns about the unknown config and continues.    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: "npm",      updateArgs: [        "install",        "-g",        "--prefix",        npmPrefix,        `--allow-scripts=${packageName}`,        `${packageName}@latest`,      ],      updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`,    });  }   const homebrew = homebrewOwnershipFromCommandPath(context.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) }),    );    if (!brewPath) {      return manual;    }    // 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 realBrewPrefix = brewPrefix      ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))      : null;    if (      !realBrewPrefix ||      normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)    ) {      return manual;    }    const args =      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);    return makeProviderMaintenanceCapabilities({      provider: definition.provider,      packageName,      updateExecutable: brewPath,      updateArgs: args,      updateLockKey: "homebrew",      updateCommand: ["brew", ...args].join(" "),      latestVersion: info ? parseHomebrewLatestVersion(info, homebrew) : null,    });  }   return manual;}); /** * POSIX npm links `<prefix>/bin/<cmd>` into the package, so the real path is * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof * is the package manifest next to the shim. */const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* (  context: ProviderMaintenanceResolutionContext,  packageName: string,) {  const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName);  if (fromRealPath) {    return fromRealPath;  }  if ((yield* HostProcessPlatform) !== "win32") {    return null;  }  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const shimDir = path.dirname(context.resolvedCommandPath);  const manifestPath = path.join(    shimDir,    "node_modules",    ...packageName.split("/"),    "package.json",

Lines 1–500 of 723.