refactor: apply minimal tfcode branding

- Rename packages/opencode → packages/tfcode (directory only)
- Rename bin/opencode → bin/tfcode (CLI binary)
- Rename .opencode → .tfcode (config directory)
- Update package.json name and bin field
- Update config directory path references (.tfcode)
- Keep internal code references as 'opencode' for easy upstream sync
- Keep @opencode-ai/* workspace package names

This minimal branding approach allows clean merges from upstream
opencode repository while providing tfcode branding for users.
This commit is contained in:
Gab
2026-03-24 13:19:59 +11:00
parent 8bcbd40e9b
commit a8b73fd754
608 changed files with 26 additions and 32 deletions

View File

@@ -0,0 +1,20 @@
import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
export function getAdaptor(type: string): Promise<Adaptor> {
return ADAPTORS[type]()
}
export function installAdaptor(type: string, adaptor: Adaptor) {
// This is experimental: mostly used for testing right now, but we
// will likely allow this in the future. Need to figure out the
// TypeScript story
// @ts-expect-error we force the builtin types right now, but we
// will implement a way to extend the types for custom adaptors
ADAPTORS[type] = () => adaptor
}

View File

@@ -0,0 +1,46 @@
import z from "zod"
import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types"
const Config = WorkspaceInfo.extend({
name: WorkspaceInfo.shape.name.unwrap(),
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
type Config = z.infer<typeof Config>
export const WorktreeAdaptor: Adaptor = {
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
return {
...info,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
}
},
async create(info) {
const config = Config.parse(info)
const bootstrap = await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
})
return bootstrap()
},
async remove(info) {
const config = Config.parse(info)
await Worktree.remove({ directory: config.directory })
},
async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
const config = Config.parse(info)
const { WorkspaceServer } = await import("../workspace-server/server")
const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
headers.set("x-opencode-directory", config.directory)
const request = new Request(url, { ...init, headers })
return WorkspaceServer.App().fetch(request)
},
}

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})),
)

View File

@@ -0,0 +1,66 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onEvent: (event: unknown) => void,
) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
let last = ""
let retry = 1000
const abort = () => {
void reader.cancel().catch(() => undefined)
}
signal.addEventListener("abort", abort)
try {
while (!signal.aborted) {
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
if (chunk.done) break
buf += decoder.decode(chunk.value, { stream: true })
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buf.split("\n\n")
buf = chunks.pop() ?? ""
chunks.forEach((chunk) => {
const data: string[] = []
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) {
data.push(line.replace(/^data:\s*/, ""))
return
}
if (line.startsWith("id:")) {
last = line.replace(/^id:\s*/, "")
return
}
if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) retry = parsed
}
})
if (!data.length) return
const raw = data.join("\n")
try {
onEvent(JSON.parse(raw))
} catch {
onEvent({
type: "sse.message",
properties: {
data: raw,
id: last || undefined,
retry,
},
})
}
})
}
} finally {
signal.removeEventListener("abort", abort)
reader.releaseLock()
}
}

View File

@@ -0,0 +1,21 @@
import z from "zod"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
})
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
export type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise<Response>
}

View File

@@ -0,0 +1,24 @@
import { Context } from "../util/context"
import type { WorkspaceID } from "./schema"
interface Context {
workspaceID?: WorkspaceID
}
const context = Context.create<Context>("workspace")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, async () => {
return input.fn()
})
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (e) {
return undefined
}
},
}

View File

@@ -0,0 +1,49 @@
import type { MiddlewareHandler } from "hono"
import { Flag } from "../flag/flag"
import { getAdaptor } from "./adaptors"
import { Workspace } from "./workspace"
import { WorkspaceContext } from "./workspace-context"
// This middleware forwards all non-GET requests if the workspace is a
// remote. The remote workspace needs to handle session mutations
async function routeRequest(req: Request) {
// Right now, we need to forward all requests to the workspace
// because we don't have syncing. In the future all GET requests
// which don't mutate anything will be handled locally
//
// if (req.method === "GET") return
if (!WorkspaceContext.workspaceID) return
const workspace = await Workspace.get(WorkspaceContext.workspaceID)
if (!workspace) {
return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, {
status: 500,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
const adaptor = await getAdaptor(workspace.type)
return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, {
method: req.method,
body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
signal: req.signal,
headers: req.headers,
})
}
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
// Only available in development for now
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
return next()
}
const response = await routeRequest(c.req.raw)
if (response) {
return response
}
return next()
}

View File

@@ -0,0 +1,33 @@
import { GlobalBus } from "../../bus/global"
import { Hono } from "hono"
import { streamSSE } from "hono/streaming"
export function WorkspaceServerRoutes() {
return new Hono().get("/event", async (c) => {
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const send = async (event: unknown) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
const handler = async (event: { directory?: string; payload: unknown }) => {
await send(event.payload)
}
GlobalBus.on("event", handler)
await send({ type: "server.connected", properties: {} })
const heartbeat = setInterval(() => {
void send({ type: "server.heartbeat", properties: {} })
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
})
})
})
}

View File

@@ -0,0 +1,65 @@
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
import { SessionRoutes } from "../../server/routes/session"
import { WorkspaceServerRoutes } from "./routes"
import { WorkspaceContext } from "../workspace-context"
import { WorkspaceID } from "../schema"
export namespace WorkspaceServer {
export function App() {
const session = new Hono()
.use(async (c, next) => {
// Right now, we need handle all requests because we don't
// have syncing. In the future all GET requests will handled
// by the control plane
//
// if (c.req.method === "GET") return c.notFound()
await next()
})
.route("/", SessionRoutes())
return new Hono()
.use(async (c, next) => {
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
if (rawWorkspaceID == null) {
throw new Error("workspaceID parameter is required")
}
if (raw == null) {
throw new Error("directory parameter is required")
}
const directory = (() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(rawWorkspaceID),
async fn() {
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return next()
},
})
},
})
})
.route("/session", session)
.route("/", WorkspaceServerRoutes())
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
fetch: App().fetch,
})
}
}

View File

@@ -0,0 +1,17 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
branch: text(),
name: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
})

View File

@@ -0,0 +1,154 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log"
import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
export namespace Workspace {
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
}
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
})
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
await adaptor.create(config)
return info
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
}
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
while (!stop.aborted) {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", {
directory: space.id,
payload: event,
})
})
// Wait 250ms and retry if SSE connection fails
await sleep(250)
}
}
export function startSyncing(project: Project.Info) {
const stop = new AbortController()
const spaces = list(project).filter((space) => space.type !== "worktree")
spaces.forEach((space) => {
void workspaceEventLoop(space, stop.signal).catch((error) => {
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
})
})
})
return {
async stop() {
stop.abort()
},
}
}
}