From 68cdfeffeb91f56d86e0e31a21194de2c6dea4c7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 19 Jul 2026 19:46:34 -0400 Subject: [PATCH] fix(cli): simplify service channel config --- packages/cli/src/services/service-config.ts | 35 +++++++++++++++------ packages/cli/test/service.test.ts | 25 +++++++++++++-- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 58c7c76935..abcaf00dcf 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -25,17 +25,21 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) export function filename(channel = InstallationChannel) { - if (channel === "latest") return "service.json" - if (channel === "local") return "service-local.json" - return `service-${Hash.fast(channel)}.json` + if (channel === "latest" || channel === "next") return "service.json" + return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` } export function defaultPort(channel = InstallationChannel) { - if (channel === "latest") return 0xc0de + if (channel === "latest" || channel === "next") return 0xc0de if (channel === "local") return 0xc0df return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000) } +export function legacyFilename(channel = InstallationChannel) { + if (channel === "latest" || channel === "local") return + return `service-${Hash.fast(channel)}.json` +} + export function versionBelongsToChannel( version: string | undefined, channel = InstallationChannel, @@ -54,7 +58,6 @@ export const migrateRegistration = Effect.fnUntraced(function* ( channel = InstallationChannel, installedVersion = InstallationVersion, ) { - if (channel === "latest" || channel === "local") return const fs = yield* FileSystem.FileSystem const text = yield* fs.readFileString(legacy).pipe(Effect.option) if (Option.isNone(text)) return @@ -64,6 +67,14 @@ export const migrateRegistration = Effect.fnUntraced(function* ( yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore) }) +export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: string) { + const fs = yield* FileSystem.FileSystem + const text = yield* fs.readFileString(legacy).pipe(Effect.option) + if (Option.isNone(text)) return + if (Option.isNone(yield* decodeInfo(text.value).pipe(Effect.option))) return + yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore) +}) + function configKey(key: string): Key { if (key === "hostname" || key === "port" || key === "password") return key throw new Error(`Unknown service config key: ${key}`) @@ -73,18 +84,23 @@ const paths = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const name = filename() + const legacy = legacyFilename() const file = path.join(global.state, name) return { fs, file, - legacyFile: path.join(global.state, "service.json"), + legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined, + legacyRegistrationFiles: [ + ...(legacy ? [path.join(global.state, legacy)] : []), + ...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []), + ], configFile: path.join(global.config, name), } }) export const options = Effect.fnUntraced(function* () { - const { file, legacyFile } = yield* paths - yield* migrateRegistration(legacyFile, file) + const { file, legacyRegistrationFiles } = yield* paths + yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file)) return { file, version: InstallationVersion, @@ -93,7 +109,8 @@ export const options = Effect.fnUntraced(function* () { }) export const read = Effect.fn("cli.service-config.read")(function* () { - const { fs, configFile } = yield* paths + const { fs, configFile, legacyConfigFile } = yield* paths + if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile) return yield* fs.readFileString(configFile).pipe( Effect.flatMap(decodeInfo), Effect.catch(() => Effect.succeed({} as Info)), diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 707a3fbdb7..23a1f96b47 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -20,6 +20,7 @@ import { ServiceConfig } from "../src/services/service-config" test("managed service ports are stable per installation channel", () => { expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de) + expect(ServiceConfig.defaultPort("next")).toBe(0xc0de) expect(ServiceConfig.defaultPort("local")).toBe(0xc0df) expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a")) expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b")) @@ -43,17 +44,35 @@ test("local channel stores service config with the local service filename", asyn } }) -test("service filenames isolate installation channels", () => { +test("service filenames share release channels and identify preview channels", () => { expect(ServiceConfig.filename("latest")).toBe("service.json") + expect(ServiceConfig.filename("next")).toBe("service.json") expect(ServiceConfig.filename("local")).toBe("service-local.json") - expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b")) - expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest")) + expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json") + expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json") expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true) expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true) expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false) expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false) }) +test("service config migrates from the hashed channel filename", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-")) + const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!) + const target = path.join(root, ServiceConfig.filename("preview-a")) + try { + await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 })) + await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer))) + expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 }) + + await fs.writeFile(target, JSON.stringify({ port: 4099 })) + await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer))) + expect(await Bun.file(target).json()).toEqual({ port: 4099 }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("preview registration migration never moves stable discovery", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-")) const legacy = path.join(root, "service.json")