diff --git a/install-dev.sh b/install-dev.sh new file mode 100755 index 000000000000..f2eec94a9dee --- /dev/null +++ b/install-dev.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="https://github.com/byule/opencode.git" +BRANCH="dev" +INSTALL_DIR="$HOME/.cfcode" +BIN_DIR="$INSTALL_DIR/bin" + +MUTED='\033[0;2m' +RED='\033[0;31m' +ORANGE='\033[38;5;214m' +GREEN='\033[0;32m' +NC='\033[0m' + +print_message() { + local level=$1 + local message=$2 + local color="" + case $level in + info) color="${NC}" ;; + success) color="${GREEN}" ;; + warning) color="${ORANGE}" ;; + error) color="${RED}" ;; + esac + echo -e "${color}${message}${NC}" +} + +# Check prerequisites +print_message info "Checking prerequisites..." + +if ! command -v git >/dev/null 2>&1; then + print_message error "git is required but not installed. Install it first:" + echo " macOS: brew install git" + echo " Ubuntu/Debian: sudo apt-get install git" + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + print_message error "bun is required but not installed. Install it first:" + echo " curl -fsSL https://bun.sh/install | bash" + exit 1 +fi + +print_message success " git: $(git --version | awk '{print $3}')" +print_message success " bun: $(bun --version)" + +# Clone or update the repository +if [ -d "$INSTALL_DIR/.git" ]; then + print_message info "\n${MUTED}Updating existing repo at ${NC}$INSTALL_DIR" + cd "$INSTALL_DIR" + git fetch origin + git checkout "$BRANCH" + git reset --hard "origin/$BRANCH" +else + print_message info "\n${MUTED}Cloning ${NC}byule/opencode${MUTED} (${BRANCH} branch) into ${NC}$INSTALL_DIR" + rm -rf "$INSTALL_DIR" + git clone --branch "$BRANCH" --single-branch "$REPO" "$INSTALL_DIR" +fi + +# Install dependencies +print_message info "\n${MUTED}Installing dependencies...${NC}" +cd "$INSTALL_DIR" +bun install + +# Create wrapper scripts +print_message info "\n${MUTED}Creating wrapper scripts in ${NC}$BIN_DIR" +mkdir -p "$BIN_DIR" + +cat > "$BIN_DIR/cfcode" <<'WRAPPER' +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_DIR="$HOME/.cfcode" + +# If first argument looks like a URL, treat it as 'attach ' +if [ $# -gt 0 ] && [[ "${1:-}" =~ ^https?:// ]]; then + URL="$1" + shift + + # cloudflared is required for URL shorthand + if ! command -v cloudflared >/dev/null 2>&1; then + echo "Error: cloudflared is required to connect to remote URLs." >&2 + echo "Install it: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" >&2 + exit 1 + fi + + # Ensure CF Access token is available (login if needed, shows browser link) + cloudflared access login "$URL" + TOKEN=$(cloudflared access token --app="$URL") + + exec bun run --cwd "$INSTALL_DIR/packages/opencode" dev -- attach "$URL" -H "cf-access-token: $TOKEN" "$@" +fi + +# Otherwise pass through to opencode normally +exec bun run --cwd "$INSTALL_DIR/packages/opencode" dev -- "$@" +WRAPPER +chmod +x "$BIN_DIR/cfcode" + +# Determine shell config file +current_shell=$(basename "$SHELL") +case $current_shell in + zsh) + config_file="${ZDOTDIR:-$HOME}/.zshrc" + ;; + bash) + config_file="$HOME/.bashrc" + ;; + fish) + config_file="$HOME/.config/fish/config.fish" + ;; + *) + config_file="$HOME/.profile" + ;; +esac + +# Add to PATH if not already there +print_message info "\n${MUTED}Configuring PATH...${NC}" + +if [[ ":$PATH:" == *":$BIN_DIR:"* ]]; then + print_message success " $BIN_DIR is already in your PATH" +else + if [ -f "$config_file" ]; then + if grep -Fq "$BIN_DIR" "$config_file" 2>/dev/null; then + print_message warning " PATH entry already exists in $(basename "$config_file"), skipping." + else + echo "" >> "$config_file" + echo "# cfcode (byule's opencode fork)" >> "$config_file" + echo "export PATH=\"$BIN_DIR:\$PATH\"" >> "$config_file" + print_message success " Added $BIN_DIR to PATH in $(basename "$config_file")" + fi + else + print_message warning " Could not find shell config. Add this manually:" + echo " export PATH=\"$BIN_DIR:\$PATH\"" + fi +fi + +# Summary +echo "" +echo -e "${MUTED}  ${NC} ▄ " +echo -e "${MUTED}█▀▀█ █▀▀█ █▀▀█ █▀▀▄ ${NC}█▀▀▀ █▀▀█ █▀▀█ █▀▀█" +echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░░░ █░░█ █░░█ █▀▀▀" +echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀" +echo -e "" +print_message success "cfcode installed successfully!" +echo "" +echo -e "${MUTED}To use it now, run:${NC}" +echo -e " source $(basename "$config_file" 2>/dev/null || echo 'your shell config')" +echo "" +echo -e "${MUTED}Then verify:${NC}" +echo -e " cfcode --version ${MUTED}# Should print 'local'${NC}" +echo -e " cfcode attach -H \"cf-access-token: \"" +echo "" +echo -e "${MUTED}Quick connect to a remote URL (requires cloudflared):${NC}" +echo -e " cfcode ${MUTED}# Auto-fetches CF Access token and attaches${NC}" +echo "" +echo -e "${MUTED}Example:${NC}" +echo -e " cfcode https://4096-sb-867770819f83-j6kaxtmu0u7opzod.superseal.cloudflare.dev/" +echo "" diff --git a/packages/opencode/src/auth/auth.ts b/packages/opencode/src/auth/auth.ts index fb9d2b149575..375789ca18a9 100644 --- a/packages/opencode/src/auth/auth.ts +++ b/packages/opencode/src/auth/auth.ts @@ -29,6 +29,12 @@ export class WellKnown extends Schema.Class("WellKnownAuth")({ type: Schema.Literal("wellknown"), key: Schema.String, token: Schema.String, + // Command to re-run when the token has expired (stdout becomes the new token). + // Present when the well-known response included an `auth.command` field. + command: Schema.optional(Schema.Array(Schema.String)), + // Unix-second expiry decoded from the token's JWT `exp` claim. + // When set and in the past, `command` is re-run before the token is used. + expires: Schema.optional(Schema.Number), }) {} const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 4bc3f0ea6c3a..ebe46715289f 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -297,7 +297,25 @@ export const ProvidersLoginCommand = cmd({ prompts.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) + let wellknown: any + try { + const res = await fetch(`${url}/.well-known/opencode`) + if (!res.ok) { + prompts.log.error(`Server returned ${res.status} for ${url}/.well-known/opencode`) + prompts.outro("Done") + return + } + wellknown = await res.json() + } catch { + prompts.log.error(`Could not reach ${url}`) + prompts.outro("Done") + return + } + if (!Array.isArray(wellknown?.auth?.command) || wellknown.auth.command.length === 0) { + prompts.log.error("Server did not return a valid auth.command") + prompts.outro("Done") + return + } prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", @@ -313,10 +331,25 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } + const trimmed = token.trim() + // Decode exp from the JWT payload without verifying the signature so + // the refresh logic in `attach` knows when to re-run the command. + const exp = (() => { + const parts = trimmed.split(".") + if (parts.length !== 3) return undefined + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) + return typeof payload.exp === "number" ? payload.exp : undefined + } catch { + return undefined + } + })() await put(url, { type: "wellknown", key: wellknown.auth.env, - token: token.trim(), + token: trimmed, + command: wellknown.auth.command, + expires: exp, }) prompts.log.success("Logged into " + url) prompts.outro("Done") diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0874beee16c8..22089bf75e10 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -62,6 +62,20 @@ function block(info: Inline, output?: string) { UI.empty() } +function parseHeaders(values: string[] | undefined): Record | undefined { + if (!values || values.length === 0) return undefined + const result: Record = {} + for (const value of values) { + const idx = value.indexOf(":") + if (idx === -1) throw new Error(`Invalid header format: "${value}". Expected "Key: Value"`) + const key = value.slice(0, idx).trim() + const val = value.slice(idx + 1).trim() + if (!key) throw new Error(`Invalid header format: "${value}". Missing key.`) + result[key] = val + } + return result +} + function fallback(part: ToolPart) { const state = part.state const input = "input" in state ? state.input : undefined @@ -277,6 +291,12 @@ export const RunCommand = cmd({ type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }) + .option("header", { + alias: ["H"], + type: "string", + array: true, + describe: "custom header to send in the format 'Key: Value' (can be specified multiple times)", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -660,10 +680,11 @@ export const RunCommand = cmd({ if (args.attach) { const headers = (() => { const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined + const custom = parseHeaders(args.header) + if (!password) return custom const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } + return { Authorization: auth, ...custom } })() const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 9a93f3f57a63..250529799481 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -3,6 +3,100 @@ import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import * as prompts from "@clack/prompts" +import { cfAccessToken, listWorkspaces, sandboxConnect } from "./seal" + +function parseHeaders(values: string[] | undefined): Record | undefined { + if (!values || values.length === 0) return undefined + const result: Record = {} + for (const value of values) { + const idx = value.indexOf(":") + if (idx === -1) throw new Error(`Invalid header format: "${value}". Expected "Key: Value"`) + const key = value.slice(0, idx).trim() + const val = value.slice(idx + 1).trim() + if (!key) throw new Error(`Invalid header format: "${value}". Missing key.`) + result[key] = val + } + return result +} + +// Resolve the TUI target URL and Basic Auth headers. +// +// Three cases: +// 1. --password given (or OPENCODE_SERVER_PASSWORD set) → use as-is (direct attach) +// 2. No password, CF Access credentials stored for the URL → auto-fetch workspace +// secret + preview URL from the Seal API +// 3. No password, no stored credentials → attach without auth (local server) +async function resolveTarget( + url: string, + password: string | undefined, + customHeaders: Record | undefined, +): Promise<{ url: string; headers: Record | undefined }> { + const explicit = password ?? process.env.OPENCODE_SERVER_PASSWORD + if (explicit) { + return { + url, + headers: { Authorization: `Basic ${Buffer.from(`opencode:${explicit}`).toString("base64")}`, ...customHeaders }, + } + } + + // The URL passed to `attach` doubles as the Seal base URL (e.g. + // https://superseal.cloudflare.dev/code). Normalize it the same way + // `opencode auth login` does so the auth.json lookup hits the right key. + const sealBase = url.replace(/\/+$/, "") + + const token = await cfAccessToken(sealBase).catch(() => null) + if (!token) { + // No CF Access credentials stored — treat as a plain local server. + return { url, headers: customHeaders } + } + + // Fetch workspace list from the Seal API. + const workspaces = await listWorkspaces(sealBase).catch((err: unknown) => { + throw new Error(`Failed to fetch workspaces from ${sealBase}: ${err instanceof Error ? err.message : String(err)}`) + }) + + if (workspaces.length === 0) { + throw new Error("No workspaces found. Create one from the web UI first.") + } + + const workspace = await (async () => { + if (workspaces.length === 1) return workspaces[0] + const choice = await prompts.select({ + message: "Select workspace", + options: workspaces.map((w) => ({ label: w.name, value: w.id })), + }) + if (prompts.isCancel(choice)) throw new UI.CancelledError() + return workspaces.find((w) => w.id === choice)! + })() + + // Poll until the sandbox's opencode port is exposed (container may still be + // starting). Retry every 5 seconds for up to 60 seconds before giving up. + const spinner = prompts.spinner() + spinner.start(`Waiting for sandbox to start…`) + const connect = await (async () => { + const MAX_ATTEMPTS = 12 + const DELAY_MS = 5000 + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const result = await sandboxConnect(sealBase, workspace.id) + if (!result) { + spinner.stop("No active sandbox") + throw new Error(`No active sandbox for workspace "${workspace.name}". Start one from the web UI first.`) + } + if (result.opencodeUrl) return result + if (attempt < MAX_ATTEMPTS - 1) await new Promise((r) => setTimeout(r, DELAY_MS)) + } + spinner.stop("Timed out") + throw new Error(`Sandbox for workspace "${workspace.name}" did not become ready within 60 seconds.`) + })() + spinner.stop("Sandbox ready") + + const opencodeUrl = new URL(connect.opencodeUrl!).origin + return { + url: opencodeUrl, + headers: { Authorization: `Basic ${Buffer.from(`opencode:${connect.secret}`).toString("base64")}`, ...customHeaders }, + } +} export const AttachCommand = cmd({ command: "attach ", @@ -11,7 +105,7 @@ export const AttachCommand = cmd({ yargs .positional("url", { type: "string", - describe: "http://localhost:4096", + describe: "http://localhost:4096 or https://superseal.cloudflare.dev/code", demandOption: true, }) .option("dir", { @@ -36,6 +130,12 @@ export const AttachCommand = cmd({ alias: ["p"], type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("header", { + alias: ["H"], + type: "string", + array: true, + describe: "custom header to send in the format 'Key: Value' (can be specified multiple times)", }), handler: async (args) => { const unguard = win32InstallCtrlCGuard() @@ -58,15 +158,12 @@ export const AttachCommand = cmd({ return args.dir } })() - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() + + const customHeaders = parseHeaders(args.header) + const target = await resolveTarget(args.url, args.password, customHeaders) const config = await TuiConfig.get() await tui({ - url: args.url, + url: target.url, config, args: { continue: args.continue, @@ -74,7 +171,7 @@ export const AttachCommand = cmd({ fork: args.fork, }, directory, - headers, + headers: target.headers, }) } finally { unguard?.() diff --git a/packages/opencode/src/cli/cmd/tui/seal.ts b/packages/opencode/src/cli/cmd/tui/seal.ts new file mode 100644 index 000000000000..f8b28212b340 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/seal.ts @@ -0,0 +1,106 @@ +import { text } from "node:stream/consumers" +import { AppRuntime } from "@/effect/app-runtime" +import { Auth } from "@/auth" +import { Process } from "@/util" +import { Effect } from "effect" + +// Decode the `exp` claim from a JWT without verifying the signature. +// Returns undefined if the token is not a valid JWT or has no exp. +function jwtExp(token: string): number | undefined { + const parts = token.split(".") + if (parts.length !== 3) return undefined + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) + return typeof payload.exp === "number" ? payload.exp : undefined + } catch { + return undefined + } +} + +function isExpired(expires: number | undefined): boolean { + if (expires === undefined) return false + // Refresh 60 seconds before actual expiry so we don't send a just-expired token. + return Date.now() / 1000 > expires - 60 +} + +// Run the stored command and return its stdout trimmed. +async function runCommand(command: readonly string[]): Promise { + const proc = Process.spawn([...command], { stdout: "pipe" }) + if (!proc.stdout) throw new Error(`command produced no stdout: ${command[0]}`) + const [exit, output] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) throw new Error(`command exited ${exit}: ${command[0]}`) + return output.trim() +} + +// Return the CF Access token for `baseUrl`, refreshing via the stored command +// if the cached token is expired. Throws if no wellknown entry exists. +export async function cfAccessToken(baseUrl: string): Promise { + const norm = baseUrl.replace(/\/+$/, "") + const auth = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Auth.Service + return yield* svc.get(norm) + }), + ) + + if (!auth || auth.type !== "wellknown") { + throw new Error(`No CF Access credentials stored for ${norm}. Run: opencode auth login ${norm}`) + } + + if (!isExpired(auth.expires)) return auth.token + + if (!auth.command || auth.command.length === 0) { + throw new Error(`Token for ${norm} is expired and no refresh command is stored. Run: opencode auth login ${norm}`) + } + + const token = await runCommand(auth.command) + const expires = jwtExp(token) + + await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Auth.Service + yield* svc.set(norm, { ...auth, token, expires }) + }), + ) + + return token +} + +type SealWorkspace = { + id: string + name: string +} + +// Fetch the list of workspaces from the Seal API using the stored CF Access token. +// WorkspaceInfo (returned by GET /workspaces) contains only id/name/status — +// sandbox state lives on WorkspaceDO, not in the list response. +export async function listWorkspaces(baseUrl: string): Promise { + const norm = baseUrl.replace(/\/+$/, "") + const token = await cfAccessToken(norm) + const res = await fetch(`${norm}/api/workspaces`, { + headers: { "cf-access-jwt-assertion": token }, + }) + if (!res.ok) throw new Error(`Seal API returned ${res.status} fetching workspaces`) + const data = (await res.json()) as { workspaces: SealWorkspace[] } + return data.workspaces +} + +type SandboxConnect = { + secret: string + // The preview URL for the sandbox's OpenCode port. Null when the sandbox + // is not yet active (container still starting or not provisioned). + opencodeUrl: string | null +} + +// Fetch the per-sandbox Basic Auth secret and preview URL for `workspaceId`. +// Returns null when no active sandbox exists for the workspace (404). +export async function sandboxConnect(baseUrl: string, workspaceId: string): Promise { + const norm = baseUrl.replace(/\/+$/, "") + const token = await cfAccessToken(norm) + const res = await fetch(`${norm}/api/workspaces/${workspaceId}/sandbox-secret`, { + headers: { "cf-access-jwt-assertion": token }, + }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`Seal API returned ${res.status} fetching sandbox secret`) + return res.json() as Promise +}