build: centralize the pinned opencode version (#1075)

Keep OpenCode version selection predictable by reading a single repo-wide constant and packaging that pin into orchestrator builds. Remove env and latest-release fallbacks so desktop, workers, snapshots, and CI stay aligned.

Co-authored-by: Omar McAdam <omar@OpenWork-Studio.localdomain>
This commit is contained in:
Omar McAdam
2026-03-20 12:30:24 -07:00
committed by GitHub
parent a9bf75da0a
commit db10a7b5ba
30 changed files with 186 additions and 392 deletions

View File

@@ -21,7 +21,6 @@ jobs:
env:
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
steps:
- name: Checkout
@@ -85,80 +84,27 @@ jobs:
- name: Resolve OpenCode version
id: opencode-version
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node <<'NODE' >> "$GITHUB_OUTPUT"
const fs = require('fs');
const repo = (process.env.OPENCODE_GITHUB_REPO || 'anomalyco/opencode').trim() || 'anomalyco/opencode';
async function resolveLatest() {
const token = (process.env.GITHUB_TOKEN || '').trim();
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'openwork-ci',
};
if (token) headers.Authorization = `Bearer ${token}`;
try {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
if (res.ok) {
const data = await res.json();
const tag = (typeof data.tag_name === 'string' ? data.tag_name : '').trim();
const version = (tag.startsWith('v') ? tag.slice(1) : tag).trim();
if (version) return version;
}
if (res.status !== 403) {
throw new Error(`Failed to resolve latest OpenCode version (HTTP ${res.status})`);
}
} catch {
}
const web = await fetch(`https://github.com/${repo}/releases/latest`, {
headers: { 'User-Agent': 'openwork-ci' },
redirect: 'follow',
});
const url = web && web.url ? String(web.url) : '';
const match = url.match(/\/tag\/v([^/?#]+)/);
if (!match) throw new Error('Failed to resolve latest OpenCode version (web redirect).');
return match[1];
const parsed = JSON.parse(fs.readFileSync('./constants.json', 'utf8'));
const version = String(parsed.opencodeVersion || '').replace(/^v/, '').trim();
if (!version) {
throw new Error('Pinned OpenCode version is missing from constants.json');
}
async function main() {
const pkg = JSON.parse(fs.readFileSync('./apps/desktop/package.json', 'utf8'));
const configuredRaw = (process.env.OPENCODE_VERSION || pkg.opencodeVersion || '').toString().trim();
if (configuredRaw && configuredRaw.toLowerCase() !== 'latest') {
const resolved = (configuredRaw.startsWith('v') ? configuredRaw.slice(1) : configuredRaw).trim();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${resolved}\n`);
}
console.log('version=' + resolved);
return;
}
const latest = await resolveLatest();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${latest}\n`);
}
console.log('version=' + latest);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
console.log('version=' + version);
NODE
- name: Download OpenCode sidecar
shell: bash
env:
OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
PINNED_OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
run: |
set -euo pipefail
repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}"
opencode_asset="opencode-darwin-arm64.zip"
url="https://github.com/${repo}/releases/download/v${OPENCODE_VERSION}/${opencode_asset}"
url="https://github.com/${repo}/releases/download/v${PINNED_OPENCODE_VERSION}/${opencode_asset}"
tmp_dir="$RUNNER_TEMP/opencode"
extract_dir="$tmp_dir/extracted"
rm -rf "$tmp_dir"

View File

@@ -71,45 +71,18 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
run: |
set -euo pipefail
repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}"
version="${OPENCODE_VERSION:-}"
if [ -z "$version" ]; then
version="$(node -p "require('./apps/desktop/package.json').opencodeVersion || ''" 2>/dev/null || true)"
fi
version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")"
version="$(echo "$version" | tr -d '\r\n' | sed 's/^v//')"
if [ -z "$version" ] || [ "$version" = "latest" ]; then
latest=""
if [ -n "${GITHUB_TOKEN:-}" ]; then
latest=$(curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${repo}/releases/latest" \
| sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
else
latest=$(curl -fsSL \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${repo}/releases/latest" \
| sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
fi
if [ -n "$latest" ]; then
version="$latest"
fi
fi
if [ -z "$version" ]; then
echo "Unable to resolve OpenCode version (set OPENCODE_VERSION to pin)." >&2
echo "Unable to resolve OpenCode version from constants.json." >&2
exit 1
fi
# Reuse this resolved version for subsequent steps (e.g. prepare-sidecar).
echo "OPENCODE_VERSION=$version" >> "$GITHUB_ENV"
opencode_asset="opencode-linux-x64-baseline.tar.gz"
url="https://github.com/${repo}/releases/download/v${version}/${opencode_asset}"
tmp_dir="$RUNNER_TEMP/opencode"

View File

@@ -39,40 +39,15 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
run: |
set -euo pipefail
repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}"
version="${OPENCODE_VERSION:-}"
if [ -z "$version" ]; then
version="$(node -p "require('./apps/desktop/package.json').opencodeVersion || ''" 2>/dev/null || true)"
fi
version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")"
version="$(echo "$version" | tr -d '\r\n' | sed 's/^v//')"
if [ -z "$version" ] || [ "$version" = "latest" ]; then
latest=""
if [ -n "${GITHUB_TOKEN:-}" ]; then
latest=$(curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${repo}/releases/latest" \
| sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
else
latest=$(curl -fsSL \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${repo}/releases/latest" \
| sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
fi
if [ -n "$latest" ]; then
version="$latest"
fi
fi
if [ -z "$version" ]; then
echo "Unable to resolve OpenCode version (set OPENCODE_VERSION to pin)." >&2
echo "Unable to resolve OpenCode version from constants.json." >&2
exit 1
fi

View File

@@ -20,7 +20,9 @@ jobs:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
run: |
version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")"
curl -fsSL https://opencode.ai/install | bash -s -- --version "$version" --no-modify-path
- name: Triage issue
env:
@@ -48,7 +50,9 @@ jobs:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
run: |
version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")"
curl -fsSL https://opencode.ai/install | bash -s -- --version "$version" --no-modify-path
- name: Build prompt
env:

View File

@@ -85,7 +85,6 @@ jobs:
RELEASE_BODY: ${{ needs.prepare-release.outputs.release_body }}
MACOS_NOTARIZE: ${{ vars.MACOS_NOTARIZE || 'false' }}
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
strategy:
fail-fast: false
@@ -172,78 +171,21 @@ jobs:
- name: Resolve OpenCode version
id: opencode-version
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node <<'NODE' >> "$GITHUB_OUTPUT"
const fs = require('fs');
const repo = (process.env.OPENCODE_GITHUB_REPO || 'anomalyco/opencode').trim() || 'anomalyco/opencode';
async function resolveLatest() {
const token = (process.env.GITHUB_TOKEN || '').trim();
const headers = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'openwork-ci',
};
if (token) headers.Authorization = `Bearer ${token}`;
// Prefer API, but fall back to the web "latest" redirect if rate-limited (403) or otherwise blocked.
try {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
if (res.ok) {
const data = await res.json();
const tag = (typeof data.tag_name === 'string' ? data.tag_name : '').trim();
let v = tag.startsWith('v') ? tag.slice(1) : tag;
v = v.trim();
if (v) return v;
}
if (res.status !== 403) {
throw new Error(`Failed to resolve latest OpenCode version (HTTP ${res.status})`);
}
} catch {
// continue to fallback
}
const web = await fetch(`https://github.com/${repo}/releases/latest`, {
headers: { 'User-Agent': 'openwork-ci' },
redirect: 'follow',
});
const url = (web && web.url) ? String(web.url) : '';
const match = url.match(/\/tag\/v([^/?#]+)/);
if (!match) throw new Error('Failed to resolve latest OpenCode version (web redirect).');
return match[1];
const parsed = JSON.parse(fs.readFileSync('./constants.json', 'utf8'));
const version = String(parsed.opencodeVersion || '').replace(/^v/, '').trim();
if (!version) {
throw new Error('Pinned OpenCode version is missing from constants.json');
}
async function main() {
const pkg = JSON.parse(fs.readFileSync('./apps/desktop/package.json', 'utf8'));
const configuredRaw = (process.env.OPENCODE_VERSION || pkg.opencodeVersion || '').toString().trim();
if (configuredRaw && configuredRaw.toLowerCase() !== 'latest') {
const normalized = configuredRaw.startsWith('v') ? configuredRaw.slice(1) : configuredRaw;
const resolved = normalized.trim();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${resolved}\n`);
}
console.log('version=' + resolved);
return;
}
const latest = await resolveLatest();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${latest}\n`);
}
console.log('version=' + latest);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
console.log('version=' + version);
NODE
- name: Download OpenCode sidecar
shell: bash
env:
OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
PINNED_OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
run: |
set -euo pipefail
@@ -270,7 +212,7 @@ jobs:
esac
repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}"
url="https://github.com/${repo}/releases/download/v${OPENCODE_VERSION}/${opencode_asset}"
url="https://github.com/${repo}/releases/download/v${PINNED_OPENCODE_VERSION}/${opencode_asset}"
tmp_dir="$RUNNER_TEMP/opencode"
extract_dir="$tmp_dir/extracted"
rm -rf "$tmp_dir"

View File

@@ -254,7 +254,6 @@ jobs:
MACOS_NOTARIZE: ${{ needs.resolve-release.outputs.notarize }}
# Ensure Tauri's beforeBuildCommand (prepare:sidecar) uses our fork.
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
strategy:
fail-fast: false
@@ -373,80 +372,21 @@ jobs:
- name: Resolve OpenCode version
id: opencode-version
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION || '1.2.20' }}
run: |
node <<'NODE' >> "$GITHUB_OUTPUT"
const fs = require('fs');
const repo = (process.env.OPENCODE_GITHUB_REPO || 'anomalyco/opencode').trim() || 'anomalyco/opencode';
async function resolveLatest() {
const token = (process.env.GITHUB_TOKEN || '').trim();
const headers = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'openwork-ci',
};
if (token) headers.Authorization = `Bearer ${token}`;
// Prefer API, but fall back to the web "latest" redirect if rate-limited (403) or otherwise blocked.
try {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
if (res.ok) {
const data = await res.json();
const tag = (typeof data.tag_name === 'string' ? data.tag_name : '').trim();
let v = tag.startsWith('v') ? tag.slice(1) : tag;
v = v.trim();
if (v) return v;
}
if (res.status !== 403) {
throw new Error(`Failed to resolve latest OpenCode version (HTTP ${res.status})`);
}
} catch {
// continue to fallback
}
const web = await fetch(`https://github.com/${repo}/releases/latest`, {
headers: { 'User-Agent': 'openwork-ci' },
redirect: 'follow',
});
const url = (web && web.url) ? String(web.url) : '';
const match = url.match(/\/tag\/v([^/?#]+)/);
if (!match) throw new Error('Failed to resolve latest OpenCode version (web redirect).');
return match[1];
const parsed = JSON.parse(fs.readFileSync('./constants.json', 'utf8'));
const version = String(parsed.opencodeVersion || '').replace(/^v/, '').trim();
if (!version) {
throw new Error('Pinned OpenCode version is missing from constants.json');
}
async function main() {
const pkg = JSON.parse(fs.readFileSync('./apps/desktop/package.json', 'utf8'));
const configuredRaw = (process.env.OPENCODE_VERSION || pkg.opencodeVersion || '').toString().trim();
if (configuredRaw && configuredRaw.toLowerCase() !== 'latest') {
const normalized = configuredRaw.startsWith('v') ? configuredRaw.slice(1) : configuredRaw;
const resolved = normalized.trim();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${resolved}\n`);
}
console.log('version=' + resolved);
return;
}
const latest = await resolveLatest();
if (process.env.GITHUB_ENV) {
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCODE_VERSION=${latest}\n`);
}
console.log('version=' + latest);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
console.log('version=' + version);
NODE
- name: Download OpenCode sidecar
shell: bash
env:
OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
PINNED_OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }}
OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }}
run: |
set -euo pipefail
@@ -474,7 +414,7 @@ jobs:
esac
repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}"
url="https://github.com/${repo}/releases/download/v${OPENCODE_VERSION}/${opencode_asset}"
url="https://github.com/${repo}/releases/download/v${PINNED_OPENCODE_VERSION}/${opencode_asset}"
tmp_dir="$RUNNER_TEMP/opencode"
extract_dir="$tmp_dir/extracted"
rm -rf "$tmp_dir"

View File

@@ -141,7 +141,7 @@ All repo `dev` entrypoints now opt into the same dev-mode isolation so local tes
```bash
sudo pacman -S --needed webkit2gtk-4.1
yay -s opencode # Releases version
curl -fsSL https://opencode.ai/install | bash -s -- --version "$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" --no-modify-path
```
## Architecture (high-level)

View File

@@ -111,7 +111,7 @@ pnpm dev:ui
### Arch 用户:
```bash
yay -s opencode # 已发布版本
curl -fsSL https://opencode.ai/install | bash -s -- --version "$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" --no-modify-path
```
## 架构(高级)
@@ -205,4 +205,4 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 openwork
## 许可证
MIT — 请参见 `LICENSE`
MIT — 请参见 `LICENSE`

View File

@@ -111,7 +111,7 @@ pnpm dev:ui
### Arch 用戶:
```bash
yay -s opencode # 已發布版本
curl -fsSL https://opencode.ai/install | bash -s -- --version "$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" --no-modify-path
```
## 架構(高級)
@@ -205,4 +205,4 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 openwork
## 許可證
MIT — 請參見 `LICENSE`
MIT — 請參見 `LICENSE`

View File

@@ -2556,11 +2556,11 @@ export function createWorkspaceStore(options: {
setEngineDoctorResult(result);
setEngineDoctorCheckedAt(Date.now());
if (!result.found) {
options.setError(
options.isWindowsPlatform()
? "OpenCode CLI not found. Install OpenCode for Windows or bundle opencode.exe with OpenWork, then restart. If it is installed, ensure `opencode.exe` is on PATH (try `opencode --version` in PowerShell)."
: "OpenCode CLI not found. Install with `brew install anomalyco/tap/opencode` or `curl -fsSL https://opencode.ai/install | bash`, then retry.",
if (!result.found) {
options.setError(
options.isWindowsPlatform()
? "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version for Windows or bundle opencode.exe with OpenWork, then restart. If it is installed, ensure `opencode.exe` is on PATH (try `opencode --version` in PowerShell)."
: "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version, then retry.",
);
return false;
}
@@ -2571,7 +2571,7 @@ export function createWorkspaceStore(options: {
.join("\n\n");
const suffix = serveDetails ? `\n\nServe output:\n${serveDetails}` : "";
options.setError(
`OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update OpenCode and retry.${suffix}`
`OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update to the OpenWork-pinned OpenCode version and retry.${suffix}`
);
return false;
}

View File

@@ -851,7 +851,7 @@ export default {
"app.migration.desktop_required": "Migration repair requires the desktop app.",
"app.migration.local_only": "Migration repair is only available for local workers.",
"app.migration.workspace_required": "Pick a local workspace folder before repairing migration.",
"app.migration.unsupported": "This OpenCode binary does not support `opencode db migrate`. Update OpenCode to >=1.2.6 or switch to bundled engine.",
"app.migration.unsupported": "This OpenCode binary does not support `opencode db migrate`. Update to the OpenWork-pinned OpenCode version or switch to bundled engine.",
"app.migration.failed": "OpenCode migration failed.",
"app.migration.restart_failed": "Migration completed, but OpenWork could not restart the local engine.",
"app.migration.success": "Migration repaired. Local startup was retried.",

View File

@@ -850,7 +850,7 @@ export default {
"app.migration.desktop_required": "マイグレーション修復にはデスクトップアプリが必要です。",
"app.migration.local_only": "マイグレーション修復はローカルワーカーでのみ利用可能です。",
"app.migration.workspace_required": "マイグレーション修復の前にローカルワーカーフォルダを選択してください。",
"app.migration.unsupported": "このOpenCodeバイナリは `opencode db migrate` をサポートしていません。OpenCodeを>=1.2.6にアップデートするか、バンドルされたエンジンに切り替えてください。",
"app.migration.unsupported": "このOpenCodeバイナリは `opencode db migrate` をサポートしていません。OpenWorkで固定しているOpenCodeバージョンへ更新するか、バンドルされたエンジンに切り替えてください。",
"app.migration.failed": "OpenCodeのマイグレーションに失敗しました。",
"app.migration.restart_failed": "マイグレーションは完了しましたが、OpenWorkがローカルエンジンを再起動できませんでした。",
"app.migration.success": "マイグレーションが修復されました。ローカル起動を再試行しました。",

View File

@@ -829,7 +829,7 @@ export default {
"app.migration.desktop_required": "Sửa di chuyển dữ liệu yêu cầu ứng dụng desktop.",
"app.migration.local_only": "Sửa di chuyển dữ liệu chỉ khả dụng cho worker nội bộ.",
"app.migration.workspace_required": "Chọn thư mục worker nội bộ trước khi sửa di chuyển dữ liệu.",
"app.migration.unsupported": "Bản OpenCode này không hỗ trợ `opencode db migrate`. Cập nhật lên >=1.2.6 hoặc chuyển sang engine đi kèm.",
"app.migration.unsupported": "Bản OpenCode này không hỗ trợ `opencode db migrate`. Hãy cập nhật lên phiên bản OpenCode được OpenWork ghim cố định hoặc chuyển sang engine đi kèm.",
"app.migration.failed": "Di chuyển dữ liệu OpenCode thất bại.",
"app.migration.restart_failed": "Di chuyển dữ liệu hoàn tất, nhưng OpenWork không thể khởi động lại engine nội bộ.",
"app.migration.success": "Đã sửa di chuyển dữ liệu. Đã thử khởi động lại nội bộ.",

View File

@@ -789,7 +789,7 @@ export default {
"app.migration.desktop_required": "迁移修复需要桌面应用。",
"app.migration.local_only": "迁移修复仅适用于本地工作区。",
"app.migration.workspace_required": "请先选择本地工作区文件夹再修复迁移。",
"app.migration.unsupported": "当前 OpenCode 二进制不支持 `opencode db migrate`。请将 OpenCode 更新到 >=1.2.6,或切换为内置引擎。",
"app.migration.unsupported": "当前 OpenCode 二进制不支持 `opencode db migrate`。请更新到 OpenWork 固定的 OpenCode 版本,或切换为内置引擎。",
"app.migration.failed": "OpenCode 迁移失败。",
"app.migration.restart_failed": "迁移已完成,但 OpenWork 无法重启本地引擎。",
"app.migration.success": "迁移已修复,已重试本地启动。",

View File

@@ -2,7 +2,6 @@
"name": "@openwork/desktop",
"private": true,
"version": "0.11.175",
"opencodeVersion": "1.2.26",
"opencodeRouterVersion": "0.11.175",
"type": "module",
"scripts": {

View File

@@ -33,6 +33,7 @@ const forceBuild = hasFlag("--force") || process.env.OPENWORK_SIDECAR_FORCE_BUIL
const sidecarOverride = process.env.OPENWORK_SIDECAR_DIR?.trim() || readArg("--outdir");
const sidecarDir = sidecarOverride ? resolve(sidecarOverride) : join(__dirname, "..", "src-tauri", "sidecars");
const packageJsonPath = resolve(__dirname, "..", "package.json");
const constantsPath = resolve(__dirname, "..", "..", "..", "constants.json");
const opencodeGithubRepo = (() => {
const raw =
@@ -49,15 +50,13 @@ const opencodeGithubRepo = (() => {
return normalized;
})();
const opencodeVersion = (() => {
if (process.env.OPENCODE_VERSION?.trim()) return process.env.OPENCODE_VERSION.trim();
try {
const raw = readFileSync(packageJsonPath, "utf8");
const pkg = JSON.parse(raw);
if (pkg.opencodeVersion) return String(pkg.opencodeVersion).trim();
const raw = readFileSync(constantsPath, "utf8");
const parsed = JSON.parse(raw);
return typeof parsed.opencodeVersion === "string" ? parsed.opencodeVersion.trim() || null : null;
} catch {
// ignore
return null;
}
return null;
})();
const normalizeVersion = (value) => {
@@ -67,29 +66,6 @@ const normalizeVersion = (value) => {
return raw.startsWith("v") ? raw.slice(1) : raw;
};
const fetchLatestOpencodeVersion = async () => {
// Use GitHub API (no auth required). If this fails, the caller can fall back
// to an explicitly configured version via OPENCODE_VERSION.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(`https://api.github.com/repos/${opencodeGithubRepo}/releases/latest`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal: controller.signal,
});
if (!response.ok) return null;
const data = await response.json();
const tagName = typeof data?.tag_name === "string" ? data.tag_name : "";
return normalizeVersion(tagName);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
};
const opencodeAssetOverride = process.env.OPENCODE_ASSET?.trim() || null;
const opencodeRouterVersion = (() => {
if (process.env.OPENCODE_ROUTER_VERSION?.trim()) return process.env.OPENCODE_ROUTER_VERSION.trim();
@@ -389,22 +365,11 @@ if (!existingOpencodeVersion && opencodeCandidatePath) {
: null;
}
// Prefer an explicitly pinned version. Otherwise, follow latest.
const pinnedOpencodeVersion = normalizeVersion(opencodeVersion);
let normalizedOpencodeVersion = pinnedOpencodeVersion;
if (!normalizedOpencodeVersion) {
normalizedOpencodeVersion = await fetchLatestOpencodeVersion();
}
// If GitHub is unreachable, fall back to whatever we already have.
if (!normalizedOpencodeVersion && existingOpencodeVersion) {
normalizedOpencodeVersion = normalizeVersion(existingOpencodeVersion);
}
const normalizedOpencodeVersion = normalizeVersion(opencodeVersion);
if (!normalizedOpencodeVersion) {
console.error(
"OpenCode version could not be resolved. Set OPENCODE_VERSION to pin a version, or ensure GitHub is reachable to use latest."
`OpenCode version could not be resolved from ${constantsPath}.`
);
process.exit(1);
}

View File

@@ -63,6 +63,25 @@ fn openwork_dev_mode_enabled() -> bool {
env_truthy("OPENWORK_DEV_MODE").unwrap_or(cfg!(debug_assertions))
}
fn pinned_opencode_version() -> String {
let constants = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../../constants.json"));
let parsed: serde_json::Value =
serde_json::from_str(constants).expect("constants.json must be valid JSON");
parsed["opencodeVersion"]
.as_str()
.expect("constants.json must include opencodeVersion")
.trim()
.trim_start_matches('v')
.to_string()
}
fn pinned_opencode_install_command() -> String {
format!(
"curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
pinned_opencode_version()
)
}
#[derive(Default)]
struct OutputState {
stdout: String,
@@ -250,7 +269,7 @@ pub fn engine_install() -> Result<ExecResult, String> {
ok: false,
status: -1,
stdout: String::new(),
stderr: "Guided install is not supported on Windows yet. Install OpenCode via Scoop/Chocolatey or https://opencode.ai/install, then restart OpenWork.".to_string(),
stderr: "Guided install is not supported on Windows yet. Install the OpenWork-pinned OpenCode version manually, then restart OpenWork.".to_string(),
});
}
@@ -263,7 +282,7 @@ pub fn engine_install() -> Result<ExecResult, String> {
let output = std::process::Command::new("bash")
.arg("-lc")
.arg("curl -fsSL https://opencode.ai/install | bash")
.arg(pinned_opencode_install_command())
.env("OPENCODE_INSTALL_DIR", install_dir)
.output()
.map_err(|e| format!("Failed to run installer: {e}"))?;
@@ -363,8 +382,9 @@ pub fn engine_start(
);
let Some(program) = program else {
let notes_text = notes.join("\n");
let install_command = pinned_opencode_install_command();
return Err(format!(
"OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}"
"OpenCode CLI not found.\n\nInstall with:\n- {install_command}\n\nNotes:\n{notes_text}"
));
};

View File

@@ -15,6 +15,24 @@ use crate::types::{ExecResult, WorkspaceOpenworkConfig};
use crate::workspace::state::load_workspace_state;
use tauri::{AppHandle, Manager, State};
fn pinned_opencode_install_command() -> String {
let constants = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../constants.json"
));
let parsed: serde_json::Value =
serde_json::from_str(constants).expect("constants.json must be valid JSON");
let version = parsed["opencodeVersion"]
.as_str()
.expect("constants.json must include opencodeVersion")
.trim()
.trim_start_matches('v');
format!(
"curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
version
)
}
#[derive(serde::Serialize)]
pub struct CacheResetResult {
pub removed: Vec<String>,
@@ -244,8 +262,9 @@ fn resolve_opencode_program(
program.ok_or_else(|| {
let notes_text = notes.join("\n");
let install_command = pinned_opencode_install_command();
format!(
"OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}"
"OpenCode CLI not found.\n\nInstall with:\n- {install_command}\n\nNotes:\n{notes_text}"
)
})
}

View File

@@ -57,7 +57,7 @@ Skills add new capabilities. Plugins add advanced features like scheduling or br
OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here.
Most reliable setup today:
1) Install OpenCode from opencode.ai
1) Install the OpenWork-pinned OpenCode version
2) Configure providers there (models and API keys)
3) Come back to OpenWork and start a session

View File

@@ -1,7 +1,6 @@
{
"name": "openwork-orchestrator",
"version": "0.11.175",
"opencodeVersion": "1.2.26",
"description": "OpenWork host orchestrator for opencode + OpenWork server + opencode-router",
"private": true,
"type": "module",

View File

@@ -106,6 +106,21 @@ async function buildOnce(entrypoint: string, outdir: string, filename: string, t
} catch {
// ignore
}
try {
const constants = JSON.parse(
readFileSync(resolve("..", "..", "constants.json"), "utf8"),
) as { opencodeVersion?: string };
if (
typeof constants.opencodeVersion === "string" &&
constants.opencodeVersion.trim()
) {
define.__OPENWORK_PINNED_OPENCODE_VERSION__ = `\"${constants.opencodeVersion
.trim()
.replace(/^v/, "")}\"`;
}
} catch {
// ignore
}
const resolvedTarget = target ?? defaultTarget();
const result = await bun.build({

View File

@@ -17,6 +17,10 @@ if (!version) {
const outroot = join(root, "dist", "npm")
rmSync(outroot, { recursive: true, force: true })
mkdirSync(outroot, { recursive: true })
const constantsSrc = resolve(root, "..", "..", "constants.json")
if (!existsSync(constantsSrc)) {
throw new Error(`Missing constants.json at ${constantsSrc}`)
}
const tag = String(process.env.NPM_TAG || "").trim()
const dry = String(process.env.DRY_RUN || "").trim() === "1"
@@ -101,6 +105,7 @@ if (!existsSync(postinstallSrc)) {
throw new Error(`Missing postinstall at ${postinstallSrc}`)
}
copyFileSync(postinstallSrc, join(meta, basename(postinstallSrc)))
copyFileSync(constantsSrc, join(meta, "constants.json"))
writeJson(join(meta, "package.json"), {
name: "openwork-orchestrator",
@@ -115,7 +120,7 @@ writeJson(join(meta, "package.json"), {
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
optionalDependencies,
files: ["bin", "postinstall.mjs"],
files: ["bin", "postinstall.mjs", "constants.json"],
})
published.push({ name: "openwork-orchestrator", dir: meta })

View File

@@ -127,6 +127,7 @@ type OpenCodeRouterHealthSnapshot = {
const FALLBACK_VERSION = "0.1.0";
declare const __OPENWORK_ORCHESTRATOR_VERSION__: string | undefined;
declare const __OPENWORK_PINNED_OPENCODE_VERSION__: string | undefined;
const DEFAULT_OPENWORK_PORT = 8787;
const DEFAULT_APPROVAL_TIMEOUT = 30000;
const DEFAULT_OPENCODE_USERNAME = "opencode";
@@ -960,19 +961,31 @@ async function resolveCliVersion(): Promise<string> {
return FALLBACK_VERSION;
}
async function readPackageField(field: string): Promise<string | undefined> {
async function readPinnedOpencodeVersion(): Promise<string | undefined> {
if (
typeof __OPENWORK_PINNED_OPENCODE_VERSION__ === "string" &&
__OPENWORK_PINNED_OPENCODE_VERSION__.trim()
) {
return __OPENWORK_PINNED_OPENCODE_VERSION__.trim();
}
const candidates = [
join(dirname(process.execPath), "..", "package.json"),
join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
join(dirname(process.execPath), "..", "constants.json"),
join(dirname(fileURLToPath(import.meta.url)), "..", "constants.json"),
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "constants.json"),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) {
try {
const raw = await readFile(candidate, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const value = parsed[field];
if (typeof value === "string" && value.trim()) return value.trim();
const parsed = JSON.parse(raw) as { opencodeVersion?: unknown };
const value =
typeof parsed.opencodeVersion === "string"
? parsed.opencodeVersion.trim()
: "";
if (!value) continue;
return value.startsWith("v") ? value.slice(1) : value;
} catch {
// ignore
}
@@ -1416,7 +1429,6 @@ const remoteManifestCache = new Map<
Promise<RemoteSidecarManifest | null>
>();
let latestOpencodeVersionTask: Promise<string | undefined> | null = null;
let cachedExtraPathEntries: string[] | null = null;
function isDirectory(path: string): boolean {
@@ -1713,37 +1725,6 @@ async function fetchRemoteManifest(
return task;
}
async function resolveLatestOpencodeVersion(): Promise<string | undefined> {
if (latestOpencodeVersionTask) return latestOpencodeVersionTask;
latestOpencodeVersionTask = (async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(
"https://api.github.com/repos/anomalyco/opencode/releases/latest",
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal: controller.signal,
},
);
if (!response.ok) return undefined;
const data = (await response.json()) as { tag_name?: unknown };
const tag = typeof data.tag_name === "string" ? data.tag_name.trim() : "";
if (!tag) return undefined;
const normalized = tag.startsWith("v") ? tag.slice(1) : tag;
return normalized || undefined;
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
})();
return latestOpencodeVersionTask;
}
function resolveAssetUrl(
baseUrl: string,
asset?: string,
@@ -1792,12 +1773,16 @@ async function ensureExecutable(path: string): Promise<void> {
async function downloadSidecarBinary(options: {
name: SidecarName;
sidecar: SidecarConfig;
expectedVersion?: string;
}): Promise<ResolvedBinary | null> {
if (!options.sidecar.target) return null;
const manifest = await fetchRemoteManifest(options.sidecar.manifestUrl);
if (!manifest) return null;
const entry = manifest.entries[options.name];
if (!entry) return null;
if (options.expectedVersion && entry.version !== options.expectedVersion) {
return null;
}
const targetInfo = entry.targets[options.sidecar.target];
if (!targetInfo) return null;
@@ -2054,8 +2039,10 @@ async function resolveExpectedVersion(
manifest: VersionManifest | null,
name: SidecarName,
): Promise<string | undefined> {
const manifestVersion = manifest?.entries[name]?.version;
if (manifestVersion) return manifestVersion;
if (name !== "opencode") {
const manifestVersion = manifest?.entries[name]?.version;
if (manifestVersion) return manifestVersion;
}
try {
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -2073,16 +2060,8 @@ async function resolveExpectedVersion(
if (localVersion) return localVersion;
}
if (name === "opencode") {
const envVersion = process.env.OPENCODE_VERSION?.trim();
if (envVersion && envVersion.toLowerCase() !== "latest") {
return envVersion.startsWith("v") ? envVersion.slice(1) : envVersion;
}
const pkgVersion = await readPackageField("opencodeVersion");
if (pkgVersion && pkgVersion.toLowerCase() !== "latest") {
return pkgVersion.startsWith("v") ? pkgVersion.slice(1) : pkgVersion;
}
const latest = await resolveLatestOpencodeVersion();
if (latest) return latest;
const pinnedVersion = await readPinnedOpencodeVersion();
if (pinnedVersion) return pinnedVersion;
}
} catch {
// ignore
@@ -2450,6 +2429,7 @@ async function resolveOpencodeBin(options: {
const downloaded = await downloadSidecarBinary({
name: "opencode",
sidecar: options.sidecar,
expectedVersion,
});
if (downloaded) return downloaded;
const opencodeDownloaded = await resolveOpencodeDownload(
@@ -2460,7 +2440,7 @@ async function resolveOpencodeBin(options: {
return { bin: opencodeDownloaded, source: "downloaded", expectedVersion };
}
throw new Error(
"opencode download failed. Check sidecar manifest/network access, or set OPENCODE_VERSION to pin a version.",
"opencode download failed. Check sidecar manifest/network access, or update constants.json.",
);
}
@@ -2480,6 +2460,7 @@ async function resolveOpencodeBin(options: {
const downloaded = await downloadSidecarBinary({
name: "opencode",
sidecar: options.sidecar,
expectedVersion,
});
if (downloaded) return downloaded;

3
constants.json Normal file
View File

@@ -0,0 +1,3 @@
{
"opencodeVersion": "v1.2.27"
}

View File

@@ -49,7 +49,7 @@ The script prints the exact URLs and `docker compose ... down` command to use fo
- `RENDER_WORKER_ROOT_DIR` render `rootDir` for worker services
- `RENDER_WORKER_PLAN` Render plan for worker services
- `RENDER_WORKER_REGION` Render region for worker services
- `RENDER_WORKER_OPENWORK_VERSION` `openwork-orchestrator` npm version installed in workers; the worker build uses its `opencodeVersion` metadata to bundle a matching `opencode` binary into the Render deploy
- `RENDER_WORKER_OPENWORK_VERSION` `openwork-orchestrator` npm version installed in workers; the worker build reads the pinned OpenCode version from `constants.json` shipped with that package and bundles the matching `opencode` binary into the Render deploy
- `RENDER_WORKER_NAME_PREFIX` service name prefix
- `RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX` optional domain suffix for worker custom URLs (e.g. `openwork.studio` -> `<worker-id>.openwork.studio`)
- `RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS` max time to wait for vanity URL health before falling back to Render URL
@@ -125,7 +125,7 @@ Useful optional overrides:
- `DAYTONA_SNAPSHOT_MEMORY`
- `DAYTONA_SNAPSHOT_DISK`
- `OPENWORK_ORCHESTRATOR_VERSION`
- `OPENCODE_VERSION`
- OpenCode is pinned by `constants.json`
After the snapshot is pushed, set it in `.env.daytona`:

View File

@@ -1,7 +1,7 @@
FROM node:22-bookworm-slim
ARG OPENWORK_ORCHESTRATOR_VERSION=0.11.151
ARG OPENCODE_VERSION=1.2.6
ARG OPENCODE_VERSION
ARG OPENCODE_DOWNLOAD_URL=
RUN apt-get update \
@@ -11,6 +11,7 @@ RUN apt-get update \
RUN npm install -g "openwork-orchestrator@${OPENWORK_ORCHESTRATOR_VERSION}"
RUN set -eux; \
test -n "$OPENCODE_VERSION"; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) asset="opencode-linux-x64-baseline.tar.gz" ;; \

View File

@@ -2,6 +2,6 @@
Render worker services use this directory as `rootDir`.
The control plane installs `openwork-orchestrator`, runs `scripts/install-opencode.mjs` during the Render build, and then launches workers with the `openwork` command.
The control plane installs `openwork-orchestrator`, reads the pinned OpenCode version from `constants.json` packaged with it, runs `scripts/install-opencode.mjs` during the Render build, and then launches workers with the `openwork` command.
That extra build step vendors the matching `opencode` release asset into `./bin/opencode` so the runtime does not depend on a first-boot GitHub download.

View File

@@ -51,14 +51,18 @@ function resolveOrchestratorPackageJson() {
}
function resolveOpencodeVersion() {
const explicit = process.env.OPENCODE_VERSION?.trim();
if (explicit) {
return explicit;
const orchestratorPackageJson = resolveOrchestratorPackageJson();
const orchestratorRoot = resolve(orchestratorPackageJson, "..");
const versionPath = resolve(orchestratorRoot, "constants.json");
if (!existsSync(versionPath)) {
throw new Error(`Missing pinned constants file at ${versionPath}`);
}
const orchestratorPkg = readJson(resolveOrchestratorPackageJson());
const version = String(orchestratorPkg.opencodeVersion ?? "").trim();
return version || null;
const parsed = JSON.parse(readFileSync(versionPath, "utf8"));
const version = String(parsed.opencodeVersion ?? "").trim().replace(/^v/, "");
if (!version) {
throw new Error(`Pinned OpenCode version is missing from ${versionPath}`);
}
return version;
}
function resolveAssetName() {
@@ -150,7 +154,7 @@ function findBinary(searchRoot) {
}
const version = resolveOpencodeVersion();
const versionLabel = version ?? "latest";
const versionLabel = version;
if (
existsSync(outputPath) &&
@@ -165,7 +169,10 @@ const assetName = resolveAssetName();
const downloadUrl = process.env.OPENWORK_OPENCODE_DOWNLOAD_URL?.trim()
|| (version
? `https://github.com/anomalyco/opencode/releases/download/v${version}/${assetName}`
: `https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`);
: null);
if (!downloadUrl) {
throw new Error("Pinned OpenCode version is required to bundle the worker runtime");
}
const tempDir = mkdtempSync(join(tmpdir(), "den-worker-opencode-"));
const archivePath = join(tempDir, assetName);
const extractDir = join(tempDir, "extract");

View File

@@ -35,7 +35,7 @@ SNAPSHOT_DISK="${DAYTONA_SNAPSHOT_DISK:-8}"
LOCAL_IMAGE_TAG="${DAYTONA_LOCAL_IMAGE_TAG:-openwork-daytona-snapshot:${SNAPSHOT_NAME//[^a-zA-Z0-9_.-]/-}}"
OPENWORK_ORCHESTRATOR_VERSION="${OPENWORK_ORCHESTRATOR_VERSION:-$(node -e 'const fs=require("fs"); const pkg=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(pkg.version));' "$ROOT_DIR/apps/orchestrator/package.json")}"
OPENCODE_VERSION="${OPENCODE_VERSION:-$(node -e 'const fs=require("fs"); const pkg=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(pkg.opencodeVersion));' "$ROOT_DIR/apps/orchestrator/package.json")}"
OPENCODE_VERSION="$(node -e 'const fs=require("fs"); const parsed=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(parsed.opencodeVersion || "").trim().replace(/^v/, ""));' "$ROOT_DIR/constants.json")"
echo "Building local image $LOCAL_IMAGE_TAG" >&2
echo "- openwork-orchestrator@$OPENWORK_ORCHESTRATOR_VERSION" >&2

View File

@@ -21,6 +21,11 @@ const desktopPkg = readJson(resolve(root, "apps", "desktop", "package.json"));
const orchestratorPkg = readJson(
resolve(root, "apps", "orchestrator", "package.json"),
);
const pinnedOpencodeVersion = String(
readJson(resolve(root, "constants.json")).opencodeVersion ?? "",
)
.trim()
.replace(/^v/, "");
const serverPkg = readJson(resolve(root, "apps", "server", "package.json"));
const opencodeRouterPkg = readJson(
resolve(root, "apps", "opencode-router", "package.json"),
@@ -40,10 +45,7 @@ const versions = {
server: serverPkg.version ?? null,
orchestrator: orchestratorPkg.version ?? null,
opencodeRouter: opencodeRouterPkg.version ?? null,
opencode: {
desktop: desktopPkg.opencodeVersion ?? null,
orchestrator: orchestratorPkg.opencodeVersion ?? null,
},
opencode: pinnedOpencodeVersion || null,
opencodeRouterVersionPinned: desktopPkg.opencodeRouterVersion ?? null,
orchestratorOpenworkServerRange:
orchestratorPkg.dependencies?.["openwork-server"] ?? null,
@@ -101,17 +103,15 @@ addCheck(
versions.opencodeRouter === versions.opencodeRouterVersionPinned,
`${versions.opencodeRouterVersionPinned ?? "?"} vs ${versions.opencodeRouter ?? "?"}`,
);
if (versions.opencode.desktop || versions.opencode.orchestrator) {
if (versions.opencode) {
addCheck(
"OpenCode version matches (desktop/orchestrator)",
versions.opencode.desktop &&
versions.opencode.orchestrator &&
versions.opencode.desktop === versions.opencode.orchestrator,
`${versions.opencode.desktop ?? "?"} vs ${versions.opencode.orchestrator ?? "?"}`,
"OpenCode version pin exists",
Boolean(versions.opencode),
String(versions.opencode),
);
} else {
addWarning(
"OpenCode version is not pinned (apps/desktop + apps/orchestrator). Sidecar bundling will default to the latest OpenCode release at build time.",
"OpenCode version is not pinned in constants.json.",
);
}