server.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { Service } from "@opencode-ai/client/effect"
  3. import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
  4. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  5. import { Effect, Redacted } from "effect"
  6. import { Env } from "../env"
  7. import { ServiceConfig } from "./service-config"
  8. import { Standalone } from "./standalone"
  9. export type Args = {
  10. readonly server?: string
  11. readonly standalone?: boolean
  12. readonly mismatch?: "replace" | "ignore" | "error"
  13. readonly onStart?: Service.StartOptions["onStart"]
  14. }
  15. export type Resolved = {
  16. readonly endpoint: Service.Endpoint
  17. readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
  18. readonly reload?: () => Promise<void>
  19. }
  20. export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
  21. if (args.server !== undefined && args.standalone)
  22. return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
  23. if (args.server !== undefined) {
  24. const password = yield* Env.password
  25. const endpoint = {
  26. url: args.server,
  27. auth: password
  28. ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
  29. : undefined,
  30. } satisfies Service.Endpoint
  31. const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
  32. const health = yield* Effect.tryPromise({
  33. try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
  34. catch: (cause) => connectError(endpoint, cause),
  35. })
  36. if (health.version !== InstallationVersion)
  37. process.stderr.write(
  38. `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
  39. )
  40. return { endpoint } satisfies Resolved
  41. }
  42. if (args.standalone) {
  43. return { endpoint: yield* Standalone.start() } satisfies Resolved
  44. }
  45. const options = yield* ServiceConfig.options()
  46. const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
  47. const reconnectOptions = { ...options, version: undefined }
  48. return {
  49. endpoint,
  50. reconnect: (attempt) =>
  51. Effect.runPromise(
  52. Effect.gen(function* () {
  53. if (attempt > 3) return yield* Service.start(reconnectOptions)
  54. const endpoint = yield* Service.discover(reconnectOptions)
  55. if (endpoint !== undefined) return endpoint
  56. return yield* Effect.fail(new Error("Background server is unavailable"))
  57. }).pipe(Effect.provide(NodeFileSystem.layer)),
  58. ),
  59. reload: () =>
  60. Effect.runPromise(
  61. Effect.gen(function* () {
  62. yield* Service.stop(options)
  63. yield* Service.start(options)
  64. }).pipe(Effect.provide(NodeFileSystem.layer)),
  65. ),
  66. } satisfies Resolved
  67. })
  68. const resolveManaged = Effect.fnUntraced(function* (
  69. options: Service.StartOptions,
  70. mismatch: NonNullable<Args["mismatch"]>,
  71. ) {
  72. if (mismatch === "replace") return yield* Service.start(options)
  73. if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined })
  74. const compatible = yield* Service.discover(options)
  75. if (compatible !== undefined) return compatible
  76. const existing = yield* Service.discover({ ...options, version: undefined })
  77. if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
  78. return yield* Service.start(options)
  79. })
  80. function connectError(endpoint: Service.Endpoint, cause: unknown) {
  81. if (isUnauthorizedError(cause)) {
  82. return new Error(
  83. endpoint.auth === undefined
  84. ? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD`
  85. : `Server at ${endpoint.url} rejected the password`,
  86. { cause },
  87. )
  88. }
  89. if (cause instanceof ClientError && cause.reason === "Transport")
  90. return new Error(`Could not reach server at ${endpoint.url}`, { cause })
  91. return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
  92. }
  93. export * as Server from "./server"