Compare commits

..
Author SHA1 Message Date
Kit Langton 6ff4da5639 Merge branch 'dev' into kit/effectify-command 2026-03-20 15:14:01 -04:00
Kit LangtonandGitHub 24f9df5463 fix: update stale account url/email on re-login (#18426) 2026-03-20 14:50:01 -04:00
opencode-agent[bot] 12b8e1c2be chore: generate 2026-03-20 18:38:08 +00:00
Kit LangtonandGitHub d70099b059 fix: apply Layer.fresh at instance service definition site (#18418) 2026-03-20 14:37:12 -04:00
opencode-agent[bot] ce845a0b1b chore: update nix node_modules hashes 2026-03-20 18:16:17 +00:00
Vladimir GlafirovandGitHub 05d3e65f76 feat: enable GitLab Agent Platform with workflow model discovery (#18014) 2026-03-20 12:55:22 -05:00
opencode-agent[bot] 51618e9cef chore: generate 2026-03-20 16:11:21 +00:00
Kit LangtonandGitHub e78944e9a4 effectify Installation service, drop Effect suffix from namespaces (#18266) 2026-03-20 12:10:33 -04:00
Aiden ClineandGitHub bfdc38e421 tweak: adjust codex plugin logic so that codex instruction isn't always added (oauth plan no longer enforces instruction whitelisting) (#18337) 2026-03-20 10:37:47 -05:00
83023e4f0f docs: add Cloudflare Workers AI provider (#18322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-20 10:10:22 -05:00
Brendan AllanandGitHub d0a57305ef app: file type filter on desktop + multiple files (#18403) 2026-03-20 15:02:07 +00:00
Shoubhit DashandGitHub 27a70ad70f fix(app): align review file comments with diff comments (#18406) 2026-03-20 14:44:18 +00:00
Kit LangtonandGitHub b6e94fd2a8 Merge branch 'dev' into kit/effectify-command 2026-03-20 09:17:34 -04:00
Luke ParkerandGitHub 0bbf26a1ce deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot] 83cdb4de64 chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan AllanandGitHub 4989632245 patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke ParkerandGitHub d460614cd7 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke ParkerandGitHub 7866dbcfcc fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot] e71a21e0a8 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
DaxandGitHub 1071aca91f fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
b3d0446d13 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
opencode-agent[bot] 949191ab74 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
DaxandGitHub 92cd908fb5 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
DaxandGitHub 6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
DaxandGitHub 52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Kit LangtonandGitHub 3d5c041129 Merge branch 'dev' into kit/effectify-command 2026-03-19 21:16:40 -04:00
DaxandGitHub 37b8662a9d refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
DaxandGitHub ddcb32ae0b refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Kit LangtonandGitHub 3af557512b Merge branch 'dev' into kit/effectify-command 2026-03-19 19:27:53 -04:00
Frank 2c056c90da doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
DaxandGitHub 812d1bb32a chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9a58c43ef4 go: upi translation 2026-03-19 18:54:32 -04:00
DaxandGitHub 63585db6a7 refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank bd44489ada go: upi payment 2026-03-19 18:44:24 -04:00
Kit Langton b53a95fd81 log errors in catchCause instead of silently swallowing 2026-03-19 16:23:07 -04:00
opencode-agent[bot] a6ef9e9206 chore: generate 2026-03-19 20:21:10 +00:00
Kit LangtonandGitHub 6e09a1d904 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot] 4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
2dbcd79fd2 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
Kit Langton 8e11a46fe0 use forkScoped + Fiber.join for lazy init (match old Instance.state behavior) 2026-03-19 16:03:38 -04:00
James LongandGitHub 48a7f0fd93 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
Kit Langton 8ab4d84057 handle undefined command in session prompt 2026-03-19 15:17:00 -04:00
Kit Langton 4066247988 effectify Command service: migrate from Instance.state to Effect service pattern 2026-03-19 15:14:54 -04:00
Kit Langton b9de3ad370 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-19 15:12:21 -04:00
James LongandGitHub d69962b0f7 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] a6f23cb08e chore: generate 2026-03-19 17:52:50 +00:00
James LongandGitHub 0540751897 fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
172 changed files with 5340 additions and 3897 deletions
+22 -6
View File
@@ -50,20 +50,17 @@ jobs:
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
settings: settings:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows - name: windows
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env: env:
PLAYWRIGHT_BROWSERS_PATH: 0 PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -76,9 +73,28 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install Playwright browsers - name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app working-directory: packages/app
run: ${{ matrix.settings.playwright }} run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests - name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
+5 -3
View File
@@ -1,4 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
package-lock.json bun.lock
.gitignore
package-lock.json
+1 -1
View File
@@ -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.
+10 -3
View File
@@ -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),
-10
View File
@@ -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.
+6 -3
View File
@@ -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[]])
-6
View File
@@ -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.
+30 -18
View File
@@ -325,8 +325,6 @@
"@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:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@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",
@@ -355,9 +353,10 @@
"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",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -368,6 +367,7 @@
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
@@ -409,8 +409,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",
@@ -586,6 +586,8 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
}, },
"overrides": { "overrides": {
@@ -616,8 +618,8 @@
"ai": "5.0.124", "ai": "5.0.124",
"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",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
@@ -1108,10 +1110,6 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -2736,9 +2734,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3020,6 +3018,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3028,6 +3028,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3780,6 +3782,8 @@
"opencode": ["opencode@workspace:packages/opencode"], "opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -4108,6 +4112,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -4240,7 +4246,7 @@
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
@@ -5054,10 +5060,6 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -5386,6 +5388,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -5452,6 +5456,10 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5528,6 +5536,8 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6278,6 +6288,8 @@
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+1
View File
@@ -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,
}, },
}) })
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=", "x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=", "aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=", "aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w=" "x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
} }
} }
+5 -3
View File
@@ -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",
@@ -112,6 +112,8 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
} }
+145 -7
View File
@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector, projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
.evaluate(() => { .evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "isSidebarClosed")
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true" return (await button.getAttribute("aria-expanded")) !== "true"
} }
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+B`) await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) { export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "openSidebar")
await button.click() await button.click()
const opened = await expect(button) const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "closeSidebar")
await button.click() await button.click()
const closed = await expect(button) const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page) await defocus(page)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click() await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
return dialog return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() { export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n") await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
} }
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) { export async function waitSlug(page: Page, skip: string[] = []) {
let prev = "" let prev = ""
let next = "" let next = ""
await expect await expect
.poll( .poll(
() => { async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
if (skip.includes(slug)) return "" if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
return resolveSlug(slug) return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
} }
export async function openProjectMenu(page: Page, projectSlug: string) { export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger const clicked = await trigger
.click({ timeout: 1500 }) .click({ force: true, timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
+39 -5
View File
@@ -1,7 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import {
import { promptSelector } from "./selectors" healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [ directory: [
async ({}, use) => { async ({}, use) => {
const directory = await getWorktree() const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID })
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use(async (callback, options) => {
const root = await createTestProject() const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>() const sessions = new Map<string, string>()
const dirs = new Set<string>() const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra }) await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try { try {
await gotoSession() await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally { } finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
) )
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(root)
setHealthPhase(page, "test")
} }
}) })
}, },
@@ -1,5 +1,4 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import {
defocus, defocus,
@@ -7,43 +6,14 @@ import {
cleanupTestProject, cleanupTestProject,
openSidebar, openSidebar,
sessionIDFromUrl, sessionIDFromUrl,
waitDir, setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await workspaces(page, directory, true) await setWorkspacesEnabled(page, slug, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
await waitSlug(page) await waitSession(page, { directory: space })
await waitDir(page, space)
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space) trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first() const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click({ force: true })
await waitDir(page, space) await waitSession(page, { directory: space, sessionID: created })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
@@ -1,6 +1,15 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
.poll(
async () => {
const row = page.locator(item(space)).first()
try {
await row.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
await expect(next).toBeVisible() await expect(next).toBeVisible()
await next.click({ force: true }) await next.click({ force: true })
return waitDir(page, space.directory) await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
} }
async function createSessionFromWorkspace( async function createSessionFromWorkspace(
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
space: { slug: string; raw: string; directory: string }, space: { slug: string; raw: string; directory: string },
text: string, text: string,
) { ) {
const next = await openWorkspaceNewSession(page, space) await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text) await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await page.keyboard.press("Enter")
await prompt.press("Enter")
await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next.slug }
}
async function sessionDirectory(directory: string, sessionID: string) { await waitSessionSaved(space.directory, sessionID)
const info = await createSdk(directory) await createSdk(space.directory)
.session.get({ sessionID }) .session.abort({ sessionID })
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!info) return "" return sessionID
return info.directory
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, root, true)
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(firstSession.sessionID, first.directory) trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
}) })
}) })
@@ -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"
@@ -1,6 +1,14 @@
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> { async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => { return page.evaluate(() => {
const win = window as Window & { const win = window as Window & {
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
}) })
} }
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> { async function read(page: Page): Promise<Footer> {
return { return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()), agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) { async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID })
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
} }
async function submit(page: Page, value: string) { async function submit(page: Page, value: string) {
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen])) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await waitSession(page, { directory: next.directory })
return next return next
} }
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
await button.click({ force: true }) await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page)) const next = await resolveSlug(await waitSlug(page))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) return waitSession(page, { directory: next.directory }).then((item) => item.directory)
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
} }
test("session model and variant restore per session without leaking into new sessions", async ({ test("session model and variant restore per session without leaking into new sessions", async ({
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first) await waitUser(directory, first)
await page.reload() await page.reload()
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState) await waitFooter(page, firstState)
await gotoSession() await gotoSession()
@@ -169,6 +169,70 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
} }
} }
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async (
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
+7 -2
View File
@@ -1383,11 +1383,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] const list = e.currentTarget.files
if (file) void addAttachment(file) if (list) {
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
@@ -1,4 +1,6 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
"application/yaml", "application/yaml",
]) ])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096 const SAMPLE = 4096
function kind(type: string) { function kind(type: string) {
+89
View File
@@ -0,0 +1,89 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
+1
View File
@@ -378,6 +378,7 @@ function createGlobalSync() {
return globalStore.error return globalStore.error
}, },
child: children.child, child: children.child,
peek: children.peek,
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
return childStore return childStore
} }
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory) const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
children, children,
ensureChild, ensureChild,
child, child,
peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
mark, mark,
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }
+1
View File
@@ -1,4 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
+9 -1
View File
@@ -1,11 +1,12 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js" import { Component, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = { export type InitError = {
name: string name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined, actionError: undefined as string | undefined,
}) })
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
+27 -20
View File
@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark", dark: "theme.scheme.dark",
} }
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => decode64(params.dir) ?? "") const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (directory === currentDir() && props.sessionID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active return expanded || active
}) })
}) })
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined, preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
}) })
} }
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
}) })
createEffect(() => { createEffect(() => {
params.dir route()
globalSDK.url globalSDK.url
prefetchToken.value += 1 prefetchToken.value += 1
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
createEffect( createEffect(
on( on(
() => { () => {
const dir = params.dir return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
}, },
([ready, dir, id, root, directory, resolved]) => { ([ready, slug, id, root, dir]) => {
if (!ready || !dir || !directory) { if (!ready || !slug || !dir) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = "" activeRoute.directory = ""
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
return return
} }
const next = resolved || directory const session = `${slug}/${id}`
const session = `${dir}/${id}`
if (!root) { if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.directory = next activeRoute.directory = dir
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
return return
} }
if (server.projects.last() !== root) server.projects.touch(root) if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || next !== activeRoute.directory const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) { if (changed) {
activeRoute.session = session activeRoute.session = session
activeRoute.directory = next activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(next, id, root) activeRoute.sessionProject = syncSessionRoute(dir, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.directory = next activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(next, id, root) activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
}, },
), ),
) )
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = { const projectSidebarCtx: ProjectSidebarContext = {
currentDir, currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
+2 -2
View File
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0] stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>( export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined> | undefined,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
return Object.values(request).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[] | undefined) => { export const childMapByParent = (sessions: Session[] | undefined) => {
@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const selected = createMemo( const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions } from "./helpers" import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session)) const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => props.ctx.currentDir() === props.directory) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory) const name = branch ?? getFilename(props.directory)
@@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) {
onDelete={controls.remove} onDelete={controls.remove}
/> />
), ),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
}) })
createEffect(() => { createEffect(() => {
@@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) {
commentsUi.onLineSelectionEnd(range) commentsUi.onLineSelectionEnd(range)
}} }}
search={search} search={search}
overflow="scroll"
class="select-text" class="select-text"
media={{ media={{
mode: "auto", mode: "auto",
@@ -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;
} }
} }
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 向けにダウンロード",
+2
View File
@@ -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 다운로드",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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)))
await tx // deduct balance only for top up
.update(BillingTable) if (!payment.enrichment?.type) {
.set({ await tx
balance: sql`${BillingTable.balance} - ${amount}`, .update(BillingTable)
}) .set({
.where(eq(BillingTable.workspaceID, workspaceID)) balance: sql`${BillingTable.balance} - ${payment.amount}`,
})
.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,22 +81,25 @@ 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(
"use server" async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
return json( "use server"
await withActor( return json(
() => await withActor(
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl }) () =>
.then((data) => ({ error: undefined, data })) Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.catch((e) => ({ .then((data) => ({ error: undefined, data }))
error: e.message as string, .catch((e) => ({
data: undefined, error: e.message as string,
})), data: undefined,
workspaceID, })),
), workspaceID,
{ 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>
<button <div data-slot="subscribe-actions">
data-slot="subscribe-button" <button
data-color="primary" data-slot="subscribe-button"
disabled={checkoutSubmission.pending || store.redirecting} data-color="primary"
onClick={onClickSubscribe} disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe()}
>
{store.loading === "checkout"
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<button
type="button"
data-slot="other-methods"
data-color="ghost"
onClick={() => setStore("showModal", true)}
>
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
<span data-slot="other-methods-icons">
<span> </span>
<IconAlipay style={{ width: "16px", height: "16px" }} />
<span> </span>
<IconUpi style={{ width: "auto", height: "10px" }} />
</span>
</button>
</div>
<Modal
open={store.showModal}
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
> >
{checkoutSubmission.pending || store.redirecting <div data-slot="modal-actions">
? i18n.t("workspace.lite.promo.subscribing") <button
: i18n.t("workspace.lite.promo.subscribe")} type="button"
</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>
</> </>
+95 -30
View File
@@ -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,38 +251,102 @@ 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 = () =>
mode: "subscription", Billing.stripe().checkout.sessions.create({
billing_address_collection: "required", mode: "subscription",
line_items: [{ price: LiteData.priceID(), quantity: 1 }], discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }], ...(billing.customerID
...(billing.customerID ? {
? { customer: billing.customerID,
customer: billing.customerID, customer_update: {
customer_update: { name: "auto",
name: "auto", address: "auto",
address: "auto", },
}, }
: {
customer_email: email!,
}),
...(() => {
if (method === "alipay") {
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
payment_method_types: ["alipay"],
adaptive_pricing: {
enabled: false,
},
}
} }
: { if (method === "upi") {
customer_email: email!, return {
}), line_items: [
currency: "usd", {
tax_id_collection: { price_data: {
enabled: true, currency: "inr",
}, product: LiteData.productID(),
success_url: successUrl, recurring: {
cancel_url: cancelUrl, interval: "month",
subscription_data: { interval_count: 1,
metadata: { },
workspaceID: Actor.workspace(), unit_amount: LiteData.priceInr(),
userID: user.properties.userID, },
type: "lite", quantity: 1,
},
],
payment_method_types: ["upi"] as any,
adaptive_pricing: {
enabled: false,
},
}
}
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
billing_address_collection: "required",
}
})(),
tax_id_collection: {
enabled: true,
}, },
}, success_url: successUrl,
}) cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
},
})
return session.url try {
const session = await createSession()
return session.url
} catch (e: any) {
if (
e.type !== "StripeInvalidRequestError" ||
!e.message.includes("You cannot combine currencies on a single customer")
)
throw e
// get pending payment intent
const intents = await Billing.stripe().paymentIntents.search({
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
})
if (intents.data.length === 0) throw e
for (const intent of intents.data) {
// get checkout session
const sessions = await Billing.stripe().checkout.sessions.list({
customer: billing.customerID!,
payment_intent: intent.id,
})
// delete pending payment intent
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
}
const session = await createSession()
return session.url
}
}, },
) )
+1
View File
@@ -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
} }
| { | {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
} }
+10 -1
View File
@@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar } from "./windows" import { setTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
installCli: () => Promise<string> installCli: () => Promise<string>
@@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle( ipcMain.handle(
"open-file-picker", "open-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { async (
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file", title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
}) })
if (result.canceled) return null if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0] return opts?.multiple ? result.filePaths : result.filePaths[0]
@@ -50,6 +50,8 @@ export type ElectronAPI = {
multiple?: boolean multiple?: boolean
title?: string title?: string
defaultPath?: string defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<string | string[] | null> }) => Promise<string | string[] | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null> saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void openLink: (url: string) => void
@@ -1,6 +1,8 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@@ -111,6 +113,8 @@ const createPlatform = (): Platform => {
const result = await window.api.openFilePicker({ const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },
+3
View File
@@ -1,6 +1,8 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
filePickerFilters,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@@ -98,6 +100,7 @@ const createPlatform = (): Platform => {
directory: false, directory: false,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },
+1
View File
@@ -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
View File
@@ -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"
} }
+14 -7
View File
@@ -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,9 @@
"@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",
"@gitlab/gitlab-ai-provider": "3.6.0", "gitlab-ai-provider": "5.2.2",
"@gitlab/opencode-gitlab-auth": "1.3.3", "opencode-gitlab-auth": "2.0.0",
"@effect/platform-node": "catalog:",
"@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 +105,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 +120,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 +151,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:"
} }
} }
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")
+1 -1
View File
@@ -132,7 +132,7 @@ Still open and likely worth migrating:
- [ ] `Worktree` - [ ] `Worktree`
- [ ] `Installation` - [ ] `Installation`
- [ ] `Bus` - [ ] `Bus`
- [ ] `Command` - [x] `Command`
- [ ] `Config` - [ ] `Config`
- [ ] `Session` - [ ] `Session`
- [ ] `SessionProcessor` - [ ] `SessionProcessor`
+33 -7
View File
@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
import { import {
type AccountError, type AccountError,
AccessToken, AccessToken,
Account,
AccountID, AccountID,
DeviceCode, DeviceCode,
Info,
RefreshToken, RefreshToken,
AccountServiceError, AccountServiceError,
Login, Login,
@@ -24,10 +24,30 @@ import {
UserCode, UserCode,
} from "./schema" } from "./schema"
export * from "./schema" export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = { export type AccountOrgs = {
account: Account account: Info
orgs: readonly Org[] orgs: readonly Org[]
} }
@@ -108,10 +128,10 @@ const mapAccountServiceError =
), ),
) )
export namespace AccountEffect { export namespace Account {
export interface Interface { export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError> readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError> readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
@@ -148,6 +168,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 +316,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)(
+8 -15
View File
@@ -1,31 +1,24 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountEffect,
OrgID,
} from "./effect"
export { AccessToken, AccountID, OrgID } from "./effect" export { AccessToken, AccountID, OrgID } from "./effect"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f)) return runtime.runSync(S.Service.use(f))
} }
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f)) return runtime.runPromise(S.Service.use(f))
} }
export namespace Account { export namespace Account {
export const Account = AccountSchema export const Info = Model
export type Account = AccountSchema export type Info = Model
export function active(): Account | undefined { export function active(): Info | undefined {
return Option.getOrUndefined(runSync((service) => service.active())) return Option.getOrUndefined(runSync((service) => service.active()))
} }
+6 -4
View File
@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"] export type AccountRow = (typeof AccountTable)["$inferSelect"]
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
export namespace AccountRepo { export namespace AccountRepo {
export interface Service { export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError> readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError> readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect( static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, AccountRepo,
Effect.gen(function* () { Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Account) const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: (db: DbClient) => A) => const query = <A>(f: (db: DbClient) => A) =>
Effect.try({ Effect.try({
@@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
.onConflictDoUpdate({ .onConflictDoUpdate({
target: AccountTable.id, target: AccountTable.id,
set: { set: {
email: input.email,
url: input.url,
access_token: input.accessToken, access_token: input.accessToken,
refresh_token: input.refreshToken, refresh_token: input.refreshToken,
token_expiry: input.expiry, token_expiry: input.expiry,
+1 -1
View File
@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
) )
export type UserCode = Schema.Schema.Type<typeof UserCode> export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Account extends Schema.Class<Account>("Account")({ export class Info extends Schema.Class<Info>("Account")({
id: AccountID, id: AccountID,
email: Schema.String, email: Schema.String,
url: Schema.String, url: Schema.String,
+5 -2
View File
@@ -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"],
),
) )
} }
@@ -319,11 +322,11 @@ export namespace Agent {
}), }),
} satisfies Parameters<typeof generateObject>[0] } satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(model, { providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},
+1 -1
View File
@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace AuthEffect { export namespace Auth {
export interface Interface { export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError> readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError> readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
+2 -2
View File
@@ -5,8 +5,8 @@ import * as S from "./effect"
export { OAUTH_DUMMY_KEY } from "./effect" export { OAUTH_DUMMY_KEY } from "./effect"
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) { function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f)) return runtime.runPromise(S.Auth.Service.use(f))
} }
export namespace Auth { export namespace Auth {
+70 -110
View File
@@ -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 {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
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
})
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = pubsubs.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(wildcardPubSub, payload)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
})
} }
},
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> { async (entry) => {
log.info("subscribing", { type: def.type }) const wildcard = entry.subscriptions.get("*")
return Stream.unwrap( if (!wildcard) return
Effect.gen(function* () { const event = {
const ps = yield* getOrCreate(def.type) type: InstanceDisposed.type,
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>> properties: {
}), directory: Instance.directory,
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) },
} }
for (const sub of [...wildcard]) {
function subscribeAll(): Stream.Stream<Payload> { sub(event)
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 })
}),
) )
// --------------------------------------------------------------------------- export async function publish<Definition extends BusEvent.Definition>(
// Legacy adapters — plain function API wrapping the Effect service def: Definition,
// --------------------------------------------------------------------------- properties: z.output<Definition["properties"]>,
) {
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) { const payload = {
return runCallbackInstance( type: def.type,
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))), properties,
) }
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match) {
pending.push(sub(payload))
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
} }
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) { export function subscribe<Definition extends BusEvent.Definition>(
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties))) def: Definition,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
} }
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) { export function once<Definition extends BusEvent.Definition>(
return runStream((svc) => svc.subscribe(def), callback) def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => "done" | undefined,
) {
const unsub = subscribe(def, (event) => {
if (callback(event)) unsub()
})
} }
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 function raw(type: string, callback: (event: any) => void) {
// adapter's fiber starts asynchronously and may not be running when log.info("subscribing", { type })
// disposal happens. In the Effect-native path, forkScoped + scope const subscriptions = state().subscriptions
// closure handles this correctly. This bridge can be removed once let match = subscriptions.get(type) ?? []
// upstream PubSub.shutdown properly wakes suspended subscribers: match.push(callback)
// https://github.com/Effect-TS/effect-smol/pull/1800 subscriptions.set(type, match)
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)
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)
} }
} }
} }
+5 -5
View File
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) { const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountEffect.Service const service = yield* Account.Service
yield* Prompt.intro("Log in") yield* Prompt.intro("Log in")
const login = yield* service.login(url) const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
}) })
const logoutEffect = Effect.fn("logout")(function* (email?: string) { const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const accounts = yield* service.list() const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in") if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
} }
const switchEffect = Effect.fn("switch")(function* () { const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
}) })
const orgsEffect = Effect.fn("orgs")(function* () { const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found") if (groups.length === 0) return yield* println("No accounts found")
@@ -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" })
+3 -4
View File
@@ -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)
}, },
} }
+2 -2
View File
@@ -58,10 +58,10 @@ export const UpgradeCommand = {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals // necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) { if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again") prompts.log.error("Please run the terminal as Administrator and try again")
} else { } else {
prompts.log.error(err.data.stderr) prompts.log.error(err.stderr)
} }
} else if (err instanceof Error) prompts.log.error(err.message) } else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done") prompts.outro("Done")
+117 -83
View File
@@ -1,15 +1,18 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Fiber, Layer, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt" import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Log } from "../util/log"
export namespace Command { export namespace Command {
const log = Log.create({ service: "command" })
export const Event = { export const Event = {
Executed: BusEvent.define( Executed: BusEvent.define(
"command.executed", "command.executed",
@@ -57,95 +60,126 @@ export namespace Command {
REVIEW: "review", REVIEW: "review",
} as const } as const
const state = Instance.state(async () => { export interface Interface {
const cfg = await Config.get() readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
const result: Record<string, Info> = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
for (const [name, command] of Object.entries(cfg.command ?? {})) { export const layer = Layer.effect(
result[name] = { Service,
name, Effect.gen(function* () {
agent: command.agent, const instance = yield* InstanceContext
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? // substitute each argument with $1, $2, etc.
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
// Add skills as invokable commands const commands: Record<string, Info> = {}
for (const skill of await Skill.all()) {
// Skip if a command with this name already exists
if (result[skill.name]) continue
result[skill.name] = {
name: skill.name,
description: skill.description,
source: "skill",
get template() {
return skill.content
},
hints: [],
}
}
return result const load = Effect.fn("Command.load")(function* () {
}) yield* Effect.promise(async () => {
const cfg = await Config.get()
commands[Default.INIT] = {
name: Default.INIT,
description: "create/update AGENTS.md",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
}
commands[Default.REVIEW] = {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? // substitute each argument with $1, $2, etc.
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
// Add skills as invokable commands
for (const skill of await Skill.all()) {
// Skip if a command with this name already exists
if (commands[skill.name]) continue
commands[skill.name] = {
name: skill.name,
description: skill.description,
source: "skill",
get template() {
return skill.content
},
hints: [],
}
}
})
})
const loadFiber = yield* load().pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
Effect.forkScoped,
)
const get = Effect.fn("Command.get")(function* (name: string) {
yield* Fiber.join(loadFiber)
return commands[name]
})
const list = Effect.fn("Command.list")(function* () {
yield* Fiber.join(loadFiber)
return Object.values(commands)
})
return Service.of({ get, list })
}),
)
export async function get(name: string) { export async function get(name: string) {
return state().then((x) => x[name]) return runPromiseInstance(Service.use((svc) => svc.get(name)))
} }
export async function list() { export async function list() {
return state().then((x) => Object.values(x)) return runPromiseInstance(Service.use((svc) => svc.list()))
} }
} }
@@ -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,7 +118,7 @@ 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) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails
await Bun.sleep(250) await sleep(250)
} }
} }
+24 -40
View File
@@ -1,25 +1,25 @@
import { Effect, Exit, Fiber, Layer, LayerMap, MutableHashMap, Scope, ServiceMap } from "effect" import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Command } from "@/command"
import { File } from "@/file" import { File } from "@/file/service"
import { FileTime } from "@/file/time" import { FileTime } from "@/file/time-service"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format" import { Format } from "@/format/service"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission/service"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth" import { ProviderAuth } from "@/provider/auth-service"
import { Question } from "@/question" import { Question } from "@/question/service"
import { Skill } from "@/skill/skill" import { Skill } from "@/skill/service"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot/service"
import { InstanceContext } from "./instance-context" import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context" export { InstanceContext } from "./instance-context"
export type InstanceServices = export type InstanceServices =
| Bus.Service | Command.Service
| Question.Service | Question.Service
| PermissionNext.Service | Permission.Service
| ProviderAuth.Service | ProviderAuth.Service
| FileWatcher.Service | FileWatcher.Service
| Vcs.Service | Vcs.Service
@@ -38,17 +38,17 @@ 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), Command.layer,
Layer.fresh(Question.layer), Question.layer,
Layer.fresh(PermissionNext.layer), Permission.layer,
Layer.fresh(ProviderAuth.defaultLayer), ProviderAuth.defaultLayer,
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), FileWatcher.layer,
Layer.fresh(Vcs.layer), Vcs.layer,
Layer.fresh(FileTime.layer).pipe(Layer.orDie), FileTime.layer,
Layer.fresh(Format.layer), Format.layer,
Layer.fresh(File.layer), File.layer,
Layer.fresh(Skill.defaultLayer), Skill.defaultLayer,
Layer.fresh(Snapshot.defaultLayer), Snapshot.defaultLayer,
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }
@@ -59,23 +59,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)
}), }),
+8 -12
View File
@@ -1,29 +1,25 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect" import { Account } from "@/account/effect"
import { AuthEffect } from "@/auth/effect" import { Auth } from "@/auth/effect"
import { Instances } from "@/effect/instances" import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect" import { Installation } from "@/installation"
import { Truncate } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make( export const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(
AccountEffect.defaultLayer, // Account.defaultLayer, //
TruncateEffect.defaultLayer, Installation.defaultLayer,
Truncate.defaultLayer,
Instances.layer, Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)), ).pipe(Layer.provideMerge(Auth.layer)),
) )
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) { export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
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()
} }
+17 -672
View File
@@ -1,695 +1,40 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git" import { File as S } from "./service"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File { export namespace File {
export const Info = z export const Info = S.Info
.object({ export type Info = S.Info
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info> export const Node = S.Node
export type Node = S.Node
export const Node = z export const Content = S.Content
.object({ export type Content = S.Content
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z export const Event = S.Event
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = { export type Interface = S.Interface
Edited: BusEvent.define(
"file.edited", export const Service = S.Service
z.object({ export const layer = S.layer
file: z.string(),
}),
),
}
export function init() { export function init() {
return runPromiseInstance(Service.use((svc) => svc.init())) return runPromiseInstance(S.Service.use((svc) => svc.init()))
} }
export async function status() { export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status())) return runPromiseInstance(S.Service.use((svc) => svc.status()))
} }
export async function read(file: string): Promise<Content> { export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file))) return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
} }
export async function list(dir?: string) { export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir))) return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
} }
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input))) return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
} }
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
let cache: Entry = { files: [], dirs: [] }
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
const scan = Effect.fn("File.scan")(function* () {
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(instance.directory, full),
}
})
})
})
const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
)
} }
+674
View File
@@ -0,0 +1,674 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
let cache: Entry = { files: [], dirs: [] }
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
const scan = Effect.fn("File.scan")(function* () {
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(instance.directory, full),
}
})
})
})
const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
).pipe(Layer.fresh)
}
@@ -0,0 +1,93 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie, Layer.fresh)
}
+9 -91
View File
@@ -1,110 +1,28 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem" import { FileTime as S } from "./time-service"
import { Log } from "../util/log"
export namespace FileTime { export namespace FileTime {
const log = Log.create({ service: "file.time" }) export type Stamp = S.Stamp
export type Stamp = { export type Interface = S.Interface
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) { export const Service = S.Service
const stat = Filesystem.stat(file) export const layer = S.layer
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
)
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
} }
} }
+1 -1
View File
@@ -137,5 +137,5 @@ export namespace FileWatcher {
return Effect.succeed(Service.of({})) return Effect.succeed(Service.of({}))
}), }),
), ),
) ).pipe(Layer.orDie, Layer.fresh)
} }
+7 -147
View File
@@ -1,156 +1,16 @@
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context" import { Format as S } from "./service"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format { export namespace Format {
const log = Log.create({ service: "format" }) export const Status = S.Status
export type Status = S.Status
export const Status = z export type Interface = S.Interface
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export interface Interface { export const Service = S.Service
readonly run: (filepath: string) => Effect.Effect<void> export const layer = S.layer
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
const run = Effect.fn("Format.run")(function* (filepath: string) {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command })
yield* Effect.tryPromise({
try: async () => {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
},
catch: (error) => {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
return error
},
}).pipe(Effect.ignore)
}
})
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ run, 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(S.Service.use((s) => s.status()))
} }
} }
+152
View File
@@ -0,0 +1,152 @@
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file/service"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export interface Interface {
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
yield* Effect.acquireRelease(
Effect.sync(() =>
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 await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ status })
}),
).pipe(Layer.fresh)
}
+1 -1
View File
@@ -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,
+292 -240
View File
@@ -1,12 +1,13 @@
import { BusEvent } from "@/bus/bus-event" import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { BusEvent } from "@/bus/bus-event"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Process } from "@/util/process" import { Log } from "../util/log"
import { buffer } from "node:stream/consumers"
declare global { declare global {
const OPENCODE_VERSION: string const OPENCODE_VERSION: string
@@ -16,39 +17,7 @@ declare global {
export namespace Installation { export namespace Installation {
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
@@ -75,12 +44,9 @@ export namespace Installation {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export async function info() { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
return { export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
version: VERSION, export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
latest: await latest(),
}
}
export function isPreview() { export function isPreview() {
return CHANNEL !== "latest" return CHANNEL !== "latest"
@@ -90,214 +56,300 @@ export namespace Installation {
return CHANNEL === "local" return CHANNEL === "local"
} }
export async function method() { export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" stderr: Schema.String,
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" }) {}
const exec = process.execPath.toLowerCase()
const checks = [ // Response schemas for external version APIs
{ const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
name: "npm" as const, const NpmPackage = Schema.Struct({ version: Schema.String })
command: () => text(["npm", "list", "-g", "--depth=0"]), const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
}, const BrewInfoV2 = Schema.Struct({
{ formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
name: "yarn" as const, })
command: () => text(["yarn", "global", "list"]), const ChocoPackage = Schema.Struct({
}, d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
{ })
name: "pnpm" as const, const ScoopManifest = NpmPackage
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
checks.sort((a, b) => { export interface Interface {
const aMatches = exec.includes(a.name) readonly info: () => Effect.Effect<Info>
const bMatches = exec.includes(b.name) readonly method: () => Effect.Effect<Method>
if (aMatches && !bMatches) return -1 readonly latest: (method?: Method) => Effect.Effect<string>
if (!aMatches && bMatches) return 1 readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
return 0
})
for (const check of checks) {
const output = await check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown"
} }
export const UpgradeFailedError = NamedError.create( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
async function getBrewFormula() { export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) Layer.effect(
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" Service,
const coreFormula = await text(["brew", "list", "--formula", "opencode"]) Effect.gen(function* () {
if (coreFormula.includes("opencode")) return "opencode" const http = yield* HttpClient.HttpClient
return "opencode" const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
} const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
export async function upgrade(method: Method, target: string) { const text = Effect.fnUntraced(
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
switch (method) { const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
case "curl": cwd: opts?.cwd,
result = await upgradeCurl(target) env: opts?.env,
break extendEnv: true,
case "npm": })
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) const handle = yield* spawner.spawn(proc)
break const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
case "pnpm": yield* handle.exitCode
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) return out
break },
case "bun": Effect.scoped,
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) Effect.catch(() => Effect.succeed("")),
break )
case "brew": {
const formula = await getBrewFormula() const run = Effect.fnUntraced(
const env = { function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
HOMEBREW_NO_AUTO_UPDATE: "1", const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
...process.env, cwd: opts?.cwd,
} env: opts?.env,
if (formula.includes("/")) { extendEnv: true,
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) })
if (tap.code !== 0) { const handle = yield* spawner.spawn(proc)
result = tap const [stdout, stderr] = yield* Effect.all(
break [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
} { concurrency: 2 },
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) )
if (repo.code !== 0) { const code = yield* handle.exitCode
result = repo return { code, stdout, stderr }
break },
} Effect.scoped,
const dir = repo.text.trim() Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
if (dir) { )
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) { const getBrewFormula = Effect.fnUntraced(function* () {
result = pull const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
break if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.orDie,
)
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
} }
} }
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco": return "unknown" as Method
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) })
break
case "scoop": const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) const detectedMethod = installMethod || (yield* methodImpl())
break
default: if (detectedMethod === "brew") {
throw new Error(`Unknown method: ${method}`) const formula = yield* getBrewFormula()
} if (formula.includes("/")) {
if (!result || result.code !== 0) { const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const stderr = const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" return info.formulae[0].versions.stable
throw new UpgradeFailedError({ }
stderr: stderr, const response = yield* httpOk.execute(
}) HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
} HttpClientRequest.acceptJson,
log.info("upgraded", { ),
method, )
target, const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
stdout: result.stdout.toString(), return data.versions.stable
stderr: result.stderr.toString(), }
})
await Process.text([process.execPath, "--version"], { nothrow: true }) if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = CHANNEL
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
throw new Error(`Unknown method: ${m}`)
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: VERSION,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
// Legacy adapters — dynamic import avoids circular dependency since
// foundational modules (db.ts, provider/models.ts) import Installation
// at load time, and runtime transitively loads those same modules.
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
const { runtime } = await import("@/effect/runtime")
return runtime.runPromise(Service.use(f))
} }
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export function info(): Promise<Info> {
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" return runPromise((svc) => svc.info())
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` }
export async function latest(installMethod?: Method) { export function method(): Promise<Method> {
const detectedMethod = installMethod || (await method()) return runPromise((svc) => svc.method())
}
if (detectedMethod === "brew") { export function latest(installMethod?: Method): Promise<string> {
const formula = await getBrewFormula() return runPromise((svc) => svc.latest(installMethod))
if (formula.includes("/")) { }
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { export function upgrade(m: Method, target: string): Promise<void> {
const registry = await iife(async () => { return runPromise((svc) => svc.upgrade(m, target))
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
} }
} }
+4 -7
View File
@@ -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
View File
@@ -0,0 +1 @@
export { Server } from "./server/server"
@@ -0,0 +1,15 @@
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
+30 -273
View File
@@ -1,295 +1,52 @@
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod" import z from "zod"
import { PermissionID } from "./schema" import { Permission as S } from "./service"
export namespace PermissionNext { export namespace PermissionNext {
const log = Log.create({ service: "permission" }) export const Action = S.Action
export type Action = S.Action
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Rule = S.Rule
ref: "PermissionAction", export type Rule = S.Rule
})
export type Action = z.infer<typeof Action>
export const Rule = z export const Ruleset = S.Ruleset
.object({ export type Ruleset = S.Ruleset
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({ export const Request = S.Request
ref: "PermissionRuleset", export type Request = S.Request
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z export const Reply = S.Reply
.object({ export type Reply = S.Reply
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"]) export const Approval = S.Approval
export type Reply = z.infer<typeof Reply> export type Approval = z.infer<typeof S.Approval>
export const Approval = z.object({ export const Event = S.Event
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = { export const RejectedError = S.RejectedError
Asked: BusEvent.define("permission.asked", Request), export const CorrectedError = S.CorrectedError
Replied: BusEvent.define( export const DeniedError = S.DeniedError
"permission.replied", export type Error = S.Error
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) { export const AskInput = S.AskInput
override get message() { export const ReplyInput = S.ReplyInput
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", { export type Interface = S.Interface
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", { export const Service = S.Service
ruleset: Schema.Any, export const layer = S.layer
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError export const evaluate = S.evaluate
export const fromConfig = S.fromConfig
export const merge = S.merge
export const disabled = S.disabled
export const AskInput = Request.partial({ id: true }).extend({ export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
ruleset: Ruleset,
})
export const ReplyInput = z.object({ export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
return pattern
}
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
)
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
export async function list() { export async function list() {
return runPromiseInstance(Service.use((svc) => svc.list())) return runPromiseInstance(S.Service.use((s) => s.list()))
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
} }
} }

Some files were not shown because too many files have changed in this diff Show More