mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-25 10:14:26 +00:00
Compare commits
43 Commits
fix/beta-s
...
v1.2.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
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
|
||||
|
||||
30
bun.lock
30
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",
|
||||
},
|
||||
@@ -376,7 +376,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -396,7 +396,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 +407,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 +420,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 +462,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -473,7 +473,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -64,6 +64,9 @@ export namespace Snapshot {
|
||||
.nothrow()
|
||||
// Configure git to not convert line endings on Windows
|
||||
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
@@ -86,7 +89,7 @@ export namespace Snapshot {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
@@ -113,7 +116,7 @@ export namespace Snapshot {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result =
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
@@ -135,14 +138,15 @@ export namespace Snapshot {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree =
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
@@ -164,7 +168,7 @@ export namespace Snapshot {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
@@ -201,7 +205,7 @@ export namespace Snapshot {
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses =
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
@@ -215,7 +219,7 @@ export namespace Snapshot {
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
@@ -225,13 +229,13 @@ export namespace Snapshot {
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
|
||||
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
|
||||
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
@@ -256,7 +260,10 @@ export namespace Snapshot {
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
|
||||
@@ -33,6 +33,10 @@ export namespace Database {
|
||||
|
||||
type Journal = { sql: string; timestamp: number }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
}
|
||||
|
||||
function time(tag: string) {
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
||||
if (!match) return 0
|
||||
@@ -69,6 +73,7 @@ export namespace Database {
|
||||
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
|
||||
|
||||
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
|
||||
state.sqlite = sqlite
|
||||
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
sqlite.run("PRAGMA synchronous = NORMAL")
|
||||
@@ -95,6 +100,14 @@ export namespace Database {
|
||||
return db
|
||||
})
|
||||
|
||||
export function close() {
|
||||
const sqlite = state.sqlite
|
||||
if (!sqlite) return
|
||||
sqlite.close()
|
||||
state.sqlite = undefined
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = Transaction | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -19,6 +19,61 @@ type EventController = {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
function inProgressText(update: SessionUpdateParams["update"]) {
|
||||
if (update.sessionUpdate !== "tool_call_update") return undefined
|
||||
if (update.status !== "in_progress") return undefined
|
||||
if (!update.content || !Array.isArray(update.content)) return undefined
|
||||
const first = update.content[0]
|
||||
if (!first || first.type !== "content") return undefined
|
||||
if (first.content.type !== "text") return undefined
|
||||
return first.content.text
|
||||
}
|
||||
|
||||
function isToolCallUpdate(
|
||||
update: SessionUpdateParams["update"],
|
||||
): update is Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call_update" }> {
|
||||
return update.sessionUpdate === "tool_call_update"
|
||||
}
|
||||
|
||||
function toolEvent(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
opts: {
|
||||
callID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
} & ({ status: "running"; metadata?: Record<string, unknown> } | { status: "pending"; raw: string }),
|
||||
): GlobalEventEnvelope {
|
||||
const state: ToolStatePending | ToolStateRunning =
|
||||
opts.status === "running"
|
||||
? {
|
||||
status: "running",
|
||||
input: opts.input,
|
||||
...(opts.metadata && { metadata: opts.metadata }),
|
||||
time: { start: Date.now() },
|
||||
}
|
||||
: {
|
||||
status: "pending",
|
||||
input: opts.input,
|
||||
raw: opts.raw,
|
||||
}
|
||||
const payload: EventMessagePartUpdated = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: `part_${opts.callID}`,
|
||||
sessionID: sessionId,
|
||||
messageID: `msg_${opts.callID}`,
|
||||
type: "tool",
|
||||
callID: opts.callID,
|
||||
tool: opts.tool,
|
||||
state,
|
||||
},
|
||||
},
|
||||
}
|
||||
return { directory: cwd, payload }
|
||||
}
|
||||
|
||||
function createEventStream() {
|
||||
const queue: GlobalEventEnvelope[] = []
|
||||
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
|
||||
@@ -65,6 +120,7 @@ function createEventStream() {
|
||||
function createFakeAgent() {
|
||||
const updates = new Map<string, string[]>()
|
||||
const chunks = new Map<string, string>()
|
||||
const sessionUpdates: SessionUpdateParams[] = []
|
||||
const record = (sessionId: string, type: string) => {
|
||||
const list = updates.get(sessionId) ?? []
|
||||
list.push(type)
|
||||
@@ -73,6 +129,7 @@ function createFakeAgent() {
|
||||
|
||||
const connection = {
|
||||
async sessionUpdate(params: SessionUpdateParams) {
|
||||
sessionUpdates.push(params)
|
||||
const update = params.update
|
||||
const type = update?.sessionUpdate ?? "unknown"
|
||||
record(params.sessionId, type)
|
||||
@@ -197,7 +254,7 @@ function createFakeAgent() {
|
||||
;(agent as any).eventAbort.abort()
|
||||
}
|
||||
|
||||
return { agent, controller, calls, updates, chunks, stop, sdk, connection }
|
||||
return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection }
|
||||
}
|
||||
|
||||
describe("acp.agent event subscription", () => {
|
||||
@@ -435,4 +492,192 @@ describe("acp.agent event subscription", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
const input = { command: "echo hello", description: "run command" }
|
||||
|
||||
for (const output of ["a", "a", "ab"]) {
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
status: "running",
|
||||
input,
|
||||
metadata: { output },
|
||||
}),
|
||||
)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const snapshots = sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.filter((u) => isToolCallUpdate(u.update))
|
||||
.map((u) => inProgressText(u.update))
|
||||
|
||||
expect(snapshots).toEqual(["a", undefined, "ab"])
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits synthetic pending before first running update for any tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_bash",
|
||||
tool: "bash",
|
||||
status: "running",
|
||||
input: { command: "echo hi", description: "run command" },
|
||||
metadata: { output: "hi\n" },
|
||||
}),
|
||||
)
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_read",
|
||||
tool: "read",
|
||||
status: "running",
|
||||
input: { filePath: "/tmp/example.txt" },
|
||||
}),
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const types = sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.map((u) => u.update.sessionUpdate)
|
||||
.filter((u) => u === "tool_call" || u === "tool_call_update")
|
||||
expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"])
|
||||
|
||||
const pendings = sessionUpdates.filter(
|
||||
(u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call",
|
||||
)
|
||||
expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe(
|
||||
true,
|
||||
)
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
const input = { command: "echo hi", description: "run command" }
|
||||
|
||||
sdk.session.messages = async () => ({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID: sessionId,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input,
|
||||
metadata: { output: "hi\n" },
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
status: "running",
|
||||
input,
|
||||
metadata: { output: "hi\nthere\n" },
|
||||
}),
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const types = sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.map((u) => u.update)
|
||||
.filter((u) => "toolCallId" in u && u.toolCallId === "call_1")
|
||||
.map((u) => u.sessionUpdate)
|
||||
.filter((u) => u === "tool_call" || u === "tool_call_update")
|
||||
|
||||
expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"])
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("clears bash snapshot marker on pending state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
const input = { command: "echo hello", description: "run command" }
|
||||
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
status: "running",
|
||||
input,
|
||||
metadata: { output: "a" },
|
||||
}),
|
||||
)
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
status: "pending",
|
||||
input,
|
||||
raw: '{"command":"echo hello"}',
|
||||
}),
|
||||
)
|
||||
controller.push(
|
||||
toolEvent(sessionId, cwd, {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
status: "running",
|
||||
input,
|
||||
metadata: { output: "a" },
|
||||
}),
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const snapshots = sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.filter((u) => isToolCallUpdate(u.update))
|
||||
.map((u) => inProgressText(u.update))
|
||||
|
||||
expect(snapshots).toEqual(["a", "a"])
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -689,7 +689,7 @@ test("resolves scoped npm plugins in config", async () => {
|
||||
const pluginEntries = config.plugin ?? []
|
||||
|
||||
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
||||
const expected = import.meta.resolve("@scope/plugin", baseUrl)
|
||||
const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href
|
||||
|
||||
expect(pluginEntries.includes(expected)).toBe(true)
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => {
|
||||
test("should parse and match", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim()).toBe(`# Response Formatting Requirements
|
||||
expect(result.content.trim().replace(/\r\n/g, "\n")).toBe(`# Response Formatting Requirements
|
||||
|
||||
Always structure your responses using clear markdown formatting:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test"
|
||||
import { Ide } from "../../src/ide"
|
||||
|
||||
describe("ide", () => {
|
||||
const original = structuredClone(process.env)
|
||||
const original = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
|
||||
@@ -3,14 +3,29 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import fsSync from "fs"
|
||||
import { afterAll } from "bun:test"
|
||||
|
||||
// Set XDG env vars FIRST, before any src/ imports
|
||||
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
afterAll(() => {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true })
|
||||
afterAll(async () => {
|
||||
const { Database } = await import("../src/storage/db")
|
||||
Database.close()
|
||||
const busy = (error: unknown) =>
|
||||
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
|
||||
const rm = async (left: number): Promise<void> => {
|
||||
Bun.gc(true)
|
||||
await Bun.sleep(100)
|
||||
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
|
||||
if (!busy(error)) throw error
|
||||
if (left <= 1) throw error
|
||||
return rm(left - 1)
|
||||
})
|
||||
}
|
||||
|
||||
// Windows can keep SQLite WAL handles alive until GC finalizers run, so we
|
||||
// force GC and retry teardown to avoid flaky EBUSY in test cleanup.
|
||||
await rm(30)
|
||||
})
|
||||
|
||||
process.env["XDG_DATA_HOME"] = path.join(dir, "share")
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Discovery.pull", () => {
|
||||
test("downloads reference files alongside SKILL.md", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
|
||||
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
|
||||
expect(agentsSdk).toBeDefined()
|
||||
if (agentsSdk) {
|
||||
const refs = path.join(agentsSdk, "references")
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
||||
// with path.join (which produces \ on Windows) then normalizes back to /.
|
||||
// This helper does the same for expected values so assertions match cross-platform.
|
||||
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
|
||||
|
||||
async function bootstrap() {
|
||||
return tmpdir({
|
||||
git: true,
|
||||
@@ -35,7 +41,7 @@ test("tracks deleted files correctly", async () => {
|
||||
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`)
|
||||
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -143,7 +149,7 @@ test("binary file handling", async () => {
|
||||
await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/image.png`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "image.png"))
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(
|
||||
@@ -164,9 +170,9 @@ test("symlink handling", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet()
|
||||
await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`)
|
||||
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -181,7 +187,7 @@ test("large file handling", async () => {
|
||||
|
||||
await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`)
|
||||
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -222,9 +228,9 @@ test("special characters in filenames", async () => {
|
||||
await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
|
||||
|
||||
const files = (await Snapshot.patch(before!)).files
|
||||
expect(files).toContain(`${tmp.path}/file with spaces.txt`)
|
||||
expect(files).toContain(`${tmp.path}/file-with-dashes.txt`)
|
||||
expect(files).toContain(`${tmp.path}/file_with_underscores.txt`)
|
||||
expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
|
||||
expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
|
||||
expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -293,10 +299,10 @@ test("unicode filenames", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const unicodeFiles = [
|
||||
{ path: `${tmp.path}/文件.txt`, content: "chinese content" },
|
||||
{ path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" },
|
||||
{ path: `${tmp.path}/café.txt`, content: "accented content" },
|
||||
{ path: `${tmp.path}/файл.txt`, content: "cyrillic content" },
|
||||
{ path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
|
||||
{ path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
|
||||
{ path: fwd(tmp.path, "café.txt"), content: "accented content" },
|
||||
{ path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
|
||||
]
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
@@ -329,8 +335,8 @@ test.skip("unicode filenames modification and restore", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const chineseFile = `${tmp.path}/文件.txt`
|
||||
const cyrillicFile = `${tmp.path}/файл.txt`
|
||||
const chineseFile = fwd(tmp.path, "文件.txt")
|
||||
const cyrillicFile = fwd(tmp.path, "файл.txt")
|
||||
|
||||
await Filesystem.write(chineseFile, "original chinese")
|
||||
await Filesystem.write(cyrillicFile, "original cyrillic")
|
||||
@@ -362,7 +368,7 @@ test("unicode filenames in subdirectories", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
|
||||
const deepFile = `${tmp.path}/目录/подкаталог/文件.txt`
|
||||
const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
|
||||
await Filesystem.write(deepFile, "deep unicode content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
@@ -388,7 +394,7 @@ test("very long filenames", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const longName = "a".repeat(200) + ".txt"
|
||||
const longFile = `${tmp.path}/${longName}`
|
||||
const longFile = fwd(tmp.path, longName)
|
||||
|
||||
await Filesystem.write(longFile, "long filename content")
|
||||
|
||||
@@ -419,9 +425,9 @@ test("hidden files", async () => {
|
||||
await Filesystem.write(`${tmp.path}/.config`, "config content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/.hidden`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.config`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
|
||||
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
||||
expect(patch.files).toContain(fwd(tmp.path, ".config"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -436,12 +442,12 @@ test("nested symlinks", async () => {
|
||||
|
||||
await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
|
||||
await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content")
|
||||
await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet()
|
||||
await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet()
|
||||
await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")
|
||||
await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`)
|
||||
expect(patch.files).toContain(`${tmp.path}/sub-link`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
|
||||
expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -476,7 +482,7 @@ test("circular symlinks", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create circular symlink
|
||||
await $`ln -s ${tmp.path}/circular ${tmp.path}/circular`.quiet().nothrow()
|
||||
await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {})
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
|
||||
@@ -499,11 +505,11 @@ test("gitignore changes", async () => {
|
||||
const patch = await Snapshot.patch(before!)
|
||||
|
||||
// Should track gitignore itself
|
||||
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
||||
// Should track normal files
|
||||
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
||||
// Should not track ignored files (git won't see them)
|
||||
expect(patch.files).not.toContain(`${tmp.path}/test.ignored`)
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -523,8 +529,8 @@ test("git info exclude changes", async () => {
|
||||
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
|
||||
expect(patch.files).not.toContain(`${tmp.path}/ignored.txt`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
|
||||
|
||||
const after = await Snapshot.track()
|
||||
const diffs = await Snapshot.diffFull(before!, after!)
|
||||
@@ -542,7 +548,7 @@ test("git info exclude keeps global excludes", async () => {
|
||||
const global = `${tmp.path}/global.ignore`
|
||||
const config = `${tmp.path}/global.gitconfig`
|
||||
await Bun.write(global, "global.tmp\n")
|
||||
await Bun.write(config, `[core]\n\texcludesFile = ${global}\n`)
|
||||
await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`)
|
||||
|
||||
const prev = process.env.GIT_CONFIG_GLOBAL
|
||||
process.env.GIT_CONFIG_GLOBAL = config
|
||||
@@ -559,9 +565,9 @@ test("git info exclude keeps global excludes", async () => {
|
||||
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
|
||||
expect(patch.files).not.toContain(`${tmp.path}/global.tmp`)
|
||||
expect(patch.files).not.toContain(`${tmp.path}/info.tmp`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
|
||||
} finally {
|
||||
if (prev) process.env.GIT_CONFIG_GLOBAL = prev
|
||||
else delete process.env.GIT_CONFIG_GLOBAL
|
||||
@@ -610,7 +616,7 @@ test("snapshot state isolation between projects", async () => {
|
||||
const before1 = await Snapshot.track()
|
||||
await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
|
||||
const patch1 = await Snapshot.patch(before1!)
|
||||
expect(patch1.files).toContain(`${tmp1.path}/project1.txt`)
|
||||
expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -620,10 +626,10 @@ test("snapshot state isolation between projects", async () => {
|
||||
const before2 = await Snapshot.track()
|
||||
await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
|
||||
const patch2 = await Snapshot.patch(before2!)
|
||||
expect(patch2.files).toContain(`${tmp2.path}/project2.txt`)
|
||||
expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
|
||||
|
||||
// Ensure project1 files don't appear in project2
|
||||
expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`)
|
||||
expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -647,7 +653,7 @@ test("patch detects changes in secondary worktree", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const worktreeFile = `${worktreePath}/worktree.txt`
|
||||
const worktreeFile = fwd(worktreePath, "worktree.txt")
|
||||
await Filesystem.write(worktreeFile, "worktree content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
@@ -681,7 +687,7 @@ test("revert only removes files in invoking worktree", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const worktreeFile = `${worktreePath}/worktree.txt`
|
||||
const worktreeFile = fwd(worktreePath, "worktree.txt")
|
||||
await Filesystem.write(worktreeFile, "worktree content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
@@ -832,7 +838,7 @@ test("revert should not delete files that existed but were deleted in snapshot",
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
|
||||
|
||||
const patch = await Snapshot.patch(snapshot2!)
|
||||
expect(patch.files).toContain(`${tmp.path}/a.txt`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
@@ -861,8 +867,8 @@ test("revert preserves file that existed in snapshot when deleted then recreated
|
||||
await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
|
||||
|
||||
const patch = await Snapshot.patch(snapshot!)
|
||||
expect(patch.files).toContain(`${tmp.path}/existing.txt`)
|
||||
expect(patch.files).toContain(`${tmp.path}/newfile.txt`)
|
||||
expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
|
||||
expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -138,14 +139,14 @@ describe("tool.bash permissions", () => {
|
||||
await bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
workdir: "/tmp",
|
||||
description: "List /tmp",
|
||||
workdir: os.tmpdir(),
|
||||
description: "List temp dir",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain("/tmp/*")
|
||||
expect(extDirReq!.patterns).toContain(path.join(os.tmpdir(), "*"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -366,7 +367,8 @@ describe("tool.bash truncation", () => {
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(false)
|
||||
expect(result.output).toBe("hello\n")
|
||||
const eol = process.platform === "win32" ? "\r\n" : "\n"
|
||||
expect(result.output).toBe(`hello${eol}`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
const expected = path.join(path.dirname(target), "*")
|
||||
const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/")
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
@@ -91,7 +91,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
const expected = path.join(target, "*")
|
||||
const expected = path.join(target, "*").replaceAll("\\", "/")
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
|
||||
@@ -293,19 +293,26 @@ describe("tool.write", () => {
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
test("throws error for paths outside project", async () => {
|
||||
test("throws error when OS denies write access", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const outsidePath = "/etc/passwd"
|
||||
const readonlyPath = path.join(tmp.path, "readonly.txt")
|
||||
|
||||
// Create a read-only file
|
||||
await fs.writeFile(readonlyPath, "test", "utf-8")
|
||||
await fs.chmod(readonlyPath, 0o444)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
FileTime.read(ctx.sessionID, readonlyPath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
await expect(
|
||||
write.execute(
|
||||
{
|
||||
filePath: outsidePath,
|
||||
content: "test",
|
||||
filePath: readonlyPath,
|
||||
content: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("Glob", () => {
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["nested/deep.txt"])
|
||||
expect(results).toEqual([path.join("nested", "deep.txt")])
|
||||
})
|
||||
|
||||
test("returns empty array for no matches", async () => {
|
||||
@@ -82,7 +82,7 @@ describe("Glob", () => {
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["realdir/file.txt"])
|
||||
expect(results).toEqual([path.join("realdir", "file.txt")])
|
||||
})
|
||||
|
||||
test("follows symlinks when symlink option is true", async () => {
|
||||
@@ -93,7 +93,7 @@ describe("Glob", () => {
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true })
|
||||
|
||||
expect(results.sort()).toEqual(["linkdir/file.txt", "realdir/file.txt"])
|
||||
expect(results.sort()).toEqual([path.join("linkdir", "file.txt"), path.join("realdir", "file.txt")])
|
||||
})
|
||||
|
||||
test("includes dotfiles when dot option is true", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const dir = new URL("..", import.meta.url).pathname
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
|
||||
await $`bun tsc`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user