mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-26 02:34:19 +00:00
Compare commits
56 Commits
fix/beta-s
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf53e1c24b | ||
|
|
acd7c5ad55 | ||
|
|
cf54b544e3 | ||
|
|
76b60f3779 | ||
|
|
6af7ddf03b | ||
|
|
0b3fb5d460 | ||
|
|
a487f11a30 | ||
|
|
637059a515 | ||
|
|
fa559b0385 | ||
|
|
814c1d398c | ||
|
|
da40ab7b3d | ||
|
|
e718263778 | ||
|
|
3af12c53c4 | ||
|
|
29ddd55088 | ||
|
|
2c00eb60bd | ||
|
|
2a87860c06 | ||
|
|
68cf011fd3 | ||
|
|
f8cfb697bd | ||
|
|
c6d8e7624d | ||
|
|
0d0d0578eb | ||
|
|
cc02476ea5 | ||
|
|
5190589632 | ||
|
|
c92913e962 | ||
|
|
082f0cc127 | ||
|
|
2cee947671 | ||
|
|
e27d3d5d40 | ||
|
|
32417774c4 | ||
|
|
36197f5ff8 | ||
|
|
3d379c20c4 | ||
|
|
06f25c78f6 | ||
|
|
1a0639e5b8 | ||
|
|
1af3e9e557 | ||
|
|
a292eddeb5 | ||
|
|
79254c1020 | ||
|
|
ef7f222d80 | ||
|
|
888b123387 | ||
|
|
13cabae29f | ||
|
|
659068942e | ||
|
|
3201a7d34b | ||
|
|
de796d9a00 | ||
|
|
a592bd9684 | ||
|
|
744059a00f | ||
|
|
fb6d201ee0 | ||
|
|
cda2af2589 | ||
|
|
eda71373b0 | ||
|
|
cf5cfb48cd | ||
|
|
ae190038f8 | ||
|
|
0269f39a17 | ||
|
|
0a91196919 | ||
|
|
284251ad66 | ||
|
|
34495a70d5 | ||
|
|
ad5f0816a3 | ||
|
|
24c63914bf | ||
|
|
8f2d8dd47a | ||
|
|
3b5b21a91e | ||
|
|
8e96447960 |
69
.github/actions/setup-bun/action.yml
vendored
69
.github/actions/setup-bun/action.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: "Setup Bun"
|
||||
description: "Setup Bun with caching and install dependencies"
|
||||
inputs:
|
||||
cross-compile:
|
||||
description: "Pre-cache canary cross-compile binaries for all targets"
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -11,10 +16,72 @@ runs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_ARCH" = "X64" ]; then
|
||||
case "$RUNNER_OS" in
|
||||
macOS) OS=darwin ;;
|
||||
Linux) OS=linux ;;
|
||||
Windows) OS=windows ;;
|
||||
esac
|
||||
echo "url=https://github.com/oven-sh/bun/releases/download/canary/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Pre-cache canary cross-compile binaries
|
||||
if: inputs.cross-compile == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
BUN_VERSION=$(bun --revision)
|
||||
if echo "$BUN_VERSION" | grep -q "canary"; then
|
||||
SEMVER=$(echo "$BUN_VERSION" | sed 's/^\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')
|
||||
echo "Bun version: $BUN_VERSION (semver: $SEMVER)"
|
||||
CACHE_DIR="$HOME/.bun/install/cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
TMP_DIR=$(mktemp -d)
|
||||
for TARGET in linux-aarch64 linux-x64 linux-x64-baseline linux-aarch64-musl linux-x64-musl linux-x64-musl-baseline darwin-aarch64 darwin-x64 windows-x64 windows-x64-baseline; do
|
||||
DEST="$CACHE_DIR/bun-${TARGET}-v${SEMVER}"
|
||||
if [ -f "$DEST" ]; then
|
||||
echo "Already cached: $DEST"
|
||||
continue
|
||||
fi
|
||||
URL="https://github.com/oven-sh/bun/releases/download/canary/bun-${TARGET}.zip"
|
||||
echo "Downloading $TARGET from $URL"
|
||||
if curl -sfL -o "$TMP_DIR/bun.zip" "$URL"; then
|
||||
unzip -qo "$TMP_DIR/bun.zip" -d "$TMP_DIR"
|
||||
if echo "$TARGET" | grep -q "windows"; then
|
||||
BIN_NAME="bun.exe"
|
||||
else
|
||||
BIN_NAME="bun"
|
||||
fi
|
||||
mv "$TMP_DIR/bun-${TARGET}/$BIN_NAME" "$DEST"
|
||||
chmod +x "$DEST"
|
||||
rm -rf "$TMP_DIR/bun-${TARGET}" "$TMP_DIR/bun.zip"
|
||||
echo "Cached: $DEST"
|
||||
# baseline bun resolves "bun-darwin-x64" to the baseline cache key
|
||||
# so copy the modern binary there too
|
||||
if [ "$TARGET" = "darwin-x64" ]; then
|
||||
BASELINE_DEST="$CACHE_DIR/bun-darwin-x64-baseline-v${SEMVER}"
|
||||
if [ ! -f "$BASELINE_DEST" ]; then
|
||||
cp "$DEST" "$BASELINE_DEST"
|
||||
echo "Cached (baseline alias): $BASELINE_DEST"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped: $TARGET (not available)"
|
||||
fi
|
||||
done
|
||||
rm -rf "$TMP_DIR"
|
||||
else
|
||||
echo "Not a canary build ($BUN_VERSION), skipping pre-cache"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
8
.github/workflows/beta.yml
vendored
8
.github/workflows/beta.yml
vendored
@@ -5,10 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
concurrency:
|
||||
group: beta-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -31,7 +27,11 @@ jobs:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Install OpenCode
|
||||
run: bun i -g opencode-ai
|
||||
|
||||
- name: Sync beta branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
run: bun script/beta.ts
|
||||
|
||||
9
.github/workflows/compliance-close.yml
vendored
9
.github/workflows/compliance-close.yml
vendored
@@ -65,6 +65,15 @@ jobs:
|
||||
body: closeMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
if (isPR) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
|
||||
12
.github/workflows/pr-standards.yml
vendored
12
.github/workflows/pr-standards.yml
vendored
@@ -108,11 +108,11 @@ jobs:
|
||||
|
||||
await removeLabel('needs:title');
|
||||
|
||||
// Step 2: Check for linked issue (skip for docs/refactor PRs)
|
||||
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
// Step 2: Check for linked issue (skip for docs/refactor/feat PRs)
|
||||
const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
if (skipIssueCheck) {
|
||||
await removeLabel('needs:issue');
|
||||
console.log('Skipping issue check for docs/refactor PR');
|
||||
console.log('Skipping issue check for docs/refactor/feat PR');
|
||||
return;
|
||||
}
|
||||
const query = `
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
const body = pr.body || '';
|
||||
const title = pr.title;
|
||||
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||
|
||||
const issues = [];
|
||||
|
||||
@@ -225,8 +225,8 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
// Check: issue reference (skip for docs/refactor)
|
||||
if (!isDocsOrRefactor && hasIssueSection) {
|
||||
// Check: issue reference (skip for docs/refactor/feat)
|
||||
if (!isDocsRefactorOrFeat && hasIssueSection) {
|
||||
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
||||
const issueContent = issueMatch ? issueMatch[1].trim() : '';
|
||||
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -77,6 +77,8 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
cross-compile: "true"
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
./packages/opencode/script/build.ts --all
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
4
.github/workflows/sign-cli.yml
vendored
4
.github/workflows/sign-cli.yml
vendored
@@ -20,10 +20,12 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
cross-compile: "true"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
./packages/opencode/script/build.ts --all
|
||||
|
||||
- name: Upload unsigned Windows CLI
|
||||
id: upload_unsigned_windows_cli
|
||||
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -8,8 +8,16 @@ on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
42
bun.lock
42
bun.lock
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -262,7 +262,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -342,6 +342,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -364,6 +365,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
@@ -376,7 +378,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -396,7 +398,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -407,7 +409,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -420,7 +422,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -462,7 +464,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -473,7 +475,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1978,6 +1980,8 @@
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||
@@ -2942,7 +2946,7 @@
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
@@ -4110,7 +4114,7 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -4698,6 +4702,8 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
@@ -5254,6 +5260,8 @@
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
|
||||
@@ -3,7 +3,6 @@ import { encodeFilePath } from "@/context/file/path"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -192,59 +191,6 @@ const FileTreeNode = (
|
||||
)
|
||||
}
|
||||
|
||||
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||
if (!props.enabled) return props.children
|
||||
|
||||
const parts = props.node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
const label =
|
||||
props.kind === "add"
|
||||
? "Additions"
|
||||
: props.kind === "del"
|
||||
? "Deletions"
|
||||
: props.kind === "mix"
|
||||
? "Modifications"
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label}>
|
||||
{(text) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
@@ -255,7 +201,6 @@ export default function FileTree(props: {
|
||||
modified?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
tooltip?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
@@ -267,7 +212,6 @@ export default function FileTree(props: {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
const tooltip = () => props.tooltip ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
@@ -467,21 +411,19 @@ export default function FileTree(props: {
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</FileTreeNode>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="relative pt-0.5">
|
||||
<div
|
||||
@@ -504,7 +446,6 @@ export default function FileTree(props: {
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
tooltip={props.tooltip}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
@@ -517,53 +458,51 @@ export default function FileTree(props: {
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
<Match when={node.ignored}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<Switch>
|
||||
<Match when={node.ignored}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style="color: var(--icon-weak-base)"
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active()}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style={kindTextColor(kind()!)}
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style="color: var(--icon-weak-base)"
|
||||
mono
|
||||
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active()}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style={kindTextColor(kind()!)}
|
||||
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||
/>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||
mono
|
||||
/>
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</FileTreeNode>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
@@ -15,10 +15,10 @@ describe("file path helpers", () => {
|
||||
|
||||
test("normalizes Windows absolute paths with mixed separators", () => {
|
||||
const path = createPathHelpers(() => "C:\\repo")
|
||||
expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src/app.ts")
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
})
|
||||
|
||||
test("keeps query/hash stripping behavior stable", () => {
|
||||
|
||||
@@ -103,32 +103,30 @@ export function encodeFilePath(filepath: string): string {
|
||||
|
||||
export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope().replace(/\\/g, "/")
|
||||
const root = scope()
|
||||
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(/\\/g, "/")
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
|
||||
// Remove initial root prefix, if it's a complete match or followed by /
|
||||
// (don't want /foo/bar to root of /f).
|
||||
// For Windows paths, also check for case-insensitive match.
|
||||
const windows = /^[A-Za-z]:/.test(root)
|
||||
const canonRoot = windows ? root.toLowerCase() : root
|
||||
const canonPath = windows ? path.toLowerCase() : path
|
||||
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
|
||||
// Only case-insensitive on Windows (drive letter or UNC paths)
|
||||
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
|
||||
const canonRoot = windows ? root.replace(/\\/g, "/").toLowerCase() : root.replace(/\\/g, "/")
|
||||
const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/")
|
||||
if (
|
||||
canonPath.startsWith(canonRoot) &&
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath.startsWith(canonRoot + "/"))
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
|
||||
) {
|
||||
// If we match canonRoot + "/", the slash will be removed below.
|
||||
// Slice from original path to preserve native separators
|
||||
path = path.slice(root.length)
|
||||
}
|
||||
|
||||
if (path.startsWith("./")) {
|
||||
if (path.startsWith("./") || path.startsWith(".\\")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
@@ -68,14 +71,20 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
@@ -144,6 +153,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { usePlatform } from "./platform"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -51,12 +52,6 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -207,8 +202,9 @@ function createGlobalSync() {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: errorMessage(err),
|
||||
description: formatServerError(err),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -133,8 +134,11 @@ export async function bootstrapDirectory(input: {
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(input.directory)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: `Failed to reload ${project}`,
|
||||
description: formatServerError(err),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": !store.collapsed }}
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -371,6 +371,12 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => state()?.loaded,
|
||||
@@ -484,7 +490,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onCancel={cancelCommenting}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
@@ -498,7 +504,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
cancelCommenting()
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("createOpenReviewFile", () => {
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"])
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
loadFile: (path: string) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
input.openTab(input.tabForPath(path))
|
||||
input.loadFile(path)
|
||||
const maybePromise = input.loadFile(path)
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
69
packages/app/src/utils/server-errors.test.ts
Normal file
69
packages/app/src/utils/server-errors.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ConfigInvalidError } from "./server-errors"
|
||||
import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors"
|
||||
|
||||
describe("parseReabaleConfigInvalidError", () => {
|
||||
test("formats issues with file path", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "opencode.config.ts",
|
||||
issues: [
|
||||
{ path: ["settings", "host"], message: "Required" },
|
||||
{ path: ["mode"], message: "Invalid" },
|
||||
],
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = parseReabaleConfigInvalidError(error)
|
||||
|
||||
expect(result).toBe(
|
||||
["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("uses trimmed message when issues are missing", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "config",
|
||||
message: " Bad value ",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = parseReabaleConfigInvalidError(error)
|
||||
|
||||
expect(result).toBe(["Invalid configuration", "Bad value"].join("\n"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatServerError", () => {
|
||||
test("formats config invalid errors", () => {
|
||||
const error = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
message: "Missing host",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const result = formatServerError(error)
|
||||
|
||||
expect(result).toBe(["Invalid configuration", "Missing host"].join("\n"))
|
||||
})
|
||||
|
||||
test("returns error messages", () => {
|
||||
expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503")
|
||||
})
|
||||
|
||||
test("returns provided string errors", () => {
|
||||
expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server")
|
||||
})
|
||||
|
||||
test("falls back to unknown", () => {
|
||||
expect(formatServerError(0)).toBe("Unknown error")
|
||||
})
|
||||
|
||||
test("falls back for unknown error objects and names", () => {
|
||||
expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error")
|
||||
})
|
||||
})
|
||||
32
packages/app/src/utils/server-errors.ts
Normal file
32
packages/app/src/utils/server-errors.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type ConfigInvalidError = {
|
||||
name: "ConfigInvalidError"
|
||||
data: {
|
||||
path?: string
|
||||
message?: string
|
||||
issues?: Array<{ message: string; path: string[] }>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatServerError(error: unknown) {
|
||||
if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error)
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
|
||||
}
|
||||
|
||||
export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError) {
|
||||
const head = "Invalid configuration"
|
||||
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : ""
|
||||
const detail = errorInput.data.message?.trim() ?? ""
|
||||
const issues = (errorInput.data.issues ?? []).map((issue) => {
|
||||
return `${issue.path.join(".")}: ${issue.message}`
|
||||
})
|
||||
if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n")
|
||||
return [head, file, detail].filter(Boolean).join("\n")
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
|
||||
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vite start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -243,6 +243,7 @@ export const dict = {
|
||||
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||
"black.hero.subtitle": "بما في ذلك Claude، GPT، Gemini والمزيد",
|
||||
"black.title": "OpenCode Black | الأسعار",
|
||||
"black.paused": "التسجيل في خطة Black متوقف مؤقتًا.",
|
||||
"black.plan.icon20": "خطة Black 20",
|
||||
"black.plan.icon100": "خطة Black 100",
|
||||
"black.plan.icon200": "خطة Black 200",
|
||||
@@ -344,6 +345,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "الخرج",
|
||||
"workspace.usage.breakdown.reasoning": "المنطق",
|
||||
"workspace.usage.subscription": "الاشتراك (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "التكلفة",
|
||||
"workspace.cost.subtitle": "تكاليف الاستخدام مقسمة حسب النموذج.",
|
||||
@@ -352,6 +355,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||
"workspace.cost.subscriptionShort": "اشتراك",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "مفاتيح API",
|
||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||
@@ -479,6 +483,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "مسجل",
|
||||
"workspace.black.waitlist.enrollNote": 'عند النقر فوق "تسجيل"، يبدأ اشتراكك على الفور وسيتم خصم الرسوم من بطاقتك.',
|
||||
|
||||
"workspace.lite.loading": "جارٍ التحميل...",
|
||||
"workspace.lite.time.day": "يوم",
|
||||
"workspace.lite.time.days": "أيام",
|
||||
"workspace.lite.time.hour": "ساعة",
|
||||
"workspace.lite.time.hours": "ساعات",
|
||||
"workspace.lite.time.minute": "دقيقة",
|
||||
"workspace.lite.time.minutes": "دقائق",
|
||||
"workspace.lite.time.fewSeconds": "بضع ثوان",
|
||||
"workspace.lite.subscription.title": "اشتراك Lite",
|
||||
"workspace.lite.subscription.message": "أنت مشترك في OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "إدارة الاشتراك",
|
||||
"workspace.lite.subscription.rollingUsage": "الاستخدام المتجدد",
|
||||
"workspace.lite.subscription.weeklyUsage": "الاستخدام الأسبوعي",
|
||||
"workspace.lite.subscription.monthlyUsage": "الاستخدام الشهري",
|
||||
"workspace.lite.subscription.resetsIn": "إعادة تعيين في",
|
||||
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
|
||||
"workspace.lite.other.title": "اشتراك Lite",
|
||||
"workspace.lite.other.message":
|
||||
"عضو آخر في مساحة العمل هذه مشترك بالفعل في OpenCode Lite. يمكن لعضو واحد فقط لكل مساحة عمل الاشتراك.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"احصل على وصول إلى أفضل النماذج المفتوحة — Kimi K2.5، و GLM-5، و MiniMax M2.5 — مع حدود استخدام سخية مقابل $10 شهريًا.",
|
||||
"workspace.lite.promo.subscribe": "الاشتراك في Lite",
|
||||
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
||||
|
||||
"download.title": "OpenCode | تنزيل",
|
||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||
"download.hero.title": "تنزيل OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
|
||||
"black.hero.subtitle": "Incluindo Claude, GPT, Gemini e mais",
|
||||
"black.title": "OpenCode Black | Preços",
|
||||
"black.paused": "A inscrição no plano Black está temporariamente pausada.",
|
||||
"black.plan.icon20": "Plano Black 20",
|
||||
"black.plan.icon100": "Plano Black 100",
|
||||
"black.plan.icon200": "Plano Black 200",
|
||||
@@ -349,6 +350,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Saída",
|
||||
"workspace.usage.breakdown.reasoning": "Raciocínio",
|
||||
"workspace.usage.subscription": "assinatura (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Custo",
|
||||
"workspace.cost.subtitle": "Custos de uso discriminados por modelo.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(excluído)",
|
||||
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
||||
"workspace.cost.subscriptionShort": "ass",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chaves de API",
|
||||
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
||||
@@ -485,6 +489,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Ao clicar em Inscrever-se, sua assinatura começará imediatamente e seu cartão será cobrado.",
|
||||
|
||||
"workspace.lite.loading": "Carregando...",
|
||||
"workspace.lite.time.day": "dia",
|
||||
"workspace.lite.time.days": "dias",
|
||||
"workspace.lite.time.hour": "hora",
|
||||
"workspace.lite.time.hours": "horas",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minutos",
|
||||
"workspace.lite.time.fewSeconds": "alguns segundos",
|
||||
"workspace.lite.subscription.title": "Assinatura Lite",
|
||||
"workspace.lite.subscription.message": "Você assina o OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Gerenciar Assinatura",
|
||||
"workspace.lite.subscription.rollingUsage": "Uso Contínuo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Uso Semanal",
|
||||
"workspace.lite.subscription.monthlyUsage": "Uso Mensal",
|
||||
"workspace.lite.subscription.resetsIn": "Reinicia em",
|
||||
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
|
||||
"workspace.lite.other.title": "Assinatura Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Outro membro neste workspace já assina o OpenCode Lite. Apenas um membro por workspace pode assinar.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Tenha acesso aos melhores modelos abertos — Kimi K2.5, GLM-5 e MiniMax M2.5 — com limites de uso generosos por $10 por mês.",
|
||||
"workspace.lite.promo.subscribe": "Assinar Lite",
|
||||
"workspace.lite.promo.subscribing": "Redirecionando...",
|
||||
|
||||
"download.title": "OpenCode | Baixar",
|
||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||
"download.hero.title": "Baixar OpenCode",
|
||||
|
||||
@@ -245,6 +245,7 @@ export const dict = {
|
||||
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
|
||||
"black.hero.subtitle": "Inklusive Claude, GPT, Gemini og mere",
|
||||
"black.title": "OpenCode Black | Priser",
|
||||
"black.paused": "Black-plantilmelding er midlertidigt sat på pause.",
|
||||
"black.plan.icon20": "Black 20-plan",
|
||||
"black.plan.icon100": "Black 100-plan",
|
||||
"black.plan.icon200": "Black 200-plan",
|
||||
@@ -347,6 +348,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Ræsonnement",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Omkostninger",
|
||||
"workspace.cost.subtitle": "Brugsomkostninger opdelt efter model.",
|
||||
@@ -355,6 +358,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøgler",
|
||||
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
||||
@@ -483,6 +487,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Når du klikker på Tilmeld, starter dit abonnement med det samme, og dit kort vil blive debiteret.",
|
||||
|
||||
"workspace.lite.loading": "Indlæser...",
|
||||
"workspace.lite.time.day": "dag",
|
||||
"workspace.lite.time.days": "dage",
|
||||
"workspace.lite.time.hour": "time",
|
||||
"workspace.lite.time.hours": "timer",
|
||||
"workspace.lite.time.minute": "minut",
|
||||
"workspace.lite.time.minutes": "minutter",
|
||||
"workspace.lite.time.fewSeconds": "et par sekunder",
|
||||
"workspace.lite.subscription.title": "Lite-abonnement",
|
||||
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Administrer abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Løbende forbrug",
|
||||
"workspace.lite.subscription.weeklyUsage": "Ugentligt forbrug",
|
||||
"workspace.lite.subscription.monthlyUsage": "Månedligt forbrug",
|
||||
"workspace.lite.subscription.resetsIn": "Nulstiller i",
|
||||
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
|
||||
"workspace.lite.other.title": "Lite-abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Et andet medlem i dette workspace abonnerer allerede på OpenCode Lite. Kun ét medlem pr. workspace kan abonnere.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Få adgang til de bedste åbne modeller — Kimi K2.5, GLM-5 og MiniMax M2.5 — med generøse forbrugsgrænser for $10 om måneden.",
|
||||
"workspace.lite.promo.subscribe": "Abonner på Lite",
|
||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
|
||||
"black.hero.subtitle": "Einschließlich Claude, GPT, Gemini und mehr",
|
||||
"black.title": "OpenCode Black | Preise",
|
||||
"black.paused": "Die Anmeldung zum Black-Plan ist vorübergehend pausiert.",
|
||||
"black.plan.icon20": "Black 20 Plan",
|
||||
"black.plan.icon100": "Black 100 Plan",
|
||||
"black.plan.icon200": "Black 200 Plan",
|
||||
@@ -349,6 +350,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "Abonnement (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Kosten",
|
||||
"workspace.cost.subtitle": "Nutzungskosten aufgeschlüsselt nach Modell.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(gelöscht)",
|
||||
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
||||
"workspace.cost.subscriptionShort": "Abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
||||
@@ -485,6 +489,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Wenn du auf Einschreiben klickst, startet dein Abo sofort und deine Karte wird belastet.",
|
||||
|
||||
"workspace.lite.loading": "Lade...",
|
||||
"workspace.lite.time.day": "Tag",
|
||||
"workspace.lite.time.days": "Tage",
|
||||
"workspace.lite.time.hour": "Stunde",
|
||||
"workspace.lite.time.hours": "Stunden",
|
||||
"workspace.lite.time.minute": "Minute",
|
||||
"workspace.lite.time.minutes": "Minuten",
|
||||
"workspace.lite.time.fewSeconds": "einige Sekunden",
|
||||
"workspace.lite.subscription.title": "Lite-Abonnement",
|
||||
"workspace.lite.subscription.message": "Du hast OpenCode Lite abonniert.",
|
||||
"workspace.lite.subscription.manage": "Abo verwalten",
|
||||
"workspace.lite.subscription.rollingUsage": "Fortlaufende Nutzung",
|
||||
"workspace.lite.subscription.weeklyUsage": "Wöchentliche Nutzung",
|
||||
"workspace.lite.subscription.monthlyUsage": "Monatliche Nutzung",
|
||||
"workspace.lite.subscription.resetsIn": "Setzt zurück in",
|
||||
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
|
||||
"workspace.lite.other.title": "Lite-Abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Ein anderes Mitglied in diesem Workspace hat OpenCode Lite bereits abonniert. Nur ein Mitglied pro Workspace kann abonnieren.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Erhalte Zugriff auf die besten offenen Modelle — Kimi K2.5, GLM-5 und MiniMax M2.5 — mit großzügigen Nutzungslimits für $10 pro Monat.",
|
||||
"workspace.lite.promo.subscribe": "Lite abonnieren",
|
||||
"workspace.lite.promo.subscribing": "Leite weiter...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||
"download.hero.title": "OpenCode herunterladen",
|
||||
|
||||
@@ -239,6 +239,7 @@ export const dict = {
|
||||
"black.hero.title": "Access all the world's best coding models",
|
||||
"black.hero.subtitle": "Including Claude, GPT, Gemini and more",
|
||||
"black.title": "OpenCode Black | Pricing",
|
||||
"black.paused": "Black plan enrollment is temporarily paused.",
|
||||
"black.plan.icon20": "Black 20 plan",
|
||||
"black.plan.icon100": "Black 100 plan",
|
||||
"black.plan.icon200": "Black 200 plan",
|
||||
@@ -341,6 +342,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "subscription (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Cost",
|
||||
"workspace.cost.subtitle": "Usage costs broken down by model.",
|
||||
@@ -349,6 +352,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(deleted)",
|
||||
"workspace.cost.empty": "No usage data available for the selected period.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
||||
@@ -477,6 +481,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"When you click Enroll, your subscription starts immediately and your card will be charged.",
|
||||
|
||||
"workspace.lite.loading": "Loading...",
|
||||
"workspace.lite.time.day": "day",
|
||||
"workspace.lite.time.days": "days",
|
||||
"workspace.lite.time.hour": "hour",
|
||||
"workspace.lite.time.hours": "hours",
|
||||
"workspace.lite.time.minute": "minute",
|
||||
"workspace.lite.time.minutes": "minutes",
|
||||
"workspace.lite.time.fewSeconds": "a few seconds",
|
||||
"workspace.lite.subscription.title": "Lite Subscription",
|
||||
"workspace.lite.subscription.message": "You are subscribed to OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Manage Subscription",
|
||||
"workspace.lite.subscription.rollingUsage": "Rolling Usage",
|
||||
"workspace.lite.subscription.weeklyUsage": "Weekly Usage",
|
||||
"workspace.lite.subscription.monthlyUsage": "Monthly Usage",
|
||||
"workspace.lite.subscription.resetsIn": "Resets in",
|
||||
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
|
||||
"workspace.lite.other.title": "Lite Subscription",
|
||||
"workspace.lite.other.message":
|
||||
"Another member in this workspace is already subscribed to OpenCode Lite. Only one member per workspace can subscribe.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Get access to the best open models — Kimi K2.5, GLM-5, and MiniMax M2.5 — with generous usage limits for $10 per month.",
|
||||
"workspace.lite.promo.subscribe": "Subscribe to Lite",
|
||||
"workspace.lite.promo.subscribing": "Redirecting...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -248,6 +248,7 @@ export const dict = {
|
||||
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
|
||||
"black.hero.subtitle": "Incluyendo Claude, GPT, Gemini y más",
|
||||
"black.title": "OpenCode Black | Precios",
|
||||
"black.paused": "La inscripción al plan Black está temporalmente pausada.",
|
||||
"black.plan.icon20": "Plan Black 20",
|
||||
"black.plan.icon100": "Plan Black 100",
|
||||
"black.plan.icon200": "Plan Black 200",
|
||||
@@ -350,6 +351,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Salida",
|
||||
"workspace.usage.breakdown.reasoning": "Razonamiento",
|
||||
"workspace.usage.subscription": "suscripción (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Costo",
|
||||
"workspace.cost.subtitle": "Costos de uso desglosados por modelo.",
|
||||
@@ -358,6 +361,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminado)",
|
||||
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Claves API",
|
||||
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
||||
@@ -486,6 +490,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Cuando haces clic en Inscribirse, tu suscripción comienza inmediatamente y se cargará a tu tarjeta.",
|
||||
|
||||
"workspace.lite.loading": "Cargando...",
|
||||
"workspace.lite.time.day": "día",
|
||||
"workspace.lite.time.days": "días",
|
||||
"workspace.lite.time.hour": "hora",
|
||||
"workspace.lite.time.hours": "horas",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minutos",
|
||||
"workspace.lite.time.fewSeconds": "unos pocos segundos",
|
||||
"workspace.lite.subscription.title": "Suscripción Lite",
|
||||
"workspace.lite.subscription.message": "Estás suscrito a OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Gestionar Suscripción",
|
||||
"workspace.lite.subscription.rollingUsage": "Uso Continuo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Uso Semanal",
|
||||
"workspace.lite.subscription.monthlyUsage": "Uso Mensual",
|
||||
"workspace.lite.subscription.resetsIn": "Se reinicia en",
|
||||
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
|
||||
"workspace.lite.other.title": "Suscripción Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Otro miembro de este espacio de trabajo ya está suscrito a OpenCode Lite. Solo un miembro por espacio de trabajo puede suscribirse.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Obtén acceso a los mejores modelos abiertos — Kimi K2.5, GLM-5 y MiniMax M2.5 — con generosos límites de uso por $10 al mes.",
|
||||
"workspace.lite.promo.subscribe": "Suscribirse a Lite",
|
||||
"workspace.lite.promo.subscribing": "Redirigiendo...",
|
||||
|
||||
"download.title": "OpenCode | Descargar",
|
||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||
"download.hero.title": "Descargar OpenCode",
|
||||
|
||||
@@ -251,6 +251,7 @@ export const dict = {
|
||||
"black.hero.title": "Accédez aux meilleurs modèles de code au monde",
|
||||
"black.hero.subtitle": "Y compris Claude, GPT, Gemini et plus",
|
||||
"black.title": "OpenCode Black | Tarification",
|
||||
"black.paused": "L'inscription au plan Black est temporairement suspendue.",
|
||||
"black.plan.icon20": "Forfait Black 20",
|
||||
"black.plan.icon100": "Forfait Black 100",
|
||||
"black.plan.icon200": "Forfait Black 200",
|
||||
@@ -355,6 +356,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Sortie",
|
||||
"workspace.usage.breakdown.reasoning": "Raisonnement",
|
||||
"workspace.usage.subscription": "abonnement ({{amount}} $)",
|
||||
"workspace.usage.lite": "lite ({{amount}} $)",
|
||||
"workspace.usage.byok": "BYOK ({{amount}} $)",
|
||||
|
||||
"workspace.cost.title": "Coût",
|
||||
"workspace.cost.subtitle": "Coûts d'utilisation répartis par modèle.",
|
||||
@@ -363,6 +366,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(supprimé)",
|
||||
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
||||
"workspace.cost.subscriptionShort": "abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Clés API",
|
||||
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
||||
@@ -494,6 +498,32 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Lorsque vous cliquez sur S'inscrire, votre abonnement démarre immédiatement et votre carte sera débitée.",
|
||||
|
||||
"workspace.lite.loading": "Chargement...",
|
||||
"workspace.lite.time.day": "jour",
|
||||
"workspace.lite.time.days": "jours",
|
||||
"workspace.lite.time.hour": "heure",
|
||||
"workspace.lite.time.hours": "heures",
|
||||
"workspace.lite.time.minute": "minute",
|
||||
"workspace.lite.time.minutes": "minutes",
|
||||
"workspace.lite.time.fewSeconds": "quelques secondes",
|
||||
"workspace.lite.subscription.title": "Abonnement Lite",
|
||||
"workspace.lite.subscription.message": "Vous êtes abonné à OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Gérer l'abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Utilisation glissante",
|
||||
"workspace.lite.subscription.weeklyUsage": "Utilisation hebdomadaire",
|
||||
"workspace.lite.subscription.monthlyUsage": "Utilisation mensuelle",
|
||||
"workspace.lite.subscription.resetsIn": "Réinitialisation dans",
|
||||
"workspace.lite.subscription.useBalance":
|
||||
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
|
||||
"workspace.lite.other.title": "Abonnement Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Un autre membre de cet espace de travail est déjà abonné à OpenCode Lite. Un seul membre par espace de travail peut s'abonner.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Accédez aux meilleurs modèles ouverts — Kimi K2.5, GLM-5 et MiniMax M2.5 — avec des limites d'utilisation généreuses pour 10 $ par mois.",
|
||||
"workspace.lite.promo.subscribe": "S'abonner à Lite",
|
||||
"workspace.lite.promo.subscribing": "Redirection...",
|
||||
|
||||
"download.title": "OpenCode | Téléchargement",
|
||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||
"download.hero.title": "Télécharger OpenCode",
|
||||
|
||||
@@ -246,6 +246,7 @@ export const dict = {
|
||||
"black.hero.title": "Accedi ai migliori modelli di coding al mondo",
|
||||
"black.hero.subtitle": "Inclusi Claude, GPT, Gemini e altri",
|
||||
"black.title": "OpenCode Black | Prezzi",
|
||||
"black.paused": "L'iscrizione al piano Black è temporaneamente sospesa.",
|
||||
"black.plan.icon20": "Piano Black 20",
|
||||
"black.plan.icon100": "Piano Black 100",
|
||||
"black.plan.icon200": "Piano Black 200",
|
||||
@@ -349,6 +350,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "abbonamento (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Costo",
|
||||
"workspace.cost.subtitle": "Costi di utilizzo suddivisi per modello.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chiavi API",
|
||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||
@@ -485,6 +489,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Quando clicchi su Iscriviti, il tuo abbonamento inizia immediatamente e la tua carta verrà addebitata.",
|
||||
|
||||
"workspace.lite.loading": "Caricamento...",
|
||||
"workspace.lite.time.day": "giorno",
|
||||
"workspace.lite.time.days": "giorni",
|
||||
"workspace.lite.time.hour": "ora",
|
||||
"workspace.lite.time.hours": "ore",
|
||||
"workspace.lite.time.minute": "minuto",
|
||||
"workspace.lite.time.minutes": "minuti",
|
||||
"workspace.lite.time.fewSeconds": "pochi secondi",
|
||||
"workspace.lite.subscription.title": "Abbonamento Lite",
|
||||
"workspace.lite.subscription.message": "Sei abbonato a OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Gestisci Abbonamento",
|
||||
"workspace.lite.subscription.rollingUsage": "Utilizzo Continuativo",
|
||||
"workspace.lite.subscription.weeklyUsage": "Utilizzo Settimanale",
|
||||
"workspace.lite.subscription.monthlyUsage": "Utilizzo Mensile",
|
||||
"workspace.lite.subscription.resetsIn": "Si resetta tra",
|
||||
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
|
||||
"workspace.lite.other.title": "Abbonamento Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Un altro membro in questo workspace è già abbonato a OpenCode Lite. Solo un membro per workspace può abbonarsi.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Ottieni l'accesso ai migliori modelli aperti — Kimi K2.5, GLM-5 e MiniMax M2.5 — con limiti di utilizzo generosi per $10 al mese.",
|
||||
"workspace.lite.promo.subscribe": "Abbonati a Lite",
|
||||
"workspace.lite.promo.subscribing": "Reindirizzamento...",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||
"download.hero.title": "Scarica OpenCode",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const dict = {
|
||||
"black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
|
||||
"black.hero.subtitle": "Claude、GPT、Gemini などを含む",
|
||||
"black.title": "OpenCode Black | 料金",
|
||||
"black.paused": "Blackプランの登録は一時的に停止しています。",
|
||||
"black.plan.icon20": "Black 20 プラン",
|
||||
"black.plan.icon100": "Black 100 プラン",
|
||||
"black.plan.icon200": "Black 200 プラン",
|
||||
@@ -346,6 +347,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "出力",
|
||||
"workspace.usage.breakdown.reasoning": "推論",
|
||||
"workspace.usage.subscription": "サブスクリプション (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "コスト",
|
||||
"workspace.cost.subtitle": "モデルごとの使用料金の内訳。",
|
||||
@@ -354,6 +357,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(削除済み)",
|
||||
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
||||
"workspace.cost.subscriptionShort": "サブ",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "APIキー",
|
||||
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
||||
@@ -483,6 +487,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"「登録する」をクリックすると、サブスクリプションがすぐに開始され、カードに請求されます。",
|
||||
|
||||
"workspace.lite.loading": "読み込み中...",
|
||||
"workspace.lite.time.day": "日",
|
||||
"workspace.lite.time.days": "日",
|
||||
"workspace.lite.time.hour": "時間",
|
||||
"workspace.lite.time.hours": "時間",
|
||||
"workspace.lite.time.minute": "分",
|
||||
"workspace.lite.time.minutes": "分",
|
||||
"workspace.lite.time.fewSeconds": "数秒",
|
||||
"workspace.lite.subscription.title": "Liteサブスクリプション",
|
||||
"workspace.lite.subscription.message": "あなたは OpenCode Lite を購読しています。",
|
||||
"workspace.lite.subscription.manage": "サブスクリプションの管理",
|
||||
"workspace.lite.subscription.rollingUsage": "ローリング利用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "週間利用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "月間利用量",
|
||||
"workspace.lite.subscription.resetsIn": "リセットまで",
|
||||
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
|
||||
"workspace.lite.other.title": "Liteサブスクリプション",
|
||||
"workspace.lite.other.message":
|
||||
"このワークスペースの別のメンバーが既に OpenCode Lite を購読しています。ワークスペースにつき1人のメンバーのみが購読できます。",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"月額$10で、十分な利用枠が設けられた最高のオープンモデル — Kimi K2.5、GLM-5、および MiniMax M2.5 — にアクセスできます。",
|
||||
"workspace.lite.promo.subscribe": "Liteを購読する",
|
||||
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
||||
|
||||
"download.title": "OpenCode | ダウンロード",
|
||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||
"download.hero.title": "OpenCode をダウンロード",
|
||||
|
||||
@@ -241,6 +241,7 @@ export const dict = {
|
||||
"black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
|
||||
"black.hero.subtitle": "Claude, GPT, Gemini 등 포함",
|
||||
"black.title": "OpenCode Black | 가격",
|
||||
"black.paused": "Black 플랜 등록이 일시적으로 중단되었습니다.",
|
||||
"black.plan.icon20": "Black 20 플랜",
|
||||
"black.plan.icon100": "Black 100 플랜",
|
||||
"black.plan.icon200": "Black 200 플랜",
|
||||
@@ -343,6 +344,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "출력",
|
||||
"workspace.usage.breakdown.reasoning": "추론",
|
||||
"workspace.usage.subscription": "구독 (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "비용",
|
||||
"workspace.cost.subtitle": "모델별 사용 비용 내역.",
|
||||
@@ -351,6 +354,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(삭제됨)",
|
||||
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
||||
"workspace.cost.subscriptionShort": "구독",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 키",
|
||||
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
||||
@@ -478,6 +482,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "등록됨",
|
||||
"workspace.black.waitlist.enrollNote": "등록을 클릭하면 구독이 즉시 시작되며 카드에 요금이 청구됩니다.",
|
||||
|
||||
"workspace.lite.loading": "로드 중...",
|
||||
"workspace.lite.time.day": "일",
|
||||
"workspace.lite.time.days": "일",
|
||||
"workspace.lite.time.hour": "시간",
|
||||
"workspace.lite.time.hours": "시간",
|
||||
"workspace.lite.time.minute": "분",
|
||||
"workspace.lite.time.minutes": "분",
|
||||
"workspace.lite.time.fewSeconds": "몇 초",
|
||||
"workspace.lite.subscription.title": "Lite 구독",
|
||||
"workspace.lite.subscription.message": "현재 OpenCode Lite를 구독 중입니다.",
|
||||
"workspace.lite.subscription.manage": "구독 관리",
|
||||
"workspace.lite.subscription.rollingUsage": "롤링 사용량",
|
||||
"workspace.lite.subscription.weeklyUsage": "주간 사용량",
|
||||
"workspace.lite.subscription.monthlyUsage": "월간 사용량",
|
||||
"workspace.lite.subscription.resetsIn": "초기화까지 남은 시간:",
|
||||
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
|
||||
"workspace.lite.other.title": "Lite 구독",
|
||||
"workspace.lite.other.message":
|
||||
"이 워크스페이스의 다른 멤버가 이미 OpenCode Lite를 구독 중입니다. 워크스페이스당 한 명의 멤버만 구독할 수 있습니다.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"월 $10의 넉넉한 사용 한도로 최고의 오픈 모델인 Kimi K2.5, GLM-5, MiniMax M2.5에 액세스하세요.",
|
||||
"workspace.lite.promo.subscribe": "Lite 구독하기",
|
||||
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
||||
|
||||
"download.title": "OpenCode | 다운로드",
|
||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||
"download.hero.title": "OpenCode 다운로드",
|
||||
|
||||
@@ -245,6 +245,7 @@ export const dict = {
|
||||
"black.hero.title": "Få tilgang til verdens beste kodemodeller",
|
||||
"black.hero.subtitle": "Inkludert Claude, GPT, Gemini og mer",
|
||||
"black.title": "OpenCode Black | Priser",
|
||||
"black.paused": "Black-planregistrering er midlertidig satt på pause.",
|
||||
"black.plan.icon20": "Black 20-plan",
|
||||
"black.plan.icon100": "Black 100-plan",
|
||||
"black.plan.icon200": "Black 200-plan",
|
||||
@@ -347,6 +348,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Resonnering",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Kostnad",
|
||||
"workspace.cost.subtitle": "Brukskostnader fordelt på modell.",
|
||||
@@ -355,6 +358,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøkler",
|
||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||
@@ -483,6 +487,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Når du klikker på Meld på, starter abonnementet umiddelbart og kortet ditt belastes.",
|
||||
|
||||
"workspace.lite.loading": "Laster...",
|
||||
"workspace.lite.time.day": "dag",
|
||||
"workspace.lite.time.days": "dager",
|
||||
"workspace.lite.time.hour": "time",
|
||||
"workspace.lite.time.hours": "timer",
|
||||
"workspace.lite.time.minute": "minutt",
|
||||
"workspace.lite.time.minutes": "minutter",
|
||||
"workspace.lite.time.fewSeconds": "noen få sekunder",
|
||||
"workspace.lite.subscription.title": "Lite-abonnement",
|
||||
"workspace.lite.subscription.message": "Du abonnerer på OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Administrer abonnement",
|
||||
"workspace.lite.subscription.rollingUsage": "Løpende bruk",
|
||||
"workspace.lite.subscription.weeklyUsage": "Ukentlig bruk",
|
||||
"workspace.lite.subscription.monthlyUsage": "Månedlig bruk",
|
||||
"workspace.lite.subscription.resetsIn": "Nullstilles om",
|
||||
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
|
||||
"workspace.lite.other.title": "Lite-abonnement",
|
||||
"workspace.lite.other.message":
|
||||
"Et annet medlem i dette arbeidsområdet abonnerer allerede på OpenCode Lite. Kun ett medlem per arbeidsområde kan abonnere.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Få tilgang til de beste åpne modellene — Kimi K2.5, GLM-5 og MiniMax M2.5 — med generøse bruksgrenser for $10 per måned.",
|
||||
"workspace.lite.promo.subscribe": "Abonner på Lite",
|
||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||
|
||||
"download.title": "OpenCode | Last ned",
|
||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||
"download.hero.title": "Last ned OpenCode",
|
||||
|
||||
@@ -246,6 +246,7 @@ export const dict = {
|
||||
"black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
|
||||
"black.hero.subtitle": "W tym Claude, GPT, Gemini i inne",
|
||||
"black.title": "OpenCode Black | Cennik",
|
||||
"black.paused": "Rejestracja planu Black jest tymczasowo wstrzymana.",
|
||||
"black.plan.icon20": "Plan Black 20",
|
||||
"black.plan.icon100": "Plan Black 100",
|
||||
"black.plan.icon200": "Plan Black 200",
|
||||
@@ -348,6 +349,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Wyjście",
|
||||
"workspace.usage.breakdown.reasoning": "Rozumowanie",
|
||||
"workspace.usage.subscription": "subskrypcja (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Koszt",
|
||||
"workspace.cost.subtitle": "Koszty użycia w podziale na modele.",
|
||||
@@ -356,6 +359,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(usunięte)",
|
||||
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Klucze API",
|
||||
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
||||
@@ -484,6 +488,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Po kliknięciu Zapisz się, Twoja subskrypcja rozpocznie się natychmiast, a karta zostanie obciążona.",
|
||||
|
||||
"workspace.lite.loading": "Ładowanie...",
|
||||
"workspace.lite.time.day": "dzień",
|
||||
"workspace.lite.time.days": "dni",
|
||||
"workspace.lite.time.hour": "godzina",
|
||||
"workspace.lite.time.hours": "godzin(y)",
|
||||
"workspace.lite.time.minute": "minuta",
|
||||
"workspace.lite.time.minutes": "minut(y)",
|
||||
"workspace.lite.time.fewSeconds": "kilka sekund",
|
||||
"workspace.lite.subscription.title": "Subskrypcja Lite",
|
||||
"workspace.lite.subscription.message": "Subskrybujesz OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Zarządzaj subskrypcją",
|
||||
"workspace.lite.subscription.rollingUsage": "Użycie kroczące",
|
||||
"workspace.lite.subscription.weeklyUsage": "Użycie tygodniowe",
|
||||
"workspace.lite.subscription.monthlyUsage": "Użycie miesięczne",
|
||||
"workspace.lite.subscription.resetsIn": "Resetuje się za",
|
||||
"workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia",
|
||||
"workspace.lite.other.title": "Subskrypcja Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Inny członek tego obszaru roboczego już subskrybuje OpenCode Lite. Tylko jeden członek na obszar roboczy może subskrybować.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Uzyskaj dostęp do najlepszych otwartych modeli — Kimi K2.5, GLM-5 i MiniMax M2.5 — z hojnymi limitami użycia za $10 miesięcznie.",
|
||||
"workspace.lite.promo.subscribe": "Subskrybuj Lite",
|
||||
"workspace.lite.promo.subscribing": "Przekierowywanie...",
|
||||
|
||||
"download.title": "OpenCode | Pobierz",
|
||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||
"download.hero.title": "Pobierz OpenCode",
|
||||
|
||||
@@ -249,6 +249,7 @@ export const dict = {
|
||||
"black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
|
||||
"black.hero.subtitle": "Включая Claude, GPT, Gemini и другие",
|
||||
"black.title": "OpenCode Black | Цены",
|
||||
"black.paused": "Регистрация на план Black временно приостановлена.",
|
||||
"black.plan.icon20": "План Black 20",
|
||||
"black.plan.icon100": "План Black 100",
|
||||
"black.plan.icon200": "План Black 200",
|
||||
@@ -353,6 +354,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Выход",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning (рассуждения)",
|
||||
"workspace.usage.subscription": "подписка (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Расходы",
|
||||
"workspace.cost.subtitle": "Расходы на использование с разбивкой по моделям.",
|
||||
@@ -361,6 +364,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(удалено)",
|
||||
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
|
||||
"workspace.cost.subscriptionShort": "подписка",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Ключи",
|
||||
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
|
||||
@@ -489,6 +493,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Когда вы нажмете Подключиться, ваша подписка начнется немедленно, и с карты будет списана оплата.",
|
||||
|
||||
"workspace.lite.loading": "Загрузка...",
|
||||
"workspace.lite.time.day": "день",
|
||||
"workspace.lite.time.days": "дней",
|
||||
"workspace.lite.time.hour": "час",
|
||||
"workspace.lite.time.hours": "часов",
|
||||
"workspace.lite.time.minute": "минута",
|
||||
"workspace.lite.time.minutes": "минут",
|
||||
"workspace.lite.time.fewSeconds": "несколько секунд",
|
||||
"workspace.lite.subscription.title": "Подписка Lite",
|
||||
"workspace.lite.subscription.message": "Вы подписаны на OpenCode Lite.",
|
||||
"workspace.lite.subscription.manage": "Управление подпиской",
|
||||
"workspace.lite.subscription.rollingUsage": "Скользящее использование",
|
||||
"workspace.lite.subscription.weeklyUsage": "Недельное использование",
|
||||
"workspace.lite.subscription.monthlyUsage": "Ежемесячное использование",
|
||||
"workspace.lite.subscription.resetsIn": "Сброс через",
|
||||
"workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов",
|
||||
"workspace.lite.other.title": "Подписка Lite",
|
||||
"workspace.lite.other.message":
|
||||
"Другой участник в этом рабочем пространстве уже подписан на OpenCode Lite. Только один участник в рабочем пространстве может оформить подписку.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Получите доступ к лучшим открытым моделям — Kimi K2.5, GLM-5 и MiniMax M2.5 — с щедрыми лимитами использования за $10 в месяц.",
|
||||
"workspace.lite.promo.subscribe": "Подписаться на Lite",
|
||||
"workspace.lite.promo.subscribing": "Перенаправление...",
|
||||
|
||||
"download.title": "OpenCode | Скачать",
|
||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||
"download.hero.title": "Скачать OpenCode",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const dict = {
|
||||
"black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
||||
"black.hero.subtitle": "รวมถึง Claude, GPT, Gemini และอื่นๆ อีกมากมาย",
|
||||
"black.title": "OpenCode Black | ราคา",
|
||||
"black.paused": "การสมัครแผน Black หยุดชั่วคราว",
|
||||
"black.plan.icon20": "แผน Black 20",
|
||||
"black.plan.icon100": "แผน Black 100",
|
||||
"black.plan.icon200": "แผน Black 200",
|
||||
@@ -346,6 +347,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "สมัครสมาชิก (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "ค่าใช้จ่าย",
|
||||
"workspace.cost.subtitle": "ต้นทุนการใช้งานแยกตามโมเดล",
|
||||
@@ -354,6 +357,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
|
||||
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
|
||||
@@ -482,6 +486,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"เมื่อคุณคลิกลงทะเบียน การสมัครสมาชิกของคุณจะเริ่มต้นทันทีและบัตรของคุณจะถูกเรียกเก็บเงิน",
|
||||
|
||||
"workspace.lite.loading": "กำลังโหลด...",
|
||||
"workspace.lite.time.day": "วัน",
|
||||
"workspace.lite.time.days": "วัน",
|
||||
"workspace.lite.time.hour": "ชั่วโมง",
|
||||
"workspace.lite.time.hours": "ชั่วโมง",
|
||||
"workspace.lite.time.minute": "นาที",
|
||||
"workspace.lite.time.minutes": "นาที",
|
||||
"workspace.lite.time.fewSeconds": "ไม่กี่วินาที",
|
||||
"workspace.lite.subscription.title": "การสมัครสมาชิก Lite",
|
||||
"workspace.lite.subscription.message": "คุณได้สมัครสมาชิก OpenCode Lite แล้ว",
|
||||
"workspace.lite.subscription.manage": "จัดการการสมัครสมาชิก",
|
||||
"workspace.lite.subscription.rollingUsage": "การใช้งานแบบหมุนเวียน",
|
||||
"workspace.lite.subscription.weeklyUsage": "การใช้งานรายสัปดาห์",
|
||||
"workspace.lite.subscription.monthlyUsage": "การใช้งานรายเดือน",
|
||||
"workspace.lite.subscription.resetsIn": "รีเซ็ตใน",
|
||||
"workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน",
|
||||
"workspace.lite.other.title": "การสมัครสมาชิก Lite",
|
||||
"workspace.lite.other.message":
|
||||
"สมาชิกคนอื่นใน Workspace นี้ได้สมัคร OpenCode Lite แล้ว สามารถสมัครได้เพียงหนึ่งคนต่อหนึ่ง Workspace เท่านั้น",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"เข้าถึงโมเดลเปิดที่ดีที่สุด — Kimi K2.5, GLM-5 และ MiniMax M2.5 — พร้อมขีดจำกัดการใช้งานมากมายในราคา $10 ต่อเดือน",
|
||||
"workspace.lite.promo.subscribe": "สมัครสมาชิก Lite",
|
||||
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
||||
|
||||
"download.title": "OpenCode | ดาวน์โหลด",
|
||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
||||
|
||||
@@ -247,6 +247,7 @@ export const dict = {
|
||||
"black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
|
||||
"black.hero.subtitle": "Claude, GPT, Gemini ve daha fazlası dahil",
|
||||
"black.title": "OpenCode Black | Fiyatlandırma",
|
||||
"black.paused": "Black plan kaydı geçici olarak duraklatıldı.",
|
||||
"black.plan.icon20": "Black 20 planı",
|
||||
"black.plan.icon100": "Black 100 planı",
|
||||
"black.plan.icon200": "Black 200 planı",
|
||||
@@ -349,6 +350,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "Çıkış",
|
||||
"workspace.usage.breakdown.reasoning": "Muhakeme",
|
||||
"workspace.usage.subscription": "abonelik (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Maliyet",
|
||||
"workspace.cost.subtitle": "Modele göre ayrılmış kullanım maliyetleri.",
|
||||
@@ -357,6 +360,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(silindi)",
|
||||
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
|
||||
"workspace.cost.subscriptionShort": "abonelik",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Anahtarları",
|
||||
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
|
||||
@@ -485,6 +489,31 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Kayıt Ol'a tıkladığınızda aboneliğiniz hemen başlar ve kartınızdan çekim yapılır.",
|
||||
|
||||
"workspace.lite.loading": "Yükleniyor...",
|
||||
"workspace.lite.time.day": "gün",
|
||||
"workspace.lite.time.days": "gün",
|
||||
"workspace.lite.time.hour": "saat",
|
||||
"workspace.lite.time.hours": "saat",
|
||||
"workspace.lite.time.minute": "dakika",
|
||||
"workspace.lite.time.minutes": "dakika",
|
||||
"workspace.lite.time.fewSeconds": "birkaç saniye",
|
||||
"workspace.lite.subscription.title": "Lite Aboneliği",
|
||||
"workspace.lite.subscription.message": "OpenCode Lite abonesisiniz.",
|
||||
"workspace.lite.subscription.manage": "Aboneliği Yönet",
|
||||
"workspace.lite.subscription.rollingUsage": "Devam Eden Kullanım",
|
||||
"workspace.lite.subscription.weeklyUsage": "Haftalık Kullanım",
|
||||
"workspace.lite.subscription.monthlyUsage": "Aylık Kullanım",
|
||||
"workspace.lite.subscription.resetsIn": "Sıfırlama süresi",
|
||||
"workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın",
|
||||
"workspace.lite.other.title": "Lite Aboneliği",
|
||||
"workspace.lite.other.message":
|
||||
"Bu çalışma alanındaki başka bir üye zaten OpenCode Lite abonesi. Çalışma alanı başına yalnızca bir üye abone olabilir.",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"Ayda $10 karşılığında cömert kullanım limitleriyle en iyi açık modellere — Kimi K2.5, GLM-5 ve MiniMax M2.5 — erişin.",
|
||||
"workspace.lite.promo.subscribe": "Lite'a Abone Ol",
|
||||
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
|
||||
|
||||
"download.title": "OpenCode | İndir",
|
||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||
"download.hero.title": "OpenCode'u İndir",
|
||||
|
||||
@@ -234,6 +234,7 @@ export const dict = {
|
||||
"black.hero.title": "访问全球顶尖编程模型",
|
||||
"black.hero.subtitle": "包括 Claude, GPT, Gemini 等",
|
||||
"black.title": "OpenCode Black | 定价",
|
||||
"black.paused": "Black 订阅已暂时暂停注册。",
|
||||
"black.plan.icon20": "Black 20 计划",
|
||||
"black.plan.icon100": "Black 100 计划",
|
||||
"black.plan.icon200": "Black 200 计划",
|
||||
@@ -334,6 +335,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "输出",
|
||||
"workspace.usage.breakdown.reasoning": "推理",
|
||||
"workspace.usage.subscription": "订阅 (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按模型细分的使用成本。",
|
||||
@@ -342,6 +345,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已删除)",
|
||||
"workspace.cost.empty": "所选期间无可用使用数据。",
|
||||
"workspace.cost.subscriptionShort": "订阅",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 密钥",
|
||||
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
|
||||
@@ -469,6 +473,30 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "点击加入后,您的订阅将立即开始,并将从您的卡中扣费。",
|
||||
|
||||
"workspace.lite.loading": "加载中...",
|
||||
"workspace.lite.time.day": "天",
|
||||
"workspace.lite.time.days": "天",
|
||||
"workspace.lite.time.hour": "小时",
|
||||
"workspace.lite.time.hours": "小时",
|
||||
"workspace.lite.time.minute": "分钟",
|
||||
"workspace.lite.time.minutes": "分钟",
|
||||
"workspace.lite.time.fewSeconds": "几秒钟",
|
||||
"workspace.lite.subscription.title": "Lite 订阅",
|
||||
"workspace.lite.subscription.message": "您已订阅 OpenCode Lite。",
|
||||
"workspace.lite.subscription.manage": "管理订阅",
|
||||
"workspace.lite.subscription.rollingUsage": "滚动用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "每周用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "每月用量",
|
||||
"workspace.lite.subscription.resetsIn": "重置于",
|
||||
"workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额",
|
||||
"workspace.lite.other.title": "Lite 订阅",
|
||||
"workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Lite。每个工作区只有一名成员可以订阅。",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"每月仅需 $10 即可访问最优秀的开源模型 — Kimi K2.5, GLM-5, 和 MiniMax M2.5 — 并享受充裕的使用限额。",
|
||||
"workspace.lite.promo.subscribe": "订阅 Lite",
|
||||
"workspace.lite.promo.subscribing": "正在重定向...",
|
||||
|
||||
"download.title": "OpenCode | 下载",
|
||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||
"download.hero.title": "下载 OpenCode",
|
||||
|
||||
@@ -234,6 +234,7 @@ export const dict = {
|
||||
"black.hero.title": "存取全球最佳編碼模型",
|
||||
"black.hero.subtitle": "包括 Claude、GPT、Gemini 等",
|
||||
"black.title": "OpenCode Black | 定價",
|
||||
"black.paused": "Black 訂閱暫時暫停註冊。",
|
||||
"black.plan.icon20": "Black 20 方案",
|
||||
"black.plan.icon100": "Black 100 方案",
|
||||
"black.plan.icon200": "Black 200 方案",
|
||||
@@ -334,6 +335,8 @@ export const dict = {
|
||||
"workspace.usage.breakdown.output": "輸出",
|
||||
"workspace.usage.breakdown.reasoning": "推理",
|
||||
"workspace.usage.subscription": "訂閱 (${{amount}})",
|
||||
"workspace.usage.lite": "lite (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按模型細分的使用成本。",
|
||||
@@ -342,6 +345,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已刪除)",
|
||||
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
|
||||
"workspace.cost.subscriptionShort": "訂",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 金鑰",
|
||||
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
|
||||
@@ -469,6 +473,30 @@ export const dict = {
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "當你點選「加入」後,你的訂閱將立即開始,並且將從你的卡片中扣款。",
|
||||
|
||||
"workspace.lite.loading": "載入中...",
|
||||
"workspace.lite.time.day": "天",
|
||||
"workspace.lite.time.days": "天",
|
||||
"workspace.lite.time.hour": "小時",
|
||||
"workspace.lite.time.hours": "小時",
|
||||
"workspace.lite.time.minute": "分鐘",
|
||||
"workspace.lite.time.minutes": "分鐘",
|
||||
"workspace.lite.time.fewSeconds": "幾秒",
|
||||
"workspace.lite.subscription.title": "Lite 訂閱",
|
||||
"workspace.lite.subscription.message": "您已訂閱 OpenCode Lite。",
|
||||
"workspace.lite.subscription.manage": "管理訂閱",
|
||||
"workspace.lite.subscription.rollingUsage": "滾動使用量",
|
||||
"workspace.lite.subscription.weeklyUsage": "每週使用量",
|
||||
"workspace.lite.subscription.monthlyUsage": "每月使用量",
|
||||
"workspace.lite.subscription.resetsIn": "重置時間:",
|
||||
"workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額",
|
||||
"workspace.lite.other.title": "Lite 訂閱",
|
||||
"workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Lite。每個工作區只能有一位成員訂閱。",
|
||||
"workspace.lite.promo.title": "OpenCode Lite",
|
||||
"workspace.lite.promo.description":
|
||||
"每月只需 $10 即可使用最佳的開放模型 — Kimi K2.5、GLM-5 和 MiniMax M2.5 — 並享有慷慨的使用限制。",
|
||||
"workspace.lite.promo.subscribe": "訂閱 Lite",
|
||||
"workspace.lite.promo.subscribing": "重新導向中...",
|
||||
|
||||
"download.title": "OpenCode | 下載",
|
||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||
"download.hero.title": "下載 OpenCode",
|
||||
|
||||
@@ -335,6 +335,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="paused"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
padding: 120px 20px;
|
||||
}
|
||||
|
||||
[data-slot="pricing-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PlanIcon, plans } from "./common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
|
||||
const paused = true
|
||||
|
||||
export default function Black() {
|
||||
const [params] = useSearchParams()
|
||||
const i18n = useI18n()
|
||||
@@ -42,72 +44,76 @@ export default function Black() {
|
||||
<>
|
||||
<Title>{i18n.t("black.title")}</Title>
|
||||
<section data-slot="cta">
|
||||
<Switch>
|
||||
<Match when={!selected()}>
|
||||
<div data-slot="pricing">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(plan.id)}
|
||||
data-slot="pricing-card"
|
||||
style={{ "view-transition-name": `card-${plan.id}` }}
|
||||
>
|
||||
<Show when={!paused} fallback={<p data-slot="paused">{i18n.t("black.paused")}</p>}>
|
||||
<Switch>
|
||||
<Match when={!selected()}>
|
||||
<div data-slot="pricing">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(plan.id)}
|
||||
data-slot="pricing-card"
|
||||
style={{ "view-transition-name": `card-${plan.id}` }}
|
||||
>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan.id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan.id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
|
||||
<Show when={plan.multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={selectedPlan()}>
|
||||
{(plan) => (
|
||||
<div data-slot="selected-plan">
|
||||
<div data-slot="selected-card" style={{ "view-transition-name": `card-${plan().id}` }}>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan.id} />
|
||||
<PlanIcon plan={plan().id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan.id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
|
||||
<Show when={plan.multiplier}>
|
||||
<span data-slot="amount">${plan().id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
|
||||
<Show when={plan().multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={selectedPlan()}>
|
||||
{(plan) => (
|
||||
<div data-slot="selected-plan">
|
||||
<div data-slot="selected-card" style={{ "view-transition-name": `card-${plan().id}` }}>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan().id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan().id}</span>{" "}
|
||||
<span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
|
||||
<Show when={plan().multiplier}>
|
||||
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
||||
</Show>
|
||||
</p>
|
||||
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
|
||||
<li>{i18n.t("black.terms.1")}</li>
|
||||
<li>{i18n.t("black.terms.2")}</li>
|
||||
<li>{i18n.t("black.terms.3")}</li>
|
||||
<li>{i18n.t("black.terms.4")}</li>
|
||||
<li>{i18n.t("black.terms.5")}</li>
|
||||
<li>{i18n.t("black.terms.6")}</li>
|
||||
<li>{i18n.t("black.terms.7")}</li>
|
||||
</ul>
|
||||
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
|
||||
<button type="button" onClick={() => cancel()} data-slot="cancel">
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||
{i18n.t("black.action.continue")}
|
||||
</a>
|
||||
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
|
||||
<li>{i18n.t("black.terms.1")}</li>
|
||||
<li>{i18n.t("black.terms.2")}</li>
|
||||
<li>{i18n.t("black.terms.3")}</li>
|
||||
<li>{i18n.t("black.terms.4")}</li>
|
||||
<li>{i18n.t("black.terms.5")}</li>
|
||||
<li>{i18n.t("black.terms.6")}</li>
|
||||
<li>{i18n.t("black.terms.7")}</li>
|
||||
</ul>
|
||||
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
|
||||
<button type="button" onClick={() => cancel()} data-slot="cancel">
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||
{i18n.t("black.action.continue")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
|
||||
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
|
||||
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
|
||||
</p>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
<Show when={!paused}>
|
||||
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
|
||||
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
|
||||
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, LiteTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -103,310 +103,93 @@ export async function POST(input: APIEvent) {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
||||
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
|
||||
const amountInCents = body.data.object.amount_total as number
|
||||
const customerID = body.data.object.customer as string
|
||||
const customerEmail = body.data.object.customer_details?.email as string
|
||||
const invoiceID = body.data.object.invoice as string
|
||||
const subscriptionID = body.data.object.subscription as string
|
||||
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||
if (body.type === "customer.subscription.created") {
|
||||
const type = body.data.object.metadata?.type
|
||||
if (type === "lite") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const userID = body.data.object.metadata?.userID
|
||||
const customerID = body.data.object.customer as string
|
||||
const invoiceID = body.data.object.latest_invoice as string
|
||||
const subscriptionID = body.data.object.id as string
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amountInCents) throw new Error("Amount not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!userID) throw new Error("User ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
|
||||
// get coupon id from promotion code
|
||||
const couponID = await (async () => {
|
||||
if (!promoCode) return
|
||||
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||
const couponID = coupon.coupon.id
|
||||
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||
return couponID
|
||||
})()
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
|
||||
// Temporarily skip this check because during Black drop, user can checkout
|
||||
// as a new customer
|
||||
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
// Temporarily check the user to apply to. After Black drop, we will allow
|
||||
// look up the user to apply to
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
const user = users.find((u) => u.email === customerEmail) ?? users[0]
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// set customer metadata
|
||||
if (!billing?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon: couponID,
|
||||
seats: 1,
|
||||
plan: "200",
|
||||
// set customer metadata
|
||||
if (!billing?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
}
|
||||
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
liteSubscriptionID: subscriptionID,
|
||||
lite: {},
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
couponID,
|
||||
},
|
||||
await tx.insert(LiteTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("lite"),
|
||||
userID: userID,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "customer.subscription.created") {
|
||||
/*
|
||||
{
|
||||
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
||||
object: "event",
|
||||
api_version: "2025-07-30.basil",
|
||||
created: 1767766916,
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
object: "subscription",
|
||||
application: null,
|
||||
application_fee_percent: null,
|
||||
automatic_tax: {
|
||||
disabled_reason: null,
|
||||
enabled: false,
|
||||
liability: null,
|
||||
},
|
||||
billing_cycle_anchor: 1770445200,
|
||||
billing_cycle_anchor_config: null,
|
||||
billing_mode: {
|
||||
flexible: {
|
||||
proration_discounts: "included",
|
||||
},
|
||||
type: "flexible",
|
||||
updated_at: 1770445200,
|
||||
},
|
||||
billing_thresholds: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
cancellation_details: {
|
||||
comment: null,
|
||||
feedback: null,
|
||||
reason: null,
|
||||
},
|
||||
collection_method: "charge_automatically",
|
||||
created: 1770445200,
|
||||
currency: "usd",
|
||||
customer: "cus_TkKmZZvysJ2wej",
|
||||
customer_account: null,
|
||||
days_until_due: null,
|
||||
default_payment_method: null,
|
||||
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
|
||||
default_tax_rates: [],
|
||||
description: null,
|
||||
discounts: [],
|
||||
ended_at: null,
|
||||
invoice_settings: {
|
||||
account_tax_ids: null,
|
||||
issuer: {
|
||||
type: "self",
|
||||
},
|
||||
},
|
||||
items: {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: "si_TkKnBKXFX76t0O",
|
||||
object: "subscription_item",
|
||||
billing_thresholds: null,
|
||||
created: 1770445200,
|
||||
current_period_end: 1772864400,
|
||||
current_period_start: 1770445200,
|
||||
discounts: [],
|
||||
metadata: {},
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
price: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "price",
|
||||
active: true,
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
custom_unit_amount: null,
|
||||
livemode: false,
|
||||
lookup_key: null,
|
||||
metadata: {},
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
meter: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
tax_behavior: "unspecified",
|
||||
tiers_mode: null,
|
||||
transform_quantity: null,
|
||||
type: "recurring",
|
||||
unit_amount: 20000,
|
||||
unit_amount_decimal: "20000",
|
||||
},
|
||||
quantity: 1,
|
||||
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
tax_rates: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
total_count: 1,
|
||||
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
},
|
||||
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
next_pending_invoice_item_invoice: null,
|
||||
on_behalf_of: null,
|
||||
pause_collection: null,
|
||||
payment_settings: {
|
||||
payment_method_options: null,
|
||||
payment_method_types: null,
|
||||
save_default_payment_method: "off",
|
||||
},
|
||||
pending_invoice_item_interval: null,
|
||||
pending_setup_intent: null,
|
||||
pending_update: null,
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
quantity: 1,
|
||||
schedule: null,
|
||||
start_date: 1770445200,
|
||||
status: "active",
|
||||
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
|
||||
transfer_data: null,
|
||||
trial_end: null,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: "create_invoice",
|
||||
},
|
||||
},
|
||||
trial_start: null,
|
||||
},
|
||||
},
|
||||
livemode: false,
|
||||
pending_webhooks: 0,
|
||||
request: {
|
||||
id: "req_6YO9stvB155WJD",
|
||||
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
|
||||
},
|
||||
type: "customer.subscription.created",
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
const productID = body.data.object.items.data[0].price.product as string
|
||||
if (productID === LiteData.productID()) {
|
||||
await Billing.unsubscribeLite({ subscriptionID })
|
||||
} else if (productID === BlackData.productID()) {
|
||||
await Billing.unsubscribeBlack({ subscriptionID })
|
||||
}
|
||||
}
|
||||
if (body.type === "customer.subscription.deleted") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
const productID = body.data.object.items.data[0].price.product as string
|
||||
if (productID === LiteData.productID()) {
|
||||
await Billing.unsubscribeLite({ subscriptionID })
|
||||
} else if (productID === BlackData.productID()) {
|
||||
await Billing.unsubscribeBlack({ subscriptionID })
|
||||
}
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded") {
|
||||
if (
|
||||
@@ -430,6 +213,7 @@ export async function POST(input: APIEvent) {
|
||||
typeof subscriptionData.discounts[0] === "string"
|
||||
? subscriptionData.discounts[0]
|
||||
: subscriptionData.discounts[0]?.coupon?.id
|
||||
const productID = subscriptionData.items.data[0].price.product as string
|
||||
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
@@ -459,7 +243,7 @@ export async function POST(input: APIEvent) {
|
||||
invoiceID,
|
||||
customerID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
type: productID === LiteData.productID() ? "lite" : "subscription",
|
||||
couponID,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -90,7 +90,7 @@ const enroll = action(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Billing.subscribe({ seats: 1 })
|
||||
await Billing.subscribeBlack({ seats: 1 })
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BillingSection } from "./billing-section"
|
||||
import { ReloadSection } from "./reload-section"
|
||||
import { PaymentSection } from "./payment-section"
|
||||
import { BlackSection } from "./black-section"
|
||||
import { Show } from "solid-js"
|
||||
import { LiteSection } from "./lite-section"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||
|
||||
@@ -11,14 +12,18 @@ export default function () {
|
||||
const params = useParams()
|
||||
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={sessionInfo()?.isAdmin}>
|
||||
<Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
|
||||
<Show when={isBlack()}>
|
||||
<BlackSection />
|
||||
</Show>
|
||||
<Show when={!isBlack() && sessionInfo()?.isBeta}>
|
||||
<LiteSection />
|
||||
</Show>
|
||||
<BillingSection />
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<ReloadSection />
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="usage"] {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="usage-item"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="usage-header"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
[data-slot="usage-label"] {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-slot="usage-value"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="progress"] {
|
||||
height: 8px;
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="progress-bar"] {
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
[data-slot="reset-time"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="setting-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="toggle-label"] {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.125rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: #21ad0e;
|
||||
border-color: #148605;
|
||||
|
||||
&::before {
|
||||
transform: translateX(1rem) translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
input:checked:hover + span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="other-message"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-slot="promo-description"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="subscribe-button"] {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show } from "solid-js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { queryBillingInfo } from "../../common"
|
||||
import styles from "./lite-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const row = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
userID: LiteTable.userID,
|
||||
rollingUsage: LiteTable.rollingUsage,
|
||||
weeklyUsage: LiteTable.weeklyUsage,
|
||||
monthlyUsage: LiteTable.monthlyUsage,
|
||||
timeRollingUpdated: LiteTable.timeRollingUpdated,
|
||||
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
|
||||
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
|
||||
timeCreated: LiteTable.timeCreated,
|
||||
lite: BillingTable.lite,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
|
||||
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
|
||||
.then((r) => r[0]),
|
||||
)
|
||||
if (!row) return null
|
||||
|
||||
const limits = LiteData.getLimits()
|
||||
const mine = row.userID === Actor.userID()
|
||||
|
||||
return {
|
||||
mine,
|
||||
useBalance: row.lite?.useBalance ?? false,
|
||||
rollingUsage: Subscription.analyzeRollingUsage({
|
||||
limit: limits.rollingLimit,
|
||||
window: limits.rollingWindow,
|
||||
usage: row.rollingUsage ?? 0,
|
||||
timeUpdated: row.timeRollingUpdated ?? new Date(),
|
||||
}),
|
||||
weeklyUsage: Subscription.analyzeWeeklyUsage({
|
||||
limit: limits.weeklyLimit,
|
||||
usage: row.weeklyUsage ?? 0,
|
||||
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
|
||||
}),
|
||||
monthlyUsage: Subscription.analyzeMonthlyUsage({
|
||||
limit: limits.monthlyLimit,
|
||||
usage: row.monthlyUsage ?? 0,
|
||||
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
|
||||
timeSubscribed: row.timeCreated,
|
||||
}),
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "lite.subscription.get")
|
||||
|
||||
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours >= 1)
|
||||
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
||||
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
|
||||
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) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "liteCheckoutUrl")
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateSessionUrl({ returnUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "liteSessionUrl")
|
||||
|
||||
const setLiteUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
lite: useBalance ? { useBalance: true } : {},
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||
)
|
||||
}, "setLiteUseBalance")
|
||||
|
||||
export function LiteSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const lite = createAsync(() => queryLiteSubscription(params.id!))
|
||||
const sessionAction = useAction(createSessionUrl)
|
||||
const sessionSubmission = useSubmission(createSessionUrl)
|
||||
const checkoutAction = useAction(createLiteCheckoutUrl)
|
||||
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
||||
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
||||
const [store, setStore] = createStore({
|
||||
redirecting: false,
|
||||
})
|
||||
|
||||
async function onClickSession() {
|
||||
const result = await sessionAction(params.id!, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("redirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickSubscribe() {
|
||||
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("redirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={lite() && lite()!.mine && lite()!}>
|
||||
{(sub) => (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.subscription.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={sessionSubmission.pending || store.redirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.redirecting
|
||||
? i18n.t("workspace.lite.loading")
|
||||
: i18n.t("workspace.lite.subscription.manage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="usage">
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
|
||||
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
|
||||
</div>
|
||||
<div data-slot="progress">
|
||||
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
|
||||
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
|
||||
</div>
|
||||
<div data-slot="progress">
|
||||
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-item">
|
||||
<div data-slot="usage-header">
|
||||
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
|
||||
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
|
||||
</div>
|
||||
<div data-slot="progress">
|
||||
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
|
||||
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
|
||||
<label data-slot="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sub().useBalance}
|
||||
disabled={useBalanceSubmission.pending}
|
||||
onChange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={lite() && !lite()!.mine}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.other.title")}</h2>
|
||||
</div>
|
||||
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={lite() === null}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.lite.promo.title")}</h2>
|
||||
</div>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.description")}</p>
|
||||
<button
|
||||
data-slot="subscribe-button"
|
||||
data-color="primary"
|
||||
disabled={checkoutSubmission.pending || store.redirecting}
|
||||
onClick={onClickSubscribe}
|
||||
>
|
||||
{checkoutSubmission.pending || store.redirecting
|
||||
? i18n.t("workspace.lite.promo.subscribing")
|
||||
: i18n.t("workspace.lite.promo.subscribe")}
|
||||
</button>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
model: UsageTable.model,
|
||||
totalCost: sum(UsageTable.cost),
|
||||
keyId: UsageTable.keyID,
|
||||
subscription: sql<boolean>`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
|
||||
plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
@@ -50,13 +50,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
sql`DATE(${UsageTable.timeCreated})`,
|
||||
UsageTable.model,
|
||||
UsageTable.keyID,
|
||||
sql`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
|
||||
sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
|
||||
)
|
||||
.then((x) =>
|
||||
x.map((r) => ({
|
||||
...r,
|
||||
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
|
||||
subscription: Boolean(r.subscription),
|
||||
plan: r.plan as "sub" | "lite" | "byok" | null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -218,18 +218,21 @@ export function GraphSection() {
|
||||
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
||||
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
||||
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
||||
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
|
||||
|
||||
const dailyDataRegular = new Map<string, Map<string, number>>()
|
||||
const dailyDataSub = new Map<string, Map<string, number>>()
|
||||
const dailyDataNonSub = new Map<string, Map<string, number>>()
|
||||
const dailyDataLite = new Map<string, Map<string, number>>()
|
||||
for (const dateKey of dates) {
|
||||
dailyDataRegular.set(dateKey, new Map())
|
||||
dailyDataSub.set(dateKey, new Map())
|
||||
dailyDataNonSub.set(dateKey, new Map())
|
||||
dailyDataLite.set(dateKey, new Map())
|
||||
}
|
||||
|
||||
data.usage
|
||||
.filter((row) => (store.key ? row.keyId === store.key : true))
|
||||
.forEach((row) => {
|
||||
const targetMap = row.subscription ? dailyDataSub : dailyDataNonSub
|
||||
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
|
||||
const dayMap = targetMap.get(row.date)
|
||||
if (!dayMap) return
|
||||
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
|
||||
@@ -237,15 +240,15 @@ export function GraphSection() {
|
||||
|
||||
const filteredModels = store.model === null ? getModels() : [store.model]
|
||||
|
||||
// Create datasets: non-subscription first, then subscription (with hatched pattern effect via opacity)
|
||||
// Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
|
||||
const datasets = [
|
||||
...filteredModels
|
||||
.filter((model) => dates.some((date) => (dailyDataNonSub.get(date)?.get(model) || 0) > 0))
|
||||
.filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: model,
|
||||
data: dates.map((date) => (dailyDataNonSub.get(date)?.get(model) || 0) / 100_000_000),
|
||||
data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: color,
|
||||
hoverBackgroundColor: color,
|
||||
borderWidth: 0,
|
||||
@@ -266,6 +269,21 @@ export function GraphSection() {
|
||||
stack: "subscription",
|
||||
}
|
||||
}),
|
||||
...filteredModels
|
||||
.filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: `${model}${liteSuffix}`,
|
||||
data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
|
||||
backgroundColor: addOpacityToColor(color, 0.35),
|
||||
hoverBackgroundColor: addOpacityToColor(color, 0.55),
|
||||
borderWidth: 1,
|
||||
borderColor: addOpacityToColor(color, 0.7),
|
||||
borderDash: [4, 2],
|
||||
stack: "lite",
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return {
|
||||
@@ -347,9 +365,18 @@ export function GraphSection() {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const isLite = label.endsWith(liteSuffix)
|
||||
const model = isSub
|
||||
? label.slice(0, -subSuffix.length)
|
||||
: isLite
|
||||
? label.slice(0, -liteSuffix.length)
|
||||
: label
|
||||
const baseColor = getModelColor(model)
|
||||
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
const originalColor = isSub
|
||||
? addOpacityToColor(baseColor, 0.5)
|
||||
: isLite
|
||||
? addOpacityToColor(baseColor, 0.35)
|
||||
: baseColor
|
||||
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
|
||||
meta.data.forEach((bar: any) => {
|
||||
bar.options.backgroundColor = color
|
||||
@@ -363,9 +390,18 @@ export function GraphSection() {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const isLite = label.endsWith(liteSuffix)
|
||||
const model = isSub
|
||||
? label.slice(0, -subSuffix.length)
|
||||
: isLite
|
||||
? label.slice(0, -liteSuffix.length)
|
||||
: label
|
||||
const baseColor = getModelColor(model)
|
||||
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
const color = isSub
|
||||
? addOpacityToColor(baseColor, 0.5)
|
||||
: isLite
|
||||
? addOpacityToColor(baseColor, 0.35)
|
||||
: baseColor
|
||||
meta.data.forEach((bar: any) => {
|
||||
bar.options.backgroundColor = color
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
|
||||
import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
|
||||
import { formatDateUTC, formatDateForTable } from "../common"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
|
||||
@@ -175,14 +175,23 @@ export function UsageSection() {
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="usage-cost">
|
||||
<Show
|
||||
when={usage.enrichment?.plan === "sub"}
|
||||
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
|
||||
>
|
||||
{i18n.t("workspace.usage.subscription", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Show>
|
||||
<Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
|
||||
<Match when={usage.enrichment?.plan === "sub"}>
|
||||
{i18n.t("workspace.usage.subscription", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
<Match when={usage.enrichment?.plan === "lite"}>
|
||||
{i18n.t("workspace.usage.lite", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
<Match when={usage.enrichment?.plan === "byok"}>
|
||||
{i18n.t("workspace.usage.byok", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Match>
|
||||
</Switch>
|
||||
</td>
|
||||
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
|
||||
</tr>
|
||||
|
||||
@@ -115,6 +115,8 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||
subscriptionPlan: billing.subscriptionPlan,
|
||||
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
||||
timeSubscriptionSelected: billing.timeSubscriptionSelected,
|
||||
lite: billing.lite,
|
||||
liteSubscriptionID: billing.liteSubscriptionID,
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
12
packages/console/app/src/routes/zen/lite/v1/messages.ts
Normal file
12
packages/console/app/src/routes/zen/lite/v1/messages.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "anthropic",
|
||||
modelList: "lite",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { BillingTable, LiteTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||
import { getMonthlyBounds, getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
@@ -33,13 +33,15 @@ import { createRateLimiter } from "./rateLimiter"
|
||||
import { createDataDumper } from "./dataDumper"
|
||||
import { createTrialLimiter } from "./trialLimiter"
|
||||
import { createStickyTracker } from "./stickyProviderTracker"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type RetryOptions = {
|
||||
excludeProviders: string[]
|
||||
retryCount: number
|
||||
}
|
||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "balance"
|
||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
@@ -58,7 +60,7 @@ export async function handler(
|
||||
|
||||
const MAX_FAILOVER_RETRIES = 3
|
||||
const MAX_429_RETRIES = 3
|
||||
const FREE_WORKSPACES = [
|
||||
const ADMIN_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
]
|
||||
@@ -454,6 +456,7 @@ export async function handler(
|
||||
reloadTrigger: BillingTable.reloadTrigger,
|
||||
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
||||
subscription: BillingTable.subscription,
|
||||
lite: BillingTable.lite,
|
||||
},
|
||||
user: {
|
||||
id: UserTable.id,
|
||||
@@ -461,13 +464,23 @@ export async function handler(
|
||||
monthlyUsage: UserTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||
},
|
||||
subscription: {
|
||||
black: {
|
||||
id: SubscriptionTable.id,
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
},
|
||||
lite: {
|
||||
id: LiteTable.id,
|
||||
timeCreated: LiteTable.timeCreated,
|
||||
rollingUsage: LiteTable.rollingUsage,
|
||||
weeklyUsage: LiteTable.weeklyUsage,
|
||||
monthlyUsage: LiteTable.monthlyUsage,
|
||||
timeRollingUpdated: LiteTable.timeRollingUpdated,
|
||||
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
|
||||
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
|
||||
},
|
||||
provider: {
|
||||
credentials: ProviderTable.credentials,
|
||||
},
|
||||
@@ -495,16 +508,42 @@ export async function handler(
|
||||
isNull(SubscriptionTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
LiteTable,
|
||||
and(
|
||||
eq(LiteTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(LiteTable.userID, KeyTable.userID),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!data) throw new AuthError("Invalid API key.")
|
||||
if (
|
||||
modelInfo.id.startsWith("alpha-") &&
|
||||
Resource.App.stage === "production" &&
|
||||
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
||||
)
|
||||
throw new AuthError(`Model ${modelInfo.id} not supported`)
|
||||
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
isSubscription: data.subscription ? true : false,
|
||||
subscription: data.billing.subscription?.plan,
|
||||
...(() => {
|
||||
if (data.billing.subscription)
|
||||
return {
|
||||
isSubscription: true,
|
||||
subscription: data.billing.subscription.plan,
|
||||
}
|
||||
if (data.billing.lite)
|
||||
return {
|
||||
isSubscription: true,
|
||||
subscription: "lite",
|
||||
}
|
||||
return {}
|
||||
})(),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -512,9 +551,10 @@ export async function handler(
|
||||
workspaceID: data.workspaceID,
|
||||
billing: data.billing,
|
||||
user: data.user,
|
||||
subscription: data.subscription,
|
||||
black: data.black,
|
||||
lite: data.lite,
|
||||
provider: data.provider,
|
||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||
isFree: ADMIN_WORKSPACES.includes(data.workspaceID),
|
||||
isDisabled: !!data.timeDisabled,
|
||||
}
|
||||
}
|
||||
@@ -525,20 +565,20 @@ export async function handler(
|
||||
if (authInfo.isFree) return "free"
|
||||
if (modelInfo.allowAnonymous) return "free"
|
||||
|
||||
// Validate subscription billing
|
||||
if (authInfo.billing.subscription && authInfo.subscription) {
|
||||
try {
|
||||
const sub = authInfo.subscription
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
// Validate black subscription billing
|
||||
if (authInfo.billing.subscription && authInfo.black) {
|
||||
try {
|
||||
const sub = authInfo.black
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||
@@ -577,6 +617,62 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate lite subscription billing
|
||||
if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) {
|
||||
try {
|
||||
const sub = authInfo.lite
|
||||
const liteData = LiteData.getLimits()
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.weeklyUsage && sub.timeWeeklyUpdated) {
|
||||
const result = Subscription.analyzeWeeklyUsage({
|
||||
limit: liteData.weeklyLimit,
|
||||
usage: sub.weeklyUsage,
|
||||
timeUpdated: sub.timeWeeklyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check monthly limit
|
||||
if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: liteData.monthlyLimit,
|
||||
usage: sub.monthlyUsage,
|
||||
timeUpdated: sub.timeMonthlyUpdated,
|
||||
timeSubscribed: sub.timeCreated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check rolling limit
|
||||
if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
|
||||
const result = Subscription.analyzeRollingUsage({
|
||||
limit: liteData.rollingLimit,
|
||||
window: liteData.rollingWindow,
|
||||
usage: sub.monthlyUsage,
|
||||
timeUpdated: sub.timeMonthlyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
return "lite"
|
||||
} catch (e) {
|
||||
if (!authInfo.billing.lite.useBalance) throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pay as you go billing
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID)
|
||||
@@ -740,79 +836,126 @@ export async function handler(
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
sessionID: sessionId.substring(0, 30),
|
||||
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
|
||||
enrichment: (() => {
|
||||
if (billingSource === "subscription") return { plan: "sub" }
|
||||
if (billingSource === "byok") return { plan: "byok" }
|
||||
if (billingSource === "lite") return { plan: "lite" }
|
||||
return undefined
|
||||
})(),
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(billingSource === "subscription"
|
||||
? (() => {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const week = getWeekBounds(new Date())
|
||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
fixedUsage: sql`
|
||||
...(() => {
|
||||
if (billingSource === "subscription") {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const week = getWeekBounds(new Date())
|
||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
fixedUsage: sql`
|
||||
CASE
|
||||
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeFixedUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
timeFixedUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeRollingUpdated: sql`
|
||||
timeRollingUpdated: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
||||
ELSE now()
|
||||
END
|
||||
`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||
),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||
),
|
||||
]
|
||||
})()
|
||||
: [
|
||||
),
|
||||
]
|
||||
}
|
||||
if (billingSource === "lite") {
|
||||
const lite = LiteData.getLimits()
|
||||
const week = getWeekBounds(new Date())
|
||||
const month = getMonthlyBounds(new Date(), authInfo.lite!.timeCreated)
|
||||
const rollingWindowSeconds = lite.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(BillingTable)
|
||||
.update(LiteTable)
|
||||
.set({
|
||||
balance: authInfo.isFree
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUpdated: sql`now()`,
|
||||
weeklyUsage: sql`
|
||||
CASE
|
||||
WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeWeeklyUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeRollingUpdated: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.timeRollingUpdated}
|
||||
ELSE now()
|
||||
END
|
||||
`,
|
||||
})
|
||||
.where(and(eq(LiteTable.workspaceID, authInfo.workspaceID), eq(LiteTable.userID, authInfo.user.id))),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
db
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance:
|
||||
billingSource === "free" || billingSource === "byok"
|
||||
? sql`${BillingTable.balance} - ${0}`
|
||||
: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]),
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]
|
||||
})(),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function GET(input: APIEvent) {
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !disabledModels.includes(id))
|
||||
.filter(([id]) => !id.startsWith("alpha-"))
|
||||
.map(([id, _model]) => ({
|
||||
id,
|
||||
object: "model",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `lite` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`user_id` varchar(30) NOT NULL,
|
||||
`rolling_usage` bigint,
|
||||
`weekly_usage` bigint,
|
||||
`monthly_usage` bigint,
|
||||
`time_rolling_updated` timestamp(3),
|
||||
`time_weekly_updated` timestamp(3),
|
||||
`time_monthly_updated` timestamp(3),
|
||||
CONSTRAINT `PRIMARY` PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `workspace_user_id` UNIQUE INDEX(`workspace_id`,`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite_subscription_id` varchar(28);--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite` json;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { BillingTable, SubscriptionPlan } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const plan = process.argv[2] as (typeof SubscriptionPlan)[number]
|
||||
if (!SubscriptionPlan.includes(plan)) {
|
||||
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||
if (!BlackPlans.includes(plan)) {
|
||||
console.error("Usage: bun foo.ts <count>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
SubscriptionPlan,
|
||||
UsageTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
@@ -235,7 +229,7 @@ function formatRetryTime(seconds: number) {
|
||||
|
||||
function getSubscriptionStatus(row: {
|
||||
subscription: {
|
||||
plan: (typeof SubscriptionPlan)[number]
|
||||
plan: (typeof BlackPlans)[number]
|
||||
} | null
|
||||
timeSubscriptionCreated: Date | null
|
||||
fixedUsage: number | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Stripe } from "stripe"
|
||||
import { Database, eq, sql } from "./drizzle"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
@@ -9,6 +9,7 @@ import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { BlackData } from "./black"
|
||||
import { LiteData } from "./lite"
|
||||
|
||||
export namespace Billing {
|
||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||
@@ -233,6 +234,56 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const generateLiteCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { successUrl, cancelUrl } = input
|
||||
|
||||
const email = await User.getAuthEmail(user.properties.userID)
|
||||
const billing = await Billing.get()
|
||||
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
billing_address_collection: "required",
|
||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email!,
|
||||
}),
|
||||
currency: "usd",
|
||||
payment_method_types: ["card"],
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
export const generateSessionUrl = fn(
|
||||
z.object({
|
||||
returnUrl: z.string(),
|
||||
@@ -271,7 +322,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const subscribe = fn(
|
||||
export const subscribeBlack = fn(
|
||||
z.object({
|
||||
seats: z.number(),
|
||||
coupon: z.string().optional(),
|
||||
@@ -336,7 +387,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribe = fn(
|
||||
export const unsubscribeBlack = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
@@ -360,4 +411,29 @@ export namespace Billing {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribeLite = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.liteSubscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ liteSubscriptionID: null, lite: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { SubscriptionPlan } from "./schema/billing.sql"
|
||||
import { BlackPlans } from "./schema/billing.sql"
|
||||
|
||||
export namespace BlackData {
|
||||
const Schema = z.object({
|
||||
@@ -28,7 +28,7 @@ export namespace BlackData {
|
||||
|
||||
export const getLimits = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||
@@ -36,9 +36,11 @@ export namespace BlackData {
|
||||
},
|
||||
)
|
||||
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_BLACK_PRICE.product)
|
||||
|
||||
export const planToPriceID = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||
|
||||
@@ -8,6 +8,7 @@ export namespace Identifier {
|
||||
benchmark: "ben",
|
||||
billing: "bil",
|
||||
key: "key",
|
||||
lite: "lit",
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace LiteData {
|
||||
const Schema = z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
weeklyLimit: z.number().int(),
|
||||
monthlyLimit: z.number().int(),
|
||||
})
|
||||
|
||||
export const validate = fn(Schema, (input) => {
|
||||
@@ -18,11 +19,7 @@ export namespace LiteData {
|
||||
return Schema.parse(json)
|
||||
})
|
||||
|
||||
export const planToPriceID = fn(z.void(), () => {
|
||||
return Resource.ZEN_LITE_PRICE.price
|
||||
})
|
||||
|
||||
export const priceIDToPlan = fn(z.void(), () => {
|
||||
return "lite"
|
||||
})
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||
export const planName = fn(z.void(), () => "lite")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex,
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const SubscriptionPlan = ["20", "100", "200"] as const
|
||||
export const BlackPlans = ["20", "100", "200"] as const
|
||||
export const BillingTable = mysqlTable(
|
||||
"billing",
|
||||
{
|
||||
@@ -25,14 +25,18 @@ export const BillingTable = mysqlTable(
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
seats: number
|
||||
plan: "20" | "100" | "200"
|
||||
plan: (typeof BlackPlans)[number]
|
||||
useBalance?: boolean
|
||||
coupon?: string
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", BlackPlans),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||
liteSubscriptionID: varchar("lite_subscription_id", { length: 28 }),
|
||||
lite: json("lite").$type<{
|
||||
useBalance?: boolean
|
||||
}>(),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
@@ -55,6 +59,22 @@ export const SubscriptionTable = mysqlTable(
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const LiteTable = mysqlTable(
|
||||
"lite",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
weeklyUsage: bigint("weekly_usage", { mode: "number" }),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeWeeklyUpdated: utc("time_weekly_updated"),
|
||||
timeMonthlyUpdated: utc("time_monthly_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
"payment",
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { getWeekBounds } from "./util/date"
|
||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||
|
||||
export namespace Subscription {
|
||||
export const analyzeRollingUsage = fn(
|
||||
@@ -29,7 +29,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -61,7 +61,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,38 @@ export namespace Subscription {
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const analyzeMonthlyUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
timeSubscribed: z.date(),
|
||||
}),
|
||||
({ limit, usage, timeUpdated, timeSubscribed }) => {
|
||||
const now = new Date()
|
||||
const month = getMonthlyBounds(now, timeSubscribed)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
if (timeUpdated < month.start) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
if (usage < fixedLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds } from "./date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
@@ -7,3 +7,32 @@ export function getWeekBounds(date: Date) {
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function getMonthlyBounds(now: Date, subscribed: Date) {
|
||||
const day = subscribed.getUTCDate()
|
||||
const hh = subscribed.getUTCHours()
|
||||
const mm = subscribed.getUTCMinutes()
|
||||
const ss = subscribed.getUTCSeconds()
|
||||
const ms = subscribed.getUTCMilliseconds()
|
||||
|
||||
function anchor(year: number, month: number) {
|
||||
const max = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(year, month, Math.min(day, max), hh, mm, ss, ms))
|
||||
}
|
||||
|
||||
function shift(year: number, month: number, delta: number) {
|
||||
const total = year * 12 + month + delta
|
||||
return [Math.floor(total / 12), ((total % 12) + 12) % 12] as const
|
||||
}
|
||||
|
||||
let y = now.getUTCFullYear()
|
||||
let m = now.getUTCMonth()
|
||||
let start = anchor(y, m)
|
||||
if (start > now) {
|
||||
;[y, m] = shift(y, m, -1)
|
||||
start = anchor(y, m)
|
||||
}
|
||||
const [ny, nm] = shift(y, m, 1)
|
||||
const end = anchor(ny, nm)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
76
packages/console/core/test/date.test.ts
Normal file
76
packages/console/core/test/date.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds, getMonthlyBounds } from "../src/util/date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("util.date.getMonthlyBounds", () => {
|
||||
test("resets on subscription day mid-month", () => {
|
||||
const now = new Date("2026-03-20T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("before subscription day in current month uses previous month anchor", () => {
|
||||
const now = new Date("2026-03-10T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("clamps day for short months", () => {
|
||||
const now = new Date("2026-03-01T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-31T12:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-28T12:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-31T12:00:00.000Z")
|
||||
})
|
||||
|
||||
test("handles subscription on the 1st", () => {
|
||||
const now = new Date("2026-04-15T00:00:00Z")
|
||||
const subscribed = new Date("2026-01-01T00:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-04-01T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-05-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("exactly on the reset boundary uses current period", () => {
|
||||
const now = new Date("2026-03-15T08:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("february to march with day 30 subscription", () => {
|
||||
const now = new Date("2026-02-15T06:00:00Z")
|
||||
const subscribed = new Date("2025-12-30T06:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-30T06:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-02-28T06:00:00.000Z")
|
||||
})
|
||||
})
|
||||
106
packages/console/core/test/subscription.test.ts
Normal file
106
packages/console/core/test/subscription.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test, setSystemTime, afterEach } from "bun:test"
|
||||
import { Subscription } from "../src/subscription"
|
||||
import { centsToMicroCents } from "../src/util/price"
|
||||
|
||||
afterEach(() => {
|
||||
setSystemTime()
|
||||
})
|
||||
|
||||
describe("Subscription.analyzeMonthlyUsage", () => {
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
|
||||
test("returns ok with 0% when usage was last updated before current period", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(500),
|
||||
timeUpdated: new Date("2026-02-10T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
// reset should be seconds until 2026-04-15T08:00:00Z
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-04-15T08:00:00Z").getTime() - new Date("2026-03-20T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
|
||||
test("returns ok with usage percent when under limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10 // $10
|
||||
const half = centsToMicroCents(10 * 100) / 2
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: half,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(50)
|
||||
})
|
||||
|
||||
test("returns rate-limited when at or over limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100),
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("rate-limited")
|
||||
expect(result.usagePercent).toBe(100)
|
||||
})
|
||||
|
||||
test("resets usage when crossing monthly boundary", () => {
|
||||
// subscribed on 15th, now is April 16th — period is Apr 15 to May 15
|
||||
// timeUpdated is March 20 (previous period)
|
||||
setSystemTime(new Date("2026-04-16T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(10 * 100),
|
||||
timeUpdated: new Date("2026-03-20T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
})
|
||||
|
||||
test("caps usage percent at 100", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100) - 1,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
test("handles subscription day 31 in short month", () => {
|
||||
const sub31 = new Date("2026-01-31T12:00:00Z")
|
||||
// now is March 1 — period should be Feb 28 to Mar 31
|
||||
setSystemTime(new Date("2026-03-01T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: 0,
|
||||
timeUpdated: new Date("2026-03-01T09:00:00Z"),
|
||||
timeSubscribed: sub31,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-03-31T12:00:00Z").getTime() - new Date("2026-03-01T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Native OpenCode desktop app, built with Tauri v2.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Building the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
|
||||
## Development
|
||||
|
||||
From the repo root:
|
||||
@@ -11,22 +15,18 @@ bun install
|
||||
bun run --cwd packages/desktop tauri dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server on http://localhost:1420 and opens the native window.
|
||||
|
||||
If you only want the web dev server (no native shell):
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
To create a production `dist/` and build the native app bundle:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop tauri build
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
## Troubleshooting
|
||||
|
||||
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
### Rust compiler not found
|
||||
|
||||
If you see errors about Rust not being found, install it via [rustup](https://rustup.rs/):
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
|
||||
},
|
||||
{
|
||||
rustTarget: "x86_64-apple-darwin",
|
||||
ocBinary: "opencode-darwin-x64-baseline",
|
||||
ocBinary: "opencode-darwin-x64",
|
||||
assetExt: "zip",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.10"
|
||||
version = "1.2.11"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.11/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.11/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.11/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.11/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.11/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -43,6 +43,7 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
@@ -127,6 +128,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
|
||||
@@ -56,7 +56,7 @@ const migrations = await Promise.all(
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const singleFlag = process.argv.includes("--single") || (!!process.env.CI && !process.argv.includes("--all"))
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
|
||||
@@ -103,11 +103,6 @@ const allTargets: {
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
avx2: false,
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
|
||||
@@ -41,7 +41,7 @@ import { Config } from "@/config/config"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
import { LoadAPIKeyError } from "ai"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
@@ -135,6 +135,8 @@ export namespace ACP {
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private bashSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
@@ -266,47 +268,50 @@ export namespace ACP {
|
||||
const session = this.sessionManager.tryGet(part.sessionID)
|
||||
if (!session) return
|
||||
const sessionId = session.id
|
||||
const directory = session.cwd
|
||||
|
||||
const message = await this.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!message || message.info.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = String(Bun.hash(output))
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
this.bashSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -318,6 +323,7 @@ export namespace ACP {
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(content.length > 0 && { content }),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -326,6 +332,8 @@ export namespace ACP {
|
||||
return
|
||||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -405,6 +413,8 @@ export namespace ACP {
|
||||
return
|
||||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -426,6 +436,7 @@ export namespace ACP {
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -800,26 +811,23 @@ export namespace ACP {
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -831,6 +839,7 @@ export namespace ACP {
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(runningContent.length > 0 && { content: runningContent }),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -838,6 +847,8 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -916,6 +927,8 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -937,6 +950,7 @@ export namespace ACP {
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1063,6 +1077,35 @@ export namespace ACP {
|
||||
}
|
||||
}
|
||||
|
||||
private bashOutput(part: ToolPart) {
|
||||
if (part.tool !== "bash") return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
return output
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
}
|
||||
|
||||
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
|
||||
const agents = await this.config.sdk.app
|
||||
.agents(
|
||||
|
||||
@@ -4,20 +4,21 @@ import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
||||
export async function run(cmd: string[], options?: Process.Options) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
...options,
|
||||
})
|
||||
const result = Bun.spawn([which(), ...cmd], {
|
||||
const result = Process.spawn([which(), ...cmd], {
|
||||
...options,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -28,23 +29,15 @@ export namespace BunProc {
|
||||
},
|
||||
})
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout
|
||||
? typeof result.stdout === "number"
|
||||
? result.stdout
|
||||
: await readableStreamToText(result.stdout)
|
||||
: undefined
|
||||
const stderr = result.stderr
|
||||
? typeof result.stderr === "number"
|
||||
? result.stderr
|
||||
: await readableStreamToText(result.stderr)
|
||||
: undefined
|
||||
const stdout = result.stdout ? await text(result.stdout) : undefined
|
||||
const stderr = result.stderr ? await text(result.stderr) : undefined
|
||||
log.info("done", {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
if (code !== 0) {
|
||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
||||
throw new Error(`Command failed with exit code ${code}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -93,7 +86,7 @@ export namespace BunProc {
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readableStreamToText, semver } from "bun"
|
||||
import { semver } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
const result = Bun.spawn([which(), "info", pkg, field], {
|
||||
const result = Process.spawn([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
|
||||
})
|
||||
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
|
||||
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
|
||||
const stdout = result.stdout ? await text(result.stdout) : ""
|
||||
const stderr = result.stderr ? await text(result.stderr) : ""
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr })
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Global } from "../../global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await new Response(proc.stdout).text()
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await text(proc.stdout)
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
|
||||
@@ -6,8 +6,10 @@ import { UI } from "../ui"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -16,7 +18,7 @@ function pagerCmd(): string[] {
|
||||
}
|
||||
|
||||
// user could have less installed via other options
|
||||
const lessOnPath = Bun.which("less")
|
||||
const lessOnPath = which("less")
|
||||
if (lessOnPath) {
|
||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||
}
|
||||
@@ -26,7 +28,7 @@ function pagerCmd(): string[] {
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
@@ -102,13 +104,17 @@ export const SessionListCommand = cmd({
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
const proc = Bun.spawn({
|
||||
cmd: pagerCmd(),
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
if (!proc.stdin) {
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
|
||||
@@ -1762,11 +1762,6 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
return props.input.content
|
||||
})
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
return props.metadata.diagnostics?.[filePath] ?? []
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
@@ -1780,15 +1775,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
content={code()}
|
||||
/>
|
||||
</line_number>
|
||||
<Show when={diagnostics().length}>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={props.input.filePath ?? ""} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -1972,12 +1959,6 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
|
||||
const diffContent = createMemo(() => props.metadata.diff)
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
const arr = props.metadata.diagnostics?.[filePath] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
@@ -2003,18 +1984,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
<Show when={diagnostics().length}>
|
||||
<box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
|
||||
{diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={props.input.filePath ?? ""} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -2086,6 +2056,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
}
|
||||
>
|
||||
<Diff diff={file.diff} filePath={file.filePath} />
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
|
||||
</Show>
|
||||
</BlockTool>
|
||||
)}
|
||||
@@ -2163,6 +2134,29 @@ function Skill(props: ToolProps<typeof SkillTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]>; filePath: string }) {
|
||||
const { theme } = useTheme()
|
||||
const errors = createMemo(() => {
|
||||
const normalized = Filesystem.normalizePath(props.filePath)
|
||||
const arr = props.diagnostics?.[normalized] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={errors().length}>
|
||||
<box>
|
||||
<For each={errors()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { Process } from "../../../../util/process"
|
||||
import { which } from "../../../../util/which"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
@@ -75,7 +77,7 @@ export namespace Clipboard {
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && Bun.which("osascript")) {
|
||||
if (os === "darwin" && which("osascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
@@ -84,36 +86,39 @@ export namespace Clipboard {
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xclip")) {
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xsel")) {
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
@@ -125,7 +130,7 @@ export namespace Clipboard {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Bun.spawn(
|
||||
const proc = Process.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
@@ -140,6 +145,7 @@ export namespace Clipboard {
|
||||
},
|
||||
)
|
||||
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
@@ -17,8 +18,7 @@ export namespace Editor {
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
const parts = editor.split(" ")
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...parts, filepath],
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
59
packages/opencode/src/cli/cmd/workspace-serve.ts
Normal file
59
packages/opencode/src/cli/cmd/workspace-serve.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const WorkspaceServeCommand = cmd({
|
||||
command: "workspace-serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a remote workspace websocket server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Bun.serve<{ id: string }>({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === "/ws") {
|
||||
const id = Bun.randomUUIDv7()
|
||||
if (server.upgrade(req, { data: { id } })) return
|
||||
return new Response("Upgrade failed", { status: 400 })
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
service: "workspace-server",
|
||||
ws: `ws://${server.hostname}:${server.port}/ws`,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
ws.send(JSON.stringify({ type: "ready", id: ws.data.id }))
|
||||
},
|
||||
message(ws, msg) {
|
||||
const text = typeof msg === "string" ? msg : msg.toString()
|
||||
ws.send(JSON.stringify({ type: "message", id: ws.data.id, text }))
|
||||
},
|
||||
close() {},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`)
|
||||
await new Promise(() => {})
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -276,7 +277,6 @@ export namespace Config {
|
||||
"@opencode-ai/plugin": targetVersion,
|
||||
}
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
@@ -289,7 +289,7 @@ export namespace Config {
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch((err) => {
|
||||
@@ -342,10 +342,11 @@ export namespace Config {
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
const index = item.indexOf(pattern)
|
||||
const index = normalizedItem.indexOf(pattern)
|
||||
if (index === -1) continue
|
||||
return item.slice(index + pattern.length)
|
||||
return normalizedItem.slice(index + pattern.length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1333,16 @@ export namespace Config {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (err) {}
|
||||
} catch (e) {
|
||||
try {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -22,7 +22,7 @@ export namespace ConfigMarkdown {
|
||||
if (!match) return content
|
||||
|
||||
const frontmatter = match[1]
|
||||
const lines = frontmatter.split("\n")
|
||||
const lines = frontmatter.split(/\r?\n/)
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export namespace FileIgnore {
|
||||
if (Glob.match(pattern, filepath)) return false
|
||||
}
|
||||
|
||||
const parts = filepath.split(sep)
|
||||
const parts = filepath.split(/[/\\]/)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (FOLDERS.has(parts[i])) return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
import { Log } from "@/util/log"
|
||||
@@ -124,7 +127,7 @@ export namespace Ripgrep {
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
const system = Bun.which("rg")
|
||||
const system = which("rg")
|
||||
if (system) {
|
||||
const stat = await fs.stat(system).catch(() => undefined)
|
||||
if (stat?.isFile()) return { filepath: system }
|
||||
@@ -153,17 +156,19 @@ export namespace Ripgrep {
|
||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
const stderr = proc.stderr ? await text(proc.stderr) : ""
|
||||
throw new ExtractionFailedError({
|
||||
filepath,
|
||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
|
||||
@@ -227,8 +232,7 @@ export namespace Ripgrep {
|
||||
}
|
||||
}
|
||||
|
||||
// Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
|
||||
// See https://github.com/oven-sh/bun/issues/24012
|
||||
// Guard against invalid cwd to provide a consistent ENOENT error.
|
||||
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
||||
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||
code: "ENOENT",
|
||||
@@ -237,41 +241,35 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: input.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
signal: input.signal,
|
||||
abort: input.signal,
|
||||
})
|
||||
|
||||
const reader = proc.stdout.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) yield buffer
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
await proc.exited
|
||||
if (!proc.stdout) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
let buffer = ""
|
||||
const stream = proc.stdout as AsyncIterable<Buffer | string>
|
||||
for await (const chunk of stream) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
buffer += typeof chunk === "string" ? chunk : chunk.toString()
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) yield buffer
|
||||
await proc.exited
|
||||
|
||||
input.signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ export namespace FileTime {
|
||||
const time = get(sessionID, filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const mtime = Filesystem.stat(filepath)?.mtime
|
||||
if (mtime && mtime.getTime() > time.getTime()) {
|
||||
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
|
||||
if (mtime && mtime.getTime() > time.getTime() + 50) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readableStreamToText } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export interface Info {
|
||||
@@ -17,7 +19,7 @@ export const gofmt: Info = {
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return Bun.which("gofmt") !== null
|
||||
return which("gofmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,7 +28,7 @@ export const mix: Info = {
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return Bun.which("mix") !== null
|
||||
return which("mix") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ export const zig: Info = {
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return Bun.which("zig") !== null
|
||||
return which("zig") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -170,7 +172,7 @@ export const ktlint: Info = {
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return Bun.which("ktlint") !== null
|
||||
return which("ktlint") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -179,7 +181,7 @@ export const ruff: Info = {
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ruff")) return false
|
||||
if (!which("ruff")) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
@@ -209,16 +211,17 @@ export const rlang: Info = {
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = Bun.which("air")
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["air", "--help"], {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
const output = await readableStreamToText(proc.stdout)
|
||||
if (!proc.stdout) return false
|
||||
const output = await text(proc.stdout)
|
||||
|
||||
// Check for "Air: An R language server and formatter"
|
||||
const firstLine = output.split("\n")[0]
|
||||
@@ -237,8 +240,8 @@ export const uvformat: Info = {
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (Bun.which("uv") !== null) {
|
||||
const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
}
|
||||
@@ -251,7 +254,7 @@ export const rubocop: Info = {
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("rubocop") !== null
|
||||
return which("rubocop") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -260,7 +263,7 @@ export const standardrb: Info = {
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("standardrb") !== null
|
||||
return which("standardrb") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,7 +272,7 @@ export const htmlbeautifier: Info = {
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return Bun.which("htmlbeautifier") !== null
|
||||
return which("htmlbeautifier") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,7 +281,7 @@ export const dart: Info = {
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return Bun.which("dart") !== null
|
||||
return which("dart") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -287,7 +290,7 @@ export const ocamlformat: Info = {
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ocamlformat")) return false
|
||||
if (!which("ocamlformat")) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
},
|
||||
@@ -298,7 +301,7 @@ export const terraform: Info = {
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return Bun.which("terraform") !== null
|
||||
return which("terraform") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -307,7 +310,7 @@ export const latexindent: Info = {
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return Bun.which("latexindent") !== null
|
||||
return which("latexindent") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -316,7 +319,7 @@ export const gleam: Info = {
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return Bun.which("gleam") !== null
|
||||
return which("gleam") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -325,7 +328,7 @@ export const shfmt: Info = {
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return Bun.which("shfmt") !== null
|
||||
return which("shfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -334,7 +337,7 @@ export const nixfmt: Info = {
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return Bun.which("nixfmt") !== null
|
||||
return which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -343,7 +346,7 @@ export const rustfmt: Info = {
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return Bun.which("rustfmt") !== null
|
||||
return which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -370,7 +373,7 @@ export const ormolu: Info = {
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return Bun.which("ormolu") !== null
|
||||
return which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -379,7 +382,7 @@ export const cljfmt: Info = {
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return Bun.which("cljfmt") !== null
|
||||
return which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -388,6 +391,6 @@ export const dfmt: Info = {
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return Bun.which("dfmt") !== null
|
||||
return which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
@@ -110,13 +111,15 @@ export namespace Format {
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
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", {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Installation } from "./installation"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
|
||||
import { Filesystem } from "./util/filesystem"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
@@ -45,7 +46,7 @@ process.on("uncaughtException", (e) => {
|
||||
})
|
||||
})
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
let cli = yargs(hideBin(process.argv))
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
.wrap(100)
|
||||
@@ -141,6 +142,12 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(DbCommand)
|
||||
|
||||
if (Installation.isLocal()) {
|
||||
cli = cli.command(WorkspaceServeCommand)
|
||||
}
|
||||
|
||||
cli = cli
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
|
||||
@@ -4,12 +4,15 @@ import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { $, readableStreamToText } from "bun"
|
||||
import { $ } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -73,7 +76,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
async spawn(root) {
|
||||
const deno = Bun.which("deno")
|
||||
const deno = which("deno")
|
||||
if (!deno) {
|
||||
log.info("deno not found, please install deno first")
|
||||
return
|
||||
@@ -120,7 +123,7 @@ export namespace LSPServer {
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("vue-language-server")
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -133,7 +136,7 @@ export namespace LSPServer {
|
||||
)
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -258,26 +261,28 @@ export namespace LSPServer {
|
||||
|
||||
let lintBin = await resolveBin(lintTarget)
|
||||
if (!lintBin) {
|
||||
const found = Bun.which("oxlint")
|
||||
const found = which("oxlint")
|
||||
if (found) lintBin = found
|
||||
}
|
||||
|
||||
if (lintBin) {
|
||||
const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" })
|
||||
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
|
||||
await proc.exited
|
||||
const help = await readableStreamToText(proc.stdout)
|
||||
if (help.includes("--lsp")) {
|
||||
return {
|
||||
process: spawn(lintBin, ["--lsp"], {
|
||||
cwd: root,
|
||||
}),
|
||||
if (proc.stdout) {
|
||||
const help = await text(proc.stdout)
|
||||
if (help.includes("--lsp")) {
|
||||
return {
|
||||
process: spawn(lintBin, ["--lsp"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let serverBin = await resolveBin(serverTarget)
|
||||
if (!serverBin) {
|
||||
const found = Bun.which("oxc_language_server")
|
||||
const found = which("oxc_language_server")
|
||||
if (found) serverBin = found
|
||||
}
|
||||
if (serverBin) {
|
||||
@@ -328,7 +333,7 @@ export namespace LSPServer {
|
||||
let bin: string | undefined
|
||||
if (await Filesystem.exists(localBin)) bin = localBin
|
||||
if (!bin) {
|
||||
const found = Bun.which("biome")
|
||||
const found = which("biome")
|
||||
if (found) bin = found
|
||||
}
|
||||
|
||||
@@ -364,16 +369,15 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("go")) return
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
log.info("installing gopls")
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
||||
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
|
||||
env: { ...process.env, GOBIN: Global.Path.bin },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -402,20 +406,19 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("rubocop", {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
const ruby = Bun.which("ruby")
|
||||
const gem = Bun.which("gem")
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
if (!ruby || !gem) {
|
||||
log.info("Ruby not found, please install Ruby first")
|
||||
return
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing rubocop")
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
|
||||
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
@@ -455,7 +458,7 @@ export namespace LSPServer {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let binary = Bun.which("ty")
|
||||
let binary = which("ty")
|
||||
|
||||
const initialization: Record<string, string> = {}
|
||||
|
||||
@@ -507,13 +510,13 @@ export namespace LSPServer {
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("pyright-langserver")
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
||||
await Process.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -561,7 +564,7 @@ export namespace LSPServer {
|
||||
extensions: [".ex", ".exs"],
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
let binary = which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
binary = path.join(
|
||||
@@ -572,7 +575,7 @@ export namespace LSPServer {
|
||||
)
|
||||
|
||||
if (!(await Filesystem.exists(binary))) {
|
||||
const elixir = Bun.which("elixir")
|
||||
const elixir = which("elixir")
|
||||
if (!elixir) {
|
||||
log.error("elixir is required to run elixir-ls")
|
||||
return
|
||||
@@ -623,12 +626,12 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("zls", {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = Bun.which("zig")
|
||||
const zig = which("zig")
|
||||
if (!zig) {
|
||||
log.error("Zig is required to use zls. Please install Zig first.")
|
||||
return
|
||||
@@ -735,19 +738,18 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("csharp-ls", {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing csharp-ls via dotnet tool")
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
@@ -775,19 +777,18 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("fsautocomplete", {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("installing fsautocomplete via dotnet tool")
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
|
||||
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
@@ -817,7 +818,7 @@ export namespace LSPServer {
|
||||
async spawn(root) {
|
||||
// Check if sourcekit-lsp is available in the PATH
|
||||
// This is installed with the Swift toolchain
|
||||
const sourcekit = Bun.which("sourcekit-lsp")
|
||||
const sourcekit = which("sourcekit-lsp")
|
||||
if (sourcekit) {
|
||||
return {
|
||||
process: spawn(sourcekit, {
|
||||
@@ -828,7 +829,7 @@ export namespace LSPServer {
|
||||
|
||||
// If sourcekit-lsp not found, check if xcrun is available
|
||||
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
|
||||
if (!Bun.which("xcrun")) return
|
||||
if (!which("xcrun")) return
|
||||
|
||||
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
|
||||
|
||||
@@ -877,7 +878,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".rs"],
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("rust-analyzer")
|
||||
const bin = which("rust-analyzer")
|
||||
if (!bin) {
|
||||
log.info("rust-analyzer not found in path, please install it")
|
||||
return
|
||||
@@ -896,7 +897,7 @@ export namespace LSPServer {
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
const fromPath = Bun.which("clangd")
|
||||
const fromPath = which("clangd")
|
||||
if (fromPath) {
|
||||
return {
|
||||
process: spawn(fromPath, args, {
|
||||
@@ -1041,13 +1042,13 @@ export namespace LSPServer {
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("svelteserver")
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1088,13 +1089,13 @@ export namespace LSPServer {
|
||||
}
|
||||
const tsdk = path.dirname(tsserver)
|
||||
|
||||
let binary = Bun.which("astro-ls")
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1132,7 +1133,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = Bun.which("java")
|
||||
const java = which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
@@ -1324,7 +1325,7 @@ export namespace LSPServer {
|
||||
extensions: [".yaml", ".yml"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("yaml-language-server")
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -1339,7 +1340,7 @@ export namespace LSPServer {
|
||||
const exists = await Filesystem.exists(js)
|
||||
if (!exists) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1380,7 +1381,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("lua-language-server", {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1512,13 +1513,13 @@ export namespace LSPServer {
|
||||
extensions: [".php"],
|
||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("intelephense")
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
await Process.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1556,7 +1557,7 @@ export namespace LSPServer {
|
||||
extensions: [".prisma"],
|
||||
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
||||
async spawn(root) {
|
||||
const prisma = Bun.which("prisma")
|
||||
const prisma = which("prisma")
|
||||
if (!prisma) {
|
||||
log.info("prisma not found, please install prisma")
|
||||
return
|
||||
@@ -1574,7 +1575,7 @@ export namespace LSPServer {
|
||||
extensions: [".dart"],
|
||||
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
|
||||
async spawn(root) {
|
||||
const dart = Bun.which("dart")
|
||||
const dart = which("dart")
|
||||
if (!dart) {
|
||||
log.info("dart not found, please install dart first")
|
||||
return
|
||||
@@ -1592,7 +1593,7 @@ export namespace LSPServer {
|
||||
extensions: [".ml", ".mli"],
|
||||
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("ocamllsp")
|
||||
const bin = which("ocamllsp")
|
||||
if (!bin) {
|
||||
log.info("ocamllsp not found, please install ocaml-lsp-server")
|
||||
return
|
||||
@@ -1609,13 +1610,13 @@ export namespace LSPServer {
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("bash-language-server")
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1648,7 +1649,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("terraform-ls", {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1731,7 +1732,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("texlab", {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1821,13 +1822,13 @@ export namespace LSPServer {
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("docker-langserver")
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1860,7 +1861,7 @@ export namespace LSPServer {
|
||||
extensions: [".gleam"],
|
||||
root: NearestRoot(["gleam.toml"]),
|
||||
async spawn(root) {
|
||||
const gleam = Bun.which("gleam")
|
||||
const gleam = which("gleam")
|
||||
if (!gleam) {
|
||||
log.info("gleam not found, please install gleam first")
|
||||
return
|
||||
@@ -1878,9 +1879,9 @@ export namespace LSPServer {
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("clojure-lsp")
|
||||
let bin = which("clojure-lsp")
|
||||
if (!bin && process.platform === "win32") {
|
||||
bin = Bun.which("clojure-lsp.exe")
|
||||
bin = which("clojure-lsp.exe")
|
||||
}
|
||||
if (!bin) {
|
||||
log.info("clojure-lsp not found, please install clojure-lsp first")
|
||||
@@ -1909,7 +1910,7 @@ export namespace LSPServer {
|
||||
return Instance.directory
|
||||
},
|
||||
async spawn(root) {
|
||||
const nixd = Bun.which("nixd")
|
||||
const nixd = which("nixd")
|
||||
if (!nixd) {
|
||||
log.info("nixd not found, please install nixd first")
|
||||
return
|
||||
@@ -1930,7 +1931,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("tinymist", {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -2024,7 +2025,7 @@ export namespace LSPServer {
|
||||
extensions: [".hs", ".lhs"],
|
||||
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("haskell-language-server-wrapper")
|
||||
const bin = which("haskell-language-server-wrapper")
|
||||
if (!bin) {
|
||||
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
|
||||
return
|
||||
@@ -2042,7 +2043,7 @@ export namespace LSPServer {
|
||||
extensions: [".jl"],
|
||||
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
|
||||
async spawn(root) {
|
||||
const julia = Bun.which("julia")
|
||||
const julia = which("julia")
|
||||
if (!julia) {
|
||||
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -97,7 +98,7 @@ export namespace Project {
|
||||
if (dotgit) {
|
||||
let sandbox = path.dirname(dotgit)
|
||||
|
||||
const gitBinary = Bun.which("git")
|
||||
const gitBinary = which("git")
|
||||
|
||||
// cached id calculation
|
||||
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
|
||||
@@ -138,7 +139,7 @@ export namespace Project {
|
||||
|
||||
id = roots[0]
|
||||
if (id) {
|
||||
void Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
|
||||
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@/util/which"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
|
||||
@@ -39,7 +40,7 @@ export namespace Shell {
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
|
||||
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
|
||||
@@ -49,7 +50,7 @@ export namespace Shell {
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = Bun.which("bash")
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user