mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-04 07:40:39 +08:00
Compare commits
49 Commits
effect-dri
...
thdxr/v2-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed24cbda8 | ||
|
|
fc801c70ae | ||
|
|
0ed82a1d69 | ||
|
|
aa73a5941f | ||
|
|
4b1f77e59c | ||
|
|
4d9d69526e | ||
|
|
5b31f6af68 | ||
|
|
755cd561ec | ||
|
|
6312c55d55 | ||
|
|
a9dc0fae3d | ||
|
|
7749d8e85f | ||
|
|
28112fbd12 | ||
|
|
387220f368 | ||
|
|
adb7cb1037 | ||
|
|
c06af70ab0 | ||
|
|
40dc2fa3c1 | ||
|
|
df7dd06a0f | ||
|
|
57d5c095d8 | ||
|
|
13ac849db5 | ||
|
|
8694c5b68f | ||
|
|
0a7d02c87c | ||
|
|
e77867ef05 | ||
|
|
fb224d8974 | ||
|
|
101566131d | ||
|
|
8433e8b433 | ||
|
|
379600b5ab | ||
|
|
7a503de606 | ||
|
|
2ad1eb56d3 | ||
|
|
a43f767abb | ||
|
|
0ee3b87289 | ||
|
|
3c9f3c5786 | ||
|
|
ca75ac6681 | ||
|
|
d1f597b5b5 | ||
|
|
8299fb3e2b | ||
|
|
4f7f90133d | ||
|
|
b205e104f6 | ||
|
|
252e2f98e6 | ||
|
|
e2afdc1202 | ||
|
|
a08e4c9651 | ||
|
|
7ccab8d272 | ||
|
|
fc57eb3b8e | ||
|
|
9179bafd54 | ||
|
|
2df8eda8a3 | ||
|
|
bd32252a7e | ||
|
|
1717d636a2 | ||
|
|
8e016b4703 | ||
|
|
b89d48a2a4 | ||
|
|
33312bfd1b | ||
|
|
3f1ce36418 |
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report an issue that should be fixed
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Suggest an idea, feature, or enhancement
|
||||
labels: [discussion]
|
||||
title: "[FEATURE]:"
|
||||
|
||||
body:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/question.yml
vendored
1
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Question
|
||||
description: Ask a question
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
|
||||
1
.github/TEAM_MEMBERS
vendored
1
.github/TEAM_MEMBERS
vendored
@@ -11,6 +11,5 @@ MrMushrooooom
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
RhysSullivan
|
||||
thdxr
|
||||
simonklee
|
||||
|
||||
41
.github/VOUCHED.td
vendored
41
.github/VOUCHED.td
vendored
@@ -1,41 +0,0 @@
|
||||
# Vouched contributors for this project.
|
||||
#
|
||||
# See https://github.com/mitchellh/vouch for details.
|
||||
#
|
||||
# Syntax:
|
||||
# - One handle per line (without @), sorted alphabetically.
|
||||
# - Optional platform prefix: platform:username (e.g., github:user).
|
||||
# - Denounce with minus prefix: -username or -platform:username.
|
||||
# - Optional details after a space following the handle.
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-borealbytes
|
||||
-carycooper777
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
|
||||
dmtrkovalenko
|
||||
edemaine
|
||||
fahreddinozcan
|
||||
-florianleibert
|
||||
fwang
|
||||
iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
rubdos
|
||||
-saisharan0103 spamming ai prs
|
||||
shantur
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
-terisuke
|
||||
thdxr
|
||||
-toastythebot
|
||||
170
.github/workflows/daily-issues-recap.yml
vendored
170
.github/workflows/daily-issues-recap.yml
vendored
@@ -1,170 +0,0 @@
|
||||
name: daily-issues-recap
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
|
||||
- cron: "0 23 * * *"
|
||||
workflow_dispatch: # Allow manual trigger for testing
|
||||
|
||||
jobs:
|
||||
daily-recap:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Generate daily issues recap
|
||||
id: recap
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh issue*": "allow",
|
||||
"gh search*": "allow"
|
||||
},
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
}
|
||||
run: |
|
||||
# Get today's date range
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
|
||||
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather today's issues
|
||||
Search for all OPEN issues created today (${TODAY}) using:
|
||||
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
||||
|
||||
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) issues only.
|
||||
|
||||
STEP 2: Analyze and categorize
|
||||
For each issue created today, categorize it:
|
||||
|
||||
**Severity Assessment:**
|
||||
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
|
||||
- HIGH: Significant bugs affecting many users, important features broken
|
||||
- MEDIUM: Bugs with workarounds, minor features broken
|
||||
- LOW: Minor issues, cosmetic, nice-to-haves
|
||||
|
||||
**Activity Assessment:**
|
||||
- Note issues with high comment counts or engagement
|
||||
- Note issues from repeat reporters (check if author has filed before)
|
||||
|
||||
STEP 3: Cross-reference with existing issues
|
||||
For issues that seem like feature requests or recurring bugs:
|
||||
- Search for similar older issues to identify patterns
|
||||
- Note if this is a frequently requested feature
|
||||
- Identify any issues that are duplicates of long-standing requests
|
||||
|
||||
STEP 4: Generate the recap
|
||||
Create a structured recap with these sections:
|
||||
|
||||
===DISCORD_START===
|
||||
**Daily Issues Recap - ${TODAY}**
|
||||
|
||||
**Summary Stats**
|
||||
- Total issues opened today: [count]
|
||||
- By category: [bugs/features/questions]
|
||||
|
||||
**Critical/High Priority Issues**
|
||||
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
|
||||
|
||||
**Most Active/Discussed**
|
||||
[Issues with significant engagement or from active community members]
|
||||
|
||||
**Trending Topics**
|
||||
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
|
||||
|
||||
**Duplicates & Related**
|
||||
[Issues that relate to existing open issues]
|
||||
===DISCORD_END===
|
||||
|
||||
STEP 5: Format for Discord
|
||||
Format the recap as a Discord-compatible message:
|
||||
- Use Discord markdown (**, __, etc.)
|
||||
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
|
||||
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
|
||||
- Group related issues on single lines where possible
|
||||
- Add emoji sparingly for critical items only
|
||||
- HARD LIMIT: Keep under 1800 characters total
|
||||
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
|
||||
- Prioritize signal over completeness - only surface what matters
|
||||
|
||||
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
|
||||
|
||||
# Extract only the Discord message between markers
|
||||
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
|
||||
|
||||
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
|
||||
cat /tmp/recap.txt
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the recap
|
||||
RECAP_RAW=$(cat /tmp/recap.txt)
|
||||
RECAP_LENGTH=${#RECAP_RAW}
|
||||
|
||||
echo "Recap length: ${RECAP_LENGTH} chars"
|
||||
|
||||
# Function to post a message to Discord
|
||||
post_to_discord() {
|
||||
local msg="$1"
|
||||
local content=$(echo "$msg" | jq -Rs '.')
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{\"content\": ${content}}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# If under limit, send as single message
|
||||
if [ "$RECAP_LENGTH" -le 1950 ]; then
|
||||
post_to_discord "$RECAP_RAW"
|
||||
else
|
||||
echo "Splitting into multiple messages..."
|
||||
remaining="$RECAP_RAW"
|
||||
while [ ${#remaining} -gt 0 ]; do
|
||||
if [ ${#remaining} -le 1950 ]; then
|
||||
post_to_discord "$remaining"
|
||||
break
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
|
||||
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
|
||||
chunk="${remaining:0:$last_newline}"
|
||||
remaining="${remaining:$((last_newline+1))}"
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
remaining="${remaining:1900}"
|
||||
fi
|
||||
post_to_discord "$chunk"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Posted daily recap to Discord"
|
||||
173
.github/workflows/daily-pr-recap.yml
vendored
173
.github/workflows/daily-pr-recap.yml
vendored
@@ -1,173 +0,0 @@
|
||||
name: daily-pr-recap
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
|
||||
- cron: "0 22 * * *"
|
||||
workflow_dispatch: # Allow manual trigger for testing
|
||||
|
||||
jobs:
|
||||
pr-recap:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Generate daily PR recap
|
||||
id: recap
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh pr*": "allow",
|
||||
"gh search*": "allow"
|
||||
},
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
}
|
||||
run: |
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
|
||||
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather PR data
|
||||
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
|
||||
|
||||
# Open PRs created today
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
# Open PRs with activity today (updated today)
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) contributions only.
|
||||
|
||||
|
||||
|
||||
STEP 2: For high-activity PRs, check comment counts
|
||||
For promising PRs, run:
|
||||
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
|
||||
|
||||
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
|
||||
- copilot-pull-request-reviewer
|
||||
- github-actions
|
||||
|
||||
STEP 3: Identify what matters (ONLY from today's PRs)
|
||||
|
||||
**Bug Fixes From Today:**
|
||||
- PRs with 'fix' or 'bug' in title created/updated today
|
||||
- Small bug fixes (< 100 lines changed) that are easy to review
|
||||
- Bug fixes from community contributors
|
||||
|
||||
**High Activity Today:**
|
||||
- PRs with significant human comments today (excluding bots listed above)
|
||||
- PRs with back-and-forth discussion today
|
||||
|
||||
**Quick Wins:**
|
||||
- Small PRs (< 50 lines) that are approved or nearly approved
|
||||
- PRs that just need a final review
|
||||
|
||||
STEP 4: Generate the recap
|
||||
Create a structured recap:
|
||||
|
||||
===DISCORD_START===
|
||||
**Daily PR Recap - ${TODAY}**
|
||||
|
||||
**New PRs Today**
|
||||
[PRs opened today - group by type: bug fixes, features, etc.]
|
||||
|
||||
**Active PRs Today**
|
||||
[PRs with activity/updates today - significant discussion]
|
||||
|
||||
**Quick Wins**
|
||||
[Small PRs ready to merge]
|
||||
===DISCORD_END===
|
||||
|
||||
STEP 5: Format for Discord
|
||||
- Use Discord markdown (**, __, etc.)
|
||||
- BE EXTREMELY CONCISE - surface what we might miss
|
||||
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
|
||||
- Include PR author: [#1234](<url>) (@author)
|
||||
- For bug fixes, add brief description of what it fixes
|
||||
- Show line count for quick wins: \"(+15/-3 lines)\"
|
||||
- HARD LIMIT: Keep under 1800 characters total
|
||||
- Skip empty sections
|
||||
- Focus on PRs that need human eyes
|
||||
|
||||
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
|
||||
|
||||
# Extract only the Discord message between markers
|
||||
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
|
||||
|
||||
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
|
||||
cat /tmp/pr_recap.txt
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the recap
|
||||
RECAP_RAW=$(cat /tmp/pr_recap.txt)
|
||||
RECAP_LENGTH=${#RECAP_RAW}
|
||||
|
||||
echo "Recap length: ${RECAP_LENGTH} chars"
|
||||
|
||||
# Function to post a message to Discord
|
||||
post_to_discord() {
|
||||
local msg="$1"
|
||||
local content=$(echo "$msg" | jq -Rs '.')
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{\"content\": ${content}}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# If under limit, send as single message
|
||||
if [ "$RECAP_LENGTH" -le 1950 ]; then
|
||||
post_to_discord "$RECAP_RAW"
|
||||
else
|
||||
echo "Splitting into multiple messages..."
|
||||
remaining="$RECAP_RAW"
|
||||
while [ ${#remaining} -gt 0 ]; do
|
||||
if [ ${#remaining} -le 1950 ]; then
|
||||
post_to_discord "$remaining"
|
||||
break
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
|
||||
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
|
||||
chunk="${remaining:0:$last_newline}"
|
||||
remaining="${remaining:$((last_newline+1))}"
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
remaining="${remaining:1900}"
|
||||
fi
|
||||
post_to_discord "$chunk"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Posted daily PR recap to Discord"
|
||||
219
.github/workflows/publish.yml
vendored
219
.github/workflows/publish.yml
vendored
@@ -209,182 +209,6 @@ jobs:
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
|
||||
build-tauri:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- host: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: windows-2025
|
||||
target: aarch64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Verify Certificate
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported."
|
||||
|
||||
- name: Setup Apple API Key
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
run: |
|
||||
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: |
|
||||
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo chmod -R a+rw ~/apt-cache
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/desktop/src-tauri
|
||||
shared-key: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
cd packages/desktop
|
||||
bun ./scripts/prepare.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Resolve tauri portable SHA
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||
|
||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||
- name: Install tauri-cli from portable appimage branch
|
||||
uses: taiki-e/cache-cargo-install-action@v3
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
with:
|
||||
tool: tauri-cli
|
||||
git: https://github.com/tauri-apps/tauri
|
||||
# branch: feat/truly-portable-appimage
|
||||
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||
|
||||
- name: Show tauri-cli version
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: cargo tauri --version
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build and upload artifacts
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
timeout-minutes: 60
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
uploadWorkflowArtifacts: true
|
||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
|
||||
updaterJsonPreferNsis: true
|
||||
releaseId: ${{ needs.version.outputs.release }}
|
||||
tagName: ${{ needs.version.outputs.tag }}
|
||||
releaseDraft: true
|
||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
|
||||
releaseCommitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
- name: Verify signed Windows desktop artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
|
||||
)
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
|
||||
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
@@ -524,6 +348,30 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- name: Create and upload macOS .app.tar.gz
|
||||
if: runner.os == 'macOS' && needs.version.outputs.release
|
||||
working-directory: packages/desktop-electron/dist
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
|
||||
APP_DIR="mac"
|
||||
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
|
||||
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
|
||||
APP_DIR="mac-arm64"
|
||||
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
|
||||
else
|
||||
echo "Unknown macOS target: ${{ matrix.settings.target }}"
|
||||
exit 1
|
||||
fi
|
||||
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app bundle found in $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
|
||||
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- name: Verify signed Windows Electron artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
@@ -542,7 +390,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-electron-${{ matrix.settings.target }}
|
||||
name: opencode-desktop-${{ matrix.settings.target }}
|
||||
path: packages/desktop-electron/dist/*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -556,7 +404,6 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-windows
|
||||
- build-tauri
|
||||
- build-electron
|
||||
if: always() && !failure() && !cancelled()
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -583,13 +430,6 @@ jobs:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
@@ -611,6 +451,13 @@ jobs:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Cache apt packages (AUR)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -639,3 +486,5 @@ jobs:
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
LATEST_YML_DIR: /tmp/latest-yml
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
116
.github/workflows/vouch-check-issue.yml
vendored
116
.github/workflows/vouch-check-issue.yml
vendored
@@ -1,116 +0,0 @@
|
||||
name: vouch-check-issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if issue author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.issue.user.login;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the issue
|
||||
const body = 'This issue has been automatically closed.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
|
||||
114
.github/workflows/vouch-check-pr.yml
vendored
114
.github/workflows/vouch-check-pr.yml
vendored
@@ -1,114 +0,0 @@
|
||||
name: vouch-check-pr
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if PR author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the PR
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: 'This pull request has been automatically closed.',
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
|
||||
38
.github/workflows/vouch-manage-by-issue.yml
vendored
38
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: vouch-manage-by-issue
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/minimax-m2.5
|
||||
model: opencode/gpt-5.4-nano
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
@@ -14,127 +14,30 @@ Use your github-triage tool to triage issues.
|
||||
|
||||
This file is the source of truth for ownership/routing rules.
|
||||
|
||||
## Labels
|
||||
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
|
||||
|
||||
### windows
|
||||
Do not add labels to issues. Only assign an owner.
|
||||
|
||||
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
|
||||
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
|
||||
|
||||
- Use if they mention WSL too
|
||||
## Teams
|
||||
|
||||
#### perf
|
||||
### TUI
|
||||
|
||||
Performance-related issues:
|
||||
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
|
||||
|
||||
- Slow performance
|
||||
- High RAM usage
|
||||
- High CPU usage
|
||||
### Desktop / Web
|
||||
|
||||
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
|
||||
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
|
||||
|
||||
#### desktop
|
||||
### Core
|
||||
|
||||
Desktop app issues:
|
||||
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
|
||||
|
||||
- `opencode web` command
|
||||
- The desktop app itself
|
||||
### Inference
|
||||
|
||||
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
|
||||
OpenCode Zen, OpenCode Go, and billing issues.
|
||||
|
||||
#### nix
|
||||
### Windows
|
||||
|
||||
**Only** add if the issue explicitly mentions nix.
|
||||
|
||||
If the issue does not mention nix, do not add nix.
|
||||
|
||||
If the issue mentions nix, assign to `rekram1-node`.
|
||||
|
||||
#### zen
|
||||
|
||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||
|
||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||
|
||||
#### core
|
||||
|
||||
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||
|
||||
Examples:
|
||||
|
||||
- LSP server behavior
|
||||
- Harness behavior (agent + tools)
|
||||
- Feature requests for server behavior
|
||||
- Agent context construction
|
||||
- API endpoints
|
||||
- Provider integration issues
|
||||
- New, broken, or poor-quality models
|
||||
|
||||
#### acp
|
||||
|
||||
If the issue mentions acp support, assign acp label.
|
||||
|
||||
#### docs
|
||||
|
||||
Add if the issue requests better documentation or docs updates.
|
||||
|
||||
#### opentui
|
||||
|
||||
TUI issues potentially caused by our underlying TUI library:
|
||||
|
||||
- Keybindings not working
|
||||
- Scroll speed issues (too fast/slow/laggy)
|
||||
- Screen flickering
|
||||
- Crashes with opentui in the log
|
||||
|
||||
**Do not** add for general TUI bugs.
|
||||
|
||||
When assigning to people here are the following rules:
|
||||
|
||||
Desktop / Web:
|
||||
Use for desktop-labeled issues only.
|
||||
|
||||
- adamdotdevin
|
||||
- iamdavidhill
|
||||
- Brendonovich
|
||||
- nexxeln
|
||||
|
||||
Zen:
|
||||
ONLY assign if the issue will have the "zen" label.
|
||||
|
||||
- fwang
|
||||
- MrMushrooooom
|
||||
|
||||
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||
|
||||
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||
|
||||
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||
|
||||
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||
|
||||
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||
|
||||
Docs:
|
||||
|
||||
- R44VC0RP
|
||||
|
||||
Windows:
|
||||
|
||||
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||
|
||||
Determinism rules:
|
||||
|
||||
- If title + body does not contain "zen", do not add the "zen" label
|
||||
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||
|
||||
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
ACP:
|
||||
|
||||
- rekram1-node (assign any acp issues to rekram1-node)
|
||||
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.
|
||||
|
||||
@@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution.
|
||||
|
||||
Rules:
|
||||
|
||||
- Write the final file with sections in this order:
|
||||
- Write the final file with release sections in this order:
|
||||
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
||||
- Only include sections that have at least one notable entry
|
||||
- Within each release section, keep bug fixes grouped under `### Bugfixes`
|
||||
- Keep other notable entries under `### Improvements` when a section has bug fixes too
|
||||
- Omit empty subsections
|
||||
- Keep one bullet per commit you keep
|
||||
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
||||
- Start each bullet with a capital letter
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["kommander", "rekram1-node", "simonklee"],
|
||||
core: ["kitlangton", "rekram1-node", "jlongster"],
|
||||
docs: ["R44VC0RP"],
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||
|
||||
function pick<T>(items: readonly T[]) {
|
||||
return items[Math.floor(Math.random() * items.length)]!
|
||||
}
|
||||
@@ -38,79 +36,25 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
description: `Use this tool to assign a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
.describe("The username of the assignee")
|
||||
.default("rekram1-node"),
|
||||
labels: tool.schema
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||
.describe("The labels(s) to add to the issue")
|
||||
.default([]),
|
||||
team: tool.schema
|
||||
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
|
||||
.describe("The owning team"),
|
||||
},
|
||||
async execute(args) {
|
||||
const issue = getIssueNumber()
|
||||
const owner = "anomalyco"
|
||||
const repo = "opencode"
|
||||
|
||||
const results: string[] = []
|
||||
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||
const web = labels.includes("web")
|
||||
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||
const nix = /\bnix(os)?\b/.test(text)
|
||||
|
||||
if (labels.includes("nix") && !nix) {
|
||||
labels = labels.filter((x) => x !== "nix")
|
||||
results.push("Dropped label: nix (issue does not mention nix)")
|
||||
}
|
||||
|
||||
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||
|
||||
if (labels.includes("zen") && !zen) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
}
|
||||
|
||||
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||
}
|
||||
|
||||
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||
}
|
||||
|
||||
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||
throw new Error("Only windows issues should be assigned to Hona")
|
||||
}
|
||||
|
||||
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||
}
|
||||
|
||||
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||
throw new Error("Only opentui issues should be assigned to kommander")
|
||||
}
|
||||
const assignee = pick(TEAM[args.team])
|
||||
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignees: [assignee] }),
|
||||
})
|
||||
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||
|
||||
if (labels.length > 0) {
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels }),
|
||||
})
|
||||
results.push(`Added labels: ${labels.join(", ")}`)
|
||||
}
|
||||
|
||||
return results.join("\n")
|
||||
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
|
||||
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
|
||||
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
|
||||
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
|
||||
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
|
||||
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
|
||||
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
|
||||
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
@@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const errorName = (err: unknown) => {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("name" in err)) return
|
||||
const errorName = err.name
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -478,10 +472,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id })
|
||||
.then(() => false)
|
||||
.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
.catch((err) => {
|
||||
if (errorName(err) === "NotFoundError") return true
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
@@ -509,18 +502,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const next = new URL(url + `/pty/${id}/connect`)
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (!sameOrigin && password) {
|
||||
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||
next.username = username
|
||||
next.password = password
|
||||
}
|
||||
|
||||
const socket = new WebSocket(next)
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
|
||||
36
packages/app/src/utils/terminal-websocket-url.test.ts
Normal file
36
packages/app/src/utils/terminal-websocket-url.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
||||
|
||||
describe("terminalWebSocketURL", () => {
|
||||
test("uses query auth without embedding credentials in websocket URL", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "http://127.0.0.1:49365",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 0,
|
||||
sameOrigin: false,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("ws:")
|
||||
expect(url.username).toBe("")
|
||||
expect(url.password).toBe("")
|
||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
|
||||
test("omits query auth for same-origin websocket URL", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "https://app.example.test",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 10,
|
||||
sameOrigin: true,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("wss:")
|
||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
})
|
||||
17
packages/app/src/utils/terminal-websocket-url.ts
Normal file
17
packages/app/src/utils/terminal-websocket-url.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function terminalWebSocketURL(input: {
|
||||
url: string
|
||||
id: string
|
||||
directory: string
|
||||
cursor: number
|
||||
sameOrigin: boolean
|
||||
username: string
|
||||
password?: string
|
||||
}) {
|
||||
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
||||
next.searchParams.set("directory", input.directory)
|
||||
next.searchParams.set("cursor", String(input.cursor))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (!input.sameOrigin && input.password)
|
||||
next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`))
|
||||
return next
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export namespace AppFileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
@@ -47,6 +48,12 @@ export namespace AppFileSystem {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||
return yield* fs
|
||||
.readFileString(path)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
@@ -163,6 +170,7 @@ export namespace AppFileSystem {
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
readFileStringSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
|
||||
@@ -65,6 +65,34 @@ describe("AppFileSystem", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("readFileStringSafe", () => {
|
||||
it(
|
||||
"returns file contents when file exists",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "exists.txt")
|
||||
yield* filesys.writeFileString(file, "hello")
|
||||
|
||||
const result = yield* fs.readFileStringSafe(file)
|
||||
expect(result).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it(
|
||||
"returns undefined for missing file (NotFound)",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
|
||||
const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt"))
|
||||
expect(result).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("readJson / writeJson", () => {
|
||||
it(
|
||||
"round-trips JSON data",
|
||||
|
||||
@@ -27,7 +27,7 @@ const channel = (() => {
|
||||
})()
|
||||
|
||||
const getBase = (): Configuration => ({
|
||||
artifactName: "opencode-electron-${os}-${arch}.${ext}",
|
||||
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
|
||||
directories: {
|
||||
output: "dist",
|
||||
buildResources: "resources",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Buffer } from "node:buffer"
|
||||
import { $ } from "bun"
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
@@ -12,8 +13,6 @@ const { values } = parseArgs({
|
||||
|
||||
const dryRun = values["dry-run"]
|
||||
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const repo = process.env.GH_REPO
|
||||
if (!repo) throw new Error("GH_REPO is required")
|
||||
|
||||
@@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
|
||||
const version = process.env.OPENCODE_VERSION
|
||||
if (!version) throw new Error("OPENCODE_VERSION is required")
|
||||
|
||||
const dir = process.env.LATEST_YML_DIR
|
||||
if (!dir) throw new Error("LATEST_YML_DIR is required")
|
||||
const root = dir
|
||||
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
|
||||
|
||||
const apiHeaders = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
}
|
||||
|
||||
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
|
||||
headers: apiHeaders,
|
||||
const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!releaseRes.ok) {
|
||||
throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`)
|
||||
if (!rel.ok) {
|
||||
throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
|
||||
}
|
||||
|
||||
type Asset = {
|
||||
@@ -45,115 +46,169 @@ type Asset = {
|
||||
}
|
||||
|
||||
type Release = {
|
||||
tag_name?: string
|
||||
assets?: Asset[]
|
||||
}
|
||||
|
||||
const release = (await releaseRes.json()) as Release
|
||||
const assets = release.assets ?? []
|
||||
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
|
||||
const assets = ((await rel.json()) as Release).assets ?? []
|
||||
const amap = new Map(assets.map((item) => [item.name, item]))
|
||||
|
||||
const latestAsset = assetByName.get("latest.json")
|
||||
if (!latestAsset) {
|
||||
console.log("latest.json not found, skipping tauri finalization")
|
||||
process.exit(0)
|
||||
type Item = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const latestRes = await fetch(latestAsset.url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
})
|
||||
|
||||
if (!latestRes.ok) {
|
||||
throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`)
|
||||
type Yml = {
|
||||
version: string
|
||||
files: Item[]
|
||||
}
|
||||
|
||||
const latestText = new TextDecoder().decode(await latestRes.arrayBuffer())
|
||||
const latest = JSON.parse(latestText)
|
||||
const base = { ...latest }
|
||||
delete base.platforms
|
||||
function parse(text: string): Yml {
|
||||
const lines = text.split("\n")
|
||||
let version = ""
|
||||
const files: Item[] = []
|
||||
let url = ""
|
||||
|
||||
const fetchSignature = async (asset: Asset) => {
|
||||
const res = await fetch(asset.url, {
|
||||
const flush = () => {
|
||||
if (!url) return
|
||||
files.push({ url })
|
||||
url = ""
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trim = line.trim()
|
||||
if (line.startsWith("version:")) {
|
||||
version = line.slice("version:".length).trim()
|
||||
continue
|
||||
}
|
||||
if (trim.startsWith("- url:")) {
|
||||
flush()
|
||||
url = trim.slice("- url:".length).trim()
|
||||
continue
|
||||
}
|
||||
const indented = line.startsWith(" ") || line.startsWith("\t")
|
||||
if (!indented) flush()
|
||||
}
|
||||
flush()
|
||||
|
||||
return { version, files }
|
||||
}
|
||||
|
||||
async function read(sub: string, file: string) {
|
||||
const item = Bun.file(path.join(root, sub, file))
|
||||
if (!(await item.exists())) return undefined
|
||||
return parse(await item.text())
|
||||
}
|
||||
|
||||
function pick(list: Item[], exts: string[]) {
|
||||
for (const ext of exts) {
|
||||
const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
|
||||
if (found) return found.url
|
||||
}
|
||||
}
|
||||
|
||||
function link(raw: string) {
|
||||
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
|
||||
return `https://github.com/${repo}/releases/download/v${version}/${raw}`
|
||||
}
|
||||
|
||||
async function sign(url: string, key: string) {
|
||||
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
|
||||
const asset = amap.get(name)
|
||||
const res = await fetch(asset?.url ?? url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/octet-stream",
|
||||
...(asset ? { Accept: "application/octet-stream" } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`)
|
||||
throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
|
||||
}
|
||||
|
||||
return Buffer.from(await res.arrayBuffer()).toString()
|
||||
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = path.join(tmp, name)
|
||||
await Bun.write(file, await res.arrayBuffer())
|
||||
await $`bunx @tauri-apps/cli signer sign ${file}`
|
||||
const sigFile = Bun.file(`${file}.sig`)
|
||||
if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
|
||||
return (await sigFile.text()).trim()
|
||||
}
|
||||
|
||||
const entries: Record<string, { url: string; signature: string }> = {}
|
||||
const add = (key: string, asset: Asset, signature: string) => {
|
||||
if (entries[key]) return
|
||||
entries[key] = {
|
||||
url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`,
|
||||
signature,
|
||||
}
|
||||
const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
|
||||
if (!raw) return
|
||||
if (data[key]) return
|
||||
const url = link(raw)
|
||||
data[key] = { url, signature: await sign(url, key) }
|
||||
}
|
||||
|
||||
const targets = [
|
||||
{ key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" },
|
||||
{ key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" },
|
||||
{ key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" },
|
||||
{ key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" },
|
||||
{ key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" },
|
||||
{ key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" },
|
||||
{ key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" },
|
||||
{
|
||||
key: "darwin-aarch64-app",
|
||||
asset: "opencode-desktop-darwin-aarch64.app.tar.gz",
|
||||
},
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
const asset = assetByName.get(target.asset)
|
||||
if (!asset) continue
|
||||
|
||||
const sig = assetByName.get(`${target.asset}.sig`)
|
||||
if (!sig) continue
|
||||
|
||||
const signature = await fetchSignature(sig)
|
||||
add(target.key, asset, signature)
|
||||
const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
|
||||
if (data[key]) return
|
||||
if (!data[src]) return
|
||||
data[key] = data[src]
|
||||
}
|
||||
|
||||
const alias = (key: string, source: string) => {
|
||||
if (entries[key]) return
|
||||
const entry = entries[source]
|
||||
if (!entry) return
|
||||
entries[key] = entry
|
||||
}
|
||||
const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
|
||||
const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
|
||||
const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
|
||||
const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
|
||||
const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
|
||||
const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
|
||||
|
||||
alias("linux-x86_64", "linux-x86_64-deb")
|
||||
alias("linux-aarch64", "linux-aarch64-deb")
|
||||
alias("windows-aarch64", "windows-aarch64-nsis")
|
||||
alias("windows-x86_64", "windows-x86_64-nsis")
|
||||
alias("darwin-x86_64", "darwin-x86_64-app")
|
||||
alias("darwin-aarch64", "darwin-aarch64-app")
|
||||
const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
|
||||
if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
|
||||
|
||||
const out: Record<string, { url: string; signature: string }> = {}
|
||||
|
||||
const winxexe = pick(winx?.files ?? [], [".exe"])
|
||||
const winaexe = pick(wina?.files ?? [], [".exe"])
|
||||
|
||||
const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
|
||||
const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
|
||||
|
||||
const linxDeb = pick(linx?.files ?? [], [".deb"])
|
||||
const linxRpm = pick(linx?.files ?? [], [".rpm"])
|
||||
const linxAppImage = pick(linx?.files ?? [], [".appimage"])
|
||||
const linaDeb = pick(lina?.files ?? [], [".deb"])
|
||||
const linaRpm = pick(lina?.files ?? [], [".rpm"])
|
||||
const linaAppImage = pick(lina?.files ?? [], [".appimage"])
|
||||
|
||||
await add(out, "windows-x86_64-nsis", winxexe)
|
||||
await add(out, "windows-aarch64-nsis", winaexe)
|
||||
await add(out, "darwin-x86_64-app", macxTarGz)
|
||||
await add(out, "darwin-aarch64-app", macaTarGz)
|
||||
|
||||
await add(out, "linux-x86_64-deb", linxDeb)
|
||||
await add(out, "linux-x86_64-rpm", linxRpm)
|
||||
await add(out, "linux-x86_64-appimage", linxAppImage)
|
||||
await add(out, "linux-aarch64-deb", linaDeb)
|
||||
await add(out, "linux-aarch64-rpm", linaRpm)
|
||||
await add(out, "linux-aarch64-appimage", linaAppImage)
|
||||
|
||||
alias(out, "windows-x86_64", "windows-x86_64-nsis")
|
||||
alias(out, "windows-aarch64", "windows-aarch64-nsis")
|
||||
alias(out, "darwin-x86_64", "darwin-x86_64-app")
|
||||
alias(out, "darwin-aarch64", "darwin-aarch64-app")
|
||||
alias(out, "linux-x86_64", "linux-x86_64-deb")
|
||||
alias(out, "linux-aarch64", "linux-aarch64-deb")
|
||||
|
||||
const platforms = Object.fromEntries(
|
||||
Object.keys(entries)
|
||||
Object.keys(out)
|
||||
.sort()
|
||||
.map((key) => [key, entries[key]]),
|
||||
.map((key) => [key, out[key]]),
|
||||
)
|
||||
const output = {
|
||||
...base,
|
||||
|
||||
if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
|
||||
|
||||
const data = {
|
||||
version,
|
||||
notes: "",
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
}
|
||||
|
||||
const dir = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = `${dir}/latest.json`
|
||||
await Bun.write(file, JSON.stringify(output, null, 2))
|
||||
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = path.join(tmp, "latest.json")
|
||||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
|
||||
const tag = release.tag_name
|
||||
if (!tag) throw new Error("Release tag not found")
|
||||
const tag = `v${version}`
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
},
|
||||
"#httpapi-server": {
|
||||
"bun": "./src/server/httpapi-server.node.ts",
|
||||
"node": "./src/server/httpapi-server.node.ts",
|
||||
"default": "./src/server/httpapi-server.node.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -182,7 +182,7 @@ type Runtime = {
|
||||
Todo: (typeof import("../src/session/todo"))["Todo"]
|
||||
Worktree: (typeof import("../src/worktree"))["Worktree"]
|
||||
Project: (typeof import("../src/project/project"))["Project"]
|
||||
Tui: typeof import("../src/server/routes/instance/tui")
|
||||
Tui: typeof import("../src/server/shared/tui-control")
|
||||
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
|
||||
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
|
||||
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
|
||||
@@ -203,7 +203,7 @@ function runtime() {
|
||||
const todo = await import("../src/session/todo")
|
||||
const worktree = await import("../src/worktree")
|
||||
const project = await import("../src/project/project")
|
||||
const tui = await import("../src/server/routes/instance/tui")
|
||||
const tui = await import("../src/server/shared/tui-control")
|
||||
const fixture = await import("../test/fixture/fixture")
|
||||
const db = await import("../test/fixture/db")
|
||||
return {
|
||||
@@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () {
|
||||
const options = parseOptions(Bun.argv.slice(2))
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi()))
|
||||
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
|
||||
const selected = scenarios.filter((scenario) => matches(options, scenario))
|
||||
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
|
||||
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
@@ -26,6 +27,7 @@ export const AcpCommand = effectCmd({
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
headers: ServerAuth.headers(),
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { UI } from "../ui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Agent } from "../../agent/agent"
|
||||
@@ -66,170 +65,166 @@ const AgentCreateCommand = effectCmd({
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
|
||||
const project = ctx.project
|
||||
const project = ctx.project
|
||||
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agent")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: ctx.worktree,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
}
|
||||
targetPath = path.join(
|
||||
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
|
||||
"agent",
|
||||
)
|
||||
}
|
||||
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await AppRuntime.runPromise(
|
||||
Agent.Service.use((svc) => svc.generate({ description, model })),
|
||||
).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agent")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: ctx.worktree,
|
||||
},
|
||||
{
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
}
|
||||
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
|
||||
}
|
||||
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
}
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
},
|
||||
{
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
}
|
||||
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
}
|
||||
}
|
||||
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
|
||||
if (isFullyNonInteractive) {
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import os from "os"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
@@ -26,6 +31,7 @@ export const DebugCommand = cmd({
|
||||
.command(SnapshotCommand)
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(InfoCommand)
|
||||
.command(PathsCommand)
|
||||
.command(WaitCommand)
|
||||
.demandCommand(),
|
||||
@@ -40,6 +46,34 @@ const WaitCommand = effectCmd({
|
||||
}),
|
||||
})
|
||||
|
||||
const InfoCommand = effectCmd({
|
||||
command: "info",
|
||||
describe: "show debug information",
|
||||
handler: Effect.fn("Cli.debug.info")(function* () {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const termProgram = process.env.TERM_PROGRAM
|
||||
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
|
||||
: undefined
|
||||
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
|
||||
|
||||
console.log(`opencode version: ${InstallationVersion}`)
|
||||
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
||||
console.log(`terminal: ${terminal || "unknown"}`)
|
||||
console.log("plugins:")
|
||||
if (Flag.OPENCODE_PURE) {
|
||||
console.log("external plugins disabled (--pure)")
|
||||
return
|
||||
}
|
||||
if (!config.plugin_origins?.length) {
|
||||
console.log("none")
|
||||
return
|
||||
}
|
||||
for (const plugin of config.plugin_origins) {
|
||||
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { Server } from "../../server/server"
|
||||
import { PublicApi } from "../../server/routes/instance/httpapi/public"
|
||||
import type { CommandModule } from "yargs"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
type Args = {
|
||||
httpapi: boolean
|
||||
hono: boolean
|
||||
}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) =>
|
||||
yargs.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
|
||||
}),
|
||||
yargs
|
||||
.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||
})
|
||||
.option("hono", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
|
||||
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -206,6 +205,8 @@ export const GithubInstallCommand = effectCmd({
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const gitSvc = yield* Git.Service
|
||||
yield* Effect.promise(async () => {
|
||||
{
|
||||
UI.empty()
|
||||
@@ -213,7 +214,7 @@ export const GithubInstallCommand = effectCmd({
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
|
||||
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
|
||||
const providers = await Effect.runPromise(modelsDev.get()).then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
@@ -261,9 +262,9 @@ export const GithubInstallCommand = effectCmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then(
|
||||
(x) => x.text().trim(),
|
||||
)
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -440,6 +441,10 @@ export const GithubRunCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.github.run")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
const gitSvc = yield* Git.Service
|
||||
const sessionSvc = yield* Session.Service
|
||||
const sessionShare = yield* SessionShare.Service
|
||||
const sessionPrompt = yield* SessionPrompt.Service
|
||||
yield* Effect.promise(async () => {
|
||||
const isMock = args.token || args.event
|
||||
|
||||
@@ -503,21 +508,20 @@ export const GithubRunCommand = effectCmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) =>
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
@@ -554,24 +558,22 @@ export const GithubRunCommand = effectCmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
session = await Effect.runPromise(
|
||||
sessionSvc.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
|
||||
await Effect.runPromise(sessionShare.share(session.id))
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -944,9 +946,9 @@ export const GithubRunCommand = effectCmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
return AppRuntime.runPromise(
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const prompt = sessionPrompt
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
@@ -440,158 +439,158 @@ export const McpAddCommand = effectCmd({
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const project = ctx.project
|
||||
const project = ctx.project
|
||||
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(ctx.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(ctx.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: projectConfigPath,
|
||||
hint: projectConfigPath,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: globalConfigPath,
|
||||
hint: globalConfigPath,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
}
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
label: "Current project",
|
||||
value: projectConfigPath,
|
||||
hint: projectConfigPath,
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
label: "Global",
|
||||
value: globalConfigPath,
|
||||
hint: globalConfigPath,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
}
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
const mcpConfig: ConfigMCP.Info = {
|
||||
type: "local",
|
||||
command: command.split(" "),
|
||||
}
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
const mcpConfig: ConfigMCP.Info = {
|
||||
type: "local",
|
||||
command: command.split(" "),
|
||||
}
|
||||
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
|
||||
let mcpConfig: ConfigMCP.Info
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
|
||||
let mcpConfig: ConfigMCP.Info
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
}
|
||||
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
}
|
||||
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
clientId,
|
||||
...(clientSecret && { clientSecret }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {},
|
||||
}
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
clientId,
|
||||
...(clientSecret && { clientSecret }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {},
|
||||
}
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -606,178 +605,171 @@ export const McpDebugCommand = effectCmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig)) {
|
||||
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (serverConfig.oauth === false) {
|
||||
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`Server: ${serverName}`)
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// Check stored auth status — services already in hand, run inline.
|
||||
const { authStatus, entry } = await Effect.runPromise(
|
||||
Effect.all({
|
||||
authStatus: mcp.getAuthStatus(serverName),
|
||||
entry: auth.get(serverName),
|
||||
}),
|
||||
)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
||||
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
||||
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig)) {
|
||||
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
if (entry.tokens.refreshToken) {
|
||||
prompts.log.info(` Refresh token: present`)
|
||||
}
|
||||
|
||||
if (serverConfig.oauth === false) {
|
||||
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
if (entry?.clientInfo) {
|
||||
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
||||
if (entry.clientInfo.clientSecretExpiresAt) {
|
||||
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
||||
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
prompts.log.info(`Server: ${serverName}`)
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Testing connection...")
|
||||
|
||||
// Check stored auth status
|
||||
const { authStatus, entry } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
return {
|
||||
authStatus: yield* mcp.getAuthStatus(serverName),
|
||||
entry: yield* auth.get(serverName),
|
||||
}
|
||||
}),
|
||||
)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
||||
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
||||
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
||||
}
|
||||
if (entry.tokens.refreshToken) {
|
||||
prompts.log.info(` Refresh token: present`)
|
||||
}
|
||||
}
|
||||
if (entry?.clientInfo) {
|
||||
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
||||
if (entry.clientInfo.clientSecretExpiresAt) {
|
||||
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
||||
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Testing connection...")
|
||||
|
||||
// Test basic HTTP connectivity first
|
||||
try {
|
||||
const response = await fetch(serverConfig.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
// Test basic HTTP connectivity first
|
||||
try {
|
||||
const response = await fetch(serverConfig.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||
},
|
||||
id: 1,
|
||||
}),
|
||||
id: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const wwwAuth = response.headers.get("www-authenticate")
|
||||
if (wwwAuth) {
|
||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
prompts.log.warn("Server returned 401 Unauthorized")
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
// Try creating transport with auth provider to trigger discovery
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
})
|
||||
|
||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const wwwAuth = response.headers.get("www-authenticate")
|
||||
if (wwwAuth) {
|
||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
prompts.log.warn("Server returned 401 Unauthorized")
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const auth = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* McpAuth.Service
|
||||
}),
|
||||
)
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
// Try creating transport with auth provider to trigger discovery
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode-debug",
|
||||
version: InstallationVersion,
|
||||
})
|
||||
await client.connect(transport)
|
||||
prompts.log.success("Connection successful (already authenticated)")
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode-debug",
|
||||
version: InstallationVersion,
|
||||
})
|
||||
await client.connect(transport)
|
||||
prompts.log.success("Connection successful (already authenticated)")
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
|
||||
// Check if dynamic registration would be attempted
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) {
|
||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
} else {
|
||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
}
|
||||
// Check if dynamic registration would be attempted
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) {
|
||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
} else {
|
||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
}
|
||||
}
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||
const body = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (json.result?.serverInfo) {
|
||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else {
|
||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||
const body = await response.text().catch(() => "")
|
||||
if (body) {
|
||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||
} else {
|
||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||
const body = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (json.result?.serverInfo) {
|
||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else {
|
||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||
const body = await response.text().catch(() => "")
|
||||
if (body) {
|
||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
prompts.outro("Debug complete")
|
||||
prompts.outro("Debug complete")
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
|
||||
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
|
||||
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
@@ -239,19 +237,16 @@ export const ProvidersListCommand = effectCmd({
|
||||
// Lists global credentials + provider env vars; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||
yield* Effect.promise(async () => {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const database = await getModels()
|
||||
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
const database = yield* modelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
@@ -283,7 +278,6 @@ export const ProvidersListCommand = effectCmd({
|
||||
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -307,189 +301,176 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await put(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await refreshModels().catch(() => {})
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await getModels().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
const hooks = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
return yield* plugin.list()
|
||||
}),
|
||||
)
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
const custom = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
const cfgSvc = yield* Config.Service
|
||||
const pluginSvc = yield* Plugin.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
yield* Effect.ignore(modelsDev.refresh(true))
|
||||
|
||||
const config = yield* cfgSvc.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const allProviders = yield* modelsDev.get()
|
||||
const providers: Record<string, (typeof allProviders)[string]> = {}
|
||||
for (const [key, value] of Object.entries(allProviders)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
|
||||
}
|
||||
const hooks = yield* pluginSvc.list()
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [...options, { value: "other", label: "Other" }],
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
const custom = yield* Effect.promise(() =>
|
||||
prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
|
||||
provider = (custom as string).replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = yield* Effect.promise(() =>
|
||||
handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
|
||||
)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = yield* Effect.promise(() =>
|
||||
prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
|
||||
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
|
||||
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -499,36 +480,29 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||
// Removes a global auth credential; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||
yield* Effect.promise(async () => {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await getModels()
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
const database = yield* modelsDev.get()
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||
const providerID = selected as string
|
||||
yield* Effect.orDie(authSvc.remove(providerID))
|
||||
prompts.outro("Logout successful")
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
@@ -26,7 +27,6 @@ import { ShellTool } from "../../tool/shell"
|
||||
import { ShellID } from "../../tool/shell/id"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type ToolProps<T> = {
|
||||
input: Tool.InferParameters<T>
|
||||
@@ -276,6 +276,11 @@ export const RunCommand = effectCmd({
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
describe: "directory to run in, path on remote server if attaching",
|
||||
@@ -299,6 +304,7 @@ export const RunCommand = effectCmd({
|
||||
default: false,
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const agentSvc = yield* Agent.Service
|
||||
yield* Effect.promise(async () => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
@@ -602,7 +608,7 @@ export const RunCommand = effectCmd({
|
||||
return name
|
||||
}
|
||||
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
const entry = await Effect.runPromise(agentSvc.get(name))
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
@@ -656,13 +662,7 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { validateSession } from "./validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -38,6 +39,11 @@ export const AttachCommand = cmd({
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
@@ -60,12 +66,7 @@ export const AttachCommand = cmd({
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
|
||||
@@ -68,29 +68,73 @@ function normalize(raw: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
|
||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = config.plugin
|
||||
if (!plugins) return config
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
|
||||
)
|
||||
const data = ConfigParse.jsonc(expanded, configFilepath)
|
||||
if (!isRecord(data)) return {} as Info
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const validated = ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema
|
||||
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("invalid tui config", { path: configFilepath, cause })
|
||||
return {} as Info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const loadFile = (filepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
// Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip.
|
||||
// Matches how parse/schema/plugin failures in load() are handled — every
|
||||
// broken-config path degrades gracefully rather than crashing TUI startup.
|
||||
const text = yield* afs.readFileStringSafe(filepath).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("failed to read tui config", { path: filepath, cause })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!text) return {} as Info
|
||||
return yield* load(text, filepath)
|
||||
})
|
||||
|
||||
const mergeFile = (acc: Acc, file: string) =>
|
||||
Effect.gen(function* () {
|
||||
const data = yield* loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
})
|
||||
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||
const directories = yield* ConfigPaths.directories(ctx.directory)
|
||||
@@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
|
||||
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
||||
if (Flag.OPENCODE_TUI_CONFIG) {
|
||||
const configFile = Flag.OPENCODE_TUI_CONFIG
|
||||
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
|
||||
yield* mergeFile(acc, configFile)
|
||||
log.debug("loaded custom tui config", { path: configFile })
|
||||
}
|
||||
|
||||
// 3. Project tui files, applied root-first so the closest file wins.
|
||||
for (const file of projectFiles) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
|
||||
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
|
||||
@@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,29 +236,3 @@ export async function waitForDependencies() {
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
|
||||
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
|
||||
.then((data) => {
|
||||
if (!isRecord(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
return ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
})
|
||||
.then((data) => resolvePlugins(data, configFilepath))
|
||||
.catch((error) => {
|
||||
log.warn("invalid tui config", { path: configFilepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "compaction")
|
||||
const index = messages.findIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
switch (event.type) {
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
}
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.push({
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
@@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
||||
})
|
||||
break
|
||||
case "session.next.step.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.error = event.properties.error
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", text: "" })
|
||||
@@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.tool.error":
|
||||
case "session.next.tool.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
@@ -250,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { SyntaxStyle } from "@opentui/core"
|
||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import path from "path"
|
||||
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
|
||||
const renderedMessages = createMemo(() => messages().toReversed())
|
||||
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
|
||||
const lastUserCreated = (index: number) =>
|
||||
renderedMessages()
|
||||
.slice(0, index)
|
||||
.findLast((message) => message.type === "user")?.time.created
|
||||
|
||||
createEffect(() => {
|
||||
void sync.session.message.sync(props.sessionID)
|
||||
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
last={lastAssistant()?.id === message.id}
|
||||
syntax={syntax()}
|
||||
subtleSyntax={subtleSyntax()}
|
||||
start={lastUserCreated(index())}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={message.type === "synthetic"}>
|
||||
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={message.type === "shell"}>
|
||||
<ShellMessage message={message as SessionMessageShell} />
|
||||
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={theme.primary}
|
||||
borderColor={theme.secondary}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
|
||||
<Show
|
||||
when={props.message.text.trim()}
|
||||
fallback={
|
||||
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
|
||||
}
|
||||
>
|
||||
<text fg={theme.text}>{props.message.text}</text>
|
||||
</Show>
|
||||
<Show when={attachments().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={props.message.files ?? []}>
|
||||
{(file) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.message.agents ?? []}>
|
||||
{(agent) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={theme.backgroundElement}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
paddingLeft={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.textMuted}>Synthetic</text>
|
||||
<text fg={theme.text}>{props.message.text}</text>
|
||||
<Show when={attachments().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={props.message.files ?? []}>
|
||||
{(file) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.message.agents ?? []}>
|
||||
{(agent) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -294,12 +272,13 @@ function AssistantMessage(props: {
|
||||
last: boolean
|
||||
syntax: SyntaxStyle
|
||||
subtleSyntax: SyntaxStyle
|
||||
start?: number
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const local = useLocal()
|
||||
const duration = createMemo(() => {
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - props.message.time.created
|
||||
return props.message.time.completed - (props.start ?? props.message.time.created)
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
|
||||
@@ -361,7 +340,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
@@ -521,33 +500,93 @@ function InlineTool(props: {
|
||||
part: SessionMessageAssistantTool
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const [showError, setShowError] = createSignal(false)
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const complete = createMemo(() => !!props.complete)
|
||||
const denied = createMemo(() => {
|
||||
const message = error()
|
||||
if (!message) return false
|
||||
return (
|
||||
message.includes("QuestionRejectedError") ||
|
||||
message.includes("rejected permission") ||
|
||||
message.includes("specified a rule") ||
|
||||
message.includes("user dismissed")
|
||||
)
|
||||
})
|
||||
const fg = createMemo(() => {
|
||||
if (error()) return theme.error
|
||||
if (complete()) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
|
||||
return (
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Spinner color={theme.text}>{props.children}</Spinner>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
{props.icon} {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={error() && !denied()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</Show>
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
|
||||
onMouseOver={() => error() && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (!error()) return
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
setShowError((prev) => !prev)
|
||||
}}
|
||||
renderBefore={function () {
|
||||
const el = this as BoxRenderable
|
||||
const parent = el.parent
|
||||
if (!parent) return
|
||||
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
|
||||
if (!previous) {
|
||||
setMargin(0)
|
||||
return
|
||||
}
|
||||
if (previous.id.startsWith("text")) setMargin(1)
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Spinner color={theme.text} />
|
||||
</Match>
|
||||
<Match when={complete()}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.icon}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
~
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<box>
|
||||
<Switch>
|
||||
<Match when={complete()}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.children}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.pending}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={showError() && error()}>
|
||||
<box>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
@@ -50,7 +50,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
const headers = { ...input.headers }
|
||||
const auth = getAuthorizationHeader()
|
||||
const auth = ServerAuth.header()
|
||||
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
||||
headers["Authorization"] = auth
|
||||
}
|
||||
@@ -102,10 +102,3 @@ export const rpc = {
|
||||
}
|
||||
|
||||
Rpc.listen(rpc)
|
||||
|
||||
function getAuthorizationHeader(): string | undefined {
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
return `Basic ${btoa(`${username}:${password}`)}`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
@@ -82,17 +83,21 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
return
|
||||
}
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) =>
|
||||
store.provide(
|
||||
{ directory },
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
const body = opts.handler(args)
|
||||
return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Two-phase: load ctx, then run body inside Instance.current ALS.
|
||||
// Effect's InstanceRef is provided via fiber context, but that context is
|
||||
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
|
||||
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
|
||||
// there, attach() falls back to Instance.current ALS, which Node preserves
|
||||
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
|
||||
const { store, ctx } = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||
)
|
||||
try {
|
||||
await Instance.restore(ctx, () =>
|
||||
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
} finally {
|
||||
await AppRuntime.runPromise(store.dispose(ctx))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -355,15 +355,7 @@ export const layer = Layer.effect(
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as ConfigPaths from "./paths"
|
||||
|
||||
import path from "path"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { unique } from "remeda"
|
||||
import { JsonError } from "./error"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
|
||||
export function fileInDirectory(dir: string, name: string) {
|
||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||
}
|
||||
|
||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||
export async function readFile(filepath: string) {
|
||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const fail = (err: unknown) =>
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
truncated: false,
|
||||
}) satisfies Result
|
||||
|
||||
export type Kind = "added" | "deleted" | "modified"
|
||||
@@ -45,16 +46,28 @@ export type Stat = {
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
export type Patch = {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export interface PatchOptions {
|
||||
readonly context?: number
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -68,6 +81,10 @@ export interface Interface {
|
||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
@@ -96,15 +113,31 @@ export const layer = Layer.effect(
|
||||
stderr: "pipe",
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const collect = (stream: typeof handle.stdout) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
(acc, chunk) => {
|
||||
if (opts.maxOutputBytes === undefined) {
|
||||
acc.chunks.push(chunk)
|
||||
acc.bytes += chunk.length
|
||||
return acc
|
||||
}
|
||||
|
||||
const remaining = opts.maxOutputBytes - acc.bytes
|
||||
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
|
||||
acc.bytes += chunk.length
|
||||
acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
|
||||
return acc
|
||||
},
|
||||
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
|
||||
const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
|
||||
return {
|
||||
exitCode: yield* handle.exitCode,
|
||||
text: () => stdout,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
text: () => stdout.buffer.toString("utf8"),
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated || stderr.truncated,
|
||||
} satisfies Result
|
||||
},
|
||||
Effect.scoped,
|
||||
@@ -240,6 +273,61 @@ export const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchUntracked = Effect.fn("Git.patchUntracked")(function* (
|
||||
cwd: string,
|
||||
file: string,
|
||||
options?: PatchOptions,
|
||||
) {
|
||||
const result = yield* run(
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--patch",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
`--unified=${options?.context ?? 3}`,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file,
|
||||
],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) {
|
||||
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
|
||||
cwd,
|
||||
maxOutputBytes: 4096,
|
||||
})
|
||||
if (result.truncated) return
|
||||
const parts = result.text().split("\t")
|
||||
if (parts.length < 2) return
|
||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
||||
return {
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
@@ -251,6 +339,10 @@ export const layer = Layer.effect(
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
patch,
|
||||
patchAll,
|
||||
patchUntracked,
|
||||
statUntracked,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
@@ -124,11 +125,7 @@ export const layer = Layer.effect(
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: ctx.directory,
|
||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||
? {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
const MAX_PATCH_BYTES = 10_000_000
|
||||
const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
@@ -38,59 +27,168 @@ const merge = (...lists: Git.Item[][]) => {
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
|
||||
|
||||
const parseQuotedPath = (value: string) => {
|
||||
let out = ""
|
||||
for (let idx = 1; idx < value.length; idx++) {
|
||||
const char = value[idx]
|
||||
if (char === '"') return { value: out, end: idx + 1 }
|
||||
if (char !== "\\") {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = value[++idx]
|
||||
if (next === "t") out += "\t"
|
||||
else if (next === "n") out += "\n"
|
||||
else if (next === "r") out += "\r"
|
||||
else if (next === '"' || next === "\\") out += next
|
||||
else out += next ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
return parseQuotedPath(value)?.value ?? value
|
||||
}
|
||||
|
||||
const fileFromDiffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const fileFromGitHeader = (header: string) => {
|
||||
if (header.startsWith('"')) {
|
||||
const first = parseQuotedPath(header)
|
||||
const second = first ? header.slice(first.end).trimStart() : undefined
|
||||
if (!second) return
|
||||
if (!second.startsWith('"')) return fileFromDiffPath(second)
|
||||
return fileFromDiffPath(parseQuotedPath(second)?.value)
|
||||
}
|
||||
|
||||
const separator = header.indexOf(" b/")
|
||||
if (separator === -1) return
|
||||
return fileFromDiffPath(header.slice(separator + 1))
|
||||
}
|
||||
|
||||
const fileFromPatchChunk = (chunk: string) => {
|
||||
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
|
||||
const before = /^--- (.+)$/m.exec(chunk)?.[1]
|
||||
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
|
||||
if (file) return file
|
||||
|
||||
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
|
||||
return fileFromGitHeader(header ?? "")
|
||||
}
|
||||
|
||||
const splitGitPatch = (patch: Git.Patch) => {
|
||||
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
|
||||
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
|
||||
if (!patch.truncated) return chunks
|
||||
return chunks.slice(0, -1)
|
||||
}
|
||||
|
||||
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
|
||||
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
|
||||
|
||||
const result = yield* git.patchAll(cwd, ref, {
|
||||
context: PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
|
||||
})
|
||||
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
|
||||
|
||||
return {
|
||||
patches: splitGitPatch(result).reduce((acc, patch, index) => {
|
||||
const file = fileFromPatchChunk(patch) ?? list[index]?.file
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + patch)
|
||||
return acc
|
||||
}, new Map<string, string>()),
|
||||
capped: result.truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const nativePatch = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
item: Git.Item,
|
||||
) {
|
||||
const result =
|
||||
item.code === "??" || !ref
|
||||
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
|
||||
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
|
||||
if (!result.truncated && result.text) return result.text
|
||||
|
||||
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
|
||||
return emptyPatch(item.file)
|
||||
})
|
||||
|
||||
const totalPatch = (file: string, patch: string, total: number) => {
|
||||
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
|
||||
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
|
||||
return { patch: emptyPatch(file), capped: true }
|
||||
}
|
||||
|
||||
const patchForItem = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
item: Git.Item,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
capped: boolean,
|
||||
) {
|
||||
if (capped) return emptyPatch(item.file)
|
||||
|
||||
const batched = batch.patches.get(item.file)
|
||||
if (batched !== undefined) return batched
|
||||
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
|
||||
return yield* nativePatch(git, cwd, ref, item)
|
||||
})
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
patch: patch(item.file, before, after),
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
const next: FileDiff[] = []
|
||||
let total = 0
|
||||
let capped = false
|
||||
|
||||
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
|
||||
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
|
||||
const result: { patch: string; capped: boolean } = capped
|
||||
? { patch, capped: true }
|
||||
: totalPatch(item.file, patch, total)
|
||||
capped = capped || result.capped
|
||||
if (!capped) {
|
||||
total += Buffer.byteLength(result.patch)
|
||||
capped = total >= MAX_TOTAL_PATCH_BYTES
|
||||
}
|
||||
next.push({
|
||||
file: item.file,
|
||||
patch: result.patch,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
status: item.status,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
@@ -99,9 +197,15 @@ const compare = Effect.fnUntraced(function* (
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
yield* batchPatches(git, cwd, ref, list),
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
|
||||
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
|
||||
return yield* diffAgainstRef(git, cwd, ref)
|
||||
})
|
||||
|
||||
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
@@ -147,10 +251,9 @@ interface State {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
@@ -204,23 +307,19 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
|
||||
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, ctx.directory, ref)
|
||||
return yield* diffAgainstRef(git, ctx.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
|
||||
|
||||
export * as Vcs from "./vcs"
|
||||
|
||||
48
packages/opencode/src/server/auth.ts
Normal file
48
packages/opencode/src/server/auth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export * as ServerAuth from "./auth"
|
||||
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
|
||||
|
||||
export type Credentials = {
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type DecodedCredentials = {
|
||||
readonly username: string
|
||||
readonly password: Redacted.Redacted
|
||||
}
|
||||
|
||||
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
|
||||
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
|
||||
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
|
||||
}) {}
|
||||
|
||||
export type Info = Context.Service.Shape<typeof Config>
|
||||
|
||||
export function required(config: Info) {
|
||||
return Option.isSome(config.password) && config.password.value !== ""
|
||||
}
|
||||
|
||||
export function authorized(credentials: DecodedCredentials, config: Info) {
|
||||
return (
|
||||
Option.isSome(config.password) &&
|
||||
credentials.username === config.username &&
|
||||
Redacted.value(credentials.password) === config.password.value
|
||||
)
|
||||
}
|
||||
|
||||
export function header(credentials?: Credentials) {
|
||||
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
|
||||
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
export function headers(credentials?: Credentials) {
|
||||
const authorization = header(credentials)
|
||||
if (!authorization) return undefined
|
||||
return { Authorization: authorization }
|
||||
}
|
||||
@@ -1,78 +1,8 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Database } from "@/storage/db"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { HEADER, diff, load } from "./shared/fence"
|
||||
|
||||
const HEADER = "x-opencode-sync"
|
||||
type State = Record<string, number>
|
||||
const log = Log.create({ service: "fence" })
|
||||
|
||||
export function load(ids?: string[]) {
|
||||
const rows = Database.use((db) => {
|
||||
if (!ids?.length) {
|
||||
return db.select().from(EventSequenceTable).all()
|
||||
}
|
||||
|
||||
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
|
||||
})
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
|
||||
}
|
||||
|
||||
export function diff(prev: State, next: State) {
|
||||
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
|
||||
return Object.fromEntries(
|
||||
[...ids]
|
||||
.map((id) => [id, next[id] ?? -1] as const)
|
||||
.filter(([id, seq]) => {
|
||||
return (prev[id] ?? -1) !== seq
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function parse(headers: Headers) {
|
||||
const raw = headers.get(HEADER)
|
||||
if (!raw) return
|
||||
|
||||
let data
|
||||
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") return
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([id, seq]) => {
|
||||
return typeof id === "string" && Number.isInteger(seq)
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("waiting for state", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
|
||||
log.info("state fully synced", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
|
||||
}
|
||||
const log = Log.create({ service: "fence-middleware" })
|
||||
|
||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||
|
||||
34
packages/opencode/src/server/httpapi-server.node.ts
Normal file
34
packages/opencode/src/server/httpapi-server.node.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import type { Opts } from "./adapter"
|
||||
import { Service } from "./httpapi-server"
|
||||
|
||||
export { Service }
|
||||
|
||||
export const name = "node-http-server"
|
||||
|
||||
export const layer = (opts: Opts) => {
|
||||
const server = createServer()
|
||||
const serverRef = { closeStarted: false, forceStop: false }
|
||||
const close = server.close.bind(server)
|
||||
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
|
||||
// force-closing active HTTP sockets when its finalizer calls server.close().
|
||||
server.close = ((callback?: Parameters<typeof server.close>[0]) => {
|
||||
serverRef.closeStarted = true
|
||||
const result = close(callback)
|
||||
if (serverRef.forceStop) server.closeAllConnections()
|
||||
return result
|
||||
}) as typeof server.close
|
||||
return Layer.mergeAll(
|
||||
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }),
|
||||
Layer.succeed(Service)(
|
||||
Service.of({
|
||||
closeAll: Effect.sync(() => {
|
||||
serverRef.forceStop = true
|
||||
if (serverRef.closeStarted) server.closeAllConnections()
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
9
packages/opencode/src/server/httpapi-server.ts
Normal file
9
packages/opencode/src/server/httpapi-server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
export interface Interface {
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiServer") {}
|
||||
|
||||
export * as HttpApiServer from "./httpapi-server"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Fence from "./fence"
|
||||
import * as Fence from "./shared/fence"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -80,9 +82,22 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
const services = yield* Effect.context()
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
|
||||
.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
|
||||
if (!registered) {
|
||||
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
|
||||
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
let closed = false
|
||||
const adapter = {
|
||||
@@ -100,7 +115,10 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
if (!handler) {
|
||||
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { nextTuiRequest, submitTuiResponse } from "../../tui"
|
||||
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
|
||||
|
||||
|
||||
@@ -1,71 +1,50 @@
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
|
||||
|
||||
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
|
||||
// full handler, so a downstream failure can make the next auth alternative run
|
||||
// and remap an authorized NotFound into Unauthorized.
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
|
||||
},
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
"@opencode/ExperimentalHttpApiServerAuthConfig",
|
||||
{
|
||||
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
|
||||
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
},
|
||||
) {}
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
credential: ServerAuth.DecodedCredentials,
|
||||
config: ServerAuth.Info,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!isAuthRequired(config)) return yield* effect
|
||||
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
|
||||
if (!ServerAuth.required(config)) return yield* effect
|
||||
if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
|
||||
return yield* effect
|
||||
})
|
||||
}
|
||||
|
||||
function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
|
||||
return Option.isSome(config.password) && config.password.value !== ""
|
||||
}
|
||||
|
||||
function isCredentialAuthorized(
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
) {
|
||||
return (
|
||||
Option.isSome(config.password) &&
|
||||
credential.username === config.username &&
|
||||
Redacted.value(credential.password) === config.password.value
|
||||
)
|
||||
}
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
return Encoding.decodeBase64String(input)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onFailure: () => emptyCredential,
|
||||
onFailure: emptyCredential,
|
||||
onSuccess: (header) => {
|
||||
const parts = header.split(":")
|
||||
if (parts.length !== 2) return emptyCredential
|
||||
if (parts.length !== 2) return emptyCredential()
|
||||
return {
|
||||
username: parts[0],
|
||||
password: Redacted.make(parts[1]),
|
||||
@@ -75,40 +54,41 @@ function decodeCredential(input: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) return decodeCredential(token)
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) return decodeCredential(match[1])
|
||||
return Effect.succeed(emptyCredential())
|
||||
}
|
||||
|
||||
function validateRawCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
credential: ServerAuth.DecodedCredentials,
|
||||
config: ServerAuth.Info,
|
||||
) {
|
||||
if (!isAuthRequired(config)) return effect
|
||||
if (!isCredentialAuthorized(credential, config))
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
|
||||
if (!ServerAuth.required(config)) return effect
|
||||
if (!ServerAuth.authorized(credential, config))
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.empty({
|
||||
status: UNAUTHORIZED,
|
||||
headers: { "www-authenticate": WWW_AUTHENTICATE },
|
||||
}),
|
||||
)
|
||||
return effect
|
||||
}
|
||||
|
||||
export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuthConfig
|
||||
if (!isAuthRequired(config)) return (effect) => effect
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return (effect) => effect
|
||||
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) {
|
||||
return yield* decodeCredential(match[1]).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) {
|
||||
return yield* decodeCredential(token).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -116,13 +96,15 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuthConfig
|
||||
return Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
||||
authToken: (effect, { credential }) =>
|
||||
decodeCredential(Redacted.value(credential)).pipe(
|
||||
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
|
||||
),
|
||||
})
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
|
||||
return Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
|
||||
return request.source instanceof Request ? request.source : undefined
|
||||
@@ -28,6 +29,33 @@ export function websocket(
|
||||
})
|
||||
const writeInbound = yield* inbound.writer
|
||||
const writeOutbound = yield* outbound.writer
|
||||
const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect<void, unknown>) =>
|
||||
socket
|
||||
.runRaw(() => Effect.void, {
|
||||
onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)),
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const registered = yield* WebSocketTracker.register(
|
||||
Effect.all(
|
||||
[
|
||||
writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
|
||||
writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
),
|
||||
)
|
||||
if (!registered) {
|
||||
yield* closeAccepted
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
|
||||
yield* outbound
|
||||
.runRaw((message) => writeInbound(message))
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Workspace } from "@/control-plane/workspace"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Session } from "@/session/session"
|
||||
import { HttpApiProxy } from "./proxy"
|
||||
import * as Fence from "@/server/fence"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
|
||||
import * as Fence from "@/server/shared/fence"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Data, Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -45,9 +45,10 @@ import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/routes/ui"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { EventApi, eventHandlers } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
@@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
.combine(instanceRouterMiddleware)
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer))
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
|
||||
Layer.provide(eventHandlers),
|
||||
Layer.provide(instanceRouterLayer),
|
||||
@@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
|
||||
authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)),
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
]),
|
||||
@@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
const client = yield* HttpClient.HttpClient
|
||||
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
||||
}),
|
||||
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
|
||||
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))))
|
||||
|
||||
export function createRoutes(corsOptions?: CorsOptions) {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
||||
export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing")
|
||||
|
||||
type Close = Effect.Effect<void, unknown>
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (close: Close) => Effect.Effect<boolean>
|
||||
readonly remove: (close: Close) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiWebSocketTracker") {}
|
||||
|
||||
export const layer = Layer.sync(Service)(() => {
|
||||
const sockets = new Set<Close>()
|
||||
let closing = false
|
||||
return Service.of({
|
||||
add: (close) =>
|
||||
Effect.gen(function* () {
|
||||
if (closing) return false
|
||||
sockets.add(close)
|
||||
return true
|
||||
}),
|
||||
remove: (close) =>
|
||||
Effect.sync(() => {
|
||||
sockets.delete(close)
|
||||
}),
|
||||
closeAll: Effect.gen(function* () {
|
||||
closing = true
|
||||
const active = Array.from(sockets)
|
||||
sockets.clear()
|
||||
yield* Effect.all(
|
||||
active.map((close) =>
|
||||
close.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const register = (close: Close) =>
|
||||
Effect.gen(function* () {
|
||||
const tracker = yield* Effect.serviceOption(Service)
|
||||
if (Option.isNone(tracker)) return true
|
||||
const registered = yield* tracker.value.add(close)
|
||||
if (!registered) return false
|
||||
yield* Effect.addFinalizer(() => tracker.value.remove(close))
|
||||
return true
|
||||
})
|
||||
|
||||
export * as WebSocketTracker from "./websocket-tracker"
|
||||
@@ -7,32 +7,16 @@ import { Session } from "@/session/session"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
|
||||
export const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
})
|
||||
|
||||
export type TuiRequest = z.infer<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<unknown>()
|
||||
|
||||
export function nextTuiRequest() {
|
||||
return request.next()
|
||||
}
|
||||
|
||||
export function submitTuiRequest(body: TuiRequest) {
|
||||
request.push(body)
|
||||
}
|
||||
|
||||
export function submitTuiResponse(body: unknown) {
|
||||
response.push(body)
|
||||
}
|
||||
import {
|
||||
TuiRequest,
|
||||
nextTuiRequest,
|
||||
nextTuiResponse,
|
||||
submitTuiRequest,
|
||||
submitTuiResponse,
|
||||
} from "@/server/shared/tui-control"
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
@@ -40,7 +24,7 @@ export async function callTui(ctx: Context) {
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return response.next()
|
||||
return nextTuiResponse()
|
||||
}
|
||||
|
||||
const TuiControlRoutes = new Hono()
|
||||
|
||||
@@ -1,53 +1,10 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import fs from "node:fs/promises"
|
||||
import { createHash } from "node:crypto"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
function themePreloadHash(body: string) {
|
||||
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
const len = request.headers["content-length"]
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
|
||||
}
|
||||
|
||||
function proxyResponseHeaders(headers: Record<string, string>) {
|
||||
const result = new Headers(headers)
|
||||
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
|
||||
// transfer metadata makes browsers decode already-decoded assets again.
|
||||
result.delete("content-encoding")
|
||||
result.delete("content-length")
|
||||
return result
|
||||
}
|
||||
|
||||
function upstreamURL(path: string) {
|
||||
return new URL(path, UI_UPSTREAM).toString()
|
||||
}
|
||||
|
||||
function embeddedUI() {
|
||||
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
|
||||
return embeddedUIPromise
|
||||
}
|
||||
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
|
||||
|
||||
export async function serveUI(request: Request) {
|
||||
const embeddedWebUI = await embeddedUI()
|
||||
@@ -58,7 +15,7 @@ export async function serveUI(request: Request) {
|
||||
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
const mime = AppFileSystem.mimeType(match)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
|
||||
@@ -79,49 +36,4 @@ export async function serveUI(request: Request) {
|
||||
return response
|
||||
}
|
||||
|
||||
export function serveUIEffect(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
|
||||
const path = new URL(request.url, "http://localhost").pathname
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (yield* services.fs.existsSafe(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
|
||||
}
|
||||
|
||||
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = yield* services.client.execute(
|
||||
HttpClientRequest.make(request.method)(upstreamURL(path), {
|
||||
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
|
||||
body: requestBody(request),
|
||||
}),
|
||||
)
|
||||
const headers = proxyResponseHeaders(response.headers)
|
||||
|
||||
if (response.headers["content-type"]?.includes("text/html")) {
|
||||
const body = yield* response.text
|
||||
const match = themePreloadHash(body)
|
||||
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
|
||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
headers.set("Content-Security-Policy", csp())
|
||||
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
|
||||
status: response.status,
|
||||
headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
|
||||
|
||||
@@ -5,6 +5,10 @@ import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import * as HttpApiServer from "#httpapi-server"
|
||||
import { MDNS } from "./mdns"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { FenceMiddleware } from "./fence"
|
||||
@@ -17,6 +21,9 @@ import { WorkspaceRouterMiddleware } from "./workspace"
|
||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||
import * as ServerBackend from "./backend"
|
||||
import type { CorsOptions } from "./cors"
|
||||
|
||||
@@ -135,7 +142,30 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the OpenAPI document used by the SDK build.
|
||||
*
|
||||
* Since the Effect HttpApi backend now covers every Hono route (plus the new
|
||||
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
|
||||
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
|
||||
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
|
||||
* transform that injects instance query parameters, strips Effect's optional
|
||||
* null arms, normalizes component names, and patches SSE response schemas so
|
||||
* the generated SDK keeps the legacy Hono shape.
|
||||
*
|
||||
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
|
||||
* can diff the two outputs while the Hono backend lingers; once the Hono
|
||||
* backend is deleted that helper goes with it.
|
||||
*/
|
||||
export async function openapi() {
|
||||
return OpenApi.fromApi(PublicApi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
|
||||
* the Hono backend is removed.
|
||||
*/
|
||||
export async function openapiHono() {
|
||||
// Build a fresh app with all routes registered directly so
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
@@ -157,37 +187,145 @@ export async function openapi() {
|
||||
export let url: URL
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const selected = select()
|
||||
const inner: Listener =
|
||||
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
|
||||
|
||||
const next = new URL("http://localhost")
|
||||
next.hostname = opts.hostname
|
||||
next.port = String(server.port)
|
||||
const next = new URL(inner.url)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(server.port, opts.mdnsDomain)
|
||||
MDNS.publish(inner.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
return {
|
||||
hostname: inner.hostname,
|
||||
port: inner.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
unpublish()
|
||||
// Always forward stop(true), even if a graceful stop was requested
|
||||
// first, so native listeners can escalate shutdown in-place.
|
||||
const next = inner.stop(close)
|
||||
closing ??= next
|
||||
return close ? next.then(() => closing!) : closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(server.port)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: server.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
closing ??= (async () => {
|
||||
if (mdns) MDNS.unpublish()
|
||||
await server.stop(close)
|
||||
})()
|
||||
return closing
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => server.stop(close),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the effect-httpapi backend on a native Effect HTTP server. This
|
||||
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
|
||||
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
|
||||
* adapter path can't surface `request.upgrade` because its fetch handler has
|
||||
* no reference to the platform server instance for websocket upgrades.
|
||||
*/
|
||||
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
|
||||
log.info("server backend selected", {
|
||||
...ServerBackend.attributes(selection),
|
||||
"opencode.server.runtime": HttpApiServer.name,
|
||||
})
|
||||
|
||||
const buildLayer = (port: number) =>
|
||||
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
|
||||
middleware: disposeMiddleware,
|
||||
disableLogger: true,
|
||||
disableListenLog: true,
|
||||
}).pipe(
|
||||
Layer.provideMerge(WebSocketTracker.layer),
|
||||
Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })),
|
||||
)
|
||||
|
||||
const start = async (port: number) => {
|
||||
const scope = Scope.makeUnsafe()
|
||||
try {
|
||||
// Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
|
||||
// design, which leaks `R = any` through `HttpRouter.serve`. The actual
|
||||
// requirements at this point are fully satisfied by `createRoutes` and the
|
||||
// platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
|
||||
const layer = buildLayer(port) as Layer.Layer<
|
||||
HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
|
||||
unknown,
|
||||
never
|
||||
>
|
||||
const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope))
|
||||
return { scope, ctx }
|
||||
} catch (err) {
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Match the legacy adapter port-resolution behavior: explicit `0` prefers
|
||||
// 4096 first, then any free port.
|
||||
let resolved: Awaited<ReturnType<typeof start>> | undefined
|
||||
if (opts.port === 0) {
|
||||
resolved = await start(4096).catch(() => undefined)
|
||||
if (!resolved) resolved = await start(0)
|
||||
} else {
|
||||
resolved = await start(opts.port)
|
||||
}
|
||||
if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const server = Context.get(resolved.ctx, HttpServer.HttpServer)
|
||||
if (server.address._tag !== "TcpAddress") {
|
||||
await Effect.runPromise(Scope.close(resolved.scope, Exit.void))
|
||||
throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`)
|
||||
}
|
||||
const port = server.address.port
|
||||
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(port)
|
||||
let forceStopPromise: Promise<void> | undefined
|
||||
let stopPromise: Promise<void> | undefined
|
||||
const forceStop = () => {
|
||||
forceStopPromise ??= Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll
|
||||
yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll
|
||||
}),
|
||||
).then(() => undefined)
|
||||
return forceStopPromise
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => {
|
||||
const requested = close ? forceStop() : Promise.resolve()
|
||||
// The first call starts scope shutdown. A later stop(true) cannot undo
|
||||
// that, but it still runs forceStop() before awaiting the original close.
|
||||
stopPromise ??= requested
|
||||
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
|
||||
.then(() => undefined)
|
||||
return requested.then(() => stopPromise!)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
74
packages/opencode/src/server/shared/fence.ts
Normal file
74
packages/opencode/src/server/shared/fence.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Database } from "@/storage/db"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const HEADER = "x-opencode-sync"
|
||||
export type State = Record<string, number>
|
||||
const log = Log.create({ service: "fence" })
|
||||
|
||||
export function load(ids?: string[]) {
|
||||
const rows = Database.use((db) => {
|
||||
if (!ids?.length) {
|
||||
return db.select().from(EventSequenceTable).all()
|
||||
}
|
||||
|
||||
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
|
||||
})
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
|
||||
}
|
||||
|
||||
export function diff(prev: State, next: State) {
|
||||
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
|
||||
return Object.fromEntries(
|
||||
[...ids]
|
||||
.map((id) => [id, next[id] ?? -1] as const)
|
||||
.filter(([id, seq]) => {
|
||||
return (prev[id] ?? -1) !== seq
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function parse(headers: Headers) {
|
||||
const raw = headers.get(HEADER)
|
||||
if (!raw) return
|
||||
|
||||
let data
|
||||
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") return
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([id, seq]) => {
|
||||
return typeof id === "string" && Number.isInteger(seq)
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("waiting for state", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
|
||||
log.info("state fully synced", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
|
||||
}
|
||||
28
packages/opencode/src/server/shared/tui-control.ts
Normal file
28
packages/opencode/src/server/shared/tui-control.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import z from "zod"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
|
||||
export const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
})
|
||||
|
||||
export type TuiRequest = z.infer<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<unknown>()
|
||||
|
||||
export function nextTuiRequest() {
|
||||
return request.next()
|
||||
}
|
||||
|
||||
export function submitTuiRequest(body: TuiRequest) {
|
||||
request.push(body)
|
||||
}
|
||||
|
||||
export function submitTuiResponse(body: unknown) {
|
||||
response.push(body)
|
||||
}
|
||||
|
||||
export function nextTuiResponse() {
|
||||
return response.next()
|
||||
}
|
||||
104
packages/opencode/src/server/shared/ui.ts
Normal file
104
packages/opencode/src/server/shared/ui.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createHash } from "node:crypto"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
export const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
export const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
|
||||
export const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export function themePreloadHash(body: string) {
|
||||
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
const len = request.headers["content-length"]
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
|
||||
}
|
||||
|
||||
function proxyResponseHeaders(headers: Record<string, string>) {
|
||||
const result = new Headers(headers)
|
||||
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
|
||||
// transfer metadata makes browsers decode already-decoded assets again.
|
||||
result.delete("content-encoding")
|
||||
result.delete("content-length")
|
||||
return result
|
||||
}
|
||||
|
||||
export function upstreamURL(path: string) {
|
||||
return new URL(path, UI_UPSTREAM).toString()
|
||||
}
|
||||
|
||||
export function embeddedUI() {
|
||||
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
|
||||
return embeddedUIPromise
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||
const mime = AppFileSystem.mimeType(file)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return HttpServerResponse.raw(body, { headers })
|
||||
}
|
||||
|
||||
export function serveEmbeddedUIEffect(
|
||||
requestPath: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
embeddedWebUI: Record<string, string>,
|
||||
) {
|
||||
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!file) return Effect.succeed(notFound())
|
||||
|
||||
return fs.readFile(file).pipe(
|
||||
Effect.map((body) => embeddedUIResponse(file, body)),
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
|
||||
)
|
||||
}
|
||||
|
||||
export function serveUIEffect(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
|
||||
const path = new URL(request.url, "http://localhost").pathname
|
||||
|
||||
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
|
||||
|
||||
const response = yield* services.client.execute(
|
||||
HttpClientRequest.make(request.method)(upstreamURL(path), {
|
||||
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
|
||||
body: requestBody(request),
|
||||
}),
|
||||
)
|
||||
const headers = proxyResponseHeaders(response.headers)
|
||||
|
||||
if (response.headers["content-type"]?.includes("text/html")) {
|
||||
const body = yield* response.text
|
||||
const match = themePreloadHash(body)
|
||||
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
|
||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
headers.set("Content-Security-Policy", csp())
|
||||
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
|
||||
status: response.status,
|
||||
headers,
|
||||
})
|
||||
})
|
||||
}
|
||||
36
packages/opencode/src/server/shared/workspace-routing.ts
Normal file
36
packages/opencode/src/server/shared/workspace-routing.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
const RULES: Array<Rule> = [
|
||||
{ path: "/experimental/workspace", action: "local" },
|
||||
{ path: "/session/status", action: "forward" },
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
export function isLocalWorkspaceRoute(method: string, path: string) {
|
||||
for (const rule of RULES) {
|
||||
if (rule.method && rule.method !== method) continue
|
||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
||||
if (match) return rule.action === "local"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getWorkspaceRouteSessionID(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
if (!id) return null
|
||||
|
||||
return SessionID.make(id)
|
||||
}
|
||||
|
||||
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
|
||||
const proxyURL = new URL(target)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
|
||||
proxyURL.search = requestURL.search
|
||||
proxyURL.hash = requestURL.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
return proxyURL
|
||||
}
|
||||
@@ -8,45 +8,10 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ServerProxy } from "./proxy"
|
||||
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
const RULES: Array<Rule> = [
|
||||
{ path: "/experimental/workspace", action: "local" },
|
||||
{ path: "/session/status", action: "forward" },
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
export function isLocalWorkspaceRoute(method: string, path: string) {
|
||||
for (const rule of RULES) {
|
||||
if (rule.method && rule.method !== method) continue
|
||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
||||
if (match) return rule.action === "local"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getWorkspaceRouteSessionID(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
if (!id) return null
|
||||
|
||||
return SessionID.make(id)
|
||||
}
|
||||
|
||||
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
|
||||
const proxyURL = new URL(target)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
|
||||
proxyURL.search = requestURL.search
|
||||
proxyURL.hash = requestURL.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
return proxyURL
|
||||
}
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing"
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getWorkspaceRouteSessionID(url)
|
||||
|
||||
@@ -405,7 +405,7 @@ export const layer: Layer.Layer<
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Error.Sync, {
|
||||
EventV2.run(SessionEvent.Tool.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
@@ -650,6 +650,17 @@ export const layer: Layer.Layer<
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
return
|
||||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
error: {
|
||||
type: error.name,
|
||||
message: errorMessage(e),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.assistantMessage.error = error
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
|
||||
@@ -161,6 +161,9 @@ export default [
|
||||
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
|
||||
}),
|
||||
@@ -181,8 +184,8 @@ export default [
|
||||
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data })
|
||||
SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
|
||||
|
||||
@@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string {
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
try {
|
||||
return JSON.stringify(error, null, 2)
|
||||
const json = JSON.stringify(error, null, 2)
|
||||
// Plain objects whose own properties are all non-enumerable (or empty)
|
||||
// serialize to "{}", which prints as a useless bare `{}` on stderr.
|
||||
// Fall back to a custom toString first, then to ctor name + own prop names.
|
||||
if (json === "{}") {
|
||||
const str = String(error)
|
||||
if (str && str !== "[object Object]") return str
|
||||
const ctor = error.constructor?.name
|
||||
const prefix = ctor && ctor !== "Object" ? ctor : "Error"
|
||||
const names = Object.getOwnPropertyNames(error)
|
||||
return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
|
||||
}
|
||||
return json
|
||||
} catch {
|
||||
return "Unexpected error (unserializable)"
|
||||
}
|
||||
@@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string {
|
||||
if (text && text !== "[object Object]") return text
|
||||
|
||||
const formatted = errorFormat(error)
|
||||
if (formatted && formatted !== "{}") return formatted
|
||||
if (formatted) return formatted
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
@@ -45,7 +57,7 @@ export function errorData(error: unknown) {
|
||||
message: errorMessage(error),
|
||||
stack: error.stack,
|
||||
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
|
||||
formatted: errorFormatted(error),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +65,7 @@ export function errorData(error: unknown) {
|
||||
return {
|
||||
type: typeof error,
|
||||
message: errorMessage(error),
|
||||
formatted: errorFormatted(error),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +83,6 @@ export function errorData(error: unknown) {
|
||||
|
||||
if (typeof data.message !== "string") data.message = errorMessage(error)
|
||||
if (typeof data.type !== "string") data.type = error.constructor?.name
|
||||
data.formatted = errorFormatted(error)
|
||||
data.formatted = errorFormat(error)
|
||||
return data
|
||||
}
|
||||
|
||||
function errorFormatted(error: unknown) {
|
||||
const formatted = errorFormat(error)
|
||||
if (formatted !== "{}") return formatted
|
||||
return String(error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
|
||||
let timeout: NodeJS.Timeout
|
||||
return Promise.race([
|
||||
promise.finally(() => {
|
||||
@@ -6,7 +6,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`Operation timed out after ${ms}ms`))
|
||||
reject(new Error(label ?? `Operation timed out after ${ms}ms`))
|
||||
}, ms)
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -22,6 +22,11 @@ const Base = {
|
||||
sessionID: SessionID,
|
||||
}
|
||||
|
||||
const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const AgentSwitched = EventV2.define({
|
||||
type: "session.next.agent.switched",
|
||||
aggregate: "sessionID",
|
||||
@@ -128,6 +133,16 @@ export namespace Step {
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
|
||||
export const Failed = EventV2.define({
|
||||
type: "session.next.step.failed",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
error: Error,
|
||||
},
|
||||
})
|
||||
export type Failed = Schema.Schema.Type<typeof Failed>
|
||||
}
|
||||
|
||||
export namespace Text {
|
||||
@@ -275,23 +290,20 @@ export namespace Tool {
|
||||
})
|
||||
export type Success = Schema.Schema.Type<typeof Success>
|
||||
|
||||
export const Error = EventV2.define({
|
||||
type: "session.next.tool.error",
|
||||
export const Failed = EventV2.define({
|
||||
type: "session.next.tool.failed",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
error: Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
}),
|
||||
error: Error,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type Error = Schema.Schema.Type<typeof Error>
|
||||
export type Failed = Schema.Schema.Type<typeof Failed>
|
||||
}
|
||||
|
||||
export const RetryError = Schema.Struct({
|
||||
@@ -359,6 +371,7 @@ export const All = Schema.Union(
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
@@ -368,7 +381,7 @@ export const All = Schema.Union(
|
||||
Tool.Called,
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Error,
|
||||
Tool.Failed,
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
|
||||
@@ -199,6 +199,17 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.step.failed": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = "error"
|
||||
draft.error = event.data.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.text.started": () => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
@@ -314,7 +325,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.error": (event) => {
|
||||
"session.next.tool.failed": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { V2Schema } from "./schema"
|
||||
import { optionalOmitUndefined } from "@/util/schema"
|
||||
|
||||
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
|
||||
identifier: "Session.Delivery",
|
||||
@@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery
|
||||
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: SessionID,
|
||||
parentID: SessionID.pipe(Schema.optional),
|
||||
parentID: optionalOmitUndefined(SessionID),
|
||||
projectID: ProjectID,
|
||||
workspaceID: WorkspaceID.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
workspaceID: optionalOmitUndefined(WorkspaceID),
|
||||
path: optionalOmitUndefined(Schema.String),
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
model: Schema.Struct({
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
variant: optionalOmitUndefined(Schema.String),
|
||||
}).pipe(optionalOmitUndefined),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
updated: V2Schema.DateTimeUtcFromMillis,
|
||||
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
|
||||
}),
|
||||
title: Schema.String,
|
||||
/*
|
||||
@@ -109,7 +110,7 @@ export const layer = Layer.effect(
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
function fromRow(row: typeof SessionTable.$inferSelect): Info {
|
||||
return {
|
||||
return new Info({
|
||||
id: SessionID.make(row.id),
|
||||
projectID: ProjectID.make(row.project_id),
|
||||
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
|
||||
@@ -129,7 +130,7 @@ export const layer = Layer.effect(
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const result: Interface = {
|
||||
|
||||
48
packages/opencode/test/cli/effect-cmd-instance-als.test.ts
Normal file
48
packages/opencode/test/cli/effect-cmd-instance-als.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
// Regression for PR #25522: when an effectCmd handler does
|
||||
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
|
||||
// the inner runPromise creates a fresh fiber after `await` whose Effect context
|
||||
// has lost the outer InstanceRef. Services that read `InstanceState.context`
|
||||
// then fall back to `Instance.current` ALS, which must be installed at the JS
|
||||
// callback boundary (Node ALS persists across awaits, Effect's fiber context
|
||||
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
|
||||
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
|
||||
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
|
||||
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
|
||||
// fail it. This grep guards the actual production callsite.
|
||||
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
|
||||
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
})
|
||||
|
||||
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
|
||||
await using dir = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.promise(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const current = await Effect.runPromise(
|
||||
Effect.sync(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(current?.directory).toBe(dir.path)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
@@ -627,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => {
|
||||
"local.plugin": true,
|
||||
})
|
||||
})
|
||||
|
||||
test("silently skips malformed tui.json — load failures degrade to {}", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",')
|
||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
// Project tui.json is malformed → silently skipped (logs a warning)
|
||||
// .opencode/tui.json (lower precedence in this path) still loads
|
||||
expect(config.theme).toBe("fallback")
|
||||
})
|
||||
|
||||
test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// tui.json exists as a DIRECTORY rather than a file → readFileString fails
|
||||
// with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes
|
||||
// that through catchCause → log + skip, so a fallback layer should still load.
|
||||
await fs.mkdir(path.join(dir, "tui.json"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
// Did NOT crash; .opencode/tui.json (lower precedence) still loads.
|
||||
expect(config.theme).toBe("fallback")
|
||||
})
|
||||
|
||||
test("missing tui.json — silently treated as empty (ENOENT path)", async () => {
|
||||
await using tmp = await tmpdir({})
|
||||
|
||||
// No tui.json anywhere. Should not throw.
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config).toBeDefined()
|
||||
// No theme set anywhere.
|
||||
expect(config.theme).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -114,6 +114,53 @@ describe("Git", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("patch() returns capped native patch output", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, all, capped] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
expect(patch.text).toContain("diff --git")
|
||||
expect(patch.text).toContain("-before")
|
||||
expect(patch.text).toContain("+after")
|
||||
expect(all.truncated).toBe(false)
|
||||
expect(all.text).toContain("diff --git")
|
||||
expect(all.text).toContain("other.txt")
|
||||
expect(all.text).toContain("+new")
|
||||
expect(capped.truncated).toBe(true)
|
||||
expect(capped.text).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
test("patchUntracked() and statUntracked() handle added files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, stat] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
expect(patch.text).toContain("diff --git")
|
||||
expect(patch.text).toContain("+one")
|
||||
expect(patch.text).toContain("+two")
|
||||
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
|
||||
})
|
||||
})
|
||||
|
||||
test("show() returns empty text for binary blobs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
|
||||
|
||||
@@ -234,6 +234,7 @@ describe("Vcs diff", () => {
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,6 +260,34 @@ describe("Vcs diff", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('git') keeps batched patches aligned for type changes", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet()
|
||||
await fs.unlink(path.join(tmp.path, "a.txt"))
|
||||
await fs.symlink("target", path.join(tmp.path, "a.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
const a = diff.find((item) => item.file === "a.txt")
|
||||
const b = diff.find((item) => item.file === "b.txt")
|
||||
|
||||
expect(a?.patch).toContain("deleted file mode")
|
||||
expect(a?.patch).toContain("new file mode")
|
||||
expect(b?.patch).toContain("+new")
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('branch') returns changes against default branch", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
|
||||
59
packages/opencode/test/server/auth.test.ts
Normal file
59
packages/opencode/test/server/auth.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Option, Redacted } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
})
|
||||
|
||||
describe("ServerAuth", () => {
|
||||
test("does not emit auth headers without a password", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.header()).toBeUndefined()
|
||||
expect(ServerAuth.headers()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("defaults to the opencode username", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = undefined
|
||||
|
||||
expect(ServerAuth.headers()).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the configured username", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.headers()).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers explicit credentials", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("validates decoded credentials against effect config", () => {
|
||||
const config = { password: Option.some("secret"), username: "alice" }
|
||||
|
||||
expect(ServerAuth.required(config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,9 @@ import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import {
|
||||
Authorization,
|
||||
ServerAuthConfig,
|
||||
authorizationLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const Api = HttpApi.make("test-authorization").add(
|
||||
@@ -16,27 +13,34 @@ const Api = HttpApi.make("test-authorization").add(
|
||||
HttpApiEndpoint.get("probe", "/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
HttpApiEndpoint.get("missing", "/missing", {
|
||||
success: Schema.String,
|
||||
error: HttpApiError.NotFound,
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
|
||||
handlers
|
||||
.handle("probe", () => Effect.succeed("ok"))
|
||||
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
|
||||
|
||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
@@ -93,6 +97,35 @@ describe("HttpApi authorization middleware", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("prefers auth token query credentials over basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get(
|
||||
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
|
||||
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when basic auth succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get("/missing").pipe(
|
||||
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when auth token query succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("HttpApi server", () => {
|
||||
})
|
||||
|
||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
||||
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
|
||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||
|
||||
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
||||
@@ -237,7 +237,7 @@ describe("HttpApi server", () => {
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI route parameters", async () => {
|
||||
const hono = openApiParameters(await Server.openapi())
|
||||
const hono = openApiParameters(await Server.openapiHono())
|
||||
const effect = openApiParameters(effectOpenApi())
|
||||
|
||||
expect(
|
||||
@@ -248,7 +248,7 @@ describe("HttpApi server", () => {
|
||||
})
|
||||
|
||||
test("matches generated OpenAPI request body shape", async () => {
|
||||
const hono = openApiRequestBodies(await Server.openapi())
|
||||
const hono = openApiRequestBodies(await Server.openapiHono())
|
||||
const effect = openApiRequestBodies(effectOpenApi())
|
||||
|
||||
expect(
|
||||
|
||||
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
const auth = { username: "opencode", password: "listen-secret" }
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
|
||||
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
|
||||
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
function authorization() {
|
||||
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||
}
|
||||
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("directory", dir)
|
||||
url.searchParams.set("cursor", "-1")
|
||||
url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`))
|
||||
return url
|
||||
}
|
||||
|
||||
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { id: string }
|
||||
}
|
||||
|
||||
async function openSocket(url: URL) {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = "arraybuffer"
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket open",
|
||||
)
|
||||
return ws
|
||||
}
|
||||
|
||||
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
||||
return withTimeout(listener.stop(true), 10_000, label)
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
|
||||
const decoder = new TextDecoder()
|
||||
let onMessage: ((event: MessageEvent) => void) | undefined
|
||||
return withTimeout(
|
||||
new Promise<string>((resolve) => {
|
||||
onMessage = (event: MessageEvent) => {
|
||||
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
|
||||
if (!predicate(message)) return
|
||||
resolve(message)
|
||||
}
|
||||
ws.addEventListener("message", onMessage)
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket message",
|
||||
).finally(() => {
|
||||
if (onMessage) ws.removeEventListener("message", onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
describe("HttpApi Server.listen", () => {
|
||||
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
|
||||
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
||||
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
||||
ws.send("ping-listen\n")
|
||||
expect(await message).toContain("ping-listen")
|
||||
|
||||
await stop(listener, "timed out waiting for listener.stop(true)")
|
||||
stopped = true
|
||||
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
|
||||
const restarted = await startListener()
|
||||
try {
|
||||
const nextInfo = await createCat(restarted, tmp.path)
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path))
|
||||
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
||||
nextWs.send("ping-restarted\n")
|
||||
expect(await nextMessage).toContain("ping-restarted")
|
||||
nextWs.close(1000)
|
||||
} finally {
|
||||
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
|
||||
}
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
|
||||
|
||||
const passthroughAuthorization = Layer.succeed(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect) => effect,
|
||||
authToken: (effect) => effect,
|
||||
}),
|
||||
Authorization.of((effect) => effect),
|
||||
)
|
||||
|
||||
const passthroughInstanceContext = Layer.succeed(
|
||||
|
||||
@@ -46,7 +46,7 @@ afterEach(async () => {
|
||||
|
||||
describe("tui HttpApi bridge", () => {
|
||||
test("documents legacy bad request responses", async () => {
|
||||
const legacy = await Server.openapi()
|
||||
const legacy = await Server.openapiHono()
|
||||
const effect = OpenApi.fromApi(TuiApi)
|
||||
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
|
||||
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
|
||||
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import {
|
||||
ServerAuthConfig,
|
||||
authorizationRouterMiddleware,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { serveUIEffect } from "../../src/server/routes/ui"
|
||||
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { Server } from "../../src/server/server"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
|
||||
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
input?.client ?? httpClient(new Response("ui")),
|
||||
@@ -186,6 +184,36 @@ describe("HttpApi UI fallback", () => {
|
||||
expect(await response.text()).toBe("console.log('ok')")
|
||||
})
|
||||
|
||||
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
let readPath: string | undefined
|
||||
|
||||
const response = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
return yield* serveEmbeddedUIEffect(
|
||||
"/assets/app.js",
|
||||
{
|
||||
...fs,
|
||||
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
|
||||
readFile: (path) => {
|
||||
readPath = path
|
||||
return path === "/$bunfs/root/assets/app.js"
|
||||
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
|
||||
: Effect.die(`unexpected embedded UI path: ${path}`)
|
||||
},
|
||||
},
|
||||
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
|
||||
)
|
||||
}).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(readPath).toBe("/$bunfs/root/assets/app.js")
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript")
|
||||
expect(await response.text()).toBe("console.log('embedded')")
|
||||
})
|
||||
|
||||
test("keeps matched API routes ahead of the UI fallback", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
|
||||
@@ -201,6 +229,7 @@ describe("HttpApi UI fallback", () => {
|
||||
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
})
|
||||
|
||||
test("accepts auth token for the web UI", async () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace"
|
||||
import {
|
||||
isLocalWorkspaceRoute,
|
||||
getWorkspaceRouteSessionID,
|
||||
workspaceProxyURL,
|
||||
} from "../../src/server/shared/workspace-routing"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
|
||||
describe("isLocalWorkspaceRoute", () => {
|
||||
|
||||
@@ -22,6 +22,19 @@ describe("util.error", () => {
|
||||
expect(data.code).toBe("E_BAD")
|
||||
})
|
||||
|
||||
test("never returns bare {} for opaque object errors", () => {
|
||||
// Plain empty object — what the SDK threw before we wrapped it.
|
||||
expect(errorFormat({})).not.toBe("{}")
|
||||
expect(errorFormat({})).toContain("no message")
|
||||
|
||||
// Object with only non-enumerable own properties (JSON.stringify drops them).
|
||||
class OpaqueError {}
|
||||
const opaque = new OpaqueError()
|
||||
Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false })
|
||||
expect(errorFormat(opaque)).not.toBe("{}")
|
||||
expect(errorFormat(opaque)).toContain("OpaqueError")
|
||||
})
|
||||
|
||||
test("handles opaque throwables with custom toString", () => {
|
||||
const err = {
|
||||
toString() {
|
||||
|
||||
@@ -12,10 +12,12 @@ import { createClient } from "@hey-api/openapi-ts"
|
||||
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
|
||||
const opencode = path.resolve(dir, "../../opencode")
|
||||
|
||||
// `bun dev generate` now derives the spec from the Effect HttpApi contract by
|
||||
// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs.
|
||||
if (openapiSource === "httpapi") {
|
||||
await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode)
|
||||
} else {
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||
} else {
|
||||
await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode)
|
||||
}
|
||||
|
||||
await createClient({
|
||||
|
||||
@@ -84,5 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
|
||||
|
||||
return response
|
||||
})
|
||||
// The generated client falls back to throwing a literal `{}` when the server
|
||||
// responds with an empty / unparseable error body, which surfaces as a bare
|
||||
// `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so
|
||||
// downstream formatters get a useful message — but pass through any parsed
|
||||
// JSON error body unchanged so existing consumers can still inspect fields.
|
||||
client.interceptors.error.use((error, response, request) => {
|
||||
const isEmpty =
|
||||
error === undefined ||
|
||||
error === null ||
|
||||
error === "" ||
|
||||
(typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0)
|
||||
if (!isEmpty) return error
|
||||
const method = request?.method ?? "?"
|
||||
const url = request?.url ?? "?"
|
||||
if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`)
|
||||
const status = response.status
|
||||
const statusText = response.statusText ? " " + response.statusText : ""
|
||||
return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`)
|
||||
})
|
||||
return new OpencodeClient({ client })
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export type Event =
|
||||
| EventSessionNextShellEnded
|
||||
| EventSessionNextStepStarted
|
||||
| EventSessionNextStepEnded
|
||||
| EventSessionNextStepFailed
|
||||
| EventSessionNextTextStarted
|
||||
| EventSessionNextTextDelta
|
||||
| EventSessionNextTextEnded
|
||||
@@ -70,7 +71,7 @@ export type Event =
|
||||
| EventSessionNextToolCalled
|
||||
| EventSessionNextToolProgress
|
||||
| EventSessionNextToolSuccess
|
||||
| EventSessionNextToolError
|
||||
| EventSessionNextToolFailed
|
||||
| EventSessionNextRetried
|
||||
| EventSessionNextCompactionStarted
|
||||
| EventSessionNextCompactionDelta
|
||||
@@ -823,6 +824,7 @@ export type GlobalEvent = {
|
||||
| EventSessionNextShellEnded
|
||||
| EventSessionNextStepStarted
|
||||
| EventSessionNextStepEnded
|
||||
| EventSessionNextStepFailed
|
||||
| EventSessionNextTextStarted
|
||||
| EventSessionNextTextDelta
|
||||
| EventSessionNextTextEnded
|
||||
@@ -835,7 +837,7 @@ export type GlobalEvent = {
|
||||
| EventSessionNextToolCalled
|
||||
| EventSessionNextToolProgress
|
||||
| EventSessionNextToolSuccess
|
||||
| EventSessionNextToolError
|
||||
| EventSessionNextToolFailed
|
||||
| EventSessionNextRetried
|
||||
| EventSessionNextCompactionStarted
|
||||
| EventSessionNextCompactionDelta
|
||||
@@ -857,6 +859,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionNextShellEnded
|
||||
| SyncEventSessionNextStepStarted
|
||||
| SyncEventSessionNextStepEnded
|
||||
| SyncEventSessionNextStepFailed
|
||||
| SyncEventSessionNextTextStarted
|
||||
| SyncEventSessionNextTextDelta
|
||||
| SyncEventSessionNextTextEnded
|
||||
@@ -869,7 +872,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionNextToolCalled
|
||||
| SyncEventSessionNextToolProgress
|
||||
| SyncEventSessionNextToolSuccess
|
||||
| SyncEventSessionNextToolError
|
||||
| SyncEventSessionNextToolFailed
|
||||
| SyncEventSessionNextRetried
|
||||
| SyncEventSessionNextCompactionStarted
|
||||
| SyncEventSessionNextCompactionDelta
|
||||
@@ -1973,6 +1976,22 @@ export type SyncEventSessionNextStepEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextStepFailed = {
|
||||
type: "sync"
|
||||
name: "session.next.step.failed.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: "sessionID"
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextTextStarted = {
|
||||
type: "sync"
|
||||
name: "session.next.text.started.1"
|
||||
@@ -2157,9 +2176,9 @@ export type SyncEventSessionNextToolSuccess = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextToolError = {
|
||||
export type SyncEventSessionNextToolFailed = {
|
||||
type: "sync"
|
||||
name: "session.next.tool.error.1"
|
||||
name: "session.next.tool.failed.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: "sessionID"
|
||||
@@ -2710,6 +2729,19 @@ export type EventSessionNextStepEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextStepFailed = {
|
||||
id: string
|
||||
type: "session.next.step.failed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextTextStarted = {
|
||||
id: string
|
||||
type: "session.next.text.started"
|
||||
@@ -2870,9 +2902,9 @@ export type EventSessionNextToolSuccess = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextToolError = {
|
||||
export type EventSessionNextToolFailed = {
|
||||
id: string
|
||||
type: "session.next.tool.error"
|
||||
type: "session.next.tool.failed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
@@ -3162,7 +3194,10 @@ export type SessionMessageAssistant = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: string
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,11 @@ function section(areas: Set<string>) {
|
||||
return "Core"
|
||||
}
|
||||
|
||||
function type(message: string) {
|
||||
if (message.match(/fix/i)) return "Bugfixes"
|
||||
return "Improvements"
|
||||
}
|
||||
|
||||
function reverted(commits: Commit[]) {
|
||||
const seen = new Map<string, Commit>()
|
||||
|
||||
@@ -193,13 +198,20 @@ async function thanks(from: string, to: string, reuse: boolean) {
|
||||
}
|
||||
|
||||
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
||||
const grouped = new Map<string, string[]>()
|
||||
for (const title of order) grouped.set(title, [])
|
||||
const grouped = new Map<string, Map<string, string[]>>()
|
||||
for (const title of order) {
|
||||
grouped.set(
|
||||
title,
|
||||
new Map([
|
||||
["Improvements", []],
|
||||
["Bugfixes", []],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
for (const commit of list) {
|
||||
const title = section(commit.areas)
|
||||
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
||||
grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
||||
}
|
||||
|
||||
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
||||
@@ -209,11 +221,23 @@ function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
||||
}
|
||||
|
||||
for (const title of order) {
|
||||
const entries = grouped.get(title)
|
||||
if (!entries || entries.length === 0) continue
|
||||
const groups = grouped.get(title)
|
||||
if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue
|
||||
lines.push(`## ${title}`)
|
||||
lines.push(...entries)
|
||||
lines.push("")
|
||||
const improvements = groups.get("Improvements")!
|
||||
const bugfixes = groups.get("Bugfixes")!
|
||||
if (bugfixes.length === 0) {
|
||||
lines.push(...improvements)
|
||||
lines.push("")
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [subtitle, entries] of groups) {
|
||||
if (entries.length === 0) continue
|
||||
lines.push(`### ${subtitle}`)
|
||||
lines.push(...entries)
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
if (thanks.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user