Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad741cbea0 | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
37b8662a9d | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
ddcb32ae0b | ||
|
|
2c056c90da | ||
|
|
48e867ee20 | ||
|
|
812d1bb32a | ||
|
|
9a58c43ef4 | ||
|
|
b5ebc541b9 | ||
|
|
63585db6a7 | ||
|
|
bd44489ada | ||
|
|
bd7a4cec90 | ||
|
|
a6ef9e9206 | ||
|
|
6e09a1d904 | ||
|
|
63af295a17 | ||
|
|
4f21757e0d | ||
|
|
2dbcd79fd2 | ||
|
|
48a7f0fd93 | ||
|
|
d69962b0f7 | ||
|
|
a6f23cb08e | ||
|
|
0540751897 | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
@@ -1,4 +1,6 @@
|
|||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
|
bun.lock
|
||||||
|
.gitignore
|
||||||
package-lock.json
|
package-lock.json
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Translate content for a specified locale while preserving technical terms
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: opencode/gemini-3.1-pro
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a professional translator and localization specialist.
|
You are a professional translator and localization specialist.
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
@@ -24,7 +22,16 @@ interface PR {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
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.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
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),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
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.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema
|
assignee: tool.schema
|
||||||
.enum(ASSIGNEES as [string, ...string[]])
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
#### How is this different from Claude Code?
|
#### How is this different from Claude Code?
|
||||||
|
|
||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||||
|
|
||||||
- 100% open source
|
- 100% open source
|
||||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
properties: {
|
properties: {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -43,8 +43,8 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.35",
|
"effect": "4.0.0-beta.35",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ 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>) {
|
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -62,5 +62,6 @@
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,6 +644,8 @@ export const dict = {
|
|||||||
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
|
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
|
||||||
"workspace.lite.promo.subscribe": "الاشتراك في Go",
|
"workspace.lite.promo.subscribe": "الاشتراك في Go",
|
||||||
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
||||||
|
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
|
||||||
|
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
|
||||||
|
|
||||||
"download.title": "OpenCode | تنزيل",
|
"download.title": "OpenCode | تنزيل",
|
||||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ 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.",
|
"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.subscribe": "Assinar Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirecionando...",
|
"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.title": "OpenCode | Baixar",
|
||||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||||
|
|||||||
@@ -651,6 +651,8 @@ 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.",
|
"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.subscribe": "Abonner på Go",
|
||||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||||
|
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ 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.",
|
"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.subscribe": "Go abonnieren",
|
||||||
"workspace.lite.promo.subscribing": "Leite weiter...",
|
"workspace.lite.promo.subscribing": "Leite weiter...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
|
||||||
|
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||||
|
|||||||
@@ -646,6 +646,8 @@ 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.",
|
"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.subscribe": "Subscribe to Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirecting...",
|
"workspace.lite.promo.subscribing": "Redirecting...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Other payment methods",
|
||||||
|
"workspace.lite.promo.selectMethod": "Select payment method",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ 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.",
|
"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.subscribe": "Suscribirse a Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirigiendo...",
|
"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.title": "OpenCode | Descargar",
|
||||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||||
|
|||||||
@@ -661,6 +661,8 @@ 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.",
|
"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.subscribe": "S'abonner à Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirection...",
|
"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.title": "OpenCode | Téléchargement",
|
||||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ 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.",
|
"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.subscribe": "Abbonati a Go",
|
||||||
"workspace.lite.promo.subscribing": "Reindirizzamento...",
|
"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.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ export const dict = {
|
|||||||
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
|
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
|
||||||
"workspace.lite.promo.subscribe": "Goを購読する",
|
"workspace.lite.promo.subscribe": "Goを購読する",
|
||||||
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
||||||
|
"workspace.lite.promo.otherMethods": "その他の支払い方法",
|
||||||
|
"workspace.lite.promo.selectMethod": "支払い方法を選択",
|
||||||
|
|
||||||
"download.title": "OpenCode | ダウンロード",
|
"download.title": "OpenCode | ダウンロード",
|
||||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||||
|
|||||||
@@ -645,6 +645,8 @@ export const dict = {
|
|||||||
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
|
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
|
||||||
"workspace.lite.promo.subscribe": "Go 구독하기",
|
"workspace.lite.promo.subscribe": "Go 구독하기",
|
||||||
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
||||||
|
"workspace.lite.promo.otherMethods": "기타 결제 수단",
|
||||||
|
"workspace.lite.promo.selectMethod": "결제 수단 선택",
|
||||||
|
|
||||||
"download.title": "OpenCode | 다운로드",
|
"download.title": "OpenCode | 다운로드",
|
||||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||||
|
|||||||
@@ -651,6 +651,8 @@ 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.",
|
"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.subscribe": "Abonner på Go",
|
||||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||||
|
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
|
||||||
|
|
||||||
"download.title": "OpenCode | Last ned",
|
"download.title": "OpenCode | Last ned",
|
||||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ 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.",
|
"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.subscribe": "Subskrybuj Go",
|
||||||
"workspace.lite.promo.subscribing": "Przekierowywanie...",
|
"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.title": "OpenCode | Pobierz",
|
||||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||||
|
|||||||
@@ -658,6 +658,8 @@ export const dict = {
|
|||||||
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
|
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
|
||||||
"workspace.lite.promo.subscribe": "Подписаться на Go",
|
"workspace.lite.promo.subscribe": "Подписаться на Go",
|
||||||
"workspace.lite.promo.subscribing": "Перенаправление...",
|
"workspace.lite.promo.subscribing": "Перенаправление...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
|
||||||
|
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
|
||||||
|
|
||||||
"download.title": "OpenCode | Скачать",
|
"download.title": "OpenCode | Скачать",
|
||||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||||
|
|||||||
@@ -648,6 +648,8 @@ export const dict = {
|
|||||||
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
|
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
|
||||||
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
|
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
|
||||||
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
||||||
|
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
|
||||||
|
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
|
||||||
|
|
||||||
"download.title": "OpenCode | ดาวน์โหลด",
|
"download.title": "OpenCode | ดาวน์โหลด",
|
||||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||||
|
|||||||
@@ -655,6 +655,8 @@ 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.",
|
"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.subscribe": "Go'ya Abone Ol",
|
||||||
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
|
"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.title": "OpenCode | İndir",
|
||||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ export const dict = {
|
|||||||
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
|
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
|
||||||
"workspace.lite.promo.subscribe": "订阅 Go",
|
"workspace.lite.promo.subscribe": "订阅 Go",
|
||||||
"workspace.lite.promo.subscribing": "正在重定向...",
|
"workspace.lite.promo.subscribing": "正在重定向...",
|
||||||
|
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||||
|
"workspace.lite.promo.selectMethod": "选择付款方式",
|
||||||
|
|
||||||
"download.title": "OpenCode | 下载",
|
"download.title": "OpenCode | 下载",
|
||||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ export const dict = {
|
|||||||
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
|
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
|
||||||
"workspace.lite.promo.subscribe": "訂閱 Go",
|
"workspace.lite.promo.subscribe": "訂閱 Go",
|
||||||
"workspace.lite.promo.subscribing": "重新導向中...",
|
"workspace.lite.promo.subscribing": "重新導向中...",
|
||||||
|
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||||
|
"workspace.lite.promo.selectMethod": "選擇付款方式",
|
||||||
|
|
||||||
"download.title": "OpenCode | 下載",
|
"download.title": "OpenCode | 下載",
|
||||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
|
|||||||
customerID,
|
customerID,
|
||||||
enrichment: {
|
enrichment: {
|
||||||
type: productID === LiteData.productID() ? "lite" : "subscription",
|
type: productID === LiteData.productID() ? "lite" : "subscription",
|
||||||
|
currency: body.data.object.currency === "inr" ? "inr" : undefined,
|
||||||
couponID,
|
couponID,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
|
|||||||
)
|
)
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
const amount = await Database.use((tx) =>
|
const payment = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
amount: PaymentTable.amount,
|
amount: PaymentTable.amount,
|
||||||
|
enrichment: PaymentTable.enrichment,
|
||||||
})
|
})
|
||||||
.from(PaymentTable)
|
.from(PaymentTable)
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
.then((rows) => rows[0]?.amount),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
if (!amount) throw new Error("Payment not found")
|
if (!payment) throw new Error("Payment not found")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
|
||||||
|
// deduct balance only for top up
|
||||||
|
if (!payment.enrichment?.type) {
|
||||||
await tx
|
await tx
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
balance: sql`${BillingTable.balance} - ${payment.amount}`,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
|
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
|
||||||
import styles from "./billing-section.module.css"
|
import styles from "./billing-section.module.css"
|
||||||
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
@@ -211,6 +211,9 @@ export function BillingSection() {
|
|||||||
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
||||||
<IconWechat style={{ width: "24px", height: "24px" }} />
|
<IconWechat style={{ width: "24px", height: "24px" }} />
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={billingInfo()?.paymentMethodType === "upi"}>
|
||||||
|
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="card-details">
|
<div data-slot="card-details">
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
|
|||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
import { useI18n } from "~/context/i18n"
|
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) => {
|
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
@@ -81,6 +89,10 @@ export function PaymentSection() {
|
|||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
const amount =
|
const amount =
|
||||||
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.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 (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -88,7 +100,7 @@ export function PaymentSection() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-id">{payment.id}</td>
|
<td data-slot="payment-id">{payment.id}</td>
|
||||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||||
${((amount ?? 0) / 100000000).toFixed(2)}
|
{money(amount, currency)}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={payment.enrichment?.type === "credit"}>
|
<Match when={payment.enrichment?.type === "credit"}>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@@ -188,8 +188,45 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="subscribe-button"] {
|
[data-slot="subscribe-actions"] {
|
||||||
align-self: flex-start;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
margin-top: 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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
|
import { Modal } from "~/component/modal"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.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"
|
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
|
|||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
import { formError } from "~/lib/form-error"
|
import { formError } from "~/lib/form-error"
|
||||||
|
|
||||||
|
import { IconAlipay, IconUpi } from "~/component/icon"
|
||||||
|
|
||||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
@@ -78,12 +81,13 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
|
|||||||
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
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) => {
|
const createLiteCheckoutUrl = action(
|
||||||
|
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
|
||||||
"use server"
|
"use server"
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
() =>
|
() =>
|
||||||
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
|
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
|
||||||
.then((data) => ({ error: undefined, data }))
|
.then((data) => ({ error: undefined, data }))
|
||||||
.catch((e) => ({
|
.catch((e) => ({
|
||||||
error: e.message as string,
|
error: e.message as string,
|
||||||
@@ -93,7 +97,9 @@ const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: str
|
|||||||
),
|
),
|
||||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||||
)
|
)
|
||||||
}, "liteCheckoutUrl")
|
},
|
||||||
|
"liteCheckoutUrl",
|
||||||
|
)
|
||||||
|
|
||||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -147,23 +153,30 @@ export function LiteSection() {
|
|||||||
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
||||||
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
redirecting: false,
|
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
|
||||||
|
showModal: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const busy = createMemo(() => !!store.loading)
|
||||||
|
|
||||||
async function onClickSession() {
|
async function onClickSession() {
|
||||||
|
setStore("loading", "session")
|
||||||
const result = await sessionAction(params.id!, window.location.href)
|
const result = await sessionAction(params.id!, window.location.href)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setStore("redirecting", true)
|
|
||||||
window.location.href = result.data
|
window.location.href = result.data
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setStore("loading", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClickSubscribe() {
|
async function onClickSubscribe(method?: "alipay" | "upi") {
|
||||||
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
|
setStore("loading", method ?? "checkout")
|
||||||
|
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setStore("redirecting", true)
|
|
||||||
window.location.href = result.data
|
window.location.href = result.data
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setStore("loading", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,12 +192,8 @@ export function LiteSection() {
|
|||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<div data-slot="title-row">
|
<div data-slot="title-row">
|
||||||
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
||||||
<button
|
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
|
||||||
data-color="primary"
|
{store.loading === "session"
|
||||||
disabled={sessionSubmission.pending || store.redirecting}
|
|
||||||
onClick={onClickSession}
|
|
||||||
>
|
|
||||||
{sessionSubmission.pending || store.redirecting
|
|
||||||
? i18n.t("workspace.lite.loading")
|
? i18n.t("workspace.lite.loading")
|
||||||
: i18n.t("workspace.lite.subscription.manage")}
|
: i18n.t("workspace.lite.subscription.manage")}
|
||||||
</button>
|
</button>
|
||||||
@@ -282,16 +291,64 @@ export function LiteSection() {
|
|||||||
<li>MiniMax M2.7</li>
|
<li>MiniMax M2.7</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||||
|
<div data-slot="subscribe-actions">
|
||||||
<button
|
<button
|
||||||
data-slot="subscribe-button"
|
data-slot="subscribe-button"
|
||||||
data-color="primary"
|
data-color="primary"
|
||||||
disabled={checkoutSubmission.pending || store.redirecting}
|
disabled={checkoutSubmission.pending || busy()}
|
||||||
onClick={onClickSubscribe}
|
onClick={() => onClickSubscribe()}
|
||||||
>
|
>
|
||||||
{checkoutSubmission.pending || store.redirecting
|
{store.loading === "checkout"
|
||||||
? i18n.t("workspace.lite.promo.subscribing")
|
? i18n.t("workspace.lite.promo.subscribing")
|
||||||
: i18n.t("workspace.lite.promo.subscribe")}
|
: i18n.t("workspace.lite.promo.subscribe")}
|
||||||
</button>
|
</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")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -239,10 +239,11 @@ export namespace Billing {
|
|||||||
z.object({
|
z.object({
|
||||||
successUrl: z.string(),
|
successUrl: z.string(),
|
||||||
cancelUrl: z.string(),
|
cancelUrl: z.string(),
|
||||||
|
method: z.enum(["alipay", "upi"]).optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const user = Actor.assert("user")
|
const user = Actor.assert("user")
|
||||||
const { successUrl, cancelUrl } = input
|
const { successUrl, cancelUrl, method } = input
|
||||||
|
|
||||||
const email = await User.getAuthEmail(user.properties.userID)
|
const email = await User.getAuthEmail(user.properties.userID)
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
@@ -250,10 +251,9 @@ export namespace Billing {
|
|||||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||||
|
|
||||||
const session = await Billing.stripe().checkout.sessions.create({
|
const createSession = () =>
|
||||||
|
Billing.stripe().checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
billing_address_collection: "required",
|
|
||||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
|
||||||
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
|
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
|
||||||
...(billing.customerID
|
...(billing.customerID
|
||||||
? {
|
? {
|
||||||
@@ -266,7 +266,43 @@ export namespace Billing {
|
|||||||
: {
|
: {
|
||||||
customer_email: email!,
|
customer_email: email!,
|
||||||
}),
|
}),
|
||||||
currency: "usd",
|
...(() => {
|
||||||
|
if (method === "alipay") {
|
||||||
|
return {
|
||||||
|
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||||
|
payment_method_types: ["alipay"],
|
||||||
|
adaptive_pricing: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -281,7 +317,36 @@ export namespace Billing {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await createSession()
|
||||||
return session.url
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export namespace LiteData {
|
|||||||
|
|
||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
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 firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
|
||||||
export const planName = fn(z.void(), () => "lite")
|
export const planName = fn(z.void(), () => "lite")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
|
|||||||
enrichment: json("enrichment").$type<
|
enrichment: json("enrichment").$type<
|
||||||
| {
|
| {
|
||||||
type: "subscription" | "lite"
|
type: "subscription" | "lite"
|
||||||
|
currency?: "inr"
|
||||||
couponID?: string
|
couponID?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
|
"imports": {
|
||||||
|
"#db": {
|
||||||
|
"bun": "./src/storage/db.bun.ts",
|
||||||
|
"node": "./src/storage/db.node.ts",
|
||||||
|
"default": "./src/storage/db.bun.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@effect/language-service": "0.79.0",
|
"@effect/language-service": "0.79.0",
|
||||||
@@ -50,8 +57,8 @@
|
|||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
"why-is-node-running": "3.2.2",
|
||||||
@@ -82,8 +89,11 @@
|
|||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@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/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
@@ -97,7 +107,6 @@
|
|||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.87",
|
"@opentui/core": "0.1.87",
|
||||||
"@opentui/solid": "0.1.87",
|
"@opentui/solid": "0.1.87",
|
||||||
"@effect/platform-node": "catalog:",
|
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -113,7 +122,7 @@
|
|||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
@@ -144,6 +153,6 @@
|
|||||||
"zod-to-json-schema": "3.24.5"
|
"zod-to-json-schema": "3.24.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
"drizzle-orm": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export namespace AccountEffect {
|
|||||||
mapAccountServiceError("HTTP request failed"),
|
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 resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
@@ -290,7 +296,7 @@ export namespace AccountEffect {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||||
const response = yield* executeEffectOk(
|
const response = yield* executeEffect(
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ export namespace Agent {
|
|||||||
return pipe(
|
return pipe(
|
||||||
await state(),
|
await state(),
|
||||||
values(),
|
values(),
|
||||||
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
|
sortBy(
|
||||||
|
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
|
||||||
|
[(x) => x.name, "asc"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodObject, ZodRawShape } from "zod"
|
import type { ZodType } from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
|||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
payload: { type: string; properties: Record<string, unknown> }
|
payload: any
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, PubSub, ServiceMap, Stream } from "effect"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { BusEvent } from "./bus-event"
|
import { BusEvent } from "./bus-event"
|
||||||
import { GlobalBus } from "./global"
|
import { GlobalBus } from "./global"
|
||||||
import { runCallbackInstance, runPromiseInstance } from "../effect/runtime"
|
|
||||||
|
|
||||||
export namespace Bus {
|
export namespace Bus {
|
||||||
const log = Log.create({ service: "bus" })
|
const log = Log.create({ service: "bus" })
|
||||||
|
type Subscription = (event: any) => void
|
||||||
|
|
||||||
export const InstanceDisposed = BusEvent.define(
|
export const InstanceDisposed = BusEvent.define(
|
||||||
"server.instance.disposed",
|
"server.instance.disposed",
|
||||||
@@ -16,130 +15,91 @@ export namespace Bus {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const state = Instance.state(
|
||||||
// Service definition
|
() => {
|
||||||
// ---------------------------------------------------------------------------
|
const subscriptions = new Map<any, Subscription[]>()
|
||||||
|
|
||||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
return {
|
||||||
type: D["type"]
|
subscriptions,
|
||||||
properties: z.infer<D["properties"]>
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
export interface Interface {
|
async (entry) => {
|
||||||
readonly publish: <D extends BusEvent.Definition>(
|
const wildcard = entry.subscriptions.get("*")
|
||||||
def: D,
|
if (!wildcard) return
|
||||||
properties: z.output<D["properties"]>,
|
const event = {
|
||||||
) => Effect.Effect<void>
|
type: InstanceDisposed.type,
|
||||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
properties: {
|
||||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
directory: Instance.directory,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
for (const sub of [...wildcard]) {
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
sub(event)
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
|
|
||||||
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
|
|
||||||
|
|
||||||
const getOrCreate = Effect.fnUntraced(function* (type: string) {
|
|
||||||
let ps = pubsubs.get(type)
|
|
||||||
if (!ps) {
|
|
||||||
ps = yield* PubSub.unbounded<Payload>()
|
|
||||||
pubsubs.set(type, ps)
|
|
||||||
}
|
}
|
||||||
return ps
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function publish<Definition extends BusEvent.Definition>(
|
||||||
|
def: Definition,
|
||||||
|
properties: z.output<Definition["properties"]>,
|
||||||
|
) {
|
||||||
|
const payload = {
|
||||||
|
type: def.type,
|
||||||
|
properties,
|
||||||
|
}
|
||||||
|
log.info("publishing", {
|
||||||
|
type: def.type,
|
||||||
})
|
})
|
||||||
|
const pending = []
|
||||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
for (const key of [def.type, "*"]) {
|
||||||
return Effect.gen(function* () {
|
const match = [...(state().subscriptions.get(key) ?? [])]
|
||||||
const payload: Payload = { type: def.type, properties }
|
for (const sub of match) {
|
||||||
log.info("publishing", { type: def.type })
|
pending.push(sub(payload))
|
||||||
|
}
|
||||||
const ps = pubsubs.get(def.type)
|
}
|
||||||
if (ps) yield* PubSub.publish(ps, payload)
|
|
||||||
yield* PubSub.publish(wildcardPubSub, payload)
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: Instance.directory,
|
directory: Instance.directory,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
|
return Promise.all(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe<Definition extends BusEvent.Definition>(
|
||||||
|
def: Definition,
|
||||||
|
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||||
|
) {
|
||||||
|
return raw(def.type, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function once<Definition extends BusEvent.Definition>(
|
||||||
|
def: Definition,
|
||||||
|
callback: (event: {
|
||||||
|
type: Definition["type"]
|
||||||
|
properties: z.infer<Definition["properties"]>
|
||||||
|
}) => "done" | undefined,
|
||||||
|
) {
|
||||||
|
const unsub = subscribe(def, (event) => {
|
||||||
|
if (callback(event)) unsub()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
|
||||||
log.info("subscribing", { type: def.type })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ps = yield* getOrCreate(def.type)
|
|
||||||
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
|
|
||||||
}),
|
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeAll(): Stream.Stream<Payload> {
|
|
||||||
log.info("subscribing", { type: "*" })
|
|
||||||
return Stream.fromPubSub(wildcardPubSub).pipe(
|
|
||||||
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shut down all PubSubs when the layer is torn down.
|
|
||||||
// This causes Stream.fromPubSub consumers to end, triggering
|
|
||||||
// their ensuring/finalizers.
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
log.info("shutting down PubSubs")
|
|
||||||
yield* PubSub.shutdown(wildcardPubSub)
|
|
||||||
for (const ps of pubsubs.values()) {
|
|
||||||
yield* PubSub.shutdown(ps)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ publish, subscribe, subscribeAll })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Legacy adapters — plain function API wrapping the Effect service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
|
|
||||||
return runCallbackInstance(
|
|
||||||
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
|
||||||
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) {
|
|
||||||
return runStream((svc) => svc.subscribe(def), callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeAll(callback: (event: any) => void) {
|
export function subscribeAll(callback: (event: any) => void) {
|
||||||
const directory = Instance.directory
|
return raw("*", callback)
|
||||||
|
|
||||||
// InstanceDisposed is delivered via GlobalBus because the legacy
|
|
||||||
// adapter's fiber starts asynchronously and may not be running when
|
|
||||||
// disposal happens. In the Effect-native path, forkScoped + scope
|
|
||||||
// closure handles this correctly. This bridge can be removed once
|
|
||||||
// upstream PubSub.shutdown properly wakes suspended subscribers:
|
|
||||||
// https://github.com/Effect-TS/effect-smol/pull/1800
|
|
||||||
const onDispose = (evt: { directory?: string; payload: any }) => {
|
|
||||||
if (evt.payload.type !== InstanceDisposed.type) return
|
|
||||||
if (evt.directory !== directory) return
|
|
||||||
callback(evt.payload)
|
|
||||||
GlobalBus.off("event", onDispose)
|
|
||||||
}
|
}
|
||||||
GlobalBus.on("event", onDispose)
|
|
||||||
|
|
||||||
const interrupt = runStream((svc) => svc.subscribeAll(), callback)
|
function raw(type: string, callback: (event: any) => void) {
|
||||||
|
log.info("subscribing", { type })
|
||||||
|
const subscriptions = state().subscriptions
|
||||||
|
let match = subscriptions.get(type) ?? []
|
||||||
|
match.push(callback)
|
||||||
|
subscriptions.set(type, match)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
GlobalBus.off("event", onDispose)
|
log.info("unsubscribing", { type })
|
||||||
interrupt()
|
const match = subscriptions.get(type)
|
||||||
|
if (!match) return
|
||||||
|
const index = match.indexOf(callback)
|
||||||
|
if (index === -1) return
|
||||||
|
match.splice(index, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
|||||||
process.env.OPENCODE_CLIENT = "acp"
|
process.env.OPENCODE_CLIENT = "acp"
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
|||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||||
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
|
|||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
async function openWorkspace(input: {
|
async function openWorkspace(input: {
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.response.status >= 500 && result.response.status < 600) {
|
if (result.response.status >= 500 && result.response.status < 600) {
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
|
|||||||
@@ -907,12 +907,12 @@ export function Session() {
|
|||||||
const filename = options.filename.trim()
|
const filename = options.filename.trim()
|
||||||
const filepath = path.join(exportDir, filename)
|
const filepath = path.join(exportDir, filename)
|
||||||
|
|
||||||
await Bun.write(filepath, transcript)
|
await Filesystem.write(filepath, transcript)
|
||||||
|
|
||||||
// Open with EDITOR if available
|
// Open with EDITOR if available
|
||||||
const result = await Editor.open({ value: transcript, renderer })
|
const result = await Editor.open({ value: transcript, renderer })
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
await Bun.write(filepath, result)
|
await Filesystem.write(filepath, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
|
|||||||
Rpc.emit("global.event", event)
|
Rpc.emit("global.event", event)
|
||||||
})
|
})
|
||||||
|
|
||||||
let server: Bun.Server<BunWebSocketData> | undefined
|
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||||
|
|
||||||
const eventStream = {
|
const eventStream = {
|
||||||
abort: undefined as AbortController | undefined,
|
abort: undefined as AbortController | undefined,
|
||||||
@@ -120,7 +119,7 @@ export const rpc = {
|
|||||||
},
|
},
|
||||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
server = Server.listen(input)
|
server = await Server.listen(input)
|
||||||
return { url: server.url.toString() }
|
return { url: server.url.toString() }
|
||||||
},
|
},
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
@@ -143,7 +142,7 @@ export const rpc = {
|
|||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) server.stop(true)
|
if (server) await server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
|||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
UI.empty()
|
UI.empty()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createAdaptorServer } from "@hono/node-server"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Listen(opts: { hostname: string; port: number }) {
|
export function Listen(opts: { hostname: string; port: number }) {
|
||||||
return Bun.serve({
|
const server = createAdaptorServer({
|
||||||
hostname: opts.hostname,
|
|
||||||
port: opts.port,
|
|
||||||
fetch: App().fetch,
|
fetch: App().fetch,
|
||||||
})
|
})
|
||||||
|
server.listen(opts.port, opts.hostname)
|
||||||
|
return {
|
||||||
|
hostname: opts.hostname,
|
||||||
|
port: opts.port,
|
||||||
|
stop() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
@@ -117,17 +118,17 @@ export namespace Workspace {
|
|||||||
const adaptor = await getAdaptor(space.type)
|
const adaptor = await getAdaptor(space.type)
|
||||||
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
||||||
if (!res || !res.ok || !res.body) {
|
if (!res || !res.ok || !res.body) {
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
directory: space.id,
|
||||||
payload: event as { type: string; properties: Record<string, unknown> },
|
payload: event,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
await Bun.sleep(250)
|
await sleep(250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Effect, Exit, Fiber, Layer, LayerMap, MutableHashMap, Scope, ServiceMap } from "effect"
|
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { FileTime } from "@/file/time"
|
import { FileTime } from "@/file/time"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
@@ -17,7 +16,6 @@ import { registerDisposer } from "./instance-registry"
|
|||||||
export { InstanceContext } from "./instance-context"
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
export type InstanceServices =
|
export type InstanceServices =
|
||||||
| Bus.Service
|
|
||||||
| Question.Service
|
| Question.Service
|
||||||
| PermissionNext.Service
|
| PermissionNext.Service
|
||||||
| ProviderAuth.Service
|
| ProviderAuth.Service
|
||||||
@@ -38,7 +36,6 @@ export type InstanceServices =
|
|||||||
function lookup(_key: string) {
|
function lookup(_key: string) {
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
Layer.fresh(Bus.layer),
|
|
||||||
Layer.fresh(Question.layer),
|
Layer.fresh(Question.layer),
|
||||||
Layer.fresh(PermissionNext.layer),
|
Layer.fresh(PermissionNext.layer),
|
||||||
Layer.fresh(ProviderAuth.defaultLayer),
|
Layer.fresh(ProviderAuth.defaultLayer),
|
||||||
@@ -59,23 +56,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
|||||||
Instances,
|
Instances,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
|
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||||
// Force-invalidate closes the RcMap entry scope even when refCount > 0.
|
|
||||||
// Standard RcMap.invalidate bails in that case, leaving long-running
|
|
||||||
// consumer fibers orphaned. This is an upstream issue:
|
|
||||||
// https://github.com/Effect-TS/effect-smol/pull/1799
|
|
||||||
const forceInvalidate = (directory: string) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const rcMap = layerMap.rcMap
|
|
||||||
if (rcMap.state._tag === "Closed") return
|
|
||||||
const entry = MutableHashMap.get(rcMap.state.map, directory)
|
|
||||||
if (entry._tag === "None") return
|
|
||||||
MutableHashMap.remove(rcMap.state.map, directory)
|
|
||||||
if (entry.value.fiber) yield* Fiber.interrupt(entry.value.fiber)
|
|
||||||
yield* Scope.close(entry.value.scope, Exit.void)
|
|
||||||
}).pipe(Effect.uninterruptible, Effect.ignore)
|
|
||||||
|
|
||||||
const unregister = registerDisposer((directory) => Effect.runPromise(forceInvalidate(directory)))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
return Instances.of(layerMap)
|
return Instances.of(layerMap)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceSer
|
|||||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCallbackInstance<A, E>(
|
|
||||||
effect: Effect.Effect<A, E, InstanceServices>,
|
|
||||||
): (interruptor?: number) => void {
|
|
||||||
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disposeRuntime() {
|
export function disposeRuntime() {
|
||||||
return runtime.dispose()
|
return runtime.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { InstanceContext } from "@/effect/instance-context"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@@ -25,7 +27,6 @@ export namespace Format {
|
|||||||
export type Status = z.infer<typeof Status>
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly run: (filepath: string) => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,16 +90,20 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = Effect.fn("Format.run")(function* (filepath: string) {
|
yield* Effect.acquireRelease(
|
||||||
log.info("formatting", { file: filepath })
|
Effect.sync(() =>
|
||||||
const ext = path.extname(filepath)
|
Bus.subscribe(
|
||||||
|
File.Event.Edited,
|
||||||
|
Instance.bind(async (payload) => {
|
||||||
|
const file = payload.properties.file
|
||||||
|
log.info("formatting", { file })
|
||||||
|
const ext = path.extname(file)
|
||||||
|
|
||||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
for (const item of await getFormatter(ext)) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
yield* Effect.tryPromise({
|
try {
|
||||||
try: async () => {
|
|
||||||
const proc = Process.spawn(
|
const proc = Process.spawn(
|
||||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
item.command.map((x) => x.replace("$FILE", file)),
|
||||||
{
|
{
|
||||||
cwd: instance.directory,
|
cwd: instance.directory,
|
||||||
env: { ...process.env, ...item.environment },
|
env: { ...process.env, ...item.environment },
|
||||||
@@ -113,20 +118,20 @@ export namespace Format {
|
|||||||
...item.environment,
|
...item.environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
catch: (error) => {
|
|
||||||
log.error("failed to format file", {
|
log.error("failed to format file", {
|
||||||
error,
|
error,
|
||||||
command: item.command,
|
command: item.command,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
file: filepath,
|
file,
|
||||||
})
|
})
|
||||||
return error
|
|
||||||
},
|
|
||||||
}).pipe(Effect.ignore)
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
|
)
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
@@ -142,14 +147,10 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ run, status })
|
return Service.of({ status })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function run(filepath: string) {
|
|
||||||
return runPromiseInstance(Service.use((s) => s.run(filepath)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(Service.use((s) => s.status()))
|
return runPromiseInstance(Service.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export namespace Global {
|
|||||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
bin: path.join(data, "bin"),
|
bin: path.join(cache, "bin"),
|
||||||
log: path.join(data, "log"),
|
log: path.join(data, "log"),
|
||||||
cache,
|
cache,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Process } from "../util/process"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import z from "zod/v4"
|
import z from "zod/v4"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
@@ -166,14 +167,10 @@ export namespace MCP {
|
|||||||
const queue = [pid]
|
const queue = [pid]
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const current = queue.shift()!
|
const current = queue.shift()!
|
||||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||||
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
|
for (const tok of lines) {
|
||||||
() => [-1, ""] as const,
|
|
||||||
)
|
|
||||||
if (code !== 0) continue
|
|
||||||
for (const tok of out.trim().split(/\s+/)) {
|
|
||||||
const cpid = parseInt(tok, 10)
|
const cpid = parseInt(tok, 10)
|
||||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||||
pids.push(cpid)
|
pids.push(cpid)
|
||||||
queue.push(cpid)
|
queue.push(cpid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createConnection } from "net"
|
import { createConnection } from "net"
|
||||||
|
import { createServer } from "http"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||||
|
|
||||||
@@ -52,27 +53,18 @@ interface PendingAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace McpOAuthCallback {
|
export namespace McpOAuthCallback {
|
||||||
let server: ReturnType<typeof Bun.serve> | undefined
|
let server: ReturnType<typeof createServer> | undefined
|
||||||
const pendingAuths = new Map<string, PendingAuth>()
|
const pendingAuths = new Map<string, PendingAuth>()
|
||||||
|
|
||||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
export async function ensureRunning(): Promise<void> {
|
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||||
if (server) return
|
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||||
|
|
||||||
const running = await isPortInUse()
|
|
||||||
if (running) {
|
|
||||||
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
server = Bun.serve({
|
|
||||||
port: OAUTH_CALLBACK_PORT,
|
|
||||||
fetch(req) {
|
|
||||||
const url = new URL(req.url)
|
|
||||||
|
|
||||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||||
return new Response("Not found", { status: 404 })
|
res.writeHead(404)
|
||||||
|
res.end("Not found")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = url.searchParams.get("code")
|
const code = url.searchParams.get("code")
|
||||||
@@ -86,10 +78,9 @@ export namespace McpOAuthCallback {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
status: 400,
|
res.end(HTML_ERROR(errorMsg))
|
||||||
headers: { "Content-Type": "text/html" },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -100,26 +91,24 @@ export namespace McpOAuthCallback {
|
|||||||
pendingAuths.delete(state)
|
pendingAuths.delete(state)
|
||||||
pending.reject(new Error(errorMsg))
|
pending.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
headers: { "Content-Type": "text/html" },
|
res.end(HTML_ERROR(errorMsg))
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
status: 400,
|
res.end(HTML_ERROR("No authorization code provided"))
|
||||||
headers: { "Content-Type": "text/html" },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate state parameter
|
// Validate state parameter
|
||||||
if (!pendingAuths.has(state)) {
|
if (!pendingAuths.has(state)) {
|
||||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
status: 400,
|
res.end(HTML_ERROR(errorMsg))
|
||||||
headers: { "Content-Type": "text/html" },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = pendingAuths.get(state)!
|
const pending = pendingAuths.get(state)!
|
||||||
@@ -128,13 +117,27 @@ export namespace McpOAuthCallback {
|
|||||||
pendingAuths.delete(state)
|
pendingAuths.delete(state)
|
||||||
pending.resolve(code)
|
pending.resolve(code)
|
||||||
|
|
||||||
return new Response(HTML_SUCCESS, {
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
headers: { "Content-Type": "text/html" },
|
res.end(HTML_SUCCESS)
|
||||||
})
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
export async function ensureRunning(): Promise<void> {
|
||||||
|
if (server) return
|
||||||
|
|
||||||
|
const running = await isPortInUse()
|
||||||
|
if (running) {
|
||||||
|
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server = createServer(handleRequest)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
server!.on("error", reject)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitForCallback(oauthState: string): Promise<string> {
|
export function waitForCallback(oauthState: string): Promise<string> {
|
||||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
|||||||
|
|
||||||
export async function stop(): Promise<void> {
|
export async function stop(): Promise<void> {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.stop()
|
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||||
server = undefined
|
server = undefined
|
||||||
log.info("oauth callback server stopped")
|
log.info("oauth callback server stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
|
||||||
|
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
|
||||||
|
// tar silently swallows the error and skips writing files, leaving only empty
|
||||||
|
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
|
||||||
|
// flag. See tar's get-write-flag.js.
|
||||||
|
// Must be set before @npmcli/arborist is imported since tar caches the flag
|
||||||
|
// at module evaluation time — so we use a dynamic import() below.
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
process.env.__FAKE_PLATFORM__ = "linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
import semver from "semver"
|
||||||
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Lock } from "../util/lock"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
|
import { readdir } from "fs/promises"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|
||||||
|
const { Arborist } = await import("@npmcli/arborist")
|
||||||
|
|
||||||
|
export namespace Npm {
|
||||||
|
const log = Log.create({ service: "npm" })
|
||||||
|
|
||||||
|
export const InstallFailedError = NamedError.create(
|
||||||
|
"NpmInstallFailedError",
|
||||||
|
z.object({
|
||||||
|
pkg: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function directory(pkg: string) {
|
||||||
|
return path.join(Global.Path.cache, "packages", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||||
|
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||||
|
const latestVersion = data?.["dist-tags"]?.latest
|
||||||
|
if (!latestVersion) {
|
||||||
|
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||||
|
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||||
|
|
||||||
|
return semver.lt(cachedVersion, latestVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function add(pkg: string) {
|
||||||
|
using _ = await Lock.write(`npm-install:${pkg}`)
|
||||||
|
log.info("installing package", {
|
||||||
|
pkg,
|
||||||
|
})
|
||||||
|
const dir = directory(pkg)
|
||||||
|
|
||||||
|
const arborist = new Arborist({
|
||||||
|
path: dir,
|
||||||
|
binLinks: true,
|
||||||
|
progress: false,
|
||||||
|
savePrefix: "",
|
||||||
|
})
|
||||||
|
const tree = await arborist.loadVirtual().catch(() => {})
|
||||||
|
if (tree) {
|
||||||
|
const first = tree.edgesOut.values().next().value?.to
|
||||||
|
if (first) {
|
||||||
|
log.info("package already installed", { pkg })
|
||||||
|
return first.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await arborist
|
||||||
|
.reify({
|
||||||
|
add: [pkg],
|
||||||
|
save: true,
|
||||||
|
saveType: "prod",
|
||||||
|
})
|
||||||
|
.catch((cause) => {
|
||||||
|
throw new InstallFailedError(
|
||||||
|
{ pkg },
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = result.edgesOut.values().next().value?.to
|
||||||
|
if (!first) throw new InstallFailedError({ pkg })
|
||||||
|
return first.path
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function install(dir: string) {
|
||||||
|
using _ = await Lock.write(`npm-install:${dir}`)
|
||||||
|
log.info("checking dependencies", { dir })
|
||||||
|
|
||||||
|
const reify = async () => {
|
||||||
|
const arb = new Arborist({
|
||||||
|
path: dir,
|
||||||
|
binLinks: true,
|
||||||
|
progress: false,
|
||||||
|
savePrefix: "",
|
||||||
|
})
|
||||||
|
await arb.reify().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||||
|
log.info("node_modules missing, reifying")
|
||||||
|
await reify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||||
|
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||||
|
|
||||||
|
const declared = new Set([
|
||||||
|
...Object.keys(pkg.dependencies || {}),
|
||||||
|
...Object.keys(pkg.devDependencies || {}),
|
||||||
|
...Object.keys(pkg.peerDependencies || {}),
|
||||||
|
...Object.keys(pkg.optionalDependencies || {}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = lock.packages?.[""] || {}
|
||||||
|
const locked = new Set([
|
||||||
|
...Object.keys(root.dependencies || {}),
|
||||||
|
...Object.keys(root.devDependencies || {}),
|
||||||
|
...Object.keys(root.peerDependencies || {}),
|
||||||
|
...Object.keys(root.optionalDependencies || {}),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const name of declared) {
|
||||||
|
if (!locked.has(name)) {
|
||||||
|
log.info("dependency not in lock file, reifying", { name })
|
||||||
|
await reify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("dependencies in sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function which(pkg: string) {
|
||||||
|
const dir = directory(pkg)
|
||||||
|
const binDir = path.join(dir, "node_modules", ".bin")
|
||||||
|
|
||||||
|
const pick = async () => {
|
||||||
|
const files = await readdir(binDir).catch(() => [])
|
||||||
|
if (files.length === 0) return undefined
|
||||||
|
if (files.length === 1) return files[0]
|
||||||
|
// Multiple binaries — resolve from package.json bin field like npx does
|
||||||
|
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
|
||||||
|
path.join(dir, "node_modules", pkg, "package.json"),
|
||||||
|
).catch(() => undefined)
|
||||||
|
if (pkgJson?.bin) {
|
||||||
|
const bin = pkgJson.bin
|
||||||
|
if (typeof bin === "string") return path.basename(bin)
|
||||||
|
const keys = Object.keys(bin)
|
||||||
|
if (keys.length === 1) return keys[0]
|
||||||
|
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||||
|
return bin[unscoped] ? unscoped : keys[0]
|
||||||
|
}
|
||||||
|
return files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = await pick()
|
||||||
|
if (bin) return path.join(binDir, bin)
|
||||||
|
|
||||||
|
await add(pkg)
|
||||||
|
const resolved = await pick()
|
||||||
|
if (!resolved) throw new Error(`No binary found for package "${pkg}" after install`)
|
||||||
|
return path.join(binDir, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import os from "os"
|
|||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
import { createServer } from "http"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.codex" })
|
const log = Log.create({ service: "plugin.codex" })
|
||||||
|
|
||||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
|||||||
reject: (error: Error) => void
|
reject: (error: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||||
let pendingOAuth: PendingOAuth | undefined
|
let pendingOAuth: PendingOAuth | undefined
|
||||||
|
|
||||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||||
@@ -249,10 +250,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthServer = Bun.serve({
|
oauthServer = createServer((req, res) => {
|
||||||
port: OAUTH_PORT,
|
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||||
fetch(req) {
|
|
||||||
const url = new URL(req.url)
|
|
||||||
|
|
||||||
if (url.pathname === "/auth/callback") {
|
if (url.pathname === "/auth/callback") {
|
||||||
const code = url.searchParams.get("code")
|
const code = url.searchParams.get("code")
|
||||||
@@ -264,29 +263,27 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
const errorMsg = errorDescription || error
|
const errorMsg = errorDescription || error
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
headers: { "Content-Type": "text/html" },
|
res.end(HTML_ERROR(errorMsg))
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
const errorMsg = "Missing authorization code"
|
const errorMsg = "Missing authorization code"
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
status: 400,
|
res.end(HTML_ERROR(errorMsg))
|
||||||
headers: { "Content-Type": "text/html" },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||||
const errorMsg = "Invalid state - potential CSRF attack"
|
const errorMsg = "Invalid state - potential CSRF attack"
|
||||||
pendingOAuth?.reject(new Error(errorMsg))
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
res.writeHead(400, { "Content-Type": "text/html" })
|
||||||
status: 400,
|
res.end(HTML_ERROR(errorMsg))
|
||||||
headers: { "Content-Type": "text/html" },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = pendingOAuth
|
const current = pendingOAuth
|
||||||
@@ -296,30 +293,40 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
|||||||
.then((tokens) => current.resolve(tokens))
|
.then((tokens) => current.resolve(tokens))
|
||||||
.catch((err) => current.reject(err))
|
.catch((err) => current.reject(err))
|
||||||
|
|
||||||
return new Response(HTML_SUCCESS, {
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
headers: { "Content-Type": "text/html" },
|
res.end(HTML_SUCCESS)
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/cancel") {
|
if (url.pathname === "/cancel") {
|
||||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||||
pendingOAuth = undefined
|
pendingOAuth = undefined
|
||||||
return new Response("Login cancelled", { status: 200 })
|
res.writeHead(200)
|
||||||
|
res.end("Login cancelled")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("Not found", { status: 404 })
|
res.writeHead(404)
|
||||||
},
|
res.end("Not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
oauthServer!.listen(OAUTH_PORT, () => {
|
||||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
oauthServer!.on("error", reject)
|
||||||
|
})
|
||||||
|
|
||||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopOAuthServer() {
|
function stopOAuthServer() {
|
||||||
if (oauthServer) {
|
if (oauthServer) {
|
||||||
oauthServer.stop()
|
oauthServer.close(() => {
|
||||||
oauthServer = undefined
|
|
||||||
log.info("codex oauth server stopped")
|
log.info("codex oauth server stopped")
|
||||||
|
})
|
||||||
|
oauthServer = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ import { ProviderTransform } from "./transform"
|
|||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { ModelID, ProviderID } from "./schema"
|
import { ModelID, ProviderID } from "./schema"
|
||||||
|
|
||||||
const DEFAULT_CHUNK_TIMEOUT = 300_000
|
|
||||||
|
|
||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
|
|
||||||
@@ -1130,7 +1128,7 @@ export namespace Provider {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const customFetch = options["fetch"]
|
const customFetch = options["fetch"]
|
||||||
const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
|
const chunkTimeout = options["chunkTimeout"]
|
||||||
delete options["chunkTimeout"]
|
delete options["chunkTimeout"]
|
||||||
|
|
||||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
|||||||
close: (code?: number, reason?: string) => void
|
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.
|
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||||
const meta = (cursor: number) => {
|
const meta = (cursor: number) => {
|
||||||
const json = JSON.stringify({ cursor })
|
const json = JSON.stringify({ cursor })
|
||||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
|||||||
}
|
}
|
||||||
log.info("client connected to session", { id })
|
log.info("client connected to session", { id })
|
||||||
|
|
||||||
// Use ws.data as the unique key for this connection lifecycle.
|
const sub = key(ws)
|
||||||
// If ws.data is undefined, fallback to ws object.
|
|
||||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
|
||||||
|
|
||||||
// Optionally cleanup if the key somehow exists
|
session.subscribers.delete(sub)
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.set(sub, ws)
|
||||||
session.subscribers.set(connectionKey, ws)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.delete(sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = session.bufferCursor
|
const start = session.bufferCursor
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver } from "hono-openapi"
|
||||||
|
import { streamSSE } from "hono/streaming"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { AsyncQueue } from "../../util/queue"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
|
export const EventRoutes = lazy(() =>
|
||||||
|
new Hono().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()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
log.info("event connected")
|
||||||
|
c.header("X-Accel-Buffering", "no")
|
||||||
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const q = new AsyncQueue<string | null>()
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "server.connected",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "server.heartbeat",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const unsub = Bus.subscribeAll((event) => {
|
||||||
|
q.push(JSON.stringify(event))
|
||||||
|
if (event.type === Bus.InstanceDisposed.type) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsub()
|
||||||
|
q.push(null)
|
||||||
|
log.info("event disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.onAbort(stop)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of q) {
|
||||||
|
if (data === null) return
|
||||||
|
await stream.writeSSE({ data })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ import { streamSSE } from "hono/streaming"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { AsyncQueue } from "@/util/queue"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
@@ -69,41 +70,54 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
c.header("X-Accel-Buffering", "no")
|
c.header("X-Accel-Buffering", "no")
|
||||||
c.header("X-Content-Type-Options", "nosniff")
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
stream.writeSSE({
|
const q = new AsyncQueue<string | null>()
|
||||||
data: JSON.stringify({
|
let done = false
|
||||||
|
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
payload: {
|
payload: {
|
||||||
type: "server.connected",
|
type: "server.connected",
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
async function handler(event: any) {
|
|
||||||
await stream.writeSSE({
|
|
||||||
data: JSON.stringify(event),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
GlobalBus.on("event", handler)
|
|
||||||
|
|
||||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
stream.writeSSE({
|
q.push(
|
||||||
data: JSON.stringify({
|
JSON.stringify({
|
||||||
payload: {
|
payload: {
|
||||||
type: "server.heartbeat",
|
type: "server.heartbeat",
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
async function handler(event: any) {
|
||||||
stream.onAbort(() => {
|
q.push(JSON.stringify(event))
|
||||||
|
}
|
||||||
|
GlobalBus.on("event", handler)
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
GlobalBus.off("event", handler)
|
GlobalBus.off("event", handler)
|
||||||
resolve()
|
q.push(null)
|
||||||
log.info("global event disconnected")
|
log.info("event disconnected")
|
||||||
})
|
}
|
||||||
})
|
|
||||||
|
stream.onAbort(stop)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of q) {
|
||||||
|
if (data === null) return
|
||||||
|
await stream.writeSSE({ data })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const projects = await Project.list()
|
const projects = Project.list()
|
||||||
return c.json(projects)
|
return c.json(projects)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import { upgradeWebSocket } from "hono/bun"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
import { NotFoundError } from "../../storage/db"
|
import { NotFoundError } from "../../storage/db"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
|
||||||
|
|
||||||
export const PtyRoutes = lazy(() =>
|
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||||
new Hono()
|
return new Hono()
|
||||||
.get(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { streamSSE } from "hono/streaming"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { Log } from "../util/log"
|
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 { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
import { streamSSE } from "hono/streaming"
|
|
||||||
import { proxy } from "hono/proxy"
|
import { proxy } from "hono/proxy"
|
||||||
import { basicAuth } from "hono/basic-auth"
|
import { basicAuth } from "hono/basic-auth"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -28,16 +28,18 @@ import { ProviderID } from "../provider/schema"
|
|||||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ProjectRoutes } from "./routes/project"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { SessionRoutes } from "./routes/session"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
// import { PtyRoutes } from "./routes/pty"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { McpRoutes } from "./routes/mcp"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { FileRoutes } from "./routes/file"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
import { ConfigRoutes } from "./routes/config"
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
import { ExperimentalRoutes } from "./routes/experimental"
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
import { ProviderRoutes } from "./routes/provider"
|
||||||
|
import { EventRoutes } from "./routes/event"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import { websocket } from "hono/bun"
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
@@ -51,13 +53,20 @@ import { lazy } from "@/util/lazy"
|
|||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
|
export type Listener = {
|
||||||
|
hostname: string
|
||||||
|
port: number
|
||||||
|
url: URL
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = lazy(() => create({}).app)
|
||||||
|
|
||||||
|
function create(opts: { cors?: string[] }) {
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export const Default = lazy(() => createApp({}))
|
|
||||||
|
|
||||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
return app
|
const ws = createNodeWebSocket({ app })
|
||||||
|
const route = app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
error: err,
|
error: err,
|
||||||
@@ -243,7 +252,6 @@ export namespace Server {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes())
|
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
.route("/experimental", ExperimentalRoutes())
|
.route("/experimental", ExperimentalRoutes())
|
||||||
.route("/session", SessionRoutes())
|
.route("/session", SessionRoutes())
|
||||||
@@ -251,6 +259,7 @@ export namespace Server {
|
|||||||
.route("/question", QuestionRoutes())
|
.route("/question", QuestionRoutes())
|
||||||
.route("/provider", ProviderRoutes())
|
.route("/provider", ProviderRoutes())
|
||||||
.route("/", FileRoutes())
|
.route("/", FileRoutes())
|
||||||
|
.route("/", EventRoutes())
|
||||||
.route("/mcp", McpRoutes())
|
.route("/mcp", McpRoutes())
|
||||||
.route("/tui", TuiRoutes())
|
.route("/tui", TuiRoutes())
|
||||||
.post(
|
.post(
|
||||||
@@ -556,22 +565,12 @@ export namespace Server {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||||
const path = c.req.path
|
|
||||||
|
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
return {
|
||||||
...c.req,
|
app: route as Hono,
|
||||||
headers: {
|
ws,
|
||||||
...c.req.raw.headers,
|
}
|
||||||
host: "app.opencode.ai",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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() {
|
export async function openapi() {
|
||||||
@@ -589,52 +588,89 @@ export namespace Server {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated do not use this dumb shit */
|
|
||||||
export let url: URL
|
export let url: URL
|
||||||
|
|
||||||
export function listen(opts: {
|
export async function listen(opts: {
|
||||||
port: number
|
port: number
|
||||||
hostname: string
|
hostname: string
|
||||||
mdns?: boolean
|
mdns?: boolean
|
||||||
mdnsDomain?: string
|
mdnsDomain?: string
|
||||||
cors?: string[]
|
cors?: string[]
|
||||||
}) {
|
}): Promise<Listener> {
|
||||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
const log = Log.create({ service: "server" })
|
||||||
const app = createApp(opts)
|
const built = create({
|
||||||
const args = {
|
...opts,
|
||||||
hostname: opts.hostname,
|
})
|
||||||
idleTimeout: 0,
|
const start = (port: number) =>
|
||||||
fetch: app.fetch,
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
websocket: websocket,
|
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||||
} as const
|
built.ws.injectWebSocket(server)
|
||||||
const tryServe = (port: number) => {
|
const fail = (err: Error) => {
|
||||||
try {
|
cleanup()
|
||||||
return Bun.serve({ ...args, port })
|
reject(err)
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
const ready = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
}
|
}
|
||||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
const cleanup = () => {
|
||||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("http://localhost")
|
||||||
|
url.hostname = opts.hostname
|
||||||
|
url.port = String(addr.port)
|
||||||
|
Server.url = url
|
||||||
|
|
||||||
const shouldPublishMDNS =
|
const shouldPublishMDNS =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
server.port &&
|
addr.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (shouldPublishMDNS) {
|
if (shouldPublishMDNS) {
|
||||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalStop = server.stop.bind(server)
|
let closing: Promise<void> | undefined
|
||||||
server.stop = async (closeActiveConnections?: boolean) => {
|
return {
|
||||||
|
hostname: opts.hostname,
|
||||||
|
port: addr.port,
|
||||||
|
url,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
if (shouldPublishMDNS) MDNS.unpublish()
|
if (shouldPublishMDNS) MDNS.unpublish()
|
||||||
return originalStop(closeActiveConnections)
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
|
|||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { ProviderError } from "@/provider/error"
|
import { ProviderError } from "@/provider/error"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { type SystemError } from "bun"
|
import type { SystemError } from "bun"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { Flag } from "../flag/flag"
|
|||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { $ } from "bun"
|
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
@@ -48,6 +47,7 @@ import { iife } from "@/util/iife"
|
|||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { decodeDataUrl } from "@/util/data-url"
|
import { decodeDataUrl } from "@/util/data-url"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -1812,15 +1812,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
template = template + "\n\n" + input.arguments
|
template = template + "\n\n" + input.arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
const shell = ConfigMarkdown.shell(template)
|
const shellMatches = ConfigMarkdown.shell(template)
|
||||||
if (shell.length > 0) {
|
if (shellMatches.length > 0) {
|
||||||
|
const sh = Shell.preferred()
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
shell.map(async ([, cmd]) => {
|
shellMatches.map(async ([, cmd]) => {
|
||||||
try {
|
const out = await Process.text([cmd], { shell: sh, nothrow: true })
|
||||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
return out.text
|
||||||
} catch (error) {
|
|
||||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
let index = 0
|
let index = 0
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export namespace Skill {
|
|||||||
|
|
||||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||||
yield* Effect.promise(() => state.ensure())
|
yield* Effect.promise(() => state.ensure())
|
||||||
const list = Object.values(state.skills)
|
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
if (!agent) return list
|
if (!agent) return list
|
||||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Database as BunDatabase } from "bun:sqlite"
|
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||||
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||||
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||||
export * from "drizzle-orm"
|
export * from "drizzle-orm"
|
||||||
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||||
import * as schema from "./schema"
|
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
import { init } from "#db"
|
||||||
|
|
||||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||||
|
|
||||||
@@ -36,17 +35,12 @@ export namespace Database {
|
|||||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||||
})
|
})
|
||||||
|
|
||||||
type Schema = typeof schema
|
export type Transaction = SQLiteTransaction<"sync", void>
|
||||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
|
||||||
|
|
||||||
type Client = SQLiteBunDatabase
|
type Client = SQLiteBunDatabase
|
||||||
|
|
||||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||||
|
|
||||||
const state = {
|
|
||||||
sqlite: undefined as BunDatabase | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
function time(tag: string) {
|
function time(tag: string) {
|
||||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
||||||
if (!match) return 0
|
if (!match) return 0
|
||||||
@@ -83,17 +77,14 @@ export namespace Database {
|
|||||||
export const Client = lazy(() => {
|
export const Client = lazy(() => {
|
||||||
log.info("opening database", { path: Path })
|
log.info("opening database", { path: Path })
|
||||||
|
|
||||||
const sqlite = new BunDatabase(Path, { create: true })
|
const db = init(Path)
|
||||||
state.sqlite = sqlite
|
|
||||||
|
|
||||||
sqlite.run("PRAGMA journal_mode = WAL")
|
db.run("PRAGMA journal_mode = WAL")
|
||||||
sqlite.run("PRAGMA synchronous = NORMAL")
|
db.run("PRAGMA synchronous = NORMAL")
|
||||||
sqlite.run("PRAGMA busy_timeout = 5000")
|
db.run("PRAGMA busy_timeout = 5000")
|
||||||
sqlite.run("PRAGMA cache_size = -64000")
|
db.run("PRAGMA cache_size = -64000")
|
||||||
sqlite.run("PRAGMA foreign_keys = ON")
|
db.run("PRAGMA foreign_keys = ON")
|
||||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
|
|
||||||
const db = drizzle({ client: sqlite })
|
|
||||||
|
|
||||||
// Apply schema migrations
|
// Apply schema migrations
|
||||||
const entries =
|
const entries =
|
||||||
@@ -117,14 +108,11 @@ export namespace Database {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function close() {
|
export function close() {
|
||||||
const sqlite = state.sqlite
|
Client().$client.close()
|
||||||
if (!sqlite) return
|
|
||||||
sqlite.close()
|
|
||||||
state.sqlite = undefined
|
|
||||||
Client.reset()
|
Client.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
export type TxOrDb = Transaction | Client
|
||||||
|
|
||||||
const ctx = Context.create<{
|
const ctx = Context.create<{
|
||||||
tx: TxOrDb
|
tx: TxOrDb
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
|||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { Format } from "../format"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import DESCRIPTION from "./apply_patch.txt"
|
import DESCRIPTION from "./apply_patch.txt"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
@@ -221,7 +220,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (edited) {
|
if (edited) {
|
||||||
await Format.run(edited)
|
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: edited,
|
file: edited,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { File } from "../file"
|
|||||||
import { FileWatcher } from "../file/watcher"
|
import { FileWatcher } from "../file/watcher"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
import { Format } from "../format"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -72,7 +71,6 @@ export const EditTool = Tool.define("edit", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Filesystem.write(filePath, params.newString)
|
await Filesystem.write(filePath, params.newString)
|
||||||
await Format.run(filePath)
|
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
})
|
})
|
||||||
@@ -110,7 +108,6 @@ export const EditTool = Tool.define("edit", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Filesystem.write(filePath, contentNew)
|
await Filesystem.write(filePath, contentNew)
|
||||||
await Format.run(filePath)
|
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export namespace ToolRegistry {
|
|||||||
if (matches.length) await Config.waitForDependencies()
|
if (matches.length) await Config.waitForDependencies()
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const namespace = path.basename(match, path.extname(match))
|
const namespace = path.basename(match, path.extname(match))
|
||||||
const mod = await import(pathToFileURL(match).href)
|
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
|||||||
const accessibleAgents = caller
|
const accessibleAgents = caller
|
||||||
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||||
: agents
|
: agents
|
||||||
|
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
const description = DESCRIPTION.replace(
|
const description = DESCRIPTION.replace(
|
||||||
"{agents}",
|
"{agents}",
|
||||||
accessibleAgents
|
list
|
||||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Bus } from "../bus"
|
|||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
import { FileWatcher } from "../file/watcher"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
import { Format } from "../format"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
@@ -43,7 +42,6 @@ export const WriteTool = Tool.define("write", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Filesystem.write(filepath, params.content)
|
await Filesystem.write(filepath, params.content)
|
||||||
await Format.run(filepath)
|
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filepath,
|
file: filepath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export namespace Process {
|
|||||||
|
|
||||||
const proc = launch(cmd[0], cmd.slice(1), {
|
const proc = launch(cmd[0], cmd.slice(1), {
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
|
shell: opts.shell,
|
||||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||||
shell: opts.shell,
|
|
||||||
windowsHide: process.platform === "win32",
|
windowsHide: process.platform === "win32",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import whichPkg from "which"
|
import whichPkg from "which"
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "../global"
|
||||||
|
|
||||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
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, {
|
const result = whichPkg.sync(cmd, {
|
||||||
nothrow: true,
|
nothrow: true,
|
||||||
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
|
path: full,
|
||||||
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
||||||
})
|
})
|
||||||
return typeof result === "string" ? result : null
|
return typeof result === "string" ? result : null
|
||||||
|
|||||||
@@ -34,6 +34,26 @@ const encodeOrg = Schema.encodeSync(Org)
|
|||||||
|
|
||||||
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
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", () =>
|
it.effect("orgsByAccount groups orgs per account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
@@ -172,15 +192,6 @@ it.effect("config sends the selected org header", () =>
|
|||||||
|
|
||||||
it.effect("poll stores the account and first org on success", () =>
|
it.effect("poll stores the account and first org on success", () =>
|
||||||
Effect.gen(function* () {
|
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) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
req.url === "https://one.example.com/auth/device/token"
|
req.url === "https://one.example.com/auth/device/token"
|
||||||
@@ -198,7 +209,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")
|
expect(res._tag).toBe("PollSuccess")
|
||||||
if (res._tag === "PollSuccess") {
|
if (res._tag === "PollSuccess") {
|
||||||
@@ -215,3 +226,59 @@ 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")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -384,6 +384,32 @@ test("multiple custom agents can be defined", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Agent.list keeps the default agent first and sorts the rest by name", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
config: {
|
||||||
|
default_agent: "plan",
|
||||||
|
agent: {
|
||||||
|
zebra: {
|
||||||
|
description: "Zebra",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
description: "Alpha",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const names = (await Agent.list()).map((a) => a.name)
|
||||||
|
expect(names[0]).toBe("plan")
|
||||||
|
expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("Agent.get returns undefined for non-existent agent", async () => {
|
test("Agent.get returns undefined for non-existent agent", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import { Deferred, Effect, Stream } from "effect"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "../../src/bus"
|
|
||||||
import { BusEvent } from "../../src/bus/bus-event"
|
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test event definitions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TestEvent = {
|
|
||||||
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
|
|
||||||
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
|
||||||
return Instance.provide({ directory, fn })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("Bus", () => {
|
|
||||||
afterEach(() => Instance.disposeAll())
|
|
||||||
|
|
||||||
describe("publish + subscribe", () => {
|
|
||||||
test("subscriber receives matching events", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const received: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
|
||||||
received.push(evt.properties.value)
|
|
||||||
})
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 99 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(received).toEqual([42, 99])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscriber does not receive events of other types", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const pings: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
|
||||||
pings.push(evt.properties.value)
|
|
||||||
})
|
|
||||||
await Bus.publish(TestEvent.Pong, { message: "hello" })
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(pings).toEqual([1])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("publish with no subscribers does not throw", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("multiple subscribers", () => {
|
|
||||||
test("all subscribers for same event type are called", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const a: number[] = []
|
|
||||||
const b: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 7 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(a).toEqual([7])
|
|
||||||
expect(b).toEqual([7])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribers are called in registration order", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const order: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, () => order.push("first"))
|
|
||||||
Bus.subscribe(TestEvent.Ping, () => order.push("second"))
|
|
||||||
Bus.subscribe(TestEvent.Ping, () => order.push("third"))
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(order).toEqual(["first", "second", "third"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("unsubscribe", () => {
|
|
||||||
test("unsubscribe stops delivery", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const received: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
|
|
||||||
received.push(evt.properties.value)
|
|
||||||
})
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
unsub()
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(received).toEqual([1])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unsubscribe is idempotent", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
const unsub = Bus.subscribe(TestEvent.Ping, () => {})
|
|
||||||
unsub()
|
|
||||||
unsub() // should not throw
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unsubscribing one does not affect others", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const a: number[] = []
|
|
||||||
const b: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
const unsubA = Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
unsubA()
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(a).toEqual([1])
|
|
||||||
expect(b).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("subscribeAll", () => {
|
|
||||||
test("receives events of all types", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const all: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribeAll((evt) => {
|
|
||||||
all.push(evt.type)
|
|
||||||
})
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
await Bus.publish(TestEvent.Pong, { message: "hi" })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(all).toEqual(["test.ping", "test.pong"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribeAll + typed subscribe both fire", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const typed: number[] = []
|
|
||||||
const wild: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => typed.push(evt.properties.value))
|
|
||||||
Bus.subscribeAll((evt) => wild.push(evt.type))
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 5 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(typed).toEqual([5])
|
|
||||||
expect(wild).toEqual(["test.ping"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unsubscribe from subscribeAll", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const all: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
const unsub = Bus.subscribeAll((evt) => all.push(evt.type))
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
unsub()
|
|
||||||
await Bus.publish(TestEvent.Pong, { message: "missed" })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(all).toEqual(["test.ping"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribeAll delivers InstanceDisposed on disposal", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const all: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribeAll((evt) => {
|
|
||||||
all.push(evt.type)
|
|
||||||
})
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.disposeAll()
|
|
||||||
|
|
||||||
expect(all).toContain("test.ping")
|
|
||||||
expect(all).toContain(Bus.InstanceDisposed.type)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("manual unsubscribe suppresses InstanceDisposed", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const all: string[] = []
|
|
||||||
let unsub = () => {}
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
unsub = Bus.subscribeAll((evt) => {
|
|
||||||
all.push(evt.type)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
unsub()
|
|
||||||
await Instance.disposeAll()
|
|
||||||
|
|
||||||
expect(all).not.toContain(Bus.InstanceDisposed.type)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("GlobalBus forwarding", () => {
|
|
||||||
test("publish emits to GlobalBus with directory", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const globalEvents: Array<{ directory?: string; payload: any }> = []
|
|
||||||
|
|
||||||
const handler = (evt: any) => globalEvents.push(evt)
|
|
||||||
GlobalBus.on("event", handler)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const ping = globalEvents.find((e) => e.payload.type === "test.ping")
|
|
||||||
expect(ping).toBeDefined()
|
|
||||||
expect(ping!.directory).toBe(tmp.path)
|
|
||||||
expect(ping!.payload).toEqual({
|
|
||||||
type: "test.ping",
|
|
||||||
properties: { value: 42 },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
GlobalBus.off("event", handler)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("instance isolation", () => {
|
|
||||||
test("subscribers in one instance do not receive events from another", async () => {
|
|
||||||
await using tmpA = await tmpdir()
|
|
||||||
await using tmpB = await tmpdir()
|
|
||||||
const eventsA: number[] = []
|
|
||||||
const eventsB: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmpA.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => eventsA.push(evt.properties.value))
|
|
||||||
})
|
|
||||||
|
|
||||||
await withInstance(tmpB.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, (evt) => eventsB.push(evt.properties.value))
|
|
||||||
})
|
|
||||||
|
|
||||||
await withInstance(tmpA.path, async () => {
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
await withInstance(tmpB.path, async () => {
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(eventsA).toEqual([1])
|
|
||||||
expect(eventsB).toEqual([2])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe("async subscribers", () => {
|
|
||||||
test("publish is fire-and-forget (does not await subscriber callbacks)", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const received: number[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, async () => {
|
|
||||||
Bus.subscribe(TestEvent.Ping, async (evt) => {
|
|
||||||
await new Promise((r) => setTimeout(r, 10))
|
|
||||||
received.push(evt.properties.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
// Give the async subscriber time to complete
|
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(received).toEqual([1])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Effect service", () => {
|
|
||||||
test("subscribeAll stream receives published events", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const received: string[] = []
|
|
||||||
|
|
||||||
await withInstance(tmp.path, () =>
|
|
||||||
Effect.runPromise(
|
|
||||||
Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const svc = yield* Bus.Service
|
|
||||||
const done = yield* Deferred.make<void>()
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
yield* Effect.forkScoped(
|
|
||||||
svc.subscribeAll().pipe(
|
|
||||||
Stream.runForEach((msg) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
received.push(msg.type)
|
|
||||||
if (++count >= 2) yield* Deferred.succeed(done, undefined)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Let the forked fiber start and subscribe to the PubSub
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
|
|
||||||
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
yield* svc.publish(TestEvent.Pong, { message: "hi" })
|
|
||||||
yield* Deferred.await(done)
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(Bus.layer)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(received).toEqual(["test.ping", "test.pong"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribeAll stream ends with ensuring when scope closes", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
let ensuringFired = false
|
|
||||||
|
|
||||||
await withInstance(tmp.path, () =>
|
|
||||||
Effect.runPromise(
|
|
||||||
Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const svc = yield* Bus.Service
|
|
||||||
|
|
||||||
yield* Effect.forkScoped(
|
|
||||||
svc.subscribeAll().pipe(
|
|
||||||
Stream.runForEach(() => Effect.void),
|
|
||||||
Effect.ensuring(Effect.sync(() => {
|
|
||||||
ensuringFired = true
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(Bus.layer)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(ensuringFired).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -5,9 +5,9 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Option } from "effect"
|
import { Deferred, Effect, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
import { Bus } from "../../src/bus"
|
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
|
|
||||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -16,6 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
@@ -35,17 +36,22 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
function on(evt: BusUpdate) {
|
||||||
if (done) return
|
if (done) return
|
||||||
if (!check(evt.properties)) return
|
if (evt.directory !== directory) return
|
||||||
hit(evt.properties)
|
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||||
})
|
if (!check(evt.payload.properties)) return
|
||||||
|
hit(evt.payload.properties)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
function cleanup() {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
unsub()
|
GlobalBus.off("event", on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalBus.on("event", on)
|
||||||
|
return cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
describe("session.system", () => {
|
||||||
|
test("skills output is sorted by name and stable across calls", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
git: true,
|
||||||
|
init: async (dir) => {
|
||||||
|
for (const [name, description] of [
|
||||||
|
["zeta-skill", "Zeta skill."],
|
||||||
|
["alpha-skill", "Alpha skill."],
|
||||||
|
["middle-skill", "Middle skill."],
|
||||||
|
]) {
|
||||||
|
const skillDir = path.join(dir, ".opencode", "skill", name)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(skillDir, "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const home = process.env.OPENCODE_TEST_HOME
|
||||||
|
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const build = await Agent.get("build")
|
||||||
|
const first = await SystemPrompt.skills(build!)
|
||||||
|
const second = await SystemPrompt.skills(build!)
|
||||||
|
|
||||||
|
expect(first).toBe(second)
|
||||||
|
|
||||||
|
const alpha = first!.indexOf("<name>alpha-skill</name>")
|
||||||
|
const middle = first!.indexOf("<name>middle-skill</name>")
|
||||||
|
const zeta = first!.indexOf("<name>zeta-skill</name>")
|
||||||
|
|
||||||
|
expect(alpha).toBeGreaterThan(-1)
|
||||||
|
expect(middle).toBeGreaterThan(alpha)
|
||||||
|
expect(zeta).toBeGreaterThan(middle)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
process.env.OPENCODE_TEST_HOME = home
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,56 @@ description: Skill for tool tests.
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("description sorts skills by name and is stable across calls", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
git: true,
|
||||||
|
init: async (dir) => {
|
||||||
|
for (const [name, description] of [
|
||||||
|
["zeta-skill", "Zeta skill."],
|
||||||
|
["alpha-skill", "Alpha skill."],
|
||||||
|
["middle-skill", "Middle skill."],
|
||||||
|
]) {
|
||||||
|
const skillDir = path.join(dir, ".opencode", "skill", name)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(skillDir, "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const home = process.env.OPENCODE_TEST_HOME
|
||||||
|
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const first = await SkillTool.init()
|
||||||
|
const second = await SkillTool.init()
|
||||||
|
|
||||||
|
expect(first.description).toBe(second.description)
|
||||||
|
|
||||||
|
const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
|
||||||
|
const middle = first.description.indexOf("**middle-skill**: Middle skill.")
|
||||||
|
const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
|
||||||
|
|
||||||
|
expect(alpha).toBeGreaterThan(-1)
|
||||||
|
expect(middle).toBeGreaterThan(alpha)
|
||||||
|
expect(zeta).toBeGreaterThan(middle)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
process.env.OPENCODE_TEST_HOME = home
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("execute returns skill content block with files", async () => {
|
test("execute returns skill content block with files", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
git: true,
|
git: true,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { TaskTool } from "../../src/tool/task"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
describe("tool.task", () => {
|
||||||
|
test("description sorts subagents by name and is stable across calls", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
config: {
|
||||||
|
agent: {
|
||||||
|
zebra: {
|
||||||
|
description: "Zebra agent",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
description: "Alpha agent",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const build = await Agent.get("build")
|
||||||
|
const first = await TaskTool.init({ agent: build })
|
||||||
|
const second = await TaskTool.init({ agent: build })
|
||||||
|
|
||||||
|
expect(first.description).toBe(second.description)
|
||||||
|
|
||||||
|
const alpha = first.description.indexOf("- alpha: Alpha agent")
|
||||||
|
const explore = first.description.indexOf("- explore:")
|
||||||
|
const general = first.description.indexOf("- general:")
|
||||||
|
const zebra = first.description.indexOf("- zebra: Zebra agent")
|
||||||
|
|
||||||
|
expect(alpha).toBeGreaterThan(-1)
|
||||||
|
expect(explore).toBeGreaterThan(alpha)
|
||||||
|
expect(general).toBeGreaterThan(explore)
|
||||||
|
expect(zebra).toBeGreaterThan(general)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2845,6 +2845,38 @@ export class File extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Event extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Subscribe to events
|
||||||
|
*
|
||||||
|
* Get events
|
||||||
|
*/
|
||||||
|
public subscribe<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/event",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Auth2 extends HeyApiClient {
|
export class Auth2 extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Remove MCP OAuth
|
* Remove MCP OAuth
|
||||||
@@ -3866,38 +3898,6 @@ export class Formatter extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Event extends HeyApiClient {
|
|
||||||
/**
|
|
||||||
* Subscribe to events
|
|
||||||
*
|
|
||||||
* Get events
|
|
||||||
*/
|
|
||||||
public subscribe<ThrowOnError extends boolean = false>(
|
|
||||||
parameters?: {
|
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
|
||||||
},
|
|
||||||
options?: Options<never, ThrowOnError>,
|
|
||||||
) {
|
|
||||||
const params = buildClientParams(
|
|
||||||
[parameters],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
args: [
|
|
||||||
{ in: "query", key: "directory" },
|
|
||||||
{ in: "query", key: "workspace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
|
|
||||||
url: "/event",
|
|
||||||
...options,
|
|
||||||
...params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpencodeClient extends HeyApiClient {
|
export class OpencodeClient extends HeyApiClient {
|
||||||
public static readonly __registry = new HeyApiRegistry<OpencodeClient>()
|
public static readonly __registry = new HeyApiRegistry<OpencodeClient>()
|
||||||
|
|
||||||
@@ -3981,6 +3981,11 @@ export class OpencodeClient extends HeyApiClient {
|
|||||||
return (this._file ??= new File({ client: this.client }))
|
return (this._file ??= new File({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _event?: Event
|
||||||
|
get event(): Event {
|
||||||
|
return (this._event ??= new Event({ client: this.client }))
|
||||||
|
}
|
||||||
|
|
||||||
private _mcp?: Mcp
|
private _mcp?: Mcp
|
||||||
get mcp(): Mcp {
|
get mcp(): Mcp {
|
||||||
return (this._mcp ??= new Mcp({ client: this.client }))
|
return (this._mcp ??= new Mcp({ client: this.client }))
|
||||||
@@ -4025,9 +4030,4 @@ export class OpencodeClient extends HeyApiClient {
|
|||||||
get formatter(): Formatter {
|
get formatter(): Formatter {
|
||||||
return (this._formatter ??= new Formatter({ client: this.client }))
|
return (this._formatter ??= new Formatter({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
private _event?: Event
|
|
||||||
get event(): Event {
|
|
||||||
return (this._event ??= new Event({ client: this.client }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4229,6 +4229,25 @@ export type FileStatusResponses = {
|
|||||||
|
|
||||||
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
|
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
|
||||||
|
|
||||||
|
export type EventSubscribeData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/event"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSubscribeResponses = {
|
||||||
|
/**
|
||||||
|
* Event stream
|
||||||
|
*/
|
||||||
|
200: Event
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
|
||||||
|
|
||||||
export type McpStatusData = {
|
export type McpStatusData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
@@ -4979,22 +4998,3 @@ export type FormatterStatusResponses = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
|
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
|
||||||
|
|
||||||
export type EventSubscribeData = {
|
|
||||||
body?: never
|
|
||||||
path?: never
|
|
||||||
query?: {
|
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
|
||||||
}
|
|
||||||
url: "/event"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventSubscribeResponses = {
|
|
||||||
/**
|
|
||||||
* Event stream
|
|
||||||
*/
|
|
||||||
200: Event
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
|
|
||||||
|
|||||||
+41
-41
@@ -5243,6 +5243,47 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/event": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "event.subscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "directory",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "workspace",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Subscribe to events",
|
||||||
|
"description": "Get events",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Event stream",
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Event"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-codeSamples": [
|
||||||
|
{
|
||||||
|
"lang": "js",
|
||||||
|
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/mcp": {
|
"/mcp": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "mcp.status",
|
"operationId": "mcp.status",
|
||||||
@@ -6894,47 +6935,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"/event": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "event.subscribe",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "directory",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "workspace",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "Subscribe to events",
|
|
||||||
"description": "Get events",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Event stream",
|
|
||||||
"content": {
|
|
||||||
"text/event-stream": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Event"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-codeSamples": [
|
|
||||||
{
|
|
||||||
"lang": "js",
|
|
||||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
|||||||
Vendored
+1
@@ -171,6 +171,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user