updater.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { Global } from "@opencode-ai/core/global"
  2. import { Flag } from "@opencode-ai/core/flag/flag"
  3. import { AppProcess } from "@opencode-ai/core/process"
  4. import {
  5. InstallationChannel,
  6. InstallationLocal,
  7. InstallationVersion,
  8. } from "@opencode-ai/core/installation/version"
  9. import { Context, Duration, Effect, FileSystem, Layer } from "effect"
  10. import { ChildProcess } from "effect/unstable/process"
  11. import { parse, type ParseError } from "jsonc-parser"
  12. import path from "node:path"
  13. import semver from "semver"
  14. export type Policy = boolean | "notify"
  15. export type Action = "none" | "upgrade"
  16. type Method = "npm" | "pnpm" | "bun" | "yarn"
  17. const packageName = "@opencode-ai/cli"
  18. export interface Interface {
  19. readonly check: () => Effect.Effect<void>
  20. }
  21. export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
  22. export function decodePolicy(text: string): Policy | undefined {
  23. // The CLI only projects this host-level preference instead of initializing
  24. // the location-scoped server configuration graph.
  25. const errors: ParseError[] = []
  26. const input: unknown = parse(text, errors, { allowTrailingComma: true })
  27. if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
  28. const value = input.autoupdate
  29. if (typeof value === "boolean" || value === "notify") return value
  30. }
  31. export function action(current: string, latest: string, policy: Policy): Action {
  32. if (policy === false) return "none"
  33. if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
  34. // Major upgrades are never installed automatically.
  35. if (semver.major(latest) !== semver.major(current)) return "none"
  36. return "upgrade"
  37. }
  38. export const layer = Layer.effect(
  39. Service,
  40. Effect.gen(function* () {
  41. const fs = yield* FileSystem.FileSystem
  42. const global = yield* Global.Service
  43. const appProcess = yield* AppProcess.Service
  44. const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
  45. const readPolicy = Effect.fnUntraced(function* () {
  46. const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
  47. fs
  48. .readFileString(path.join(global.config, name))
  49. .pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
  50. )
  51. return values.findLast((value) => value !== undefined) ?? true
  52. })
  53. const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
  54. return yield* appProcess
  55. .run(ChildProcess.make(command[0], command.slice(1)), {
  56. timeout,
  57. maxOutputBytes: 100_000,
  58. maxErrorBytes: 100_000,
  59. })
  60. .pipe(
  61. Effect.map((result) => ({
  62. code: result.exitCode,
  63. stdout: result.stdout.toString("utf8"),
  64. stderr: result.stderr.toString("utf8"),
  65. })),
  66. Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
  67. )
  68. })
  69. const method = Effect.fnUntraced(function* () {
  70. const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
  71. { method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
  72. { method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
  73. { method: "bun", command: ["bun", "pm", "ls", "-g"] },
  74. { method: "yarn", command: ["yarn", "global", "list"] },
  75. ]
  76. const results = yield* Effect.forEach(
  77. checks,
  78. (check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
  79. { concurrency: "unbounded" },
  80. )
  81. return results.find((result) => result.result.stdout.includes(packageName))?.check.method
  82. })
  83. const latest = Effect.fnUntraced(function* () {
  84. const response = yield* Effect.tryPromise({
  85. try: () =>
  86. fetch(
  87. `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
  88. { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
  89. ),
  90. catch: (cause) => new Error("Failed to check for updates", { cause }),
  91. })
  92. if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
  93. const data = yield* Effect.tryPromise({
  94. try: () => response.json(),
  95. catch: (cause) => new Error("Failed to read update information", { cause }),
  96. })
  97. if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
  98. return yield* Effect.fail(new Error("Update information did not include a version"))
  99. }
  100. return data.version
  101. })
  102. const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
  103. const target = `${packageName}@${version}`
  104. const commands: Record<Method, string[]> = {
  105. npm: ["npm", "install", "--global", target],
  106. pnpm: ["pnpm", "install", "--global", target],
  107. bun: ["bun", "install", "--global", target],
  108. yarn: ["yarn", "global", "add", target],
  109. }
  110. const result = yield* run(commands[method], "5 minutes")
  111. if (result.code === 0) return
  112. return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
  113. })
  114. const check = Effect.fn("cli.updater.check")(function* () {
  115. if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
  116. return yield* Effect.logInfo("update check skipped", {
  117. reason: InstallationLocal ? "local-install" : "disabled",
  118. version: InstallationVersion,
  119. channel: InstallationChannel,
  120. })
  121. const policy = yield* readPolicy()
  122. if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
  123. return yield* Effect.gen(function* () {
  124. const version = yield* latest()
  125. yield* Effect.logInfo("update check", {
  126. current: InstallationVersion,
  127. latest: version,
  128. })
  129. const next = action(InstallationVersion, version, policy)
  130. if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
  131. const detected = yield* method()
  132. if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
  133. yield* upgrade(detected, version)
  134. yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
  135. })
  136. }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
  137. return Service.of({ check })
  138. }),
  139. )
  140. export * as Updater from "./updater"