From 5143bd827a6e3b6668d41eb52789b5c89703cda3 Mon Sep 17 00:00:00 2001 From: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:47:00 -0400 Subject: [PATCH 1/6] fix(cli): put the pairing link in the remote pairing QR code opencode pair --remote pairs through one public https address, so its QR code now carries the link itself (https:///auth/connect/) instead of {"code","urls"} JSON. A phone's camera can open it: in the OpenCode iOS app once it handles /auth/connect links on opentunnel.xyz (universal link), and otherwise in the browser, where the same link signs in and redirects to the web app. Other QR codes keep the JSON, since they can list several addresses. The app scanners already read both forms (packages/app pairing.ts and the iOS app). --- packages/cli/src/commands/handlers/pair.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 3f8892c6e258..3c2ca0a62c7b 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -27,6 +27,10 @@ export default Runtime.handler( const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href) // Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses. const remote = urls.filter((url) => !isLoopback(new URL(url).hostname)) + // --remote pairs through one public https address, so its QR code carries the link itself: a phone's camera + // opens it in the OpenCode app when installed (universal link) and in the web app otherwise. Every other QR + // code may list several addresses, which needs {"code","urls"} JSON that only the app scanners read. + const qr = input.remote ? links[0] : JSON.stringify({ code: pairing.code, urls: remote }) process.stdout.write( [ "", @@ -37,7 +41,7 @@ export default Runtime.handler( ? [ "", // uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row. - renderUnicodeCompact(JSON.stringify({ code: pairing.code, urls: remote }), { + renderUnicodeCompact(qr, { border: 2, }) .split("\n") From e7e5a4920736343462e8353270307fe94f5206d8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 8 Oct 2026 09:30:58 -0400 Subject: [PATCH 2/6] fix(cli): serve remote access on a random unguessable subdomain --- packages/cli/src/commands/handlers/pair.ts | 3 ++- packages/cli/src/server-process.ts | 3 +++ packages/cli/src/services/remote-tunnel.ts | 21 +++++++---------- packages/cli/src/services/service-config.ts | 24 ++++++++++++++++--- packages/cli/test/remote-tunnel.test.ts | 13 ----------- packages/cli/test/service.test.ts | 26 +++++++++++++++++++++ 6 files changed, 61 insertions(+), 29 deletions(-) delete mode 100644 packages/cli/test/remote-tunnel.test.ts diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 3f8892c6e258..621c4bdeecb7 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -77,7 +77,8 @@ const pairingURLs = Effect.fnUntraced(function* ( // The service attaches the tunnel in the background, so wait for its URL to appear in server info. const remoteURL = Effect.fnUntraced(function* (client: ReturnType) { const tunnelURL = Effect.gen(function* () { - const hostname = yield* RemoteTunnel.hostname() + const route = (yield* ServiceConfig.read()).remoteRoute + const hostname = route === undefined ? undefined : yield* RemoteTunnel.hostname(route) const info = yield* Effect.tryPromise(() => client.server.info()) const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname) if (url === undefined) return yield* Effect.fail(new Error("Remote tunnel is not ready")) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index d19cc1e274a7..9379dcf5cfdf 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -177,8 +177,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { const bound = server.address.hostname // A wildcard bind also listens on loopback, which is all the tunnel needs to reach. const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound + // Remote access enabled by an older build has no route yet. + const route = config.remoteRoute ?? (yield* ServiceConfig.remoteRoute()) yield* Effect.forkScoped( RemoteTunnel.run({ + route, target: `${host}:${server.address.port}`, onURL: (url) => { remote.urls = url === undefined ? [] : [url] diff --git a/packages/cli/src/services/remote-tunnel.ts b/packages/cli/src/services/remote-tunnel.ts index 9efa1537c461..20929c0b684a 100644 --- a/packages/cli/src/services/remote-tunnel.ts +++ b/packages/cli/src/services/remote-tunnel.ts @@ -3,19 +3,16 @@ export * as RemoteTunnel from "./remote-tunnel" import type { OpenTunnelError } from "@opentunnel/client/effect" import { Cause, Effect, Schedule } from "effect" import { EOL } from "os" -import { OPENCODE_CHANNEL } from "../version" // OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app -// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname, -// which the CLI may route, and each channel's service gets its own subdomain. -export function route(channel = OPENCODE_CHANNEL) { - if (channel === "latest") return "opencode" - return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "") -} +// using the SDK; each claims its own routes. The service claims a subdomain (its route) rather than the tunnel +// hostname, which the CLI may route. Tunnel hostnames appear in public certificate logs but the certificate +// covers subdomains with a wildcard, so a random route keeps the service's address unguessable. // Holds the route for the life of the service. The SDK reconnects through network failures itself, so only // setup failures reach the retry here; a rejected token or failed certificate stops it for good. export const run = Effect.fnUntraced(function* (input: { + readonly route: string readonly target: string readonly onURL: (url: string | undefined) => void }) { @@ -25,8 +22,8 @@ export const run = Effect.fnUntraced(function* (input: { (error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed")) yield* Effect.gen(function* () { const client = yield* OpenTunnelClient - const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } }) - input.onURL(`https://${route()}.${connection.tunnel.hostname}`) + const connection = yield* client.tunnel.connect({ routes: { [input.route]: input.target } }) + input.onURL(`https://${input.route}.${connection.tunnel.hostname}`) yield* connection.closed }).pipe( Effect.scoped, @@ -55,7 +52,7 @@ export const ensure = Effect.fnUntraced(function* () { const client = yield* OpenTunnelClient if ((yield* client.tunnel.get()) === undefined) process.stderr.write("Setting up remote access; this can take a minute..." + EOL) - return `${route()}.${(yield* client.tunnel.ensure()).hostname}` + return (yield* client.tunnel.ensure()).hostname }).pipe( Effect.provide(OpenTunnelClient.layer()), Effect.timeoutOrElse({ @@ -66,8 +63,8 @@ export const ensure = Effect.fnUntraced(function* () { }) // The tunnel hostname is persisted once the certificate is ready, so this is undefined until then. -export const hostname = Effect.fnUntraced(function* () { +export const hostname = Effect.fnUntraced(function* (route: string) { const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect")) const identity = yield* OpenTunnelStorage.xdg().load("default") - return identity === undefined ? undefined : `${route()}.${identity.hostname}` + return identity === undefined ? undefined : `${route}.${identity.hostname}` }) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 556ab28566f9..bfd1f8f9384c 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -15,6 +15,8 @@ import { RemoteTunnel } from "./remote-tunnel" export const Info = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), remote: Schema.optional(Schema.Boolean), + // Generated, not user-settable: the secret subdomain remote access is served on. + remoteRoute: Schema.optional(Schema.String), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -155,9 +157,18 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) +// 128 random bits as hex, a valid DNS label, created once and kept so the remote URL survives restarts. +export const remoteRoute = Effect.fn("cli.service-config.remote-route")(function* () { + const existing = yield* read() + if (existing.remoteRoute) return existing.remoteRoute + const route = randomBytes(16).toString("hex") + yield* write({ ...existing, remoteRoute: route }) + return route +}) + export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { if (key === undefined) { - const { password: _password, ...safe } = yield* read() + const { password: _password, remoteRoute: _remoteRoute, ...safe } = yield* read() return JSON.stringify(safe, null, 2) } const selected = configKey(key) @@ -206,7 +217,14 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v // A tunnel that cannot be created leaves remote access off instead of a service that keeps retrying. if (value === "true") yield* RemoteTunnel.ensure() yield* Service.stop(yield* options()) - yield* write({ ...(yield* read()), remote: value === "true" }) + if (value === "true") { + yield* remoteRoute() + yield* write({ ...(yield* read()), remote: true }) + return + } + // Disabling forgets the address, so enabling again issues a new unguessable one. + const { remoteRoute: _remoteRoute, ...next } = yield* read() + yield* write({ ...next, remote: false }) return } case "hostname": { @@ -260,7 +278,7 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin } case "remote": { yield* Service.stop(yield* options()) - const { remote: _remote, ...next } = yield* read() + const { remote: _remote, remoteRoute: _remoteRoute, ...next } = yield* read() yield* write(next) return } diff --git a/packages/cli/test/remote-tunnel.test.ts b/packages/cli/test/remote-tunnel.test.ts deleted file mode 100644 index f7a0cdddb401..000000000000 --- a/packages/cli/test/remote-tunnel.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { validateRoutes } from "@opentunnel/client/effect" -import { expect, test } from "bun:test" -import { RemoteTunnel } from "../src/services/remote-tunnel" - -test("each channel serves remote access on its own valid subdomain of the shared tunnel", () => { - const channels = ["latest", "dev", "beta", "Preview/Feature_X.1", "a".repeat(80), "trailing-"] - const routes = channels.map((channel) => RemoteTunnel.route(channel)) - - expect(routes.slice(0, 3)).toEqual(["opencode", "opencode-dev", "opencode-beta"]) - expect(new Set(routes).size).toBe(routes.length) - // The SDK rejects invalid route names, which would keep the service from ever attaching. - expect(() => validateRoutes(Object.fromEntries(routes.map((name) => [name, "127.0.0.1:4096"])))).not.toThrow() -}) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 2b5f7f66df7d..f61c8c55e942 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,4 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" +import { validateRoutes } from "@opentunnel/client/effect" import { Service, type Info } from "@opencode/client/effect/service" import { Global } from "@opencode/util/global" import { OPENCODE_VERSION } from "../src/version" @@ -63,6 +64,31 @@ test("service remote accepts only booleans and persists across set and unset", a } }) +test("remote access route is random, stable once created, hidden, and forgotten when remote is turned off", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-route-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) + try { + const route = await run(ServiceConfig.remoteRoute()) + // The SDK rejects invalid route names, which would keep the service from ever attaching. + expect(() => validateRoutes({ [route]: "127.0.0.1:4096" })).not.toThrow() + expect(route).toMatch(/^[0-9a-f]{32}$/) + expect(await run(ServiceConfig.remoteRoute())).toBe(route) + expect(await run(ServiceConfig.get())).not.toContain(route) + + await run(ServiceConfig.set("remote", "false")) + expect(await run(ServiceConfig.read())).toEqual({ remote: false }) + const next = await run(ServiceConfig.remoteRoute()) + expect(next).not.toBe(route) + + await run(ServiceConfig.unset("remote")) + expect(await run(ServiceConfig.read())).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { From 950af9ba462650ae2b9e2324e27bc5e8ffa95046 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 8 Oct 2026 09:34:03 -0400 Subject: [PATCH 3/6] refactor(cli): store remote access as an object holding its route --- packages/cli/src/commands/handlers/pair.ts | 4 +- packages/cli/src/server-process.ts | 4 +- packages/cli/src/services/service-config.ts | 43 +++++++++++++-------- packages/cli/test/service.test.ts | 35 ++++++++++++++--- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 621c4bdeecb7..574e81264f2f 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -19,7 +19,7 @@ export default Runtime.handler( if (input.remote && Option.isSome(input.url)) return yield* Effect.fail(new Error("--remote cannot be combined with --url")) // Changing the setting restarts the service, and the ensure below starts it again with the tunnel. - if (input.remote && config.remote !== true) yield* ServiceConfig.set("remote", "true") + if (input.remote && config.remote === undefined) yield* ServiceConfig.set("remote", "true") const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const urls = yield* pairingURLs(client, input) @@ -77,7 +77,7 @@ const pairingURLs = Effect.fnUntraced(function* ( // The service attaches the tunnel in the background, so wait for its URL to appear in server info. const remoteURL = Effect.fnUntraced(function* (client: ReturnType) { const tunnelURL = Effect.gen(function* () { - const route = (yield* ServiceConfig.read()).remoteRoute + const route = (yield* ServiceConfig.read()).remote?.route const hostname = route === undefined ? undefined : yield* RemoteTunnel.hostname(route) const info = yield* Effect.tryPromise(() => client.server.info()) const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 9379dcf5cfdf..cc1220e9cee4 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -173,12 +173,12 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { }), ) if (server === undefined) return - if (serviceOptions !== undefined && config.remote === true && server.address._tag === "TcpAddress") { + if (serviceOptions !== undefined && config.remote !== undefined && server.address._tag === "TcpAddress") { const bound = server.address.hostname // A wildcard bind also listens on loopback, which is all the tunnel needs to reach. const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound // Remote access enabled by an older build has no route yet. - const route = config.remoteRoute ?? (yield* ServiceConfig.remoteRoute()) + const route = config.remote.route ?? (yield* ServiceConfig.remote()) yield* Effect.forkScoped( RemoteTunnel.run({ route, diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index bfd1f8f9384c..3d34b3bcb032 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -2,7 +2,7 @@ import { Global } from "@opencode/util/global" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version" import { Hash } from "@opencode/util/hash" import { Service } from "@opencode/client/effect/service" -import { Effect, FileSystem, Option, Schema } from "effect" +import { Effect, FileSystem, Option, Schema, SchemaGetter } from "effect" import { randomBytes } from "crypto" import path from "path" import { selfCommand } from "../util/process" @@ -12,11 +12,21 @@ import { RemoteTunnel } from "./remote-tunnel" // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. +// Present when remote access is on. The route is generated, never user-set: the secret subdomain the service is +// served on, missing only until a service started by an older build generates it. +const Remote = Schema.Struct({ route: Schema.optional(Schema.String) }) + export const Info = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), - remote: Schema.optional(Schema.Boolean), - // Generated, not user-settable: the secret subdomain remote access is served on. - remoteRoute: Schema.optional(Schema.String), + // Earlier builds stored remote access as a boolean. + remote: Schema.optional( + Schema.Union([Schema.Boolean, Remote]).pipe( + Schema.decodeTo(Schema.UndefinedOr(Remote), { + decode: SchemaGetter.transform((value) => (value === true ? {} : value === false ? undefined : value)), + encode: SchemaGetter.passthrough({ strict: false }), + }), + ), + ), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -157,19 +167,21 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) -// 128 random bits as hex, a valid DNS label, created once and kept so the remote URL survives restarts. -export const remoteRoute = Effect.fn("cli.service-config.remote-route")(function* () { +// Turns remote access on and returns its route: 128 random bits as hex, a valid DNS label, created once and kept +// so the remote URL survives restarts. +export const remote = Effect.fn("cli.service-config.remote")(function* () { const existing = yield* read() - if (existing.remoteRoute) return existing.remoteRoute + if (existing.remote?.route) return existing.remote.route const route = randomBytes(16).toString("hex") - yield* write({ ...existing, remoteRoute: route }) + yield* write({ ...existing, remote: { route } }) return route }) export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { if (key === undefined) { - const { password: _password, remoteRoute: _remoteRoute, ...safe } = yield* read() - return JSON.stringify(safe, null, 2) + const { password: _password, ...safe } = yield* read() + // The route is as sensitive as the password: it is the unguessable half of the remote address. + return JSON.stringify(safe.remote === undefined ? safe : { ...safe, remote: {} }, null, 2) } const selected = configKey(key) if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`) @@ -178,7 +190,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string, return String((yield* read()).disabled ?? false) } case "remote": { - return String((yield* read()).remote ?? false) + return String((yield* read()).remote !== undefined) } case "hostname": { return (yield* read()).hostname ?? "" @@ -218,13 +230,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v if (value === "true") yield* RemoteTunnel.ensure() yield* Service.stop(yield* options()) if (value === "true") { - yield* remoteRoute() - yield* write({ ...(yield* read()), remote: true }) + yield* remote() return } // Disabling forgets the address, so enabling again issues a new unguessable one. - const { remoteRoute: _remoteRoute, ...next } = yield* read() - yield* write({ ...next, remote: false }) + const { remote: _remote, ...next } = yield* read() + yield* write(next) return } case "hostname": { @@ -278,7 +289,7 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin } case "remote": { yield* Service.stop(yield* options()) - const { remote: _remote, remoteRoute: _remoteRoute, ...next } = yield* read() + const { remote: _remote, ...next } = yield* read() yield* write(next) return } diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index f61c8c55e942..474041fce52a 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -55,7 +55,7 @@ test("service remote accepts only booleans and persists across set and unset", a await expect(run(ServiceConfig.set("remote", "on"))).rejects.toThrow("Remote must be true or false") expect(await run(ServiceConfig.read())).toEqual({}) await run(ServiceConfig.set("remote", "false")) - expect(await run(ServiceConfig.read())).toEqual({ remote: false }) + expect(await run(ServiceConfig.read())).toEqual({}) expect(await run(ServiceConfig.get("remote"))).toBe("false") await run(ServiceConfig.unset("remote")) expect(await run(ServiceConfig.read())).toEqual({}) @@ -70,17 +70,19 @@ test("remote access route is random, stable once created, hidden, and forgotten const run = (effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) try { - const route = await run(ServiceConfig.remoteRoute()) + const route = await run(ServiceConfig.remote()) // The SDK rejects invalid route names, which would keep the service from ever attaching. expect(() => validateRoutes({ [route]: "127.0.0.1:4096" })).not.toThrow() expect(route).toMatch(/^[0-9a-f]{32}$/) - expect(await run(ServiceConfig.remoteRoute())).toBe(route) + expect(await run(ServiceConfig.read())).toEqual({ remote: { route } }) + expect(await run(ServiceConfig.remote())).toBe(route) + expect(await run(ServiceConfig.get("remote"))).toBe("true") expect(await run(ServiceConfig.get())).not.toContain(route) await run(ServiceConfig.set("remote", "false")) - expect(await run(ServiceConfig.read())).toEqual({ remote: false }) - const next = await run(ServiceConfig.remoteRoute()) - expect(next).not.toBe(route) + expect(await run(ServiceConfig.read())).toEqual({}) + expect(await run(ServiceConfig.get("remote"))).toBe("false") + expect(await run(ServiceConfig.remote())).not.toBe(route) await run(ServiceConfig.unset("remote")) expect(await run(ServiceConfig.read())).toEqual({}) @@ -89,6 +91,27 @@ test("remote access route is random, stable once created, hidden, and forgotten } }) +test("remote access stored as a boolean by earlier builds keeps the rest of the config and gains a route", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-legacy-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) + const file = path.join(root, "config", ServiceConfig.filename()) + try { + await fs.mkdir(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ remote: true, password: "kept" })) + expect(await run(ServiceConfig.read())).toEqual({ remote: {}, password: "kept" }) + const route = await run(ServiceConfig.remote()) + expect(await Bun.file(file).json()).toEqual({ remote: { route }, password: "kept" }) + + await Bun.write(file, JSON.stringify({ remote: false, password: "kept" })) + expect(await run(ServiceConfig.get("remote"))).toBe("false") + expect((await run(ServiceConfig.read())).password).toBe("kept") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { From 0dfbfefc32c249059abe41c699768f369a77940d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 8 Oct 2026 09:37:50 -0400 Subject: [PATCH 4/6] fix(cli): shorten the remote access route to 16 hex characters --- packages/cli/src/services/service-config.ts | 4 ++-- packages/cli/test/service.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 3d34b3bcb032..39377f6f1a72 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -167,12 +167,12 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) -// Turns remote access on and returns its route: 128 random bits as hex, a valid DNS label, created once and kept +// Turns remote access on and returns its route: 64 random bits as 16 hex characters, a valid DNS label, created once and kept // so the remote URL survives restarts. export const remote = Effect.fn("cli.service-config.remote")(function* () { const existing = yield* read() if (existing.remote?.route) return existing.remote.route - const route = randomBytes(16).toString("hex") + const route = randomBytes(8).toString("hex") yield* write({ ...existing, remote: { route } }) return route }) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 474041fce52a..543acc75db5a 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -73,7 +73,7 @@ test("remote access route is random, stable once created, hidden, and forgotten const route = await run(ServiceConfig.remote()) // The SDK rejects invalid route names, which would keep the service from ever attaching. expect(() => validateRoutes({ [route]: "127.0.0.1:4096" })).not.toThrow() - expect(route).toMatch(/^[0-9a-f]{32}$/) + expect(route).toMatch(/^[0-9a-f]{16}$/) expect(await run(ServiceConfig.read())).toEqual({ remote: { route } }) expect(await run(ServiceConfig.remote())).toBe(route) expect(await run(ServiceConfig.get("remote"))).toBe("true") From 097853fb65406cfb834e39adef0f42cd610f4dd4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 8 Oct 2026 09:41:45 -0400 Subject: [PATCH 5/6] fix(cli): repair boolean remote access configs when they are read --- packages/cli/src/server-process.ts | 4 +- packages/cli/src/services/service-config.ts | 53 +++++++++++---------- packages/cli/test/service.test.ts | 23 ++++++--- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index cc1220e9cee4..28ae68224a43 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -177,11 +177,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { const bound = server.address.hostname // A wildcard bind also listens on loopback, which is all the tunnel needs to reach. const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound - // Remote access enabled by an older build has no route yet. - const route = config.remote.route ?? (yield* ServiceConfig.remote()) yield* Effect.forkScoped( RemoteTunnel.run({ - route, + route: config.remote.route, target: `${host}:${server.address.port}`, onURL: (url) => { remote.urls = url === undefined ? [] : [url] diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 39377f6f1a72..78456b9136f3 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -2,7 +2,7 @@ import { Global } from "@opencode/util/global" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version" import { Hash } from "@opencode/util/hash" import { Service } from "@opencode/client/effect/service" -import { Effect, FileSystem, Option, Schema, SchemaGetter } from "effect" +import { Effect, FileSystem, Option, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" import { selfCommand } from "../util/process" @@ -12,21 +12,11 @@ import { RemoteTunnel } from "./remote-tunnel" // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. -// Present when remote access is on. The route is generated, never user-set: the secret subdomain the service is -// served on, missing only until a service started by an older build generates it. -const Remote = Schema.Struct({ route: Schema.optional(Schema.String) }) - export const Info = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), - // Earlier builds stored remote access as a boolean. - remote: Schema.optional( - Schema.Union([Schema.Boolean, Remote]).pipe( - Schema.decodeTo(Schema.UndefinedOr(Remote), { - decode: SchemaGetter.transform((value) => (value === true ? {} : value === false ? undefined : value)), - encode: SchemaGetter.passthrough({ strict: false }), - }), - ), - ), + // Present when remote access is on. The route is generated, never user-set: the secret subdomain the service is + // served on. + remote: Schema.optional(Schema.Struct({ route: Schema.String })), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -39,6 +29,10 @@ const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) +// Earlier builds stored remote access as a boolean. +const decodeLegacy = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ ...Info.fields, remote: Schema.Boolean })), +) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) export function filename(channel = OPENCODE_CHANNEL) { @@ -142,12 +136,24 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi export const read = Effect.fn("cli.service-config.read")(function* () { const { fs, configFile, legacyConfigFile } = yield* paths if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile) - return yield* fs.readFileString(configFile).pipe( - Effect.flatMap(decodeInfo), - Effect.orElseSucceed(() => ({}) as Info), - ) + const text = yield* fs.readFileString(configFile).pipe(Effect.option) + if (Option.isNone(text)) return {} as Info + const info = yield* decodeInfo(text.value).pipe(Effect.option) + if (Option.isSome(info)) return info.value + const legacy = decodeLegacy(text.value) + if (Option.isNone(legacy)) return {} as Info + // Repair the file in place so every reader sees the same route. + const { remote: enabled, ...rest } = legacy.value + const repaired: Info = enabled ? { ...rest, remote: { route: route() } } : rest + yield* write(repaired) + return repaired }) +// 64 random bits as 16 hex characters, a valid DNS label. +function route() { + return randomBytes(8).toString("hex") +} + const write = Effect.fn("cli.service-config.write")(function* (value: Info) { const { fs, configFile } = yield* paths const temp = configFile + ".tmp" @@ -167,14 +173,13 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) -// Turns remote access on and returns its route: 64 random bits as 16 hex characters, a valid DNS label, created once and kept -// so the remote URL survives restarts. +// Turns remote access on and returns its route, created once and kept so the remote URL survives restarts. export const remote = Effect.fn("cli.service-config.remote")(function* () { const existing = yield* read() - if (existing.remote?.route) return existing.remote.route - const route = randomBytes(8).toString("hex") - yield* write({ ...existing, remote: { route } }) - return route + if (existing.remote) return existing.remote.route + const next = route() + yield* write({ ...existing, remote: { route: next } }) + return next }) export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 543acc75db5a..1facda409419 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -91,7 +91,7 @@ test("remote access route is random, stable once created, hidden, and forgotten } }) -test("remote access stored as a boolean by earlier builds keeps the rest of the config and gains a route", async () => { +test("reading a config with remote access stored as a boolean repairs it in place and keeps other settings", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-legacy-")) const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) const run = (effect: Effect.Effect) => @@ -99,14 +99,23 @@ test("remote access stored as a boolean by earlier builds keeps the rest of the const file = path.join(root, "config", ServiceConfig.filename()) try { await fs.mkdir(path.dirname(file), { recursive: true }) - await Bun.write(file, JSON.stringify({ remote: true, password: "kept" })) - expect(await run(ServiceConfig.read())).toEqual({ remote: {}, password: "kept" }) - const route = await run(ServiceConfig.remote()) - expect(await Bun.file(file).json()).toEqual({ remote: { route }, password: "kept" }) + + await Bun.write(file, JSON.stringify({ remote: true, password: "kept", env: { A: "1" } })) + const enabled = await run(ServiceConfig.read()) + const route = enabled.remote?.route ?? "" + expect(route).toMatch(/^[0-9a-f]{16}$/) + expect(enabled).toEqual({ remote: { route }, password: "kept", env: { A: "1" } }) + expect(await Bun.file(file).json()).toEqual(enabled) + expect(await run(ServiceConfig.read())).toEqual(enabled) await Bun.write(file, JSON.stringify({ remote: false, password: "kept" })) - expect(await run(ServiceConfig.get("remote"))).toBe("false") - expect((await run(ServiceConfig.read())).password).toBe("kept") + expect(await run(ServiceConfig.read())).toEqual({ password: "kept" }) + expect(await Bun.file(file).json()).toEqual({ password: "kept" }) + + const current = JSON.stringify({ remote: { route: "0123456789abcdef" }, password: "kept" }) + await Bun.write(file, current) + expect(await run(ServiceConfig.read())).toEqual({ remote: { route: "0123456789abcdef" }, password: "kept" }) + expect(await Bun.file(file).text()).toBe(current) } finally { await fs.rm(root, { recursive: true, force: true }) } From b8548c7f32804d30636da69d74a3cb4b70c70924 Mon Sep 17 00:00:00 2001 From: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:14:41 -0400 Subject: [PATCH 6/6] feat(cli): build the pairing QR code while remote access is set up A device's first opencode pair --remote waits ~25 s for its tunnel certificate behind one plain line. In a terminal, the QR code is now built where it will be printed: two builders lay the outer wall, the corner squares and timing beams, then the rest from the bottom row up, each block popping in in its builder's color. Nothing unbuilt is shown. The step comes from OpenTunnel's provisioning progress. When the pairing code exists (after the service restarts with the tunnel), a finishing pass lays the real code and flashes, and the normal output replaces the animation line for line. Only on a first-time remote setup with a TTY stdout and color (not NO_COLOR, TERM=dumb or CI); otherwise unchanged, including the plain notice. --- packages/cli/src/commands/handlers/pair.ts | 39 +++-- packages/cli/src/services/remote-tunnel.ts | 17 +- packages/cli/src/ui/qr-build.ts | 178 +++++++++++++++++++++ 3 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/ui/qr-build.ts diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 36db7ec8eb51..44a92e08e8aa 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -7,6 +7,7 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { RemoteTunnel } from "../../services/remote-tunnel" import { ServiceConfig } from "../../services/service-config" +import { QrBuild } from "../../ui/qr-build" export default Runtime.handler( Commands.commands.pair, @@ -18,12 +19,27 @@ export default Runtime.handler( ) if (input.remote && Option.isSome(input.url)) return yield* Effect.fail(new Error("--remote cannot be combined with --url")) - // Changing the setting restarts the service, and the ensure below starts it again with the tunnel. - if (input.remote && config.remote === undefined) yield* ServiceConfig.set("remote", "true") - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) - const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const urls = yield* pairingURLs(client, input) - const pairing = yield* Effect.tryPromise(() => client.server.pair()) + // A device's first tunnel takes about half a minute (its certificate). In a terminal, the QR code is built + // where it will be printed while that runs; the link's length is known, so the code's shape is too. + const build = + input.remote && config.remote === undefined && QrBuild.enabled() && !(yield* RemoteTunnel.created()) + ? // The route is 16 random hex characters, the tunnel ID 12, the pairing code 22. + QrBuild.start(`https://${"x".repeat(16)}.${"x".repeat(12)}.opentunnel.xyz/auth/connect/${"x".repeat(22)}`) + : undefined + const setup = Effect.gen(function* () { + if (build) { + yield* RemoteTunnel.ensure((stage) => build.stage(QrBuild.stageOf(stage))) + build.stage("connecting") + } + // Changing the setting restarts the service, and the ensure below starts it again with the tunnel. + if (input.remote && config.remote === undefined) yield* ServiceConfig.set("remote", "true") + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const urls = yield* pairingURLs(client, input) + const pairing = yield* Effect.tryPromise(() => client.server.pair()) + return { endpoint, urls, pairing } + }) + const { endpoint, urls, pairing } = yield* build ? setup.pipe(Effect.onError(() => Effect.sync(build.clear))) : setup const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href) // Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses. const remote = urls.filter((url) => !isLoopback(new URL(url).hostname)) @@ -31,7 +47,7 @@ export default Runtime.handler( // opens it in the OpenCode app when installed (universal link) and in the web app otherwise. Every other QR // code may list several addresses, which needs {"code","urls"} JSON that only the app scanners read. const qr = input.remote ? links[0] : JSON.stringify({ code: pairing.code, urls: remote }) - process.stdout.write( + const output = [ "", ` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`, @@ -50,8 +66,13 @@ export default Runtime.handler( ] : []), "", - ].join(EOL) + EOL, - ) + ].join(EOL) + EOL + // The animation finishes on the real code, then the output takes its place line for line. + if (build && remote.length) yield* Effect.promise(() => build.finish(qr, output)) + else { + build?.clear() + process.stdout.write(output) + } if (input.remote || Option.isSome(input.url)) return const url = new URL(endpoint.url) diff --git a/packages/cli/src/services/remote-tunnel.ts b/packages/cli/src/services/remote-tunnel.ts index 20929c0b684a..8e2892b1c757 100644 --- a/packages/cli/src/services/remote-tunnel.ts +++ b/packages/cli/src/services/remote-tunnel.ts @@ -1,6 +1,6 @@ export * as RemoteTunnel from "./remote-tunnel" -import type { OpenTunnelError } from "@opentunnel/client/effect" +import type { OpenTunnelError, OpenTunnelProvisionStage } from "@opentunnel/client/effect" import { Cause, Effect, Schedule } from "effect" import { EOL } from "os" @@ -46,13 +46,14 @@ export const run = Effect.fnUntraced(function* (input: { // A device without a tunnel creates its shared one here, in the foreground, because issuing the certificate // takes a while; the service then only attaches its route on start. An interrupted issuance resumes next time. -export const ensure = Effect.fnUntraced(function* () { +// `onProgress` takes over reporting a first-time setup (pairing animates it) from the plain notice. +export const ensure = Effect.fnUntraced(function* (onProgress?: (stage: OpenTunnelProvisionStage) => void) { const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect")) return yield* Effect.gen(function* () { const client = yield* OpenTunnelClient - if ((yield* client.tunnel.get()) === undefined) - process.stderr.write("Setting up remote access; this can take a minute..." + EOL) - return (yield* client.tunnel.ensure()).hostname + if ((yield* client.tunnel.get()) !== undefined) return (yield* client.tunnel.ensure()).hostname + if (!onProgress) process.stderr.write("Setting up remote access; this can take a minute..." + EOL) + return (yield* client.tunnel.create({ onProgress })).hostname }).pipe( Effect.provide(OpenTunnelClient.layer()), Effect.timeoutOrElse({ @@ -62,6 +63,12 @@ export const ensure = Effect.fnUntraced(function* () { ) }) +// Whether this device has its tunnel yet: the identity is persisted once the certificate is ready. +export const created = Effect.fnUntraced(function* () { + const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect")) + return (yield* OpenTunnelStorage.xdg().load("default")) !== undefined +}) + // The tunnel hostname is persisted once the certificate is ready, so this is undefined until then. export const hostname = Effect.fnUntraced(function* (route: string) { const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect")) diff --git a/packages/cli/src/ui/qr-build.ts b/packages/cli/src/ui/qr-build.ts new file mode 100644 index 000000000000..265f7bdcf78a --- /dev/null +++ b/packages/cli/src/ui/qr-build.ts @@ -0,0 +1,178 @@ +export * as QrBuild from "./qr-build" + +import { EOL } from "os" +import { encode, QrCodeDataType } from "uqr" + +// While `opencode pair --remote` sets up a device's tunnel for the first time (~25 s, mostly waiting for its +// certificate), two builders build the pairing QR code block by block where it will be printed: the outer wall, +// the corner squares and the timing beams first, then the rest from the bottom row up, each block popping in in +// its builder's color. Nothing unbuilt is shown. The pairing code only exists once the service restarts with +// the tunnel, so the builders lay a stand-in of the same length; when it's ready, the finishing pass re-lays +// every data block from the real code and the whole thing flashes, ending exactly on the printed output. + +export type Stage = "address" | "key" | "certificate" | "connecting" + +const labels: Record = { + address: "Reserving an address for this computer", + key: "Making a key for this computer", + certificate: "Getting a certificate for the tunnel", + connecting: "Connecting the tunnel", +} + +// Only in a terminal that can redraw in place and show color. +export function enabled() { + return ( + process.stdout.isTTY === true && + process.env.NO_COLOR === undefined && + process.env.TERM !== "dumb" && + process.env.CI === undefined + ) +} + +type Builder = "left" | "right" +type Look = "blank" | "stone" | "done" | "flash" | Builder + +const FPS = 30 +const POP = 0.3 // seconds a new block shows its builder's color +const FINISH = 0.55 // seconds for the finishing pass +const FLASH = 0.35 + +// `sample` is any text of the final QR code's length: same length, same version, so same structure. +export function start(sample: string) { + const placeholder = encode(sample, { border: 2 }) + const n = placeholder.size + const mid = (n - 1) / 2 + const height = 5 + Math.ceil(n / 2) + 1 + const started = performance.now() + const now = () => (performance.now() - started) / 1000 + + // Blocks are light modules; dark ones are left empty. Walls go up from the bottom corners, then the + // corner squares, then the beams, then the rest bottom row up, edge inward. + const rank = (type: QrCodeDataType) => + type === QrCodeDataType.Border + ? 0 + : type === QrCodeDataType.Position + ? 1 + : type === QrCodeDataType.Timing || type === QrCodeDataType.Alignment + ? 2 + : 3 + const owner = (x: number, y: number): Builder => (x < mid || (x === mid && y % 2 === 0) ? "left" : "right") + const placedAt = Array.from({ length: n }, () => new Array(n).fill(Infinity)) + for (const builder of ["left", "right"] as const) { + const queue = placeholder.data + .flatMap((row, y) => row.map((dark, x) => ({ x, y, dark }))) + .filter(({ x, y, dark }) => !dark && owner(x, y) === builder) + .map(({ x, y }) => { + const fromEdge = builder === "left" ? x : n - 1 - x + const kind = rank(placeholder.types[y][x]) + const within = + kind === 0 ? (n - 1 - y) * 0.6 + fromEdge * 0.2 : kind === 3 ? (n - 1 - y) * 3 + fromEdge * 0.35 : n - 1 - y + fromEdge * 0.4 + return { x, y, order: kind * 1000 + within + Math.random() * 1.5 } + }) + .sort((a, b) => a.order - b.order) + // A steady rhythm that eases off, so the build is still going whenever the certificate arrives. + queue.forEach(({ x, y }, i) => { + const share = (i + 1) / queue.length + placedAt[y][x] = share >= 0.92 ? Infinity : -11 * Math.log(1 - share / 0.92) + 0.6 + }) + } + + let stage: Stage = "address" + let real: ReturnType | undefined + let finishAt = Infinity + const finishDelay = (x: number, y: number) => (1 - Math.abs(x - mid) / (mid + 1)) * 0.45 + (y / n) * 0.1 + + function look(x: number, y: number, t: number): Look { + if (y >= n) return "blank" + const builder = owner(x, y) + if (real && t >= finishAt + finishDelay(x, y)) { + // The finishing pass lays the real block (wall and beams match the stand-in already). + if (real.data[y][x]) return "blank" + const laid = finishAt + finishDelay(x, y) + const settled = t >= finishAt + FINISH ? (t < finishAt + FINISH + FLASH ? "flash" : "done") : "stone" + // Data blocks are laid again; wall, corners and beams already built stay put. + const fresh = rank(real.types[y][x]) === 3 || placedAt[y][x] > finishAt + return fresh && t - laid < POP ? builder : settled + } + if (placeholder.data[y][x] || t < placedAt[y][x]) return "blank" + return t - placedAt[y][x] < POP ? builder : "stone" + } + + const sgr: Record, string> = { + stone: "\x1b[2m", + done: "", + flash: "\x1b[1m", + left: "\x1b[38;2;246;178;108m", + right: "\x1b[38;2;124;176;246m", + } + const priority: Look[] = ["left", "right", "flash", "done", "stone"] + + function frame(t: number) { + const rows: string[] = [] + for (let y = 0; y < n; y += 2) { + let line = " " + for (let x = 0; x < n; x++) { + const top = look(x, y, t) + const bottom = look(x, y + 1, t) + if (top === "blank" && bottom === "blank") { + line += " " + continue + } + // One style per character: the liveliest of its two blocks. + const style = priority.find((p) => p === top || p === bottom) as Exclude + line += sgr[style] + (top !== "blank" && bottom !== "blank" ? "█" : top !== "blank" ? "▀" : "▄") + "\x1b[0m" + } + rows.push(line) + } + const status = real ? "Ready" : `${labels[stage]}…` + return ["", ` \x1b[2m${status}\x1b[0m`, "", "", "", ...rows, ""] + } + + let drawn = false + const draw = (lines: string[]) => { + process.stdout.write((drawn ? `\x1b[${height}A` : "") + lines.map((line) => `\x1b[2K${line}`).join(EOL) + EOL) + drawn = true + } + const restoreCursor = () => process.stdout.write("\x1b[?25h") + process.stdout.write("\x1b[?25l") + process.once("exit", restoreCursor) + draw(frame(0)) + const timer = setInterval(() => draw(frame(now())), 1000 / FPS) + + const stop = () => { + clearInterval(timer) + restoreCursor() + process.removeListener("exit", restoreCursor) + } + + return { + stage: (next: Stage) => { + stage = next + }, + // Lays the real code, flashes, then writes `output` over the animation; `output` must put the code + // where the animation drew it (5 lines down, indented 2). + finish: async (content: string, output: string) => { + const final = encode(content, { border: 2 }) + if (final.size === n) { + real = final + finishAt = now() + await new Promise((resolve) => setTimeout(resolve, (FINISH + FLASH + 0.15) * 1000)) + } + stop() + process.stdout.write(`\x1b[${height}A\x1b[J` + output) + }, + // Clears the animation, for a failed setup. + clear: () => { + stop() + if (drawn) process.stdout.write(`\x1b[${height}A\x1b[J`) + }, + } +} + +// OpenTunnel's provisioning stages, as the steps a person cares about. +export function stageOf(stage: string): Stage { + if (stage === "creating-tunnel") return "address" + if (stage === "generating-key" || stage === "generating-csr") return "key" + if (stage === "saving-identity" || stage === "ready") return "connecting" + return "certificate" +}