Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline
f4a86db544 Merge branch 'dev' into fix-e2e 2026-03-19 15:05:02 -05:00
Aiden Cline
665b251ee1 fix: e2e 2026-03-19 15:03:21 -05:00
95 changed files with 1097 additions and 1660 deletions

View File

@@ -1,6 +1,4 @@
node_modules
plans
package.json
plans/
bun.lock
.gitignore
package-lock.json
package.json
package-lock.json

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gpt-5.4
model: opencode/gemini-3.1-pro
---
You are a professional translator and localization specialist.

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -22,16 +24,7 @@ interface PR {
}
export default tool({
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
@@ -38,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -0,0 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

679
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -122,7 +122,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})

View File

@@ -43,8 +43,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.35",
"ai": "5.0.124",
"hono": "4.10.7",
@@ -112,7 +112,6 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"hono-openapi@1.1.2": "patches/hono-openapi@1.1.2.patch"
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
}
}

View File

@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
import { createSdk, dirDecode, dirSlug, resolveDirectory } from "../utils"
function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -101,13 +101,13 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
const firstSession = await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
const secondSession = await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
const thirdSession = await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)

View File

@@ -20,7 +20,7 @@ import {
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
import { createSdk, dirDecode, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
@@ -258,7 +258,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect.poll(() => dirDecode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(

View File

@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { base64Decode, base64Encode, checksum } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -48,6 +48,10 @@ export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirDecode(slug: string) {
return base64Decode(slug)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}

View File

@@ -158,7 +158,7 @@ try {
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)

View File

@@ -76,19 +76,6 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
<path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
<path
d="M22.41 16.47 16.38 37.945l21.407.15 5.88-21.625h5.427l-7.05 25.14c-.27.96-1.298 1.74-2.295 1.74H12.31c-1.664 0-2.65-1.3-2.2-2.9l6.724-23.98Zm66.182-.15h5.427l-7.538 27.03h-5.58ZM49.698 27.582l27.136-.15 1.81-5.707H51.054l1.658-5.256 29.4-.27c1.83-.017 2.92 1.4 2.438 3.167L81.78 29.49c-.483 1.766-2.36 3.197-4.19 3.197H53.316L50.454 43.8h-5.28Z"
fill-rule="evenodd"
/>
</svg>
)
}
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">

View File

@@ -62,6 +62,5 @@
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-align: center;
}
}

View File

@@ -644,8 +644,6 @@ export const dict = {
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
"workspace.lite.promo.subscribe": "الاشتراك في Go",
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
"download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",

View File

@@ -654,8 +654,6 @@ export const dict = {
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
"workspace.lite.promo.subscribe": "Assinar Go",
"workspace.lite.promo.subscribing": "Redirecionando...",
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
"download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",

View File

@@ -651,8 +651,6 @@ export const dict = {
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
"workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",

View File

@@ -654,8 +654,6 @@ export const dict = {
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
"workspace.lite.promo.subscribe": "Go abonnieren",
"workspace.lite.promo.subscribing": "Leite weiter...",
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
"download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",

View File

@@ -646,8 +646,6 @@ export const dict = {
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
"workspace.lite.promo.subscribe": "Subscribe to Go",
"workspace.lite.promo.subscribing": "Redirecting...",
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",

View File

@@ -654,8 +654,6 @@ export const dict = {
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
"workspace.lite.promo.subscribe": "Suscribirse a Go",
"workspace.lite.promo.subscribing": "Redirigiendo...",
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
"download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",

View File

@@ -661,8 +661,6 @@ export const dict = {
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
"workspace.lite.promo.subscribe": "S'abonner à Go",
"workspace.lite.promo.subscribing": "Redirection...",
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
"download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",

View File

@@ -652,8 +652,6 @@ export const dict = {
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
"workspace.lite.promo.subscribe": "Abbonati a Go",
"workspace.lite.promo.subscribing": "Reindirizzamento...",
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
"download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",

View File

@@ -653,8 +653,6 @@ export const dict = {
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
"workspace.lite.promo.subscribe": "Goを購読する",
"workspace.lite.promo.subscribing": "リダイレクト中...",
"workspace.lite.promo.otherMethods": "その他の支払い方法",
"workspace.lite.promo.selectMethod": "支払い方法を選択",
"download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",

View File

@@ -645,8 +645,6 @@ export const dict = {
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
"workspace.lite.promo.subscribe": "Go 구독하기",
"workspace.lite.promo.subscribing": "리디렉션 중...",
"workspace.lite.promo.otherMethods": "기타 결제 수단",
"workspace.lite.promo.selectMethod": "결제 수단 선택",
"download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",

View File

@@ -651,8 +651,6 @@ export const dict = {
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
"workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
"download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",

View File

@@ -652,8 +652,6 @@ export const dict = {
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
"workspace.lite.promo.subscribe": "Subskrybuj Go",
"workspace.lite.promo.subscribing": "Przekierowywanie...",
"workspace.lite.promo.otherMethods": "Inne metody płatności",
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
"download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",

View File

@@ -658,8 +658,6 @@ export const dict = {
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
"workspace.lite.promo.subscribe": "Подписаться на Go",
"workspace.lite.promo.subscribing": "Перенаправление...",
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
"download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",

View File

@@ -648,8 +648,6 @@ export const dict = {
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
"download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",

View File

@@ -655,8 +655,6 @@ export const dict = {
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
"workspace.lite.promo.subscribe": "Go'ya Abone Ol",
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
"download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",

View File

@@ -626,8 +626,6 @@ export const dict = {
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
"workspace.lite.promo.subscribe": "订阅 Go",
"workspace.lite.promo.subscribing": "正在重定向...",
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式",
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",

View File

@@ -626,8 +626,6 @@ export const dict = {
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
"workspace.lite.promo.subscribe": "訂閱 Go",
"workspace.lite.promo.subscribing": "重新導向中...",
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "選擇付款方式",
"download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",

View File

@@ -244,7 +244,6 @@ export async function POST(input: APIEvent) {
customerID,
enrichment: {
type: productID === LiteData.productID() ? "lite" : "subscription",
currency: body.data.object.currency === "inr" ? "inr" : undefined,
couponID,
},
}),
@@ -332,17 +331,16 @@ export async function POST(input: APIEvent) {
)
if (!workspaceID) throw new Error("Workspace ID not found")
const payment = await Database.use((tx) =>
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
enrichment: PaymentTable.enrichment,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]),
.then((rows) => rows[0]?.amount),
)
if (!payment) throw new Error("Payment not found")
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
@@ -352,15 +350,12 @@ export async function POST(input: APIEvent) {
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
// deduct balance only for top up
if (!payment.enrichment?.type) {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${payment.amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
}
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
})()

View File

@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
@@ -211,9 +211,6 @@ export function BillingSection() {
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} />
</Match>
<Match when={billingInfo()?.paymentMethodType === "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">

View File

@@ -6,14 +6,6 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
function money(amount: number, currency?: string) {
const formatter =
currency === "inr"
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
return formatter.format(amount / 100_000_000)
}
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -89,10 +81,6 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
const currency =
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
? payment.enrichment.currency
: undefined
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -100,7 +88,7 @@ export function PaymentSection() {
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
{money(amount, currency)}
${((amount ?? 0) / 100000000).toFixed(2)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}>
{" "}

View File

@@ -188,45 +188,8 @@
line-height: 1.4;
}
[data-slot="subscribe-actions"] {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
[data-slot="subscribe-button"] {
align-self: stretch;
}
[data-slot="other-methods"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
[data-slot="other-methods-icons"] {
display: inline-flex;
align-items: center;
gap: 4px;
}
[data-slot="modal-actions"] {
display: flex;
gap: var(--space-3);
align-self: flex-start;
margin-top: var(--space-4);
button {
flex: 1;
}
}
[data-slot="method-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
height: 48px;
}
}

View File

@@ -1,7 +1,6 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -15,8 +14,6 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -81,25 +78,22 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
},
"liteCheckoutUrl",
)
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "liteCheckoutUrl")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -153,30 +147,23 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
redirecting: false,
})
const busy = createMemo(() => !!store.loading)
async function onClickSession() {
setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
return
}
setStore("loading", undefined)
}
async function onClickSubscribe(method?: "alipay" | "upi") {
setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
async function onClickSubscribe() {
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
return
}
setStore("loading", undefined)
}
return (
@@ -192,8 +179,12 @@ export function LiteSection() {
<div data-slot="section-title">
<div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p>
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
{store.loading === "session"
<button
data-color="primary"
disabled={sessionSubmission.pending || store.redirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.redirecting
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
</button>
@@ -291,64 +282,16 @@ export function LiteSection() {
<li>MiniMax M2.7</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<div data-slot="subscribe-actions">
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe()}
>
{store.loading === "checkout"
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<button
type="button"
data-slot="other-methods"
data-color="ghost"
onClick={() => setStore("showModal", true)}
>
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
<span data-slot="other-methods-icons">
<span> </span>
<IconAlipay style={{ width: "16px", height: "16px" }} />
<span> </span>
<IconUpi style={{ width: "auto", height: "10px" }} />
</span>
</button>
</div>
<Modal
open={store.showModal}
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || store.redirecting}
onClick={onClickSubscribe}
>
<div data-slot="modal-actions">
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
</div>
</Modal>
{checkoutSubmission.pending || store.redirecting
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
</section>
</Show>
</>

View File

@@ -239,11 +239,10 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
const { successUrl, cancelUrl, method } = input
const { successUrl, cancelUrl } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -251,102 +250,38 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: email!,
}),
...(() => {
if (method === "alipay") {
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
payment_method_types: ["alipay"],
adaptive_pricing: {
enabled: false,
},
}
const session = await Billing.stripe().checkout.sessions.create({
mode: "subscription",
billing_address_collection: "required",
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
if (method === "upi") {
return {
line_items: [
{
price_data: {
currency: "inr",
product: LiteData.productID(),
recurring: {
interval: "month",
interval_count: 1,
},
unit_amount: LiteData.priceInr(),
},
quantity: 1,
},
],
payment_method_types: ["upi"] as any,
adaptive_pricing: {
enabled: false,
},
}
}
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
billing_address_collection: "required",
}
})(),
tax_id_collection: {
enabled: true,
: {
customer_email: email!,
}),
currency: "usd",
tax_id_collection: {
enabled: true,
},
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
},
})
},
})
try {
const session = await createSession()
return session.url
} catch (e: any) {
if (
e.type !== "StripeInvalidRequestError" ||
!e.message.includes("You cannot combine currencies on a single customer")
)
throw e
// get pending payment intent
const intents = await Billing.stripe().paymentIntents.search({
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
})
if (intents.data.length === 0) throw e
for (const intent of intents.data) {
// get checkout session
const sessions = await Billing.stripe().checkout.sessions.list({
customer: billing.customerID!,
payment_intent: intent.id,
})
// delete pending payment intent
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
}
const session = await createSession()
return session.url
}
return session.url
},
)

View File

@@ -10,7 +10,6 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -88,7 +88,6 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
currency?: "inr"
couponID?: string
}
| {

View File

@@ -145,7 +145,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,7 +145,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,7 +145,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -1,6 +1,5 @@
import * as fs from "node:fs/promises"
import appPlugin from "@opencode-ai/app/vite"
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -8,8 +7,6 @@ const channel = (() => {
return "dev"
})()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
export default defineConfig({
main: {
define: {
@@ -20,25 +17,6 @@ export default defineConfig({
input: { index: "src/main/index.ts" },
},
},
plugins: [
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
enforce: "post",
async closeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (l.endsWith(".js")) continue
await fs.writeFile(`./out/main/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
},
preload: {
build: {

View File

@@ -30,18 +30,14 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@valibot/to-json-schema": "1.5.0",
"effect": "catalog:",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
"jsonc-parser": "3.3.1",
"marked": "^15",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"tree-kill": "^1.2.2",
"zod-openapi": "5.4.6"
"tree-kill": "^1.2.2"
},
"devDependencies": {
"@actions/artifact": "4.0.0",

View File

@@ -1,5 +1,17 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts && bun tsgo`
const RUST_TARGET = Bun.env.RUST_TARGET
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

View File

@@ -0,0 +1,279 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const envs = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const { cmd, cmdArgs } = buildCommand(args, envs)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const shell = process.env.SHELL || "/bin/sh"
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}

View File

@@ -5,26 +5,3 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -22,43 +22,23 @@ app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
const { autoUpdater } = pkg
import { Database, JsonMigration, Log, type Server } from "virtual:opencode-server"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
checkHealth,
checkHealthOrAskRetry,
getDefaultServerUrl,
getSavedServerUrl,
getWslConfig,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
} from "./server"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
type ServerConnection =
| { variant: "existing"; url: string }
| {
variant: "local"
url: string
password: null | string
health: {
wait: Promise<void>
}
server: Server.Listener
}
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -102,9 +82,11 @@ function setupApp() {
})
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
setDockIcon()
setupAutoUpdater()
syncCli()
await initialize()
})
}
@@ -127,79 +109,49 @@ function setInitStep(step: InitStep) {
initEmitter.emit("step", step)
}
async function setupServerConnection(): Promise<ServerConnection> {
const customUrl = await getSavedServerUrl()
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
serverReady.resolve({ url: customUrl, username: "opencode", password: null })
return { variant: "existing", url: customUrl }
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const localUrl = `http://${hostname}:${port}`
if (await checkHealth(localUrl)) {
serverReady.resolve({ url: localUrl, username: "opencode", password: null })
return { variant: "existing", url: localUrl }
}
const url = `http://${hostname}:${port}`
const password = randomUUID()
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
return {
variant: "local",
url: localUrl,
logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
serverReady.resolve({
url,
username: "opencode",
password,
health,
server,
}
}
})
async function initialize() {
await Log.init({ print: true, level: "WARN" })
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let loadingWindow: BrowserWindow | undefined
const serverConnection = await setupServerConnection()
if (needsMigration) {
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
void migrate(initEmitter)
}
const loadingTask = (async () => {
logger.log("server connection started")
const cliHealthCheck = (() => {
if (serverConnection.variant === "local") {
return async () => {
const { health } = serverConnection
await health.wait
serverReady.resolve({
url: serverConnection.url,
username: "opencode",
password: serverConnection.password,
})
}
} else {
serverReady.resolve({ url: serverConnection.url, username: "opencode", password: null })
return null
}
})()
if (cliHealthCheck) {
if (needsMigration) await sqliteDone?.promise
cliHealthCheck()
if (needsMigration) {
await sqliteDone?.promise
}
await Promise.race([
health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished")
})()
const globals = {
@@ -208,30 +160,33 @@ async function initialize() {
}
if (needsMigration) {
loadingWindow = createLoadingWindow(globals)
} else {
logger.log("showing main window without loading window")
mainWindow = createMainWindow(globals)
wireMenu()
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
await delay(1_000)
}
}
await loadingTask
setInitStep({ phase: "done" })
if (loadingWindow) {
if (overlay) {
await loadingComplete.promise
}
mainWindow = createMainWindow(globals)
wireMenu()
loadingWindow?.close()
overlay?.close()
}
function wireMenu() {
if (!mainWindow) return
createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => {
void checkForUpdates(true)
},
@@ -246,6 +201,7 @@ function wireMenu() {
registerIpcHandlers({
killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -277,9 +233,9 @@ registerIpcHandlers({
})
function killSidecar() {
if (!server) return
server.stop()
server = null
if (!sidecar) return
sidecar.kill()
sidecar = null
}
function ensureLoopbackNoProxy() {
@@ -331,18 +287,6 @@ function sqliteFileExists() {
return existsSync(join(base, "opencode", "opencode.db"))
}
async function migrate(events: EventEmitter) {
await JsonMigration.run(Database.Client(), {
progress: (event: JsonMigration.Progress) => {
const percent = Math.floor((event.current / event.total) * 100)
events.emit("sqlite", { type: "InProgress", value: percent })
if (event.current === event.total) {
events.emit("sqlite", { type: "Done" })
}
},
})
}
function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
autoUpdater.logger = logger

View File

@@ -1,6 +1,6 @@
import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { app, BrowserWindow, clipboard, dialog, ipcMain, Notification, shell } from "electron"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
import { getStore } from "./store"
@@ -8,6 +8,7 @@ import { setTitlebar } from "./windows"
type Deps = {
killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -28,6 +29,7 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)

View File

@@ -5,6 +5,7 @@ import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void
reload: () => void
relaunch: () => void
@@ -23,6 +24,10 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(),
},
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{
label: "Reload Webview",
click: () => deps.reload(),

View File

@@ -1,5 +1,4 @@
import { bootstrap, Config, Server } from "virtual:opencode-server"
import { dialog } from "electron"
import { serve, type CommandChild } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -30,23 +29,8 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export async function getSavedServerUrl(): Promise<string | null> {
const config = await bootstrap(process.cwd(), () => Config.get())
const direct = getDefaultServerUrl()
if (direct) return direct
if (!config) return null
return getServerUrlFromConfig(config)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
const wait = (async () => {
const url = `http://${hostname}:${port}`
@@ -58,10 +42,19 @@ export async function spawnLocalServer(hostname: string, port: number, password:
}
}
await ready()
const terminated = async () => {
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
})()
return { listener, health: { wait } }
return { child, health: { wait }, events }
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -90,34 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
while (true) {
if (await checkHealth(url)) return true
const result = await dialog.showMessageBox({
type: "warning",
message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
title: "Connection Failed",
buttons: ["Retry", "Start Local"],
defaultId: 0,
cancelId: 1,
})
if (result.response === 0) continue
return false
}
}
export function normalizeHostnameForUrl(hostname: string) {
if (hostname === "0.0.0.0") return "127.0.0.1"
if (hostname === "::") return "[::1]"
if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
return hostname
}
export function getServerUrlFromConfig(config: Config.Info) {
const server = config.server
if (!server?.port) return null
const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
return `http://${host}:${server.port}`
}
export type { CommandChild }

View File

@@ -3,6 +3,7 @@ import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: (onStep) => {
const handler = (_: unknown, step: InitStep) => onStep(step)
ipcRenderer.on("init-step", handler)

View File

@@ -17,6 +17,7 @@ export type TitlebarTheme = {
export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>

View File

@@ -0,0 +1,12 @@
import { initI18n, t } from "./i18n"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await window.api.installCli()
window.alert(t("desktop.cli.installed.message", { path }))
} catch (e) {
window.alert(t("desktop.cli.failed.message", { error: String(e) }))
}
}

View File

@@ -8,13 +8,13 @@
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"rootDir": "src",
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"isolatedModules": true,
"noEmit": true,
"emitDeclarationOnly": false,
"outDir": "node_modules/.ts-dist",
"types": ["vite/client", "node", "electron"]
},
"references": [{ "path": "../app" }],

View File

@@ -145,7 +145,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,7 +145,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -26,13 +26,6 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
@@ -57,9 +50,8 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -90,11 +82,8 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -108,6 +97,7 @@
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -123,7 +113,7 @@
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
@@ -136,7 +126,6 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"openapi-types": "12.1.3",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
@@ -155,6 +144,6 @@
"zod-to-json-schema": "3.24.5"
},
"overrides": {
"drizzle-orm": "catalog:"
"drizzle-orm": "1.0.0-beta.16-ea816b6"
}
}

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist/node",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -22,7 +22,7 @@ const modelsData = process.env.MODELS_DEV_API_JSON
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as Record<string, unknown>\n`,
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
)
console.log("Generated models-snapshot.ts")

View File

@@ -148,12 +148,6 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -296,7 +290,7 @@ export namespace AccountEffect {
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -1,12 +1,11 @@
import { Database as BunDatabase } from "bun:sqlite"
import { spawn } from "child_process"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { EOL } from "os"
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { JsonMigration } from "../../storage/json-migration"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "../../storage/json-migration"
import { EOL } from "os"
const QueryCommand = cmd({
command: "$0 [query]",
@@ -74,7 +73,7 @@ const MigrateCommand = cmd({
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
const stats = await JsonMigration.run(sqlite, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -9,7 +9,6 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -57,7 +56,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
await Bun.sleep(1000)
continue
}
if (!result.data) {

View File

@@ -907,12 +907,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Filesystem.write(filepath, transcript)
await Bun.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Filesystem.write(filepath, result)
await Bun.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -8,6 +8,7 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -37,7 +38,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
let server: Bun.Server<BunWebSocketData> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -119,7 +120,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)
server = Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -142,7 +143,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) await server.stop(true)
if (server) server.stop(true)
},
}

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -1,4 +1,3 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -57,24 +56,10 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
fetch: App().fetch,
})
}
}

View File

@@ -1,5 +1,4 @@
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"
@@ -118,7 +117,7 @@ export namespace Workspace {
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)
await Bun.sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -128,7 +127,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await sleep(250)
await Bun.sleep(250)
}
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(cache, "bin"),
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -1,41 +1,39 @@
import { NamedError } from "@opencode-ai/util/error"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { EOL } from "os"
import path from "path"
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { LoginCommand, LogoutCommand, OrgsCommand, SwitchCommand } from "./cli/cmd/account"
import { AcpCommand } from "./cli/cmd/acp"
import { ConsoleCommand } from "./cli/cmd/account"
import { GenerateCommand } from "./cli/cmd/generate"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { DbCommand } from "./cli/cmd/db"
import { DebugCommand } from "./cli/cmd/debug"
import { ExportCommand } from "./cli/cmd/export"
import { GithubCommand } from "./cli/cmd/github"
import { ImportCommand } from "./cli/cmd/import"
import { McpCommand } from "./cli/cmd/mcp"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models"
import { PrCommand } from "./cli/cmd/pr"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { SessionCommand } from "./cli/cmd/session"
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
import { Filesystem } from "./util/filesystem"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { UninstallCommand } from "./cli/cmd/uninstall"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
import { FormatError } from "./cli/error"
import { UI } from "./cli/ui"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "./global"
import { Installation } from "./installation"
import { Database } from "./storage/db"
import { JsonMigration } from "./storage/json-migration"
import { Filesystem } from "./util/filesystem"
import { Log } from "./util/log"
import { Database } from "./storage/db"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -97,7 +95,7 @@ let cli = yargs(hideBin(process.argv))
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
await JsonMigration.run(Database.Client().$client, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return
@@ -170,7 +168,7 @@ cli = cli
try {
await cli.parse()
} catch (e) {
const data: Record<string, any> = {}
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {

View File

@@ -11,7 +11,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -167,10 +166,14 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && !pids.includes(cpid)) {
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
pids.push(cpid)
queue.push(cpid)
}

View File

@@ -1,6 +0,0 @@
export { Config } from "./config/config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util/log"
export { Database } from "./storage/db"
export { JsonMigration } from "./storage/json-migration"

View File

@@ -23,8 +23,6 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -99,9 +97,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [id, ws] of session.subscribers.entries()) {
for (const [key, ws] of session.subscribers.entries()) {
try {
if (key(ws) === id) ws.close()
if (ws.data === key) ws.close()
} catch {
// ignore
}
@@ -232,9 +230,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [id, ws] of session.subscribers.entries()) {
for (const [key, ws] of session.subscribers.entries()) {
try {
if (key(ws) === id) ws.close()
if (ws.data === key) ws.close()
} catch {
// ignore
}
@@ -265,13 +263,16 @@ export namespace Pty {
}
log.info("client connected to session", { id })
const sub = key(ws)
// Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
const cleanup = () => {
session.subscribers.delete(sub)
session.subscribers.delete(connectionKey)
}
const start = session.bufferCursor

View File

@@ -32,5 +32,5 @@ export const ERRORS = {
} as const
export function errors(...codes: number[]) {
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) as Record<string, unknown>
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
}

View File

@@ -1,16 +1,16 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import { MCP } from "../../mcp"
import { ProviderID, ModelID } from "../../provider/schema"
import { ToolRegistry } from "../../tool/registry"
import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { ToolRegistry } from "../../tool/registry"
import { lazy } from "../../util/lazy"
import { Worktree } from "../../worktree"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace"
export const ExperimentalRoutes = lazy(() =>
@@ -84,7 +84,7 @@ export const ExperimentalRoutes = lazy(() =>
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : (t.parameters as unknown),
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
})),
)
},
@@ -211,15 +211,15 @@ export const ExperimentalRoutes = lazy(() =>
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce.number().optional().meta({
description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)",
}),
cursor: z.coerce.number().optional().meta({
description: "Return sessions updated before this timestamp (milliseconds since epoch)",
}),
search: z.string().optional().meta({
description: "Filter sessions by title (case-insensitive)",
}),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
cursor: z.coerce
.number()
.optional()
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
}),

View File

@@ -1,14 +1,15 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
export const PtyRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
@@ -196,5 +197,5 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}
}),
)
}
),
)

View File

@@ -1,20 +1,20 @@
import { Hono } from "hono"
import { stream } from "hono/streaming"
import { describeRoute, resolver, validator } from "hono-openapi"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SessionID, MessageID, PartID } from "@/session/schema"
import z from "zod"
import { PermissionNext } from "@/permission"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Snapshot } from "@/snapshot"
import { Agent } from "../../agent/agent"
import { Session } from "../../session"
import { SessionCompaction } from "../../session/compaction"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "../../session/prompt"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
@@ -46,12 +46,11 @@ export const SessionRoutes = lazy(() =>
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce.number().optional().meta({
description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)",
}),
search: z.string().optional().meta({
description: "Filter sessions by title (case-insensitive)",
}),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
}),
),
@@ -285,10 +284,7 @@ export const SessionRoutes = lazy(() =>
session = await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.time?.archived !== undefined) {
session = await Session.setArchived({
sessionID,
time: updates.time.archived,
})
session = await Session.setArchived({ sessionID, time: updates.time.archived })
}
return c.json(session)
@@ -845,11 +841,13 @@ export const SessionRoutes = lazy(() =>
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
return c.body(null, 204)
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
})
},
)
.post(

View File

@@ -1,7 +1,4 @@
import { streamSSE } from "hono/streaming"
import { Log } from "../util/log"
import { Bus } from "../bus"
import { BusEvent } from "../bus/bus-event"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
@@ -28,7 +25,7 @@ import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
// import { PtyRoutes } from "./routes/pty"
import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
@@ -38,8 +35,7 @@ import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
@@ -53,20 +49,13 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
const log = Log.create({ service: "server" })
export const Default = lazy(() => create({}).app)
export const Default = lazy(() => createApp({}))
function create(opts: { cors?: string[]; password?: string; username?: string }) {
const log = Log.create({ service: "server" })
export const createApp = (opts: { cors?: string[] }): Hono => {
const app = new Hono()
const ws = createNodeWebSocket({ app })
const route = app
return app
.onError((err, c) => {
log.error("failed", {
error: err,
@@ -252,6 +241,7 @@ export namespace Server {
),
)
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
@@ -507,70 +497,22 @@ export namespace Server {
return c.json(await Format.status())
},
)
.get(
"/event",
describeRoute({
summary: "Subscribe to events",
description: "Get events",
operationId: "event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
},
.all("/*", async (c) => {
const path = c.req.path
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
if (event.type === Bus.InstanceDisposed.type) {
stream.close()
}
})
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
log.info("event disconnected")
})
})
})
},
)
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
return {
app: route as Hono,
ws,
}
})
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
)
return response
})
}
export async function openapi() {
@@ -588,91 +530,52 @@ export namespace Server {
return result
}
/** @deprecated do not use this dumb shit */
export let url: URL
export async function listen(opts: {
export function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
password?: string
username?: string
}): Promise<Listener> {
const log = Log.create({ service: "server" })
const built = create({
...opts,
})
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: built.app.fetch })
built.ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}) {
url = new URL(`http://${opts.hostname}:${opts.port}`)
const app = createApp(opts)
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: app.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return undefined
}
}
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(addr.port)
Server.url = url
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const shouldPublishMDNS =
opts.mdns &&
addr.port &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(addr.port, opts.mdnsDomain)
MDNS.publish(server.port!, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: addr.port,
url,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
if (shouldPublishMDNS) MDNS.unpublish()
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
const originalStop = server.stop.bind(server)
server.stop = async (closeActiveConnections?: boolean) => {
if (shouldPublishMDNS) MDNS.unpublish()
return originalStop(closeActiveConnections)
}
return server
}
}

View File

@@ -32,6 +32,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -47,7 +48,6 @@ import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1812,13 +1812,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const results = await Promise.all(
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
}),
)
let index = 0

View File

@@ -1,8 +0,0 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,8 +0,0 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,6 +1,8 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { Context } from "../util/context"
import { lazy } from "../util/lazy"
import { Global } from "../global"
@@ -9,11 +11,10 @@ import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { init } from "#db"
export * from "drizzle-orm"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -35,12 +36,17 @@ export namespace Database {
return path.join(Global.Path.data, `opencode-${safe}.db`)
})
export type Transaction = SQLiteTransaction<"sync", void>
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
}
function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0
@@ -77,14 +83,17 @@ export namespace Database {
export const Client = lazy(() => {
log.info("opening database", { path: Path })
const db = init(Path)
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite })
// Apply schema migrations
const entries =
@@ -108,11 +117,14 @@ export namespace Database {
})
export function close() {
Client().$client.close()
const sqlite = state.sqlite
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client.reset()
}
export type TxOrDb = Transaction | Client
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
const ctx = Context.create<{
tx: TxOrDb

View File

@@ -1,14 +1,14 @@
import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { existsSync } from "fs"
import path from "path"
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Global } from "../global"
import { Log } from "../util/log"
import { ProjectTable } from "../project/project.sql"
import { MessageTable, PartTable, PermissionTable, SessionTable, TodoTable } from "../session/session.sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
import { SessionShareTable } from "../share/share.sql"
import path from "path"
import { existsSync } from "fs"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
export namespace JsonMigration {
const log = Log.create({ service: "json-migration" })
@@ -23,7 +23,7 @@ export namespace JsonMigration {
progress?: (event: Progress) => void
}
export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
export async function run(sqlite: Database, options?: Options) {
const storageDir = path.join(Global.Path.data, "storage")
if (!existsSync(storageDir)) {
@@ -43,13 +43,13 @@ export namespace JsonMigration {
log.info("starting json to sqlite migration", { storageDir })
const start = performance.now()
// const db = drizzle({ client: sqlite })
const db = drizzle({ client: sqlite })
// Optimize SQLite for bulk inserts
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = OFF")
db.run("PRAGMA cache_size = 10000")
db.run("PRAGMA temp_store = MEMORY")
sqlite.exec("PRAGMA journal_mode = WAL")
sqlite.exec("PRAGMA synchronous = OFF")
sqlite.exec("PRAGMA cache_size = 10000")
sqlite.exec("PRAGMA temp_store = MEMORY")
const stats = {
projects: 0,
sessions: 0,
@@ -146,7 +146,7 @@ export namespace JsonMigration {
progress?.({ current, total, label: "starting" })
db.run("BEGIN TRANSACTION")
sqlite.exec("BEGIN TRANSACTION")
// Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content
@@ -178,10 +178,7 @@ export namespace JsonMigration {
stats.projects += insert(projectValues, ProjectTable, "project")
step("projects", end - i)
}
log.info("migrated projects", {
count: stats.projects,
duration: Math.round(performance.now() - start),
})
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
// Migrate sessions (depends on projects)
// Derive all IDs from directory/file paths, not JSON content, since earlier
@@ -393,12 +390,7 @@ export namespace JsonMigration {
errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
continue
}
shareValues.push({
session_id: sessionID,
id: data.id,
secret: data.secret,
url: data.url,
})
shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
}
stats.shares += insert(shareValues, SessionShareTable, "session_share")
step("shares", end - i)
@@ -408,7 +400,7 @@ export namespace JsonMigration {
log.warn("skipped orphaned session shares", { count: orphans.shares })
}
db.run("COMMIT")
sqlite.exec("COMMIT")
log.info("json migration complete", {
projects: stats.projects,

View File

@@ -37,9 +37,9 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const description = DESCRIPTION.replace(
"{agents}",
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
list.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`).join(
"\n",
),
)
return {
description,

View File

@@ -1,12 +1,12 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission"
import type { MessageV2 } from "../session/message-v2"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
export namespace Tool {
export interface Metadata {
interface Metadata {
[key: string]: any
}
@@ -60,9 +60,7 @@ export namespace Tool {
toolInfo.parameters.parse(args)
} catch (error) {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
throw new Error(toolInfo.formatValidationError(error), {
cause: error,
})
throw new Error(toolInfo.formatValidationError(error), { cause: error })
}
throw new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,

View File

@@ -17,7 +17,7 @@ export const withStatics =
Object.assign(schema, methods(schema))
declare const NewtypeBrand: unique symbol
export type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
/**
* Nominal wrapper for scalar types. The class itself is a valid schema —

View File

@@ -1,13 +1,9 @@
import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null

View File

@@ -34,26 +34,6 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
const login = () =>
new Login({
code: DeviceCode.make("device-code"),
user: UserCode.make("user-code"),
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: Duration.seconds(600),
interval: Duration.seconds(5),
})
const deviceTokenClient = (body: unknown, status = 400) =>
HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
),
)
const poll = (body: unknown, status = 400) =>
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
@@ -192,6 +172,15 @@ it.effect("config sends the selected org header", () =>
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
const login = new Login({
code: DeviceCode.make("device-code"),
user: UserCode.make("user-code"),
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: Duration.seconds(600),
interval: Duration.seconds(5),
})
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
@@ -209,7 +198,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
@@ -226,59 +215,3 @@ it.effect("poll stores the account and first org on success", () =>
)
}),
)
for (const [name, body, expectedTag] of [
[
"pending",
{
error: "authorization_pending",
error_description: "The authorization request is still pending",
},
"PollPending",
],
[
"slow",
{
error: "slow_down",
error_description: "Polling too frequently, please slow down",
},
"PollSlow",
],
[
"denied",
{
error: "access_denied",
error_description: "The authorization request was denied",
},
"PollDenied",
],
[
"expired",
{
error: "expired_token",
error_description: "The device code has expired",
},
"PollExpired",
],
] as const) {
it.effect(`poll returns ${name} for ${body.error}`, () =>
Effect.gen(function* () {
const result = yield* poll(body)
expect(result._tag).toBe(expectedTag)
}),
)
}
it.effect("poll returns poll error for other OAuth errors", () =>
Effect.gen(function* () {
const result = yield* poll({
error: "server_error",
error_description: "An unexpected error occurred",
})
expect(result._tag).toBe("PollError")
if (result._tag === "PollError") {
expect(String(result.cause)).toContain("server_error")
}
}),
)

View File

@@ -1,16 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { Database } from "bun:sqlite"
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { readdirSync, readFileSync } from "fs"
import fs from "fs/promises"
import path from "path"
import fs from "fs/promises"
import { readFileSync, readdirSync } from "fs"
import { JsonMigration } from "../../src/storage/json-migration"
import { Global } from "../../src/global"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
import { MessageTable, PartTable, PermissionTable, SessionTable, TodoTable } from "../../src/session/session.sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
import { SessionShareTable } from "../../src/share/share.sql"
import { JsonMigration } from "../../src/storage/json-migration"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Test fixtures
@@ -53,15 +53,9 @@ async function setupStorageDir() {
const storageDir = path.join(Global.Path.data, "storage")
await fs.rm(storageDir, { recursive: true, force: true })
await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), {
recursive: true,
})
await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), {
recursive: true,
})
await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), {
recursive: true,
})
await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
@@ -103,12 +97,10 @@ function createTestDb() {
describe("JSON to SQLite migration", () => {
let storageDir: string
let sqlite: Database
let db!: ReturnType<typeof drizzle>
beforeEach(async () => {
storageDir = await setupStorageDir()
sqlite = createTestDb()
db = drizzle({ client: sqlite })
})
afterEach(async () => {
@@ -126,10 +118,11 @@ describe("JSON to SQLite migration", () => {
sandboxes: ["/test/sandbox"],
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -150,10 +143,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
@@ -170,10 +164,11 @@ describe("JSON to SQLite migration", () => {
commands: { start: "npm run dev" },
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
@@ -190,10 +185,11 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
@@ -220,8 +216,9 @@ describe("JSON to SQLite migration", () => {
share: { url: "https://example.com/share" },
})
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
@@ -250,11 +247,12 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ ...fixtures.part }),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -289,11 +287,12 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@@ -330,10 +329,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
@@ -367,10 +367,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
@@ -391,7 +392,7 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(0)
})
@@ -419,10 +420,11 @@ describe("JSON to SQLite migration", () => {
time: { created: 1700000000000, updated: 1700000001000 },
})
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
@@ -450,10 +452,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
@@ -468,9 +471,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: [],
})
await JsonMigration.run(db)
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
})
@@ -503,10 +507,11 @@ describe("JSON to SQLite migration", () => {
]),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("First todo")
@@ -535,8 +540,9 @@ describe("JSON to SQLite migration", () => {
]),
)
await JsonMigration.run(db)
await JsonMigration.run(sqlite)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(3)
@@ -558,28 +564,17 @@ describe("JSON to SQLite migration", () => {
// Create permission file (named by projectID, contains array of rules)
const permissionData = [
{
permission: "file.read",
pattern: "/test/file1.ts",
action: "allow" as const,
},
{
permission: "file.write",
pattern: "/test/file2.ts",
action: "ask" as const,
},
{
permission: "command.run",
pattern: "npm install",
action: "deny" as const,
},
{ permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
{ permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
{ permission: "command.run", pattern: "npm install", action: "deny" as const },
]
await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.permissions).toBe(1)
const db = drizzle({ client: sqlite })
const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc")
@@ -605,10 +600,11 @@ describe("JSON to SQLite migration", () => {
}),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats?.shares).toBe(1)
const db = drizzle({ client: sqlite })
const shares = db.select().from(SessionShareTable).all()
expect(shares.length).toBe(1)
expect(shares[0].session_id).toBe("ses_test456def")
@@ -620,7 +616,7 @@ describe("JSON to SQLite migration", () => {
test("returns empty stats when storage directory does not exist", async () => {
await fs.rm(storageDir, { recursive: true, force: true })
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(0)
expect(stats.sessions).toBe(0)
@@ -641,11 +637,12 @@ describe("JSON to SQLite migration", () => {
})
await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(1)
expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@@ -669,9 +666,10 @@ describe("JSON to SQLite migration", () => {
]),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2)
expect(todos[0].content).toBe("keep-0")
@@ -709,27 +707,20 @@ describe("JSON to SQLite migration", () => {
await Bun.write(
path.join(storageDir, "session_share", "ses_test456def.json"),
JSON.stringify({
id: "share_ok",
secret: "secret",
url: "https://ok.example.com",
}),
JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
)
await Bun.write(
path.join(storageDir, "session_share", "ses_missing.json"),
JSON.stringify({
id: "share_missing",
secret: "secret",
url: "https://missing.example.com",
}),
JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
)
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.shares).toBe(1)
const db = drizzle({ client: sqlite })
expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1)
@@ -824,23 +815,15 @@ describe("JSON to SQLite migration", () => {
await Bun.write(
path.join(storageDir, "session_share", "ses_test456def.json"),
JSON.stringify({
id: "share_ok",
secret: "secret",
url: "https://ok.example.com",
}),
JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
)
await Bun.write(
path.join(storageDir, "session_share", "ses_missing.json"),
JSON.stringify({
id: "share_orphan",
secret: "secret",
url: "https://missing.example.com",
}),
JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
)
await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
const stats = await JsonMigration.run(db)
const stats = await JsonMigration.run(sqlite)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
@@ -854,6 +837,7 @@ describe("JSON to SQLite migration", () => {
expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1)

View File

@@ -8,11 +8,6 @@
"types": [],
"noUncheckedIndexedAccess": false,
"customConditions": ["browser"],
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
@@ -24,6 +19,5 @@
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
},
"include": ["src"]
}
}

View File

@@ -1,144 +0,0 @@
diff --git a/dist/index.d.cts b/dist/index.d.cts
index 0f49b720b0b8c827fef52a2982fb194e98bc0c50..f690bb3304abeb78a1c10323bdb605d81eb99078 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -5,66 +5,10 @@ import { TypedResponse, RouterRoute, ValidationTargets as ValidationTargets$1, B
import { loadVendor as loadVendor$2, ToOpenAPISchemaContext } from '@standard-community/standard-openapi';
import { Hook } from '@hono/standard-validator';
import { loadVendor as loadVendor$1 } from '@standard-community/standard-json';
+import { StandardSchemaV1 } from '@standard-schema/spec';
import { StatusCode } from 'hono/utils/http-status';
import { JSONSchema7 } from 'json-schema';
-/** The Standard Schema interface. */
-interface StandardSchemaV1<Input = unknown, Output = Input> {
- /** The Standard Schema properties. */
- readonly "~standard": StandardSchemaV1.Props<Input, Output>;
-}
-declare namespace StandardSchemaV1 {
- /** The Standard Schema properties interface. */
- export interface Props<Input = unknown, Output = Input> {
- /** The version number of the standard. */
- readonly version: 1;
- /** The vendor name of the schema library. */
- readonly vendor: string;
- /** Validates unknown input values. */
- readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
- /** Inferred types associated with the schema. */
- readonly types?: Types<Input, Output> | undefined;
- }
- /** The result interface of the validate function. */
- export type Result<Output> = SuccessResult<Output> | FailureResult;
- /** The result interface if validation succeeds. */
- export interface SuccessResult<Output> {
- /** The typed output value. */
- readonly value: Output;
- /** The non-existent issues. */
- readonly issues?: undefined;
- }
- /** The result interface if validation fails. */
- export interface FailureResult {
- /** The issues of failed validation. */
- readonly issues: ReadonlyArray<Issue>;
- }
- /** The issue interface of the failure output. */
- export interface Issue {
- /** The error message of the issue. */
- readonly message: string;
- /** The path of the issue, if any. */
- readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
- }
- /** The path segment interface of the issue. */
- export interface PathSegment {
- /** The key representing a path segment. */
- readonly key: PropertyKey;
- }
- /** The Standard Schema types interface. */
- export interface Types<Input = unknown, Output = Input> {
- /** The input type of the schema. */
- readonly input: Input;
- /** The output type of the schema. */
- readonly output: Output;
- }
- /** Infers the input type of a Standard Schema. */
- export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
- /** Infers the output type of a Standard Schema. */
- export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
- export { };
-}
-
declare function loadVendor(vendor: string, fn: {
toJSONSchema?: Parameters<typeof loadVendor$1>[1];
toOpenAPISchema?: Parameters<typeof loadVendor$2>[1];
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 0f49b720b0b8c827fef52a2982fb194e98bc0c50..f690bb3304abeb78a1c10323bdb605d81eb99078 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -5,66 +5,10 @@ import { TypedResponse, RouterRoute, ValidationTargets as ValidationTargets$1, B
import { loadVendor as loadVendor$2, ToOpenAPISchemaContext } from '@standard-community/standard-openapi';
import { Hook } from '@hono/standard-validator';
import { loadVendor as loadVendor$1 } from '@standard-community/standard-json';
+import { StandardSchemaV1 } from '@standard-schema/spec';
import { StatusCode } from 'hono/utils/http-status';
import { JSONSchema7 } from 'json-schema';
-/** The Standard Schema interface. */
-interface StandardSchemaV1<Input = unknown, Output = Input> {
- /** The Standard Schema properties. */
- readonly "~standard": StandardSchemaV1.Props<Input, Output>;
-}
-declare namespace StandardSchemaV1 {
- /** The Standard Schema properties interface. */
- export interface Props<Input = unknown, Output = Input> {
- /** The version number of the standard. */
- readonly version: 1;
- /** The vendor name of the schema library. */
- readonly vendor: string;
- /** Validates unknown input values. */
- readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
- /** Inferred types associated with the schema. */
- readonly types?: Types<Input, Output> | undefined;
- }
- /** The result interface of the validate function. */
- export type Result<Output> = SuccessResult<Output> | FailureResult;
- /** The result interface if validation succeeds. */
- export interface SuccessResult<Output> {
- /** The typed output value. */
- readonly value: Output;
- /** The non-existent issues. */
- readonly issues?: undefined;
- }
- /** The result interface if validation fails. */
- export interface FailureResult {
- /** The issues of failed validation. */
- readonly issues: ReadonlyArray<Issue>;
- }
- /** The issue interface of the failure output. */
- export interface Issue {
- /** The error message of the issue. */
- readonly message: string;
- /** The path of the issue, if any. */
- readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
- }
- /** The path segment interface of the issue. */
- export interface PathSegment {
- /** The key representing a path segment. */
- readonly key: PropertyKey;
- }
- /** The Standard Schema types interface. */
- export interface Types<Input = unknown, Output = Input> {
- /** The input type of the schema. */
- readonly input: Input;
- /** The output type of the schema. */
- readonly output: Output;
- }
- /** Infers the input type of a Standard Schema. */
- export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
- /** Infers the output type of a Standard Schema. */
- export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
- export { };
-}
-
declare function loadVendor(vendor: string, fn: {
toJSONSchema?: Parameters<typeof loadVendor$1>[1];
toOpenAPISchema?: Parameters<typeof loadVendor$2>[1];

1
sst-env.d.ts vendored
View File

@@ -171,7 +171,6 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}