Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fd15e9e4 | ||
|
|
4f2514d9f5 | ||
|
|
5d9801f206 | ||
|
|
a300cd6465 | ||
|
|
399699469b | ||
|
|
cacf9df24c | ||
|
|
2844086752 | ||
|
|
75df5040ea | ||
|
|
f2b2940298 | ||
|
|
22f51c6b47 | ||
|
|
de286b08f6 | ||
|
|
0f2124db32 | ||
|
|
e30a159264 | ||
|
|
a5edf3a311 | ||
|
|
7c2907cbb4 | ||
|
|
bce9dc040f | ||
|
|
44297ffe78 | ||
|
|
cbb3141130 | ||
|
|
18cf4df6c6 | ||
|
|
f3e8a275b8 |
@@ -105,7 +105,7 @@ jobs:
|
|||||||
query($owner: String!, $repo: String!, $number: Int!) {
|
query($owner: String!, $repo: String!, $number: Int!) {
|
||||||
repository(owner: $owner, name: $repo) {
|
repository(owner: $owner, name: $repo) {
|
||||||
pullRequest(number: $number) {
|
pullRequest(number: $number) {
|
||||||
issuesReferences(first: 1) {
|
closingIssuesReferences(first: 1) {
|
||||||
totalCount
|
totalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
number: pr.number
|
number: pr.number
|
||||||
});
|
});
|
||||||
|
|
||||||
const linkedIssues = result.repository.pullRequest.issuesReferences.totalCount;
|
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||||
|
|
||||||
if (linkedIssues === 0) {
|
if (linkedIssues === 0) {
|
||||||
await addLabel('needs:issue');
|
await addLabel('needs:issue');
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
|||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
|
|||||||
@@ -286,8 +286,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.71",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.71",
|
"@opentui/solid": "0.1.72",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -1201,21 +1201,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.71", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.71", "@opentui/core-darwin-x64": "0.1.71", "@opentui/core-linux-arm64": "0.1.71", "@opentui/core-linux-x64": "0.1.71", "@opentui/core-win32-arm64": "0.1.71", "@opentui/core-win32-x64": "0.1.71", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-0gmqiBnM80QexPEpxv84QZn4h3IKrvLNn/9wIEzpHBtP7hunI1cJqb9fpMIlYDmf03PDW3KFHx9Oo2v+9jtosQ=="],
|
"@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.71", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0H8tYWuBDmBuUyuoSFeLi1KTVAa7d4RpftS5E4XUfyRtS5afr08DxXft15Y4unMnKzRNQGYaU9Rk/JdbAy9MKg=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.71", "", { "os": "darwin", "cpu": "x64" }, "sha512-3fRfmfpH1GgjphMewETsykRj3FvTJaEf2WttzRPHNaX5zeJQEJYjFLCKFYRujAofvB4I1oxdjGhxEvwD+7AZ0A=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.71", "", { "os": "linux", "cpu": "arm64" }, "sha512-dmLl53gGLEKCef7Cl4v97JmGc5QV9zDjL6djBLS4X88oVY5F0YU8eLW8/3d0DpD/gWQOOOvGvL6fUP1wON1WIg=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.71", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhTfXqNqIb62O/GAjc6MCFheIQLwUULdes4oa0eCab14tyfooc7atzNQPNmsug0blBq0EfqlejRfaOp7f2D9Wg=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.71", "", { "os": "win32", "cpu": "arm64" }, "sha512-F2zXQFBs/DncYCNIwFx6ps7/9N3tLJ3OZNZZrxUXmwKrO+kziB1lBehBlW5TzzBIPkNY9UBaQg+iekncwiDEcg=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.71", "", { "os": "win32", "cpu": "x64" }, "sha512-IJBIrwgQFgPLDNEQVLSVaYZMbGETQlfZ1v/gz/zGtU2NwAHCllmPT1E1AauV/7Ex9Da082/dTrnl351IsipcPQ=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.71", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.71", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-09mimwa2eTkttE72BMv1SNvH/NSf088Ebz0nmrJ7qpmHjbngn7FwlTXK+98sukUTeR6fQqFOnMnuLQieF6QdiA=="],
|
"@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-vuv1AO4iW6cR/+IgPSLzAb1iif1fo9ti5VzSrX4fo5E="
|
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-30
@@ -14,36 +14,7 @@
|
|||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
;(function () {
|
||||||
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
|
if (!themeId) return
|
||||||
|
|
||||||
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = themeId
|
||||||
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
|
if (css) {
|
||||||
|
var style = document.createElement("style")
|
||||||
|
style.id = "oc-theme-preload"
|
||||||
|
style.textContent =
|
||||||
|
":root{color-scheme:" +
|
||||||
|
mode +
|
||||||
|
";--text-mix-blend-mode:" +
|
||||||
|
(isDark ? "plus-lighter" : "multiply") +
|
||||||
|
";" +
|
||||||
|
css +
|
||||||
|
"}"
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -38,9 +38,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerUrl = iife(() => {
|
const defaultServerUrl = iife(() => {
|
||||||
const param = new URLSearchParams(document.location.search).get("url")
|
|
||||||
if (param) return param
|
|
||||||
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function Download() {
|
|||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
|
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
|
||||||
<code>
|
<code>
|
||||||
brew install <strong>opencode</strong>
|
brew install <strong>anomalyco/tap/opencode</strong>
|
||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function Home() {
|
|||||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||||
<span>
|
<span>
|
||||||
<span data-slot="protocol">brew install </span>
|
<span data-slot="protocol">brew install </span>
|
||||||
<span data-slot="highlight">opencode</span>
|
<span data-slot="highlight">anomalyco/tap/opencode</span>
|
||||||
</span>
|
</span>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
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 { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
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"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -39,7 +41,7 @@ export async function POST(input: APIEvent) {
|
|||||||
.where(eq(BillingTable.customerID, customerID))
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "checkout.session.completed") {
|
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
@@ -102,85 +104,112 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "charge.refunded") {
|
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 customerID = body.data.object.customer as string
|
||||||
const paymentIntentID = body.data.object.payment_intent as string
|
const customerEmail = body.data.object.customer_details?.email as string
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
const invoiceID = body.data.object.invoice as string
|
||||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
const subscriptionID = body.data.object.subscription as string
|
||||||
|
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
workspaceID: BillingTable.workspaceID,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
const amount = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
amount: PaymentTable.amount,
|
|
||||||
})
|
|
||||||
.from(PaymentTable)
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
.then((rows) => rows[0]?.amount),
|
|
||||||
)
|
|
||||||
if (!amount) throw new Error("Payment not found")
|
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.update(PaymentTable)
|
|
||||||
.set({
|
|
||||||
timeRefunded: new Date(body.created * 1000),
|
|
||||||
})
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
|
|
||||||
const invoiceID = body.data.object.id as string
|
|
||||||
const amountInCents = body.data.object.amount_paid
|
|
||||||
const customerID = body.data.object.customer as string
|
|
||||||
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
|
||||||
|
|
||||||
if (!customerID) throw new Error("Customer 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 (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
if (!subscriptionID) throw new Error("Subscription 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, {
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
expand: ["payments"],
|
expand: ["payments"],
|
||||||
})
|
})
|
||||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
if (!paymentID) throw new Error("Payment ID not found")
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
// get payment method for the payment intent
|
||||||
tx
|
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||||
.select({ workspaceID: BillingTable.workspaceID })
|
expand: ["payment_method"],
|
||||||
.from(BillingTable)
|
})
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
const paymentMethod = paymentIntent.payment_method
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
|
||||||
|
|
||||||
await Database.use((tx) =>
|
// get coupon id from promotion code
|
||||||
tx.insert(PaymentTable).values({
|
const couponID = await (async () => {
|
||||||
workspaceID,
|
if (!promoCode) return
|
||||||
id: Identifier.create("payment"),
|
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||||
amount: centsToMicroCents(amountInCents),
|
const couponID = coupon.coupon.id
|
||||||
paymentID,
|
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||||
invoiceID,
|
return couponID
|
||||||
customerID,
|
})()
|
||||||
}),
|
|
||||||
)
|
// get user
|
||||||
|
|
||||||
|
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,
|
||||||
|
subscriptionCouponID: couponID,
|
||||||
|
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 tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "customer.subscription.created") {
|
if (body.type === "customer.subscription.created") {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -377,11 +406,113 @@ export async function POST(input: APIEvent) {
|
|||||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({ subscriptionID: null, subscriptionCouponID: null })
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (body.type === "invoice.payment_succeeded") {
|
||||||
|
if (body.data.object.billing_reason === "subscription_cycle") {
|
||||||
|
const invoiceID = body.data.object.id as string
|
||||||
|
const amountInCents = body.data.object.amount_paid
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
||||||
|
|
||||||
|
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 coupon id from subscription
|
||||||
|
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
|
||||||
|
expand: ["discounts"],
|
||||||
|
})
|
||||||
|
const couponID =
|
||||||
|
typeof subscriptionData.discounts[0] === "string"
|
||||||
|
? subscriptionData.discounts[0]
|
||||||
|
: subscriptionData.discounts[0]?.coupon?.id
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// payment id can be undefined when using coupon
|
||||||
|
if (!couponID) throw new Error("Payment ID not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.type === "charge.refunded") {
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const paymentIntentID = body.data.object.payment_intent as string
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: BillingTable.workspaceID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
|
const amount = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
amount: PaymentTable.amount,
|
||||||
|
})
|
||||||
|
.from(PaymentTable)
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
.then((rows) => rows[0]?.amount),
|
||||||
|
)
|
||||||
|
if (!amount) throw new Error("Payment not found")
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(PaymentTable)
|
||||||
|
.set({
|
||||||
|
timeRefunded: new Date(body.created * 1000),
|
||||||
|
})
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: sql`${BillingTable.balance} - ${amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
})
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
.then((message) => {
|
.then((message) => {
|
||||||
return Response.json({ message: message ?? "done" }, { status: 200 })
|
return Response.json({ message: message ?? "done" }, { status: 200 })
|
||||||
|
|||||||
@@ -13,36 +13,7 @@
|
|||||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [appPlugin],
|
plugins: [appPlugin],
|
||||||
|
publicDir: "../app/public",
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.71",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.71",
|
"@opentui/solid": "0.1.72",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Global } from "../global"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export const Oauth = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
|
|||||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||||
)
|
)
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "opencode") {
|
if (provider === "opencode") {
|
||||||
|
|||||||
@@ -97,16 +97,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) {
|
||||||
|
|
||||||
export function tui(input: {
|
|
||||||
url: string
|
|
||||||
args: Args
|
|
||||||
directory?: string
|
|
||||||
fetch?: typeof fetch
|
|
||||||
events?: EventSource
|
|
||||||
onExit?: () => Promise<void>
|
|
||||||
}) {
|
|
||||||
// promise to prevent immediate exit
|
// promise to prevent immediate exit
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
const mode = await getTerminalBackgroundColor()
|
const mode = await getTerminalBackgroundColor()
|
||||||
@@ -126,12 +117,7 @@ export function tui(input: {
|
|||||||
<KVProvider>
|
<KVProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
<SDKProvider
|
<SDKProvider url={input.url} directory={input.directory}>
|
||||||
url={input.url}
|
|
||||||
directory={input.directory}
|
|
||||||
fetch={input.fetch}
|
|
||||||
events={input.events}
|
|
||||||
>
|
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<ThemeProvider mode={mode}>
|
||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
|
|||||||
@@ -3,66 +3,21 @@ import { createSimpleContext } from "./helper"
|
|||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
|
||||||
on: (handler: (event: Event) => void) => () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
|
init: (props: { url: string; directory?: string }) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: props.url,
|
baseUrl: props.url,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
fetch: props.fetch,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let queue: Event[] = []
|
|
||||||
let timer: Timer | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
if (queue.length === 0) return
|
|
||||||
const events = queue
|
|
||||||
queue = []
|
|
||||||
timer = undefined
|
|
||||||
last = Date.now()
|
|
||||||
// Batch all event emissions so all store updates result in a single render
|
|
||||||
batch(() => {
|
|
||||||
for (const event of events) {
|
|
||||||
emitter.emit(event.type, event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEvent = (event: Event) => {
|
|
||||||
queue.push(event)
|
|
||||||
const elapsed = Date.now() - last
|
|
||||||
|
|
||||||
if (timer) return
|
|
||||||
// If we just flushed recently (within 16ms), batch this with future events
|
|
||||||
// Otherwise, process immediately to avoid latency
|
|
||||||
if (elapsed < 16) {
|
|
||||||
timer = setTimeout(flush, 16)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// If an event source is provided, use it instead of SSE
|
|
||||||
if (props.events) {
|
|
||||||
const unsub = props.events.on(handleEvent)
|
|
||||||
onCleanup(unsub)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to SSE
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted) break
|
if (abort.signal.aborted) break
|
||||||
const events = await sdk.event.subscribe(
|
const events = await sdk.event.subscribe(
|
||||||
@@ -71,9 +26,36 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
let queue: Event[] = []
|
||||||
|
let timer: Timer | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (queue.length === 0) return
|
||||||
|
const events = queue
|
||||||
|
queue = []
|
||||||
|
timer = undefined
|
||||||
|
last = Date.now()
|
||||||
|
// Batch all event emissions so all store updates result in a single render
|
||||||
|
batch(() => {
|
||||||
|
for (const event of events) {
|
||||||
|
emitter.emit(event.type, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
handleEvent(event)
|
queue.push(event)
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
|
||||||
|
if (timer) continue
|
||||||
|
// If we just flushed recently (within 16ms), batch this with future events
|
||||||
|
// Otherwise, process immediately to avoid latency
|
||||||
|
if (elapsed < 16) {
|
||||||
|
timer = setTimeout(flush, 16)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any remaining events
|
// Flush any remaining events
|
||||||
@@ -86,7 +68,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return { client: sdk, event: emitter, url: props.url }
|
return { client: sdk, event: emitter, url: props.url }
|
||||||
|
|||||||
@@ -7,39 +7,11 @@ import { UI } from "@/cli/ui"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
|
||||||
import type { EventSource } from "./context/sdk"
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
|
|
||||||
|
|
||||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
|
||||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
||||||
const request = new Request(input, init)
|
|
||||||
const body = request.body ? await request.text() : undefined
|
|
||||||
const result = await client.call("fetch", {
|
|
||||||
url: request.url,
|
|
||||||
method: request.method,
|
|
||||||
headers: Object.fromEntries(request.headers.entries()),
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
return new Response(result.body, {
|
|
||||||
status: result.status,
|
|
||||||
headers: result.headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return fn as typeof fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEventSource(client: RpcClient): EventSource {
|
|
||||||
return {
|
|
||||||
on: (handler) => client.on<Event>("event", handler),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TuiThreadCommand = cmd({
|
export const TuiThreadCommand = cmd({
|
||||||
command: "$0 [project]",
|
command: "$0 [project]",
|
||||||
describe: "start opencode tui",
|
describe: "start opencode tui",
|
||||||
@@ -108,45 +80,16 @@ export const TuiThreadCommand = cmd({
|
|||||||
process.on("SIGUSR2", async () => {
|
process.on("SIGUSR2", async () => {
|
||||||
await client.call("reload", undefined)
|
await client.call("reload", undefined)
|
||||||
})
|
})
|
||||||
|
const opts = await resolveNetworkOptions(args)
|
||||||
|
const server = await client.call("server", opts)
|
||||||
const prompt = await iife(async () => {
|
const prompt = await iife(async () => {
|
||||||
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
|
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
|
||||||
if (!args.prompt) return piped
|
if (!args.prompt) return piped
|
||||||
return piped ? piped + "\n" + args.prompt : args.prompt
|
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
|
||||||
const networkOpts = await resolveNetworkOptions(args)
|
|
||||||
const shouldStartServer =
|
|
||||||
process.argv.includes("--port") ||
|
|
||||||
process.argv.includes("--hostname") ||
|
|
||||||
process.argv.includes("--mdns") ||
|
|
||||||
networkOpts.mdns ||
|
|
||||||
networkOpts.port !== 0 ||
|
|
||||||
networkOpts.hostname !== "127.0.0.1"
|
|
||||||
|
|
||||||
// Subscribe to events from worker
|
|
||||||
await client.call("subscribe", { directory: cwd })
|
|
||||||
|
|
||||||
let url: string
|
|
||||||
let customFetch: typeof fetch | undefined
|
|
||||||
let events: EventSource | undefined
|
|
||||||
|
|
||||||
if (shouldStartServer) {
|
|
||||||
// Start HTTP server for external access
|
|
||||||
const server = await client.call("server", networkOpts)
|
|
||||||
url = server.url
|
|
||||||
} else {
|
|
||||||
// Use direct RPC communication (no HTTP)
|
|
||||||
url = "http://opencode.internal"
|
|
||||||
customFetch = createWorkerFetch(client)
|
|
||||||
events = createEventSource(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tuiPromise = tui({
|
const tuiPromise = tui({
|
||||||
url,
|
url: server.url,
|
||||||
fetch: customFetch,
|
|
||||||
events,
|
|
||||||
args: {
|
args: {
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
sessionID: args.session,
|
sessionID: args.session,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { Instance } from "@/project/instance"
|
|||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
import type { BunWebSocketData } from "hono/bun"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
print: process.argv.includes("--print-logs"),
|
print: process.argv.includes("--print-logs"),
|
||||||
@@ -31,46 +29,19 @@ process.on("uncaughtException", (e) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to global events and forward them via RPC
|
let server: Bun.Server<BunWebSocketData>
|
||||||
GlobalBus.on("event", (event) => {
|
|
||||||
Rpc.emit("global.event", event)
|
|
||||||
})
|
|
||||||
|
|
||||||
let server: Bun.Server<BunWebSocketData> | undefined
|
|
||||||
|
|
||||||
export const rpc = {
|
export const rpc = {
|
||||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
async server(input: { port: number; hostname: string; mdns?: boolean }) {
|
||||||
const request = new Request(input.url, {
|
|
||||||
method: input.method,
|
|
||||||
headers: input.headers,
|
|
||||||
body: input.body,
|
|
||||||
})
|
|
||||||
const response = await Server.App().fetch(request)
|
|
||||||
const body = await response.text()
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
headers: Object.fromEntries(response.headers.entries()),
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
server = Server.listen(input)
|
try {
|
||||||
return { url: server.url.toString() }
|
server = Server.listen(input)
|
||||||
},
|
return {
|
||||||
async subscribe(input: { directory: string }) {
|
url: server.url.toString(),
|
||||||
return Instance.provide({
|
}
|
||||||
directory: input.directory,
|
} catch (e) {
|
||||||
init: InstanceBootstrap,
|
console.error(e)
|
||||||
fn: async () => {
|
throw e
|
||||||
Bus.subscribeAll((event) => {
|
}
|
||||||
Rpc.emit("event", event)
|
|
||||||
})
|
|
||||||
// Emit connected event
|
|
||||||
Rpc.emit("event", { type: "server.connected", properties: {} })
|
|
||||||
return { subscribed: true }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -88,7 +59,9 @@ export const rpc = {
|
|||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) server.stop(true)
|
// TODO: this should be awaited, but ws connections are
|
||||||
|
// causing this to hang, need to revisit this
|
||||||
|
server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ await Promise.all([
|
|||||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const CACHE_VERSION = "15"
|
const CACHE_VERSION = "16"
|
||||||
|
|
||||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
@@ -99,14 +99,16 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
.command(GithubCommand)
|
.command(GithubCommand)
|
||||||
.command(PrCommand)
|
.command(PrCommand)
|
||||||
.command(SessionCommand)
|
.command(SessionCommand)
|
||||||
.fail((msg) => {
|
.fail((msg, err) => {
|
||||||
if (
|
if (
|
||||||
msg?.startsWith("Unknown argument") ||
|
msg?.startsWith("Unknown argument") ||
|
||||||
msg?.startsWith("Not enough non-option arguments") ||
|
msg?.startsWith("Not enough non-option arguments") ||
|
||||||
msg?.startsWith("Invalid values:")
|
msg?.startsWith("Invalid values:")
|
||||||
) {
|
) {
|
||||||
|
if (err) throw err
|
||||||
cli.showHelp("log")
|
cli.showHelp("log")
|
||||||
}
|
}
|
||||||
|
if (err) throw err
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "plugin.codex" })
|
||||||
|
|
||||||
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
const ISSUER = "https://auth.openai.com"
|
||||||
|
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
const OAUTH_PORT = 1455
|
||||||
|
|
||||||
|
interface PkceCodes {
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePKCE(): Promise<PkceCodes> {
|
||||||
|
const verifier = generateRandomString(43)
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(verifier)
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||||
|
const challenge = base64UrlEncode(hash)
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomString(length: number): string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => chars[b % chars.length])
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const binary = String.fromCharCode(...bytes)
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateState(): string {
|
||||||
|
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
code_challenge: pkce.challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
id_token_add_organizations: "true",
|
||||||
|
codex_cli_simplified_flow: "true",
|
||||||
|
state,
|
||||||
|
originator: "opencode",
|
||||||
|
})
|
||||||
|
return `${ISSUER}/oauth/authorize?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
id_token: string
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||||
|
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code_verifier: pkce.verifier,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token exchange failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||||
|
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(() => window.close(), 2000);</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Failed</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<p>An error occurred during authorization.</p>
|
||||||
|
<div class="error">${error}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
interface PendingOAuth {
|
||||||
|
pkce: PkceCodes
|
||||||
|
state: string
|
||||||
|
resolve: (tokens: TokenResponse) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||||
|
let pendingOAuth: PendingOAuth | undefined
|
||||||
|
|
||||||
|
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||||
|
if (oauthServer) {
|
||||||
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthServer = Bun.serve({
|
||||||
|
port: OAUTH_PORT,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
if (url.pathname === "/auth/callback") {
|
||||||
|
const code = url.searchParams.get("code")
|
||||||
|
const state = url.searchParams.get("state")
|
||||||
|
const error = url.searchParams.get("error")
|
||||||
|
const errorDescription = url.searchParams.get("error_description")
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorMsg = errorDescription || error
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
const errorMsg = "Missing authorization code"
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||||
|
const errorMsg = "Invalid state - potential CSRF attack"
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = pendingOAuth
|
||||||
|
pendingOAuth = undefined
|
||||||
|
|
||||||
|
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||||
|
.then((tokens) => current.resolve(tokens))
|
||||||
|
.catch((err) => current.reject(err))
|
||||||
|
|
||||||
|
return new Response(HTML_SUCCESS, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/cancel") {
|
||||||
|
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response("Login cancelled", { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||||
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopOAuthServer() {
|
||||||
|
if (oauthServer) {
|
||||||
|
oauthServer.stop()
|
||||||
|
oauthServer = undefined
|
||||||
|
log.info("codex oauth server stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (pendingOAuth) {
|
||||||
|
pendingOAuth = undefined
|
||||||
|
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
) // 5 minute timeout
|
||||||
|
|
||||||
|
pendingOAuth = {
|
||||||
|
pkce,
|
||||||
|
state,
|
||||||
|
resolve: (tokens) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(tokens)
|
||||||
|
},
|
||||||
|
reject: (error) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(error)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider: "openai",
|
||||||
|
async loader(getAuth, provider) {
|
||||||
|
const auth = await getAuth()
|
||||||
|
if (auth.type !== "oauth") return {}
|
||||||
|
|
||||||
|
// Filter models to only allowed Codex models for OAuth
|
||||||
|
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
||||||
|
for (const modelId of Object.keys(provider.models)) {
|
||||||
|
if (!allowedModels.has(modelId)) {
|
||||||
|
delete provider.models[modelId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.models["gpt-5.2-codex"]) {
|
||||||
|
provider.models["gpt-5.2-codex"] = {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5.2 Codex",
|
||||||
|
capabilities: {
|
||||||
|
temperature: false,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
|
limit: { context: 400000, output: 128000 },
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||||
|
for (const model of Object.values(provider.models)) {
|
||||||
|
model.cost = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: OAUTH_DUMMY_KEY,
|
||||||
|
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
// Remove dummy API key authorization header
|
||||||
|
if (init?.headers) {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.delete("authorization")
|
||||||
|
init.headers.delete("Authorization")
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
|
||||||
|
} else {
|
||||||
|
delete init.headers["authorization"]
|
||||||
|
delete init.headers["Authorization"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAuth = await getAuth()
|
||||||
|
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||||
|
|
||||||
|
// Check if token needs refresh
|
||||||
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||||
|
log.info("refreshing codex access token")
|
||||||
|
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||||
|
await input.client.auth.set({
|
||||||
|
path: { id: "codex" },
|
||||||
|
body: {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
currentAuth.access = tokens.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
const headers = new Headers()
|
||||||
|
if (init?.headers) {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.forEach((value, key) => headers.set(key, value))
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
for (const [key, value] of init.headers) {
|
||||||
|
if (value !== undefined) headers.set(key, String(value))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(init.headers)) {
|
||||||
|
if (value !== undefined) headers.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authorization header with access token
|
||||||
|
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||||
|
|
||||||
|
// Rewrite URL to Codex endpoint
|
||||||
|
let url: URL
|
||||||
|
if (typeof requestInput === "string") {
|
||||||
|
url = new URL(requestInput)
|
||||||
|
} else if (requestInput instanceof URL) {
|
||||||
|
url = requestInput
|
||||||
|
} else {
|
||||||
|
url = new URL(requestInput.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a messages/responses request, redirect to Codex endpoint
|
||||||
|
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
||||||
|
url = new URL(CODEX_API_ENDPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
label: "ChatGPT Pro/Plus",
|
||||||
|
type: "oauth",
|
||||||
|
authorize: async () => {
|
||||||
|
const { redirectUri } = await startOAuthServer()
|
||||||
|
const pkce = await generatePKCE()
|
||||||
|
const state = generateState()
|
||||||
|
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||||
|
|
||||||
|
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: authUrl,
|
||||||
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
|
method: "auto" as const,
|
||||||
|
callback: async () => {
|
||||||
|
const tokens = await callbackPromise
|
||||||
|
stopOAuthServer()
|
||||||
|
return {
|
||||||
|
type: "success" as const,
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,15 @@ import { Server } from "../server/server"
|
|||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.5"]
|
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
|
||||||
|
|
||||||
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
@@ -20,7 +24,7 @@ export namespace Plugin {
|
|||||||
fetch: async (...args) => Server.App().fetch(...args),
|
fetch: async (...args) => Server.App().fetch(...args),
|
||||||
})
|
})
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const hooks = []
|
const hooks: Hooks[] = []
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
client,
|
client,
|
||||||
project: Instance.project,
|
project: Instance.project,
|
||||||
@@ -29,11 +33,23 @@ export namespace Plugin {
|
|||||||
serverUrl: Server.url(),
|
serverUrl: Server.url(),
|
||||||
$: Bun.$,
|
$: Bun.$,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load internal plugins first
|
||||||
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
|
for (const plugin of INTERNAL_PLUGINS) {
|
||||||
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
|
const init = await plugin(input)
|
||||||
|
hooks.push(init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plugins = [...(config.plugin ?? [])]
|
const plugins = [...(config.plugin ?? [])]
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
plugins.push(...BUILTIN)
|
plugins.push(...BUILTIN)
|
||||||
}
|
}
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
if (plugin.includes("opencode-openai-codex-auth")) continue
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export namespace Server {
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
export const App: () => Hono = lazy(
|
export const App: () => Hono = lazy(
|
||||||
() =>
|
() =>
|
||||||
|
// TODO: Break server.ts into smaller route files to fix type inference
|
||||||
app
|
app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
@@ -2826,10 +2827,6 @@ export namespace Server {
|
|||||||
host: "app.opencode.ai",
|
host: "app.opencode.ai",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
response.headers.set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
}) as unknown as Hono,
|
}) as unknown as Hono,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -151,12 +151,19 @@ export namespace Session {
|
|||||||
directory: Instance.directory,
|
directory: Instance.directory,
|
||||||
})
|
})
|
||||||
const msgs = await messages({ sessionID: input.sessionID })
|
const msgs = await messages({ sessionID: input.sessionID })
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
if (input.messageID && msg.info.id >= input.messageID) break
|
if (input.messageID && msg.info.id >= input.messageID) break
|
||||||
|
const newID = Identifier.ascending("message")
|
||||||
|
idMap.set(msg.info.id, newID)
|
||||||
|
|
||||||
|
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
|
||||||
const cloned = await updateMessage({
|
const cloned = await updateMessage({
|
||||||
...msg.info,
|
...msg.info,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: Identifier.ascending("message"),
|
id: newID,
|
||||||
|
...(parentID && { parentID }),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const part of msg.parts) {
|
for (const part of msg.parts) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import os from "os"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
|
|||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { PermissionNext } from "@/permission/next"
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
@@ -82,12 +85,24 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = await Provider.getProvider(input.model.providerID)
|
const provider = await Provider.getProvider(input.model.providerID)
|
||||||
|
const auth = await Auth.get(input.model.providerID)
|
||||||
|
const isCodex = provider.id === "openai" && auth?.type === "oauth"
|
||||||
|
|
||||||
const variant =
|
const variant =
|
||||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||||
const base = input.small
|
const base = input.small
|
||||||
? ProviderTransform.smallOptions(input.model)
|
? ProviderTransform.smallOptions(input.model)
|
||||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
||||||
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
|
const options: Record<string, any> = pipe(
|
||||||
|
base,
|
||||||
|
mergeDeep(input.model.options),
|
||||||
|
mergeDeep(input.agent.options),
|
||||||
|
mergeDeep(variant),
|
||||||
|
)
|
||||||
|
if (isCodex) {
|
||||||
|
options.instructions = SystemPrompt.instructions()
|
||||||
|
options.store = false
|
||||||
|
}
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
const params = await Plugin.trigger(
|
||||||
"chat.params",
|
"chat.params",
|
||||||
@@ -108,16 +123,14 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
l.info("params", {
|
const maxOutputTokens = isCodex
|
||||||
params,
|
? undefined
|
||||||
})
|
: ProviderTransform.maxOutputTokens(
|
||||||
|
input.model.api.npm,
|
||||||
const maxOutputTokens = ProviderTransform.maxOutputTokens(
|
params.options,
|
||||||
input.model.api.npm,
|
input.model.limit.output,
|
||||||
params.options,
|
OUTPUT_TOKEN_MAX,
|
||||||
input.model.limit.output,
|
)
|
||||||
OUTPUT_TOKEN_MAX,
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = await resolveTools(input)
|
const tools = await resolveTools(input)
|
||||||
|
|
||||||
@@ -157,6 +170,13 @@ export namespace LLM {
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: {
|
||||||
|
...(isCodex
|
||||||
|
? {
|
||||||
|
originator: "opencode",
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
|
session_id: input.sessionID,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
...(input.model.providerID.startsWith("opencode")
|
...(input.model.providerID.startsWith("opencode")
|
||||||
? {
|
? {
|
||||||
"x-opencode-project": Instance.project.id,
|
"x-opencode-project": Instance.project.id,
|
||||||
@@ -169,12 +189,19 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...(isCodex
|
||||||
(x): ModelMessage => ({
|
? [
|
||||||
role: "system",
|
{
|
||||||
content: x,
|
role: "user",
|
||||||
}),
|
content: system.join("\n\n"),
|
||||||
),
|
} as ModelMessage,
|
||||||
|
]
|
||||||
|
: system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
...input.messages,
|
...input.messages,
|
||||||
],
|
],
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.
|
||||||
@@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
|
|||||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||||
|
|
||||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||||
|
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function instructions() {
|
||||||
|
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function provider(model: Provider.Model) {
|
export function provider(model: Provider.Model) {
|
||||||
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
||||||
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||||
|
|||||||
@@ -13,16 +13,11 @@ export namespace Rpc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emit(event: string, data: unknown) {
|
|
||||||
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function client<T extends Definition>(target: {
|
export function client<T extends Definition>(target: {
|
||||||
postMessage: (data: string) => void | null
|
postMessage: (data: string) => void | null
|
||||||
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
|
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
|
||||||
}) {
|
}) {
|
||||||
const pending = new Map<number, (result: any) => void>()
|
const pending = new Map<number, (result: any) => void>()
|
||||||
const listeners = new Map<string, Set<(data: any) => void>>()
|
|
||||||
let id = 0
|
let id = 0
|
||||||
target.onmessage = async (evt) => {
|
target.onmessage = async (evt) => {
|
||||||
const parsed = JSON.parse(evt.data)
|
const parsed = JSON.parse(evt.data)
|
||||||
@@ -33,14 +28,6 @@ export namespace Rpc {
|
|||||||
pending.delete(parsed.id)
|
pending.delete(parsed.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parsed.type === "rpc.event") {
|
|
||||||
const handlers = listeners.get(parsed.event)
|
|
||||||
if (handlers) {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
handler(parsed.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
|
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
|
||||||
@@ -50,17 +37,6 @@ export namespace Rpc {
|
|||||||
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
|
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
on<Data>(event: string, handler: (data: Data) => void) {
|
|
||||||
let handlers = listeners.get(event)
|
|
||||||
if (!handlers) {
|
|
||||||
handlers = new Set()
|
|
||||||
listeners.set(event, handlers)
|
|
||||||
}
|
|
||||||
handlers.add(handler)
|
|
||||||
return () => {
|
|
||||||
handlers!.delete(handler)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
[data-slot="user-message-text"] {
|
[data-slot="user-message-text"] {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -254,6 +254,7 @@
|
|||||||
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -271,6 +272,7 @@
|
|||||||
/* Hide scrollbar */
|
/* Hide scrollbar */
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -458,6 +460,7 @@
|
|||||||
from {
|
from {
|
||||||
--border-angle: 0deg;
|
--border-angle: 0deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
--border-angle: 360deg;
|
--border-angle: 360deg;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user