refactor(cli): simplify updater, remove cache and confirmation
- Remove the 24h disk cache (State, readState/writeState, checkInterval) that served stale latest versions within the TTL window - Remove file lock (Flock) and confirmation prompt (Prompt/Terminal) - Every check now hits the npm registry directly - Updates always auto-install on any version mismatch within the same major, including rollbacks where the registry version is lower - Drop the interactive option from check() and the confirm/declined/ dismissed action concepts
This commit is contained in:
parent
c22973ab9f
commit
11bf8d8a42
4 changed files with 38 additions and 95 deletions
|
|
@ -10,7 +10,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
const directory = Option.getOrUndefined(input.directory)
|
const directory = Option.getOrUndefined(input.directory)
|
||||||
if (directory !== undefined) process.chdir(directory)
|
if (directory !== undefined) process.chdir(directory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check({ interactive: true })
|
yield* updater.check()
|
||||||
const daemon = yield* Daemon.Service
|
const daemon = yield* Daemon.Service
|
||||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
||||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default Runtime.handler(
|
||||||
const url = HttpServer.formatAddress(address)
|
const url = HttpServer.formatAddress(address)
|
||||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check({ interactive: false }).pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
||||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,26 @@ describe("updater", () => {
|
||||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("automatically updates patches", () => {
|
test("automatically updates patches and minors", () => {
|
||||||
expect(action("1.2.3", "1.2.4", true, false)).toBe("upgrade")
|
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||||
expect(action("1.2.3", "1.2.4", "notify", true)).toBe("confirm")
|
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||||
expect(action("1.2.3", "1.2.4", false, true)).toBe("none")
|
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
||||||
|
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requires an interactive confirmation for minors", () => {
|
test("skips when autoupdate is disabled", () => {
|
||||||
expect(action("1.2.3", "1.3.0", true, true)).toBe("confirm")
|
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||||
expect(action("1.2.3", "1.3.0", true, false)).toBe("none")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("never automatically updates majors", () => {
|
test("never automatically updates majors", () => {
|
||||||
expect(action("1.2.3", "2.0.0", true, true)).toBe("none")
|
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports up-to-date only when versions match", () => {
|
||||||
|
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("upgrades when latest is lower (rollback)", () => {
|
||||||
|
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,25 @@
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
|
||||||
import {
|
import {
|
||||||
InstallationChannel,
|
InstallationChannel,
|
||||||
InstallationLocal,
|
InstallationLocal,
|
||||||
InstallationVersion,
|
InstallationVersion,
|
||||||
} from "@opencode-ai/core/installation/version"
|
} from "@opencode-ai/core/installation/version"
|
||||||
import { Context, Duration, Effect, FileSystem, Layer, Option, Path, Schema, Terminal } from "effect"
|
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||||
import { Prompt } from "effect/unstable/cli"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
|
|
||||||
export type Policy = boolean | "notify"
|
export type Policy = boolean | "notify"
|
||||||
export type Action = "none" | "confirm" | "upgrade"
|
export type Action = "none" | "upgrade"
|
||||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||||
|
|
||||||
const packageName = "@opencode-ai/cli"
|
const packageName = "@opencode-ai/cli"
|
||||||
const checkInterval = 24 * 60 * 60 * 1_000
|
|
||||||
|
|
||||||
const State = Schema.Struct({
|
|
||||||
checked: Schema.Number,
|
|
||||||
latest: Schema.String,
|
|
||||||
dismissed: Schema.optional(Schema.String),
|
|
||||||
})
|
|
||||||
type State = typeof State.Type
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly check: (options: { interactive: boolean }) => Effect.Effect<void>
|
readonly check: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||||
|
|
@ -44,13 +34,11 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||||
if (typeof value === "boolean" || value === "notify") return value
|
if (typeof value === "boolean" || value === "notify") return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function action(current: string, latest: string, policy: Policy, interactive: boolean): Action {
|
export function action(current: string, latest: string, policy: Policy): Action {
|
||||||
if (policy === false) return "none"
|
if (policy === false) return "none"
|
||||||
if (!semver.valid(current) || !semver.valid(latest) || !semver.gt(latest, current)) return "none"
|
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||||
// Major upgrades are never offered or installed automatically.
|
// Major upgrades are never installed automatically.
|
||||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||||
if (semver.minor(latest) !== semver.minor(current)) return interactive ? "confirm" : "none"
|
|
||||||
if (policy === "notify") return interactive ? "confirm" : "none"
|
|
||||||
return "upgrade"
|
return "upgrade"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,11 +48,7 @@ export const layer = Layer.effect(
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const appProcess = yield* AppProcess.Service
|
const appProcess = yield* AppProcess.Service
|
||||||
const effectPath = yield* Path.Path
|
|
||||||
const terminal = yield* Terminal.Terminal
|
|
||||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
const stateFile = path.join(global.state, `updater-${channel}.json`)
|
|
||||||
const decodeState = Schema.decodeUnknownOption(Schema.fromJsonString(State))
|
|
||||||
|
|
||||||
const readPolicy = Effect.fnUntraced(function* () {
|
const readPolicy = Effect.fnUntraced(function* () {
|
||||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||||
|
|
@ -75,19 +59,6 @@ export const layer = Layer.effect(
|
||||||
return values.findLast((value) => value !== undefined) ?? true
|
return values.findLast((value) => value !== undefined) ?? true
|
||||||
})
|
})
|
||||||
|
|
||||||
const readState = Effect.fnUntraced(function* () {
|
|
||||||
const text = yield* fs.readFileString(stateFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!text) return
|
|
||||||
return Option.getOrUndefined(decodeState(text))
|
|
||||||
})
|
|
||||||
|
|
||||||
const writeState = Effect.fnUntraced(function* (state: State) {
|
|
||||||
const temp = stateFile + ".tmp"
|
|
||||||
yield* fs.makeDirectory(global.state, { recursive: true })
|
|
||||||
yield* fs.writeFileString(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 })
|
|
||||||
yield* fs.rename(temp, stateFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||||
return yield* appProcess
|
return yield* appProcess
|
||||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||||
|
|
@ -153,18 +124,7 @@ export const layer = Layer.effect(
|
||||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirm = (version: string) =>
|
const check = Effect.fn("cli.updater.check")(function* () {
|
||||||
Prompt.confirm({
|
|
||||||
message: `Update OpenCode from ${InstallationVersion} to ${version}?`,
|
|
||||||
initial: true,
|
|
||||||
}).pipe(
|
|
||||||
Effect.provideService(FileSystem.FileSystem, fs),
|
|
||||||
Effect.provideService(Path.Path, effectPath),
|
|
||||||
Effect.provideService(Terminal.Terminal, terminal),
|
|
||||||
Effect.orElseSucceed(() => false),
|
|
||||||
)
|
|
||||||
|
|
||||||
const check = Effect.fn("cli.updater.check")(function* (options: { interactive: boolean }) {
|
|
||||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
||||||
return yield* Effect.logInfo("update check skipped", {
|
return yield* Effect.logInfo("update check skipped", {
|
||||||
reason: InstallationLocal ? "local-install" : "disabled",
|
reason: InstallationLocal ? "local-install" : "disabled",
|
||||||
|
|
@ -174,44 +134,19 @@ export const layer = Layer.effect(
|
||||||
const policy = yield* readPolicy()
|
const policy = yield* readPolicy()
|
||||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||||
|
|
||||||
return yield* Effect.scoped(
|
return yield* Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const version = yield* latest()
|
||||||
yield* Flock.effect(`opencode-cli-updater-${channel}`, { dir: path.join(global.state, "locks") })
|
yield* Effect.logInfo("update check", {
|
||||||
const previous = yield* readState()
|
current: InstallationVersion,
|
||||||
const now = Date.now()
|
latest: version,
|
||||||
const cached = previous && now - previous.checked < checkInterval
|
})
|
||||||
const version = cached
|
const next = action(InstallationVersion, version, policy)
|
||||||
? previous!.latest
|
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||||
: yield* latest().pipe(
|
const detected = yield* method()
|
||||||
Effect.tap((value) =>
|
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||||
writeState({ checked: now, latest: value, dismissed: previous?.dismissed }),
|
yield* upgrade(detected, version)
|
||||||
),
|
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||||
)
|
})
|
||||||
yield* Effect.logInfo("update check", {
|
|
||||||
current: InstallationVersion,
|
|
||||||
latest: version,
|
|
||||||
source: cached ? "cached" : "registry",
|
|
||||||
})
|
|
||||||
const next = action(
|
|
||||||
InstallationVersion,
|
|
||||||
version,
|
|
||||||
Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE ? "notify" : policy,
|
|
||||||
options.interactive && process.stdin.isTTY && process.stdout.isTTY,
|
|
||||||
)
|
|
||||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
|
||||||
if (next === "confirm" && previous?.dismissed === version)
|
|
||||||
return yield* Effect.logInfo("update check done", { action: "dismissed", version })
|
|
||||||
const install = next === "upgrade" || (yield* confirm(version))
|
|
||||||
if (!install) {
|
|
||||||
yield* writeState({ checked: previous?.checked ?? now, latest: version, dismissed: version })
|
|
||||||
return yield* Effect.logInfo("update check done", { action: "declined", version })
|
|
||||||
}
|
|
||||||
const detected = yield* method()
|
|
||||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
|
||||||
yield* upgrade(detected, version)
|
|
||||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||||
|
|
||||||
return Service.of({ check })
|
return Service.of({ check })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue