1import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import { compareSemverVersions } from "@t3tools/shared/semver";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Schema from "effect/Schema";
6import * as HttpClient from "effect/http/HttpClient";
7import * as HttpClientRequest from "effect/http/HttpClientRequest";
8
9import {
10 enrichProviderSnapshotWithVersionAdvisory,
11 makeManualOnlyProviderMaintenanceCapabilities,
12 makeProviderMaintenanceCapabilities,
13 type ProviderMaintenanceCapabilities,
14 type ProviderMaintenanceCapabilitiesResolver,
15} from "@t3tools/provider-core/server/maintenanceResolver";
16import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
17import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
18
19const DRIVER = ProviderDriverKind.make("muse");
20/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */
21const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]);
22const 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. */
27export function parseMuseVersion(output: string): string | null {
28 return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output);
29}
30
31function 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
36const MUSE_REVISION = /-R\d+(?:\.\d+)?$/;
37
38/** A version printed without a revision is compared by its release alone. */
39export 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. */
45export 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
89function shellQuote(word: string) {
90 return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
91}
92
93export 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. */
121export 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});