| 1 | import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as HttpClient from "effect/http/HttpClient"; |
| 7 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 8 | |
| 9 | import { |
| 10 | enrichProviderSnapshotWithVersionAdvisory, |
| 11 | makeManualOnlyProviderMaintenanceCapabilities, |
| 12 | makeProviderMaintenanceCapabilities, |
| 13 | type ProviderMaintenanceCapabilities, |
| 14 | type ProviderMaintenanceCapabilitiesResolver, |
| 15 | } from "@t3tools/provider-core/server/maintenanceResolver"; |
| 16 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 17 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; |
| 18 | |
| 19 | const DRIVER = ProviderDriverKind.make("muse"); |
| 20 | /** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ |
| 21 | const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); |
| 22 | const ChannelManifest = Schema.Struct({ |
| 23 | version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)), |
| 24 | }); |
| 25 | |
| 26 | /** Muse prints both a display version and a release revision; prefer the latter. */ |
| 27 | export function parseMuseVersion(output: string): string | null { |
| 28 | return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); |
| 29 | } |
| 30 | |
| 31 | function comparableMuseVersion(version: string): string { |
| 32 | // Make the release sequence numeric for semver comparison (R9 precedes R10). |
| 33 | return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0"); |
| 34 | } |
| 35 | |
| 36 | const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; |
| 37 | |
| 38 | /** A version printed without a revision is compared by its release alone. */ |
| 39 | export const compareMuseVersions = (current: string, latest: string) => |
| 40 | MUSE_REVISION.test(current) === MUSE_REVISION.test(latest) |
| 41 | ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) |
| 42 | : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, "")); |
| 43 | |
| 44 | /** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ |
| 45 | export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { |
| 46 | resolve: (context) => |
| 47 | Effect.gen(function* () { |
| 48 | const manual = { |
| 49 | ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), |
| 50 | compareVersions: compareMuseVersions, |
| 51 | }; |
| 52 | if (!context || context.platform === "win32") return manual; |
| 53 | const fs = yield* FileSystem.FileSystem; |
| 54 | const isLauncher = yield* Effect.gen(function* () { |
| 55 | const stat = yield* fs.stat(context.realCommandPath); |
| 56 | if (Number(stat.size) > 128 * 1_024) return false; |
| 57 | const source = yield* fs.readFileString(context.realCommandPath); |
| 58 | return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); |
| 59 | }).pipe(Effect.orElseSucceed(() => false)); |
| 60 | if (!isLauncher) return manual; |
| 61 | // The launcher has no `update` subcommand. Its documented launch environment |
| 62 | // forces a synchronous refresh of this executable's own installation. |
| 63 | const updateEnv = { |
| 64 | ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}), |
| 65 | MUSE_NO_AUTO_UPDATE: "0", |
| 66 | MUSE_SYNC_UPDATE: "1", |
| 67 | MUSE_UPDATE_INTERVAL_SECONDS: "0", |
| 68 | }; |
| 69 | return { |
| 70 | ...makeProviderMaintenanceCapabilities({ |
| 71 | provider: DRIVER, |
| 72 | packageName: null, |
| 73 | updateExecutable: context.resolvedCommandPath, |
| 74 | updateArgs: ["--version"], |
| 75 | updateCommand: [ |
| 76 | ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), |
| 77 | shellQuote(context.resolvedCommandPath), |
| 78 | "--version", |
| 79 | ].join(" "), |
| 80 | updateLockKey: `muse:${context.realCommandPath}`, |
| 81 | platform: context.platform, |
| 82 | env: { ...context.env, ...updateEnv }, |
| 83 | }), |
| 84 | compareVersions: compareMuseVersions, |
| 85 | }; |
| 86 | }), |
| 87 | }; |
| 88 | |
| 89 | function shellQuote(word: string) { |
| 90 | return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; |
| 91 | } |
| 92 | |
| 93 | export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( |
| 94 | environment: NodeJS.ProcessEnv, |
| 95 | options?: { readonly fresh?: boolean }, |
| 96 | ) { |
| 97 | const channel = environment.MUSE_CHANNEL || "muse-stable"; |
| 98 | if (!MUSE_CHANNELS.has(channel)) return null; |
| 99 | const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`; |
| 100 | const client = yield* HttpClient.HttpClient; |
| 101 | const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; |
| 102 | return yield* latestVersions.cached( |
| 103 | `muse:${channelUrl}`, |
| 104 | client.execute(HttpClientRequest.get(channelUrl)).pipe( |
| 105 | Effect.flatMap((response) => |
| 106 | response.status >= 200 && response.status < 300 |
| 107 | ? response.json.pipe( |
| 108 | Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)), |
| 109 | Effect.map((manifest) => parseMuseVersion(manifest.version)), |
| 110 | ) |
| 111 | : Effect.succeed(null), |
| 112 | ), |
| 113 | Effect.timeout(4_000), |
| 114 | Effect.orElseSucceed(() => null), |
| 115 | ), |
| 116 | options, |
| 117 | ); |
| 118 | }); |
| 119 | |
| 120 | /** Feed Muse's release channel into the same advisory and notification flow as other drivers. */ |
| 121 | export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: { |
| 122 | readonly snapshot: ServerProvider; |
| 123 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; |
| 124 | readonly enableProviderUpdateChecks: boolean; |
| 125 | readonly environment: NodeJS.ProcessEnv; |
| 126 | }) { |
| 127 | const { snapshot } = input; |
| 128 | const latestVersion = |
| 129 | input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version |
| 130 | ? yield* latestMuseVersion(input.environment) |
| 131 | : null; |
| 132 | return yield* enrichProviderSnapshotWithVersionAdvisory( |
| 133 | snapshot, |
| 134 | { ...input.maintenanceCapabilities, latestVersion }, |
| 135 | { enableProviderUpdateChecks: input.enableProviderUpdateChecks }, |
| 136 | ); |
| 137 | }); |