Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/cli/src/commands/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
Expand Down Expand Up @@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
updater: {
monitor: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
Expand Down
61 changes: 6 additions & 55 deletions packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"

export type Mode = "default" | "service" | "stdio"
Expand All @@ -29,7 +27,6 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
Expand All @@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
Expand All @@ -66,7 +62,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
if (incumbent !== undefined) return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
Expand Down Expand Up @@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
? waitForStdinClose()
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})

const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})

const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
Expand Down
9 changes: 3 additions & 6 deletions packages/cli/src/services/updater-action.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"

const maximumComponent = "9007199254740991"
const versionPattern =
Expand All @@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
return "notify"
}

export function parseReleaseVersion(input: string) {
Expand Down
47 changes: 19 additions & 28 deletions packages/cli/src/services/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,17 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})

test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
})

test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})

test("reports patches and minors without automatically installing them", () => {
test("reports every available release", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
Expand All @@ -32,25 +27,21 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})

test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})

test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})

test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
test("reports when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
})

test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
})

test("preserves strict validity", () => {
Expand All @@ -71,21 +62,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
})

test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
})

test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
})

test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
})
})
Loading
Loading