diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index aecbfcbf59326..80f1eb96af7ac 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -77,6 +77,8 @@ export type ProviderMaintenanceResolverServices = | ChildProcessSpawner.ChildProcessSpawner; export interface ProviderMaintenanceCapabilitiesResolver { + /** Scripts this resolver launches through an interpreter instead of the OS. */ + readonly windowsScriptExtensions?: ReadonlyArray; readonly resolve: ( context: ProviderMaintenanceResolutionContext | null, ) => Effect.Effect; @@ -1120,7 +1122,14 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } const env = options?.env ?? (yield* readCommandLookupEnv); - const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }); + const scriptExtension = resolver.windowsScriptExtensions?.find((extension) => + binaryPath.toLowerCase().endsWith(extension), + ); + const lookupEnv = + (yield* HostProcess.Platform) === "win32" && scriptExtension + ? { ...env, PATHEXT: scriptExtension } + : env; + const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env: lookupEnv }); if (!resolvedCommandPath) { return yield* resolver.resolve(null); } diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts index 61b1a9fc32f28..07b77e973b0db 100644 --- a/packages/provider-muse/src/server/maintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -1,10 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { compareMuseVersions, @@ -13,7 +19,46 @@ import { parseMuseVersion, } from "./maintenance.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; + +const windowsShim = [ + "@echo off", + "setlocal", + '"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0.muse-launcher.ps1" %*', + "exit /b %ERRORLEVEL%", +].join("\r\n"); +const windowsLauncherMarkers = "# muse-code/launcher-ps-3\n# MUSE_SYNC_UPDATE\n"; +// Keep the official launcher's update structure, with inert download functions. +const windowsLauncher = `Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$launcher_url = 'https://api.meta.ai/muse-launcher.ps1' +$launcher_version = '3' +$user_agent = "muse-code/launcher-ps-$launcher_version" +function Update-Launcher { + param([string]$Self) +} +function Update-Binary { + param([string]$Dir) +} +function Get-ActiveBinary { + param([string]$Dir) + return 'muse.exe' +} +function Invoke-Main { + $self = $PSCommandPath + $dir = Split-Path -Parent $self + if ($env:MUSE_SYNC_UPDATE -eq '1') { + Update-Launcher -Self $self + Update-Binary -Dir $dir + $binary = Get-ActiveBinary -Dir $dir + } +} +Invoke-Main @args +`; const maintenanceCapabilities = { ...makeManualOnlyProviderMaintenanceCapabilities({ @@ -116,4 +161,214 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { }), ), ); + + it.effect("resolves official Windows shims using the instance's path and environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); + const installation = path.join(directory, "Muse R&D (canary) @ ^ ' $ %PATH%"); + yield* fs.makeDirectory(installation); + const shim = path.join(installation, "muse.cmd"); + const launcher = path.join(installation, ".muse-launcher.ps1"); + yield* fs.writeFileString(shim, windowsShim); + yield* fs.writeFileString(launcher, windowsLauncher); + const env = { + PATH: installation, + PATHEXT: ".EXE;.CMD", + SystemRoot: "C:\\Windows", + MUSE_CHANNEL: "muse-canary", + MUSE_NO_AUTO_UPDATE: "1", + MUSE_SYNC_UPDATE: "0", + MUSE_UPDATE_INTERVAL_SECONDS: "3600", + MUSE_AUTH_PATH: "custom credentials", + }; + for (const binaryPath of ["muse", path.join(installation, "muse")]) { + const result = yield* resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { + binaryPath, + env, + }).pipe(Effect.provideService(HostProcess.Platform, "win32")); + expect(result.update).toMatchObject({ + executable: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", launcher, "--version"], + env: { + ...env, + MUSE_NO_AUTO_UPDATE: "0", + MUSE_SYNC_UPDATE: "1", + MUSE_UPDATE_INTERVAL_SECONDS: "0", + }, + }); + expect(result.update?.lockKey.toLowerCase()).toBe( + `muse:${yield* fs.realPath(launcher)}`.toLowerCase(), + ); + expect(result.update?.command).toContain("$env:MUSE_CHANNEL='muse-canary';"); + expect(result.update?.command).toContain("$env:MUSE_NO_AUTO_UPDATE='0';"); + expect(result.update?.command).toContain("$env:MUSE_SYNC_UPDATE='1';"); + expect(result.update?.command).toContain("$env:MUSE_UPDATE_INTERVAL_SECONDS='0';"); + expect(result.update?.command).toContain("'-File' '"); + expect(result.update?.command).toContain(" ^ '' $ %PATH%"); + expect(result.compareVersions).toBe(compareMuseVersions); + } + expect(env.MUSE_NO_AUTO_UPDATE).toBe("1"); + }).pipe(Effect.scoped), + ); + + it.effect("resolves a configured PowerShell launcher with default Windows command lookup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); + const launcher = path.join(directory, ".muse-launcher.ps1"); + yield* fs.writeFileString(launcher, windowsLauncher); + const result = yield* resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { + binaryPath: launcher, + env: { SYSTEMROOT: "D:\\Windows" }, + }).pipe(Effect.provideService(HostProcess.Platform, "win32")); + expect(result.update).toMatchObject({ + executable: "D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", launcher, "--version"], + lockKey: `muse:${launcher}`, + }); + expect(result.update?.env).not.toHaveProperty("MUSE_CHANNEL"); + expect(result.update?.env).not.toHaveProperty("PATHEXT"); + }).pipe(Effect.scoped), + ); + + it.effect("keeps standalone Windows binaries and unrecognized wrappers manual-only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); + const launcher = path.join(directory, ".muse-launcher.ps1"); + for (const [name, source, adjacentSource] of [ + ["muse.exe", windowsLauncher, windowsLauncher], + ["muse.cmd", "@echo off\r\nmuse --version %*", windowsLauncher], + ["muse.cmd", windowsShim.replace("%~dp0", "C:\\other\\"), windowsLauncher], + ["muse.cmd", windowsShim + "\r\nother-command", windowsLauncher], + ["muse.cmd", windowsShim, "# MUSE_SYNC_UPDATE"], + ["muse.cmd", windowsShim, "# muse-code/launcher-ps-3"], + ["muse.cmd", windowsShim, windowsLauncher + " ".repeat(128 * 1_024)], + ["muse.cmd", windowsShim, null], + ["muse.ps1", "# unrecognized wrapper", windowsLauncher], + ] as const) { + const binaryPath = path.join(directory, name); + yield* fs.writeFileString(binaryPath, source); + if (adjacentSource === null) yield* fs.remove(launcher); + else yield* fs.writeFileString(launcher, adjacentSource); + expect( + (yield* museMaintenance.resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "win32", + env: {}, + })).update, + ).toBeNull(); + } + }).pipe(Effect.scoped), + ); + + it.effect("rejects marker-only Windows launchers and incomplete official update structures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); + for (const source of [ + windowsLauncherMarkers, + windowsLauncherMarkers + "Write-Output 'unrecognized wrapper'\n", + windowsLauncher.replace("Set-StrictMode -Version Latest", "# custom wrapper"), + windowsLauncher.replace( + "https://api.meta.ai/muse-launcher.ps1", + "https://example.com/wrapper.ps1", + ), + windowsLauncher.replace("function Update-Binary {", "function Other-Update {"), + windowsLauncher.replace("$self = $PSCommandPath", "$self = 'other.ps1'"), + windowsLauncher.replace("Update-Binary -Dir $dir", "Write-Output 'no update'"), + windowsLauncher.replace("Invoke-Main @args", "Write-Output 'no entry point'"), + ]) { + const launcher = path.join(directory, ".muse-launcher.ps1"); + yield* fs.writeFileString(launcher, source); + const shim = path.join(directory, "muse.cmd"); + yield* fs.writeFileString(shim, windowsShim); + for (const binaryPath of [launcher, shim]) { + expect( + (yield* museMaintenance.resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "win32", + env: {}, + })).update, + ).toBeNull(); + } + } + }).pipe(Effect.scoped), + ); }); + +it.live( + "runs direct and copied Windows update commands with literal paths and forced environment", + () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }), + "Muse R&D (canary) @ ^ ' \u2018 \u2019 \u201a \u201b $; %PATH%", + ); + yield* fs.makeDirectory(directory); + const shim = path.join(directory, "muse.cmd"); + yield* fs.writeFileString(shim, windowsShim); + yield* fs.writeFileString( + path.join(directory, ".muse-launcher.ps1"), + windowsLauncher.replace( + " $binary = Get-ActiveBinary -Dir $dir", + ' $binary = Get-ActiveBinary -Dir $dir\n [Console]::WriteLine(($args -join ",") + "|" + $env:MUSE_CHANNEL + "|" + $env:MUSE_NO_AUTO_UPDATE + "|" + $env:MUSE_SYNC_UPDATE + "|" + $env:MUSE_UPDATE_INTERVAL_SECONDS + "|" + $env:MUSE_AUTH_PATH)', + ), + ); + const capabilities = yield* museMaintenance.resolve({ + binaryPath: "muse", + resolvedCommandPath: shim, + realCommandPath: shim, + platform: "win32", + env: { MUSE_CHANNEL: "muse-canary", MUSE_NO_AUTO_UPDATE: "1", MUSE_AUTH_PATH: "kept" }, + }); + expect(capabilities.update).not.toBeNull(); + const update = capabilities.update!; + const resolved = yield* resolveSpawnCommand(update.executable, update.args); + expect(resolved.shell).toBe(false); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const command of [ + resolved, + { + command: update.executable, + // Preserve Unicode through the CLI transport; PowerShell still parses the copied command. + args: [ + "-NoProfile", + "-EncodedCommand", + Buffer.from(update.command, "utf16le").toString("base64"), + ], + shell: false, + }, + ]) { + const child = yield* spawner.spawn( + ChildProcess.make(command.command, command.args, { + env: update.env, + extendEnv: true, + shell: command.shell, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.decodeText(), Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + expect(Number(exitCode), stderr).toBe(0); + expect(stdout.trim()).toBe("--version|muse-canary|0|1|0|kept"); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 342e91638b830..13a69cd497d96 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -2,6 +2,7 @@ import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -16,9 +17,18 @@ import { } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; +import { museLaunch } from "./sdk.ts"; const DRIVER = ProviderDriverKind.make("muse"); const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); +const WINDOWS_LAUNCHER_SHIM = [ + "@echo off", + "setlocal", + '"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0.muse-launcher.ps1" %*', + "exit /b %ERRORLEVEL%", +] + .join("\n") + .toLowerCase(); const ChannelManifest = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)), }); @@ -43,12 +53,13 @@ export const compareMuseVersions = (current: string, latest: string) => * an update channel; a standalone binary cannot update itself. */ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { + windowsScriptExtensions: [".ps1"], resolve: Effect.fn("MuseMaintenance.resolve")(function* (context) { const manual = { ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), compareVersions: compareMuseVersions, }; - if (!context || context.platform === "win32") return manual; + if (!context) return manual; const mise = yield* resolveMiseProviderMaintenance( { provider: DRIVER, packageName: null }, { tool: "muse" }, @@ -56,13 +67,36 @@ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { ); if (mise) return { ...mise, compareVersions: compareMuseVersions }; const fs = yield* FileSystem.FileSystem; - const isLauncher = yield* Effect.gen(function* () { + const windows = context.platform === "win32"; + const launcherPath = yield* Effect.gen(function* () { const stat = yield* fs.stat(context.realCommandPath); - if (Number(stat.size) > 128 * 1_024) return false; - const source = yield* fs.readFileString(context.realCommandPath); - return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); - }).pipe(Effect.orElseSucceed(() => false)); - if (!isLauncher) return manual; + if (Number(stat.size) > 128 * 1_024) return null; + let source = yield* fs.readFileString(context.realCommandPath); + let launcher = context.realCommandPath; + if (windows) { + if (/\.(?:cmd|bat)$/i.test(launcher)) { + if (source.trim().replaceAll("\r\n", "\n").toLowerCase() !== WINDOWS_LAUNCHER_SHIM) { + return null; + } + const path = yield* Path.Path; + launcher = yield* fs.realPath(path.join(path.dirname(launcher), ".muse-launcher.ps1")); + if (Number((yield* fs.stat(launcher)).size) > 128 * 1_024) return null; + source = yield* fs.readFileString(launcher); + } else if (!/\.ps1$/i.test(launcher)) { + return null; + } + } + const recognized = windows + ? isOfficialWindowsLauncher(source) + : source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); + return recognized ? launcher : null; + }).pipe(Effect.orElseSucceed(() => null)); + if (!launcherPath) return manual; + const launch = windows + ? yield* museLaunch(launcherPath, context.env) + : { command: context.resolvedCommandPath, args: [] }; + const executable = launch.command; + const args = [...launch.args, "--version"]; // The launcher has no `update` subcommand. Its documented launch environment // forces a synchronous refresh of this executable's own installation. const updateEnv = { @@ -75,14 +109,18 @@ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { ...makeProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["--version"], + updateExecutable: executable, + updateArgs: args, updateCommand: [ - ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), - shellQuote(context.resolvedCommandPath), - "--version", + ...Object.entries(updateEnv).map(([key, value]) => + windows ? `$env:${key}=${powerShellQuote(value)};` : `${key}=${shellQuote(value)}`, + ), + ...(windows ? ["&"] : []), + ...[executable, ...args].map((word) => + windows ? powerShellQuote(word) : shellQuote(word), + ), ].join(" "), - updateLockKey: `muse:${context.realCommandPath}`, + updateLockKey: `muse:${launcherPath}`, platform: context.platform, env: { ...context.env, ...updateEnv }, }), @@ -91,10 +129,37 @@ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { }), }; +function isOfficialWindowsLauncher(source: string) { + const normalized = source + .split(/\r?\n/) + .map((line) => line.trim()) + .join("\n") + .trim(); + // The banner alone also appears in wrappers; require the official entry point and updater. + return ( + normalized.startsWith("Set-StrictMode -Version Latest\n$ErrorActionPreference = 'Stop'\n") && + /^\$launcher_version = '\d+'$/m.test(normalized) && + [ + "$launcher_url = 'https://api.meta.ai/muse-launcher.ps1'", + '$user_agent = "muse-code/launcher-ps-$launcher_version"', + "function Update-Launcher {", + "function Update-Binary {", + "function Invoke-Main {", + "$self = $PSCommandPath\n$dir = Split-Path -Parent $self", + "if ($env:MUSE_SYNC_UPDATE -eq '1') {\nUpdate-Launcher -Self $self\nUpdate-Binary -Dir $dir\n$binary = Get-ActiveBinary -Dir $dir", + ].every((signature) => normalized.includes(`\n${signature}\n`)) && + normalized.endsWith("\nInvoke-Main @args") + ); +} + function shellQuote(word: string) { return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; } +function powerShellQuote(word: string) { + return `'${word.replace(/['\u2018\u2019\u201a\u201b]/g, "$&$&")}'`; +} + export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( environment: NodeJS.ProcessEnv, options?: { readonly fresh?: boolean }, diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts index 739b8c04ff441..be9f0868cdda3 100644 --- a/packages/provider-muse/src/server/sdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -286,7 +286,36 @@ describe("Muse launch", () => { expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe( "C:\\Windows\\System32\\cmd.exe", ); + expect( + yield* museLaunch("C:\\Muse R&D\\.muse-launcher.ps1", { SystemRoot: "D:\\Windows" }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, (command) => command), + ), + ).toEqual({ + command: "D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Muse R&D\\.muse-launcher.ps1", + ], + }); + expect( + (yield* museLaunch("C:\\Muse\\.muse-launcher.ps1", {}).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { SYSTEMROOT: "E:\\Windows" }), + Effect.provideService(SpawnExecutableResolution, (command) => command), + )).command, + ).toBe("E:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] }); + const missing = "C:\\Muse\\missing.ps1"; + expect( + yield* museLaunch(missing).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => undefined), + ), + ).toEqual({ command: missing, args: [] }); }), ); diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index 0269a64d9c967..3c19465cf8876 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -108,7 +108,7 @@ function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { return key === undefined ? undefined : environment[key]; } -/** Resolve Windows launchers with PATHEXT and run .cmd/.bat under cmd.exe; SDK spawn has no shell. */ +/** The SDK spawns without a shell, so Windows scripts need their interpreter. */ export const museLaunch = Effect.fn("museLaunch")(function* ( binaryPath: string, environment?: NodeJS.ProcessEnv, @@ -116,7 +116,23 @@ export const museLaunch = Effect.fn("museLaunch")(function* ( if ((yield* HostProcess.Platform) !== "win32") return { command: binaryPath, args: [] }; const env = environment ?? (yield* HostProcess.Environment); const resolveExecutable = yield* SpawnExecutableResolution; - const resolved = resolveExecutable(binaryPath, "win32", env) ?? binaryPath; + const resolved = resolveExecutable( + binaryPath, + "win32", + /\.ps1$/i.test(binaryPath) ? { ...env, PATHEXT: ".PS1" } : env, + ); + if (resolved === undefined) return { command: binaryPath, args: [] }; + if (/\.ps1$/i.test(resolved)) { + const systemRoot = + windowsEnvironmentValue(env, "SystemRoot") ?? + windowsEnvironmentValue(yield* HostProcess.Environment, "SystemRoot"); + return { + command: systemRoot + ? `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` + : "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved], + }; + } if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command: resolved, args: [] }; const systemRoot = windowsEnvironmentValue(env, "SystemRoot"); return { diff --git a/packages/provider-muse/src/server/status.test.ts b/packages/provider-muse/src/server/status.test.ts index 582b0ee37b6b9..9bd81e927d7b0 100644 --- a/packages/provider-muse/src/server/status.test.ts +++ b/packages/provider-muse/src/server/status.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { MuseSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -76,6 +78,34 @@ it.layer(NodeServices.layer)("Muse status", (it) => { ), ); + it.effect("checks a configured PowerShell launcher through PowerShell", () => + Effect.gen(function* () { + const binaryPath = "C:\\Muse R&D\\.muse-launcher.ps1"; + const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, "--version"]; + const executable = yield* fakeCli( + `if (JSON.stringify(process.argv.slice(2)) !== ${JSON.stringify(JSON.stringify(args))}) process.exit(2); +process.stdout.write("muse 1.0.3-R2198.1\\n");`, + ); + const result = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + {}, + undefined, + async () => makeHost(metaCatalog), + ).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, (command) => + command === binaryPath + ? binaryPath + : command.toLowerCase().endsWith("powershell.exe") + ? executable + : undefined, + ), + ); + expect(result.status).toBe("ready"); + expect(result.version).toBe("1.0.3-R2198.1"); + }).pipe(Effect.scoped), + ); + it.effect("tells the user to finish setup when the launcher has not installed its runtime", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index ef4940df11641..6c41c2d8ceffd 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -7,7 +7,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as ChildProcess from "effect/process/ChildProcess"; -import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; +import { createMuseSdkHost, makeMuseEnvironment, museLaunch } from "./sdk.ts"; import { parseMuseVersion } from "./maintenance.ts"; import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; import { @@ -72,7 +72,10 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func probe, }); const versionResult = yield* Effect.gen(function* () { - const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { + const launch = /\.ps1$/i.test(settings.binaryPath) + ? yield* museLaunch(settings.binaryPath, museEnvironment) + : { command: settings.binaryPath, args: [] }; + const spawnCommand = yield* resolveSpawnCommand(launch.command, [...launch.args, "--version"], { env: museEnvironment, }); return yield* spawnAndCollect(