Compare commits

..
3 Commits
11 changed files with 139 additions and 44 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -15,7 +16,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -28,6 +29,7 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+2 -9
View File
@@ -1,4 +1,5 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
},
})
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
createWebApp("app." + domain)
+18
View File
@@ -0,0 +1,18 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+22 -1
View File
@@ -71,4 +71,25 @@ Environment options:
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+2 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
? "1px"
: "8px",
}}
>
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
servers.set(name, server)
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, server)
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
})
})
+1 -1
View File
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
const remaining = limit - outputBytes
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
output.push(next)
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
outputBytes += encoder.encode(next).byteLength
last = next.at(-1) ?? last
}
const appendRaw = (value: string) => {
+62
View File
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
-9
View File
@@ -128,15 +128,6 @@ describe("WebFetchTool helpers", () => {
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
})
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
expect(truncated).toBe(fitting)
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
+26 -18
View File
@@ -7,27 +7,35 @@ export default $config({
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "cloudflare",
providers: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
providers:
input.stage === "beta"
? {}
: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
}
},
async run() {
if ($app.stage === "beta") {
const { createWebApp } = await import("./infra/webapp.js")
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
}
const stage = await import("./infra/stage.js")
await import("./infra/app.js")
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined