Ver código fonte

Update service configuration CLI

Dax Raad 1 mês atrás
pai
commit
1b83c08b8a

+ 13 - 5
packages/cli/src/commands/commands.ts

@@ -44,18 +44,26 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
         Spec.make("restart", { description: "Restart the background server" }),
         Spec.make("status", { description: "Show background server status" }),
         Spec.make("stop", { description: "Stop the background server" }),
-        Spec.make("password", {
-          description: "Get or set the server password",
-          params: { value: Argument.string("value").pipe(Argument.optional) },
+        Spec.make("get", {
+          description: "Get service configuration",
+          params: { key: Argument.string("key").pipe(Argument.optional) },
+        }),
+        Spec.make("set", {
+          description: "Set service configuration",
+          params: { key: Argument.string("key"), value: Argument.string("value") },
+        }),
+        Spec.make("unset", {
+          description: "Unset service configuration",
+          params: { key: Argument.string("key") },
         }),
       ],
     }),
     Spec.make("serve", {
       description: "Start the v2 API server",
       params: {
-        hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
+        hostname: Flag.string("hostname").pipe(Flag.optional),
         port: Flag.integer("port").pipe(Flag.optional),
-        register: Flag.boolean("register").pipe(Flag.withDefault(false)),
+        service: Flag.boolean("service").pipe(Flag.withDefault(false)),
         stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
       },
     }),

+ 14 - 3
packages/cli/src/commands/handlers/serve.ts

@@ -12,6 +12,7 @@ import { Commands } from "../commands"
 import { Runtime } from "../../framework/runtime"
 import { Daemon } from "../../services/daemon"
 import { Updater } from "../../services/updater"
+import { randomBytes } from "crypto"
 
 export default Runtime.handler(
   Commands.commands.serve,
@@ -21,18 +22,28 @@ export default Runtime.handler(
         const daemon = yield* Daemon.Service
         const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
         if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
-        const password = input.stdio ? standalonePassword : yield* daemon.password()
+        const config = input.service ? yield* daemon.config() : {}
+        const password = input.service
+          ? yield* daemon.password()
+          : standalonePassword || randomBytes(32).toString("base64url")
         if (!password) return yield* Effect.fail(new Error("Missing server password"))
-        const address = yield* listen(input.hostname, input.port, password)
+        const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
+        const port = Option.isSome(input.port)
+          ? input.port
+          : config.port === undefined
+            ? Option.none<number>()
+            : Option.some(config.port)
+        const address = yield* listen(hostname, port, password)
         yield* Effect.tryPromise(() =>
           createOpencodeClient({
             baseUrl: HttpServer.formatAddress(address),
             headers: ServerAuth.headers({ password }),
           }).v2.location.get(undefined, { throwOnError: true }),
         )
-        if (input.register) yield* daemon.register(address)
+        if (input.service) yield* daemon.register(address)
         const url = HttpServer.formatAddress(address)
         console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
+        if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
         const updater = yield* Updater.Service
         yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
         return yield* (input.stdio ? waitForStdinClose() : Effect.never)

+ 3 - 5
packages/cli/src/commands/handlers/service/password.ts → packages/cli/src/commands/handlers/service/get.ts

@@ -6,11 +6,9 @@ import { Runtime } from "../../../framework/runtime"
 import { Daemon } from "../../../services/daemon"
 
 export default Runtime.handler(
-  Commands.commands.service.commands.password,
-  Effect.fn("cli.service.password")(function* (input) {
+  Commands.commands.service.commands.get,
+  Effect.fn("cli.service.get")(function* (input) {
     const daemon = yield* Daemon.Service
-    const value = Option.getOrUndefined(input.value)
-    if (value !== undefined) yield* daemon.stop()
-    process.stdout.write((yield* daemon.password(value)) + EOL)
+    process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
   }),
 )

+ 11 - 0
packages/cli/src/commands/handlers/service/set.ts

@@ -0,0 +1,11 @@
+import * as Effect from "effect/Effect"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../services/daemon"
+
+export default Runtime.handler(
+  Commands.commands.service.commands.set,
+  Effect.fn("cli.service.set")(function* (input) {
+    yield* (yield* Daemon.Service).set(input.key, input.value)
+  }),
+)

+ 11 - 0
packages/cli/src/commands/handlers/service/unset.ts

@@ -0,0 +1,11 @@
+import * as Effect from "effect/Effect"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../services/daemon"
+
+export default Runtime.handler(
+  Commands.commands.service.commands.unset,
+  Effect.fn("cli.service.unset")(function* (input) {
+    yield* (yield* Daemon.Service).unset(input.key)
+  }),
+)

+ 3 - 1
packages/cli/src/index.ts

@@ -30,7 +30,9 @@ const Handlers = Runtime.handlers(Commands, {
     restart: () => import("./commands/handlers/service/restart"),
     status: () => import("./commands/handlers/service/status"),
     stop: () => import("./commands/handlers/service/stop"),
-    password: () => import("./commands/handlers/service/password"),
+    get: () => import("./commands/handlers/service/get"),
+    set: () => import("./commands/handlers/service/set"),
+    unset: () => import("./commands/handlers/service/unset"),
   },
   serve: () => import("./commands/handlers/serve"),
 })

+ 102 - 19
packages/cli/src/services/daemon.ts

@@ -15,6 +15,10 @@ export interface Interface {
   readonly status: () => Effect.Effect<string | undefined>
   readonly stop: () => Effect.Effect<void, unknown>
   readonly password: (value?: string) => Effect.Effect<string, unknown>
+  readonly config: () => Effect.Effect<ServiceConfig, unknown>
+  readonly get: (key?: string) => Effect.Effect<string, unknown>
+  readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
+  readonly unset: (key: string) => Effect.Effect<void, unknown>
   readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
 }
 
@@ -28,9 +32,20 @@ const Registration = Schema.Struct({
 })
 type Registration = typeof Registration.Type
 
-const Config = Schema.Struct({
+const ServiceConfig = Schema.Struct({
+  hostname: Schema.optional(Schema.String),
+  port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
   password: Schema.optional(Schema.String),
 })
+export type ServiceConfig = typeof ServiceConfig.Type
+
+const serviceConfigKeys = ["hostname", "port", "password"] as const
+type ServiceConfigKey = (typeof serviceConfigKeys)[number]
+
+function serviceConfigKey(key: string): ServiceConfigKey {
+  if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
+  throw new Error(`Unknown service config key: ${key}`)
+}
 
 function sameRegistration(left: Registration, right: Registration) {
   return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
@@ -41,32 +56,100 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     const fs = yield* FileSystem.FileSystem
     const directory = Global.Path.state
-    const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
+    const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json")
     const configFile = path.join(Global.Path.config, "service.json")
-    const legacyPasswordFile = path.join(directory, "password")
     const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
-    const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
+    const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
 
-    const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
-      const config = yield* fs
-        .readFileString(configFile)
-        .pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
-      if (value === undefined && config?.password) return config.password
+    const config = Effect.fn("cli.daemon.config")(function* () {
+      return yield* fs.readFileString(configFile).pipe(
+        Effect.flatMap(decodeServiceConfig),
+        Effect.catch(() => Effect.succeed({} as ServiceConfig)),
+      )
+    })
 
-      const legacy = yield* fs
-        .readFileString(legacyPasswordFile)
-        .pipe(Effect.catch(() => Effect.succeed(undefined)))
-      const next = value ?? legacy ?? randomBytes(32).toString("base64url")
+    const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
+      const temp = configFile + ".tmp"
+      yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
+      yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
+      yield* fs.rename(temp, configFile)
+    })
+
+    const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
+      const existing = yield* config()
+      if (value === undefined && existing.password) return existing.password
+      const next = value ?? randomBytes(32).toString("base64url")
 
       // Keep one private credential across server restarts so discovered clients
       // can reconnect without exposing a password flag or environment variable.
-      const temp = configFile + ".tmp"
-      yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
-      yield* fs.rename(temp, configFile)
-      if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
+      yield* writeConfig({ ...existing, password: next })
       return next
     })
 
+    const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
+      if (key === undefined) {
+        const { password: _password, ...safe } = yield* config()
+        return JSON.stringify(safe, null, 2)
+      }
+      switch (serviceConfigKey(key)) {
+        case "hostname": {
+          return (yield* config()).hostname ?? ""
+        }
+        case "port": {
+          const port = (yield* config()).port
+          return port === undefined ? "" : String(port)
+        }
+        case "password": {
+          return yield* password()
+        }
+      }
+    })
+
+    const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
+      switch (serviceConfigKey(key)) {
+        case "hostname": {
+          yield* stop()
+          yield* writeConfig({ ...(yield* config()), hostname: value })
+          return
+        }
+        case "port": {
+          const port = Number(value)
+          if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
+          yield* stop()
+          yield* writeConfig({ ...(yield* config()), port })
+          return
+        }
+        case "password": {
+          yield* stop()
+          yield* password(value)
+          return
+        }
+      }
+    })
+
+    const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
+      switch (serviceConfigKey(key)) {
+        case "hostname": {
+          yield* stop()
+          const { hostname: _hostname, ...next } = yield* config()
+          yield* writeConfig(next)
+          return
+        }
+        case "port": {
+          yield* stop()
+          const { port: _port, ...next } = yield* config()
+          yield* writeConfig(next)
+          return
+        }
+        case "password": {
+          yield* stop()
+          const { password: _password, ...next } = yield* config()
+          yield* writeConfig(next)
+          return
+        }
+      }
+    })
+
     const registration = Effect.fnUntraced(function* () {
       return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
     })
@@ -131,7 +214,7 @@ export const layer = Layer.effect(
         return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
       yield* Effect.try({
         try: () => {
-          spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
+          spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
             detached: true,
             stdio: "ignore",
           }).unref()
@@ -197,7 +280,7 @@ export const layer = Layer.effect(
       )
     })
 
-    return Service.of({ client, transport, start, status, stop, password, register })
+    return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
   }),
 )