Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ jobs:
OPENCODE_CHANNEL: latest
OPENCODE_VERSION: ${{ inputs.version || '' }}

# Bundle ripgrep into each dist/opencode-*/bin so air-gapped users do not
# hit the runtime download in packages/opencode/src/file/ripgrep.ts.
- name: Prefetch ripgrep
run: bun run ./packages/opencode/script/prefetch-ripgrep.ts

- name: Package artifacts
working-directory: packages/opencode/dist
run: |
Expand Down
101 changes: 101 additions & 0 deletions docs/air-gapped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Air-gapped / 内网部署指南

本 fork 的 release 包已为内网/无外网环境做了开箱即用准备。
关键策略:把构建期可拿到的东西打包进二进制,把运行期会触发外网的开关交给配置。

## release 已经替你做了什么

1. **models.dev 快照已内嵌**
`script/generate.ts` 在 build 时拉一次 `https://models.dev/api.json`,
写到 `src/provider/models-snapshot.js`,编译到二进制内。
运行时 `src/provider/models.ts` 会优先使用网络版本,拉取失败时回退到 snapshot。

2. **ripgrep 二进制已并排打包**
`script/prefetch-ripgrep.ts` 在 CI 里把对应平台的 `rg`(Windows 上是 `rg.exe`)
放进 `dist/opencode-<plat>/bin/`,与 `opencode` 同目录。
运行时 `src/file/ripgrep.ts` 第一步 `which("rg")` 即命中,跳过 GitHub 下载。

## 部署清单

### 1. 解压并把 bin 加入 PATH

```bash
tar -xzf opencode-linux-x64.tar.gz -C /opt/opencode
export PATH="/opt/opencode:$PATH" # bin 内同时有 opencode 和 rg
```

Windows:解压 `opencode-windows-x64.zip` 后把目录加到系统 PATH。

> ⚠️ 不要单独拷贝 `opencode` 而把 `rg` 丢掉。`which("rg")` 找不到时
> `Global.Path.bin/rg` 也找不到,就会发起 GitHub 下载并失败。

### 2. opencode.json 关闭主动外网

放在工程根目录或 `~/.config/opencode/config.json`:

```jsonc
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false, // 关闭升级检查(installation 自动更新)
"share": "disabled" // 关闭 share,避免连 opncd.ai / app.opencode.ai
}
```

`autoupdate` 也可设为 `"notify"`,只提示不下载。

### 3. 环境变量(按需)

| 变量 | 作用 |
|---|---|
| `OPENCODE_DISABLE_MODELS_FETCH=1` | 禁用运行时每小时拉取 models.dev,强制使用内嵌 snapshot |
| `OPENCODE_DISABLE_AUTOUPDATE=1` | 等价于 `autoupdate: false` |
| `OPENCODE_DISABLE_LSP_DOWNLOAD=1` | 禁用 LSP server 按需下载(你本机应已手动装好 LSP) |
| `OPENCODE_MODELS_URL=https://内网网关/models` | 把 models.dev 指向内网镜像(覆盖 snapshot fallback) |
| `OPENCODE_MODELS_PATH=/path/to/api.json` | 直接读本地 api.json |

POSIX 系统建议在 `/etc/profile.d/opencode.sh` 或部署脚本里统一注入。

### 4. AI Provider 走内网网关

所有需要联网的 provider(OpenAI/Anthropic/Bedrock 等)在 opencode.json
的 `provider.<id>.options.baseURL` 中显式指向内网网关。详见
[官方 provider 配置](https://opencode.ai/docs/providers)。

未在配置里出现的 provider 不会被加载,因此不会产生连接。

## 还会发起外连的场景(请按业务判断)

- **Copilot / Codex OAuth**:登录流程仍要访问微软/OpenAI 域名。内网环境一般用 API key 而非 OAuth,关掉即可。
- **MCP web search 等用户自定义 MCP**:完全取决于你启用的 MCP,与本 fork 无关。
- **AI 推理调用**:本身就是 provider 网关流量,不在"无网络"讨论范围。

## 校验是否真的不外连

部署后跑一遍:

```bash
# 在断开外网的机器上
opencode --version
opencode run "hello" # 触发 ripgrep 索引、models.dev 加载、provider 推理
```

如果任何一步阻塞超过 5 秒并报 `fetch failed` / `ENOTFOUND`,就是上面某项没配齐。
建议在测试机抓一次 `strace -f -e trace=connect`(Linux)或 Wireshark
确认实际出站连接清单与上文一致。

## 重新自打 release

如果需要为非官方平台或私有版本号重打:

```bash
# 在有外网的机器
cd packages/opencode
bun run script/build.ts --single --skip-install # 生成 dist/opencode-<plat>/
bun run script/prefetch-ripgrep.ts # 注入 rg
# 然后参照 .github/workflows/fork-release.yml 的 Package artifacts 步骤打包
```

`prefetch-ripgrep.ts` 支持的参数:

- `--only <dirName>`:只处理某一个 `dist/opencode-*` 目录
- `--version 15.1.0`:覆盖 ripgrep 版本(默认与 `src/file/ripgrep.ts` 中的常量一致)
157 changes: 157 additions & 0 deletions packages/opencode/script/prefetch-ripgrep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bun
//
// Prefetch the ripgrep binary into every dist/opencode-<name>/bin output produced
// by build.ts, so air-gapped users never hit the runtime download in
// packages/opencode/src/file/ripgrep.ts.
//
// Strategy:
// 1. Scan dist/opencode-* directories (created by build.ts).
// 2. Derive the upstream ripgrep PLATFORM key from each directory name.
// Several variants (baseline / musl) share the same rg binary, so we
// cache one download per rg-key under dist/.rg-cache/.
// 3. Extract rg (or rg.exe) into each dist/opencode-<name>/bin/, where
// opencode's which("rg") step picks it up before any network fallback.
//
// Usage:
// bun run script/prefetch-ripgrep.ts # all platforms in dist/
// bun run script/prefetch-ripgrep.ts --only <dir> # one directory only
// bun run script/prefetch-ripgrep.ts --version 15.1.0
//
// Exit codes:
// 0 success / 1 fatal error / 2 no matching dist directories found.

import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const pkgDir = path.resolve(__dirname, "..")

// Keep in sync with packages/opencode/src/file/ripgrep.ts
const DEFAULT_VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const

type RgKey = keyof typeof PLATFORM

const args = process.argv.slice(2)
const versionFlag = args.indexOf("--version")
const version = versionFlag >= 0 ? args[versionFlag + 1] : DEFAULT_VERSION
const onlyFlag = args.indexOf("--only")
const only = onlyFlag >= 0 ? args[onlyFlag + 1] : undefined

const distDir = path.join(pkgDir, "dist")
if (!fs.existsSync(distDir)) {
console.error(`[prefetch-ripgrep] no dist/ directory at ${distDir}; build first.`)
process.exit(2)
}

const dirs = fs
.readdirSync(distDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith("opencode-"))
.map((e) => e.name)
.filter((name) => (only ? name === only : true))

if (dirs.length === 0) {
console.error(`[prefetch-ripgrep] no opencode-* directories under ${distDir}.`)
process.exit(2)
}

const cacheDir = path.join(distDir, ".rg-cache")
fs.mkdirSync(cacheDir, { recursive: true })

/**
* Map a build.ts output directory like "opencode-windows-x64-baseline" to the
* ripgrep PLATFORM key. The `baseline` / `musl` modifiers do not affect rg.
*/
function deriveRgKey(dirName: string): RgKey | undefined {
// dirName = opencode-<os>-<arch>[-modifier...]
const parts = dirName.split("-")
if (parts.length < 3) return undefined
const [, osTok, archTok] = parts
const os = osTok === "windows" ? "win32" : osTok
const arch = archTok
const key = `${arch}-${os}` as RgKey
return key in PLATFORM ? key : undefined
}

async function fetchArchive(rgKey: RgKey): Promise<string> {
const cfg = PLATFORM[rgKey]
const filename = `ripgrep-${version}-${cfg.platform}.${cfg.extension}`
const cached = path.join(cacheDir, filename)
if (fs.existsSync(cached) && fs.statSync(cached).size > 0) {
console.log(`[prefetch-ripgrep] cache hit: ${filename}`)
return cached
}
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
console.log(`[prefetch-ripgrep] downloading ${url}`)
const res = await fetch(url, { redirect: "follow" })
if (!res.ok) throw new Error(`download failed ${res.status} ${res.statusText}: ${url}`)
const buf = new Uint8Array(await res.arrayBuffer())
if (buf.byteLength === 0) throw new Error(`empty archive: ${url}`)
fs.writeFileSync(cached, buf)
return cached
}

async function extractRg(archive: string, rgKey: RgKey, targetBinDir: string): Promise<void> {
const cfg = PLATFORM[rgKey]
const rgName = cfg.extension === "zip" ? "rg.exe" : "rg"
const targetPath = path.join(targetBinDir, rgName)
if (fs.existsSync(targetPath)) {
console.log(`[prefetch-ripgrep] rg already present at ${path.relative(pkgDir, targetPath)}`)
return
}
fs.mkdirSync(targetBinDir, { recursive: true })
// Extract to a sibling temp dir, then copy. Use bsdtar / GNU tar — both handle
// .tar.gz and .zip on Linux/macOS/modern Windows (libarchive-backed `tar`).
const tmp = path.join(cacheDir, `extract-${rgKey}`)
fs.rmSync(tmp, { recursive: true, force: true })
fs.mkdirSync(tmp, { recursive: true })
await $`tar -xf ${archive} -C ${tmp}`.quiet()
// ripgrep archives extract to ripgrep-<version>-<platform>/rg(.exe)
const subdirs = fs.readdirSync(tmp, { withFileTypes: true }).filter((e) => e.isDirectory())
let extractedRg: string | undefined
for (const d of subdirs) {
const candidate = path.join(tmp, d.name, rgName)
if (fs.existsSync(candidate)) {
extractedRg = candidate
break
}
}
if (!extractedRg) {
throw new Error(`rg binary not found inside archive ${archive}`)
}
fs.copyFileSync(extractedRg, targetPath)
if (cfg.extension !== "zip") fs.chmodSync(targetPath, 0o755)
fs.rmSync(tmp, { recursive: true, force: true })
console.log(`[prefetch-ripgrep] wrote ${path.relative(pkgDir, targetPath)}`)
}

let injected = 0
const skipped: string[] = []
for (const dirName of dirs) {
const rgKey = deriveRgKey(dirName)
if (!rgKey) {
skipped.push(`${dirName} (unsupported platform)`)
continue
}
console.log(`[prefetch-ripgrep] ${dirName} -> ${rgKey}`)
const archive = await fetchArchive(rgKey)
const binDir = path.join(distDir, dirName, "bin")
await extractRg(archive, rgKey, binDir)
injected++
}

console.log(`[prefetch-ripgrep] done: ${injected} directory(ies) injected, ${skipped.length} skipped.`)
if (skipped.length) {
for (const s of skipped) console.log(` - skip ${s}`)
}
Loading