Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fcd004f
docs(spec): Linux headless CLI support design (plan A)
Jun 24, 2026
04af813
docs(spec): adopt CJK-capable monospace font (option B)
Jun 24, 2026
f31d8f1
docs(spec): correct Linux compile-blocker analysis
Jun 24, 2026
a703efe
docs(plan): Linux headless CLI support implementation plan
Jun 24, 2026
9f97968
docs(spec,plan): switch headless font to runtime-path loading
Jun 25, 2026
fbfb9df
chore(core): add vt100/ab_glyph deps for headless terminal renderer
Jun 25, 2026
873bd94
feat(process): un-gate PTY implementation to cfg(unix)
Jun 25, 2026
3840cca
feat(capture): add HeadlessTerminal (vt100 + ab_glyph PNG renderer)
Jun 25, 2026
b523a74
feat(process): mount HeadlessTerminal on PTY sessions
Jun 25, 2026
04e0d72
feat(daemon): route screenshots/scrollback to headless renderer
Jun 25, 2026
2f172b3
feat(cli): pass --headless to daemon and short-circuit Electron wait
Jun 25, 2026
a41bef1
ci: add Linux compile/clippy/test gate (rust-linux)
Jun 25, 2026
de70277
test(e2e): add Linux headless E2E gate
Jun 25, 2026
b7906fe
ci(npm): publish cli-box-linux-x64 and add build-linux release job
Jun 25, 2026
892f958
fix(process): unblock Linux compile — unconditional libc + spawn_app_…
Jun 25, 2026
c5eef82
fix(process): cfg-gate macOS-only app helpers for Linux clippy
Jun 25, 2026
e14cc8c
fix(cli): detach daemon stdio to prevent piped-CLI hangs
Jun 25, 2026
0675709
fix(e2e): extract sandbox id by anchoring on 'Sandbox created: id='
Jun 25, 2026
2e0b942
fix(cli): make Electron discovery macOS-only (Linux goes headless)
Jun 25, 2026
cf20c3f
fix(capture): reset scrollback offset after headless render_png
Jul 19, 2026
852203d
docs(capture): note .ttc loads face 0 via ab_glyph
Jul 19, 2026
fb6e7e0
docs(plan): mark Linux headless tasks complete
Jul 19, 2026
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
81 changes: 80 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -607,10 +607,83 @@ jobs:
run: node --test

# ==================== 门禁结果汇总 ====================
rust-linux:
name: Rust 编译/测试
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CLIBOX_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 安装 Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy

- name: 设置 Rust 缓存
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: "v2-linux"

- name: 安装字体(DejaVu Mono,供 headless 渲染测试)
run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core

- name: cargo check (all crates)
run: cargo check -p cli-box-core -p cli-box-cli -p cli-box-daemon

- name: cargo clippy
run: cargo clippy -p cli-box-core -p cli-box-cli -p cli-box-daemon --all-targets -- -D warnings

- name: cargo test (core, incl. headless renderer)
run: cargo test -p cli-box-core

e2e-linux-headless:
name: Headless E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 12
env:
CLIBOX_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 安装 Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_VERSION }}

- name: 设置 Rust 缓存
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: "v2-linux-e2e"

- name: 安装字体(DejaVu Mono,供 headless 渲染)
run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core

- name: 构建 CLI + daemon (release)
run: cargo build --release -p cli-box-cli -p cli-box-daemon

- name: 置于 PATH
run: |
mkdir -p "$HOME/.local/bin"
ln -sf "$PWD/target/release/cli-box" "$HOME/.local/bin/cli-box"
ln -sf "$PWD/target/release/cli-box-daemon" "$HOME/.local/bin/cli-box-daemon"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: 运行 headless E2E
run: bash tests/e2e-linux-headless.sh

gate-result:
name: 门禁结果
runs-on: ubuntu-latest
needs: [rust-fmt, rust-clippy, rust-test, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test]
needs: [rust-fmt, rust-clippy, rust-test, rust-linux, e2e-linux-headless, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test]
if: always()

steps:
Expand Down Expand Up @@ -647,6 +720,8 @@ jobs:
PUBLISH_SIM="${{ needs.publish-sim.result }}"
UPGRADE_FLOW="${{ needs.skill-upgrade-flow.result }}"
SKILL_UNIT="${{ needs.skill-unit-test.result }}"
RUST_LINUX="${{ needs.rust-linux.result }}"
E2E_LINUX_HEADLESS="${{ needs.e2e-linux-headless.result }}"

echo "| 检查项 | 状态 |"
echo "|-------|------|"
Expand All @@ -660,6 +735,8 @@ jobs:
echo "| 发布模拟验证 | $PUBLISH_SIM |"
echo "| 升级流程测试 | $UPGRADE_FLOW |"
echo "| cli-box-skill 单元测试 | $SKILL_UNIT |"
echo "| Rust 编译/测试 | $RUST_LINUX |"
echo "| Headless E2E | $E2E_LINUX_HEADLESS |"
echo ""

if [ -f coverage-artifacts/rust-coverage-summary.md ]; then
Expand All @@ -677,6 +754,8 @@ jobs:
if [[ "$RUST_FMT" == "success" && \
"$RUST_CLIPPY" == "success" && \
"$RUST_TEST" == "success" && \
"$RUST_LINUX" == "success" && \
"$E2E_LINUX_HEADLESS" == "success" && \
"$FRONTEND_TEST" == "success" && \
"$E2E_TEST" == "success" && \
"$UNIFIED_TEST" == "success" && \
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ jobs:
const files = [
'packages/cli-box-darwin-arm64/package.json',
'packages/cli-box-electron-darwin-arm64/package.json',
'packages/cli-box-linux-x64/package.json',
'packages/cli-box-skill/package.json'
];
for (const file of files) {
Expand Down Expand Up @@ -205,6 +206,62 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/cli-box-darwin-arm64/package.json \
packages/cli-box-electron-darwin-arm64/package.json \
packages/cli-box-linux-x64/package.json \
packages/cli-box-skill/package.json
git diff --cached --quiet || git commit -m "chore(npm): bump package versions to ${GITHUB_REF_NAME#v}"
git push

build-linux:
name: Build Linux (headless)
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }}

- name: Install Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}

- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
key: "v2-linux-release"

- name: Build CLI + daemon (release, headless)
run: cargo build --release -p cli-box-cli -p cli-box-daemon

- name: Collect artifacts
run: |
mkdir -p release
cp target/release/cli-box release/cli-box-linux-x64
cp target/release/cli-box-daemon release/cli-box-daemon-linux-x64
chmod +x release/cli-box-linux-x64 release/cli-box-daemon-linux-x64
cd release && tar czf cli-box-linux-x64.tar.gz cli-box-linux-x64 cli-box-daemon-linux-x64 && cd ..

- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
files: release/cli-box-linux-x64.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Publish npm platform package
if: github.event_name == 'release'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p packages/cli-box-linux-x64/bin
cp target/release/cli-box packages/cli-box-linux-x64/bin/
cp target/release/cli-box-daemon packages/cli-box-linux-x64/bin/
chmod +x packages/cli-box-linux-x64/bin/*
node -e "const fs=require('fs');const f='packages/cli-box-linux-x64/package.json';const p=JSON.parse(fs.readFileSync(f,'utf8'));p.version='$VERSION';fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n');"
npm publish ./packages/cli-box-linux-x64 --access public
66 changes: 63 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion crates/cli-box-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,12 @@ fn find_daemon_binary() -> anyhow::Result<PathBuf> {

/// Locate the Electron app binary next to the current executable.
fn find_electron_binary() -> Option<PathBuf> {
// Electron is a macOS .app bundle; it cannot run on other platforms.
// Returning None on non-macOS drives the headless path (no renderer)
// and skips the macOS-app auto-download.
if cfg!(not(target_os = "macos")) {
return None;
}
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;

Expand Down Expand Up @@ -1841,7 +1847,43 @@ async fn ensure_healthy_daemon() -> anyhow::Result<u16> {
let daemon_bin = find_daemon_binary()?;
tracing::info!("[start] spawning daemon: {}", daemon_bin.display());

let _child = Command::new(&daemon_bin)
// Headless when no Electron app is available (Linux / no GUI).
let mut daemon_cmd = Command::new(&daemon_bin);
if find_electron_binary().is_none() {
daemon_cmd.arg("--headless");
}
// Detach the daemon's stdio: it must NOT inherit the CLI's pipes, otherwise
// it keeps the write-end open and hangs callers like `$(cli-box start | sed)`
// that wait for EOF. Redirect to ~/.cli-box/daemon.log (logs preserved),
// falling back to /dev/null if the log file cannot be opened.
daemon_cmd.stdin(std::process::Stdio::null());
let log_path = dirs::home_dir()
.map(|h| h.join(".cli-box").join("daemon.log"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/cli-box-daemon.log"));
let _ = std::fs::create_dir_all(log_path.parent().unwrap_or(std::path::Path::new("/tmp")));
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(f) => {
let f2 = f.try_clone();
daemon_cmd.stdout(std::process::Stdio::from(f));
match f2 {
Ok(g) => {
daemon_cmd.stderr(std::process::Stdio::from(g));
}
Err(_) => {
daemon_cmd.stderr(std::process::Stdio::null());
}
}
}
Err(_) => {
daemon_cmd.stdout(std::process::Stdio::null());
daemon_cmd.stderr(std::process::Stdio::null());
}
}
let _child = daemon_cmd
.spawn()
.context("Failed to launch cli-box-daemon")?;

Expand Down Expand Up @@ -1877,6 +1919,14 @@ async fn ensure_healthy_daemon() -> anyhow::Result<u16> {
async fn ensure_healthy_electron() {
use std::io::Write;

// Headless: no Electron app present (Linux / cloud). Don't spawn or wait.
if find_electron_binary().is_none() {
eprintln!(
"Running in headless mode (no Electron). Screenshots use the server-side renderer."
);
return;
}

// If existing Electron is alive, just wait for renderer_connected.
// The IPC fix (removed cache) means old Electron auto-discovers new daemon.
let electron_alive = read_electron_json()
Expand Down
4 changes: 3 additions & 1 deletion crates/cli-box-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ futures-util.workspace = true
tower.workspace = true
tower-http.workspace = true
rusqlite.workspace = true
vt100 = "0.16"
ab_glyph = "0.2"
libc = "0.2"

[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = { version = "0.25", features = ["highsierra", "elcapitan"] }
core-foundation = "0.10"
libc = "0.2"
objc = "0.2"
screencapturekit = { version = "2.1", features = ["macos_14_0"], optional = true }
Loading
Loading