feat(cli): pair with direct server links (#49971)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
Shoubhit Dash
2026-09-22 20:04:52 +05:30
committed by GitHub
Unverified
parent 18eeb3201d
commit 788f0affcb
6 changed files with 87 additions and 14 deletions
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingUrl, pairingUrl } from "./pairing"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
@@ -48,3 +48,48 @@ describe("pairing URL", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
test("decodes the raw JSON code", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
})
test("decodes a direct /connect URL", () => {
expect(
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
).toEqual({
urls: ["http://192.168.1.2:49374"],
password: info.password,
})
})
test("keeps accepting /connect URLs with query data", () => {
expect(
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
).toEqual({
urls: info.urls,
password: info.password,
})
})
test("falls back to the URL origin when the payload omits server URLs", () => {
const origin = "https://opencode.example.com:49709"
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
urls: [origin],
password: "secret",
})
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
@@ -34,6 +34,13 @@ export function pairingUrl(value: { username: "opencode"; password: string }, ho
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
}
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
export function decodePairingScan(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
+3 -3
View File
@@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
import { decodePairingScan } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
const language = useLanguage()
@@ -22,7 +22,7 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
const pairing = decodePairingScan(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
+17 -7
View File
@@ -2,6 +2,7 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -18,6 +19,9 @@ export default Runtime.handler(
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
const link = info.urls[0]
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
: undefined
process.stdout.write(
[
"",
@@ -25,13 +29,19 @@ export default Runtime.handler(
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
"",
" Scan to pair",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
...(link
? [
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
].join(EOL) + EOL,
)
@@ -362,13 +362,15 @@ $ opencode serve --help
## pair
Shows server pairing information, including URLs, credentials, and a QR code.
Shows server pairing information, including URLs, credentials, and a QR code. The
QR code contains a direct link to the advertised server, with credentials in the
URL fragment.
```bash
$ opencode pair
```
Advertise an external URL in the QR code.
Advertise an external URL in the QR code and link.
```bash
$ opencode pair --url https://dev.example.com
+10 -1
View File
@@ -14,8 +14,18 @@ $ opencode pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
Scan to pair
█▀▀▀▀▀█ ...
Link http://127.0.0.1:49374/connect#...
```
The QR code and link open the advertised server directly. Connection credentials
stay in the URL fragment and are saved by the `/connect` page before it starts
authenticated requests.
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode service` command.
@@ -66,7 +76,6 @@ Use it when you want to:
a foreground process.
- Keep a dedicated server instead of the shared background service.
Connect a client to it with `--server`:
```bash