Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c718f0f1cb |
@@ -0,0 +1,40 @@
|
||||
name: coderouter CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "coderouter/**"
|
||||
- ".github/workflows/coderouter-*.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "coderouter/**"
|
||||
- ".github/workflows/coderouter-*.yml"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: coderouter
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
|
||||
with:
|
||||
workspaces: coderouter
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo test --all-targets
|
||||
- run: node scripts/check-version.mjs
|
||||
- run: npm pack --dry-run
|
||||
working-directory: coderouter/npm
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: coderouter publish npm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable X.Y.Z release version
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-coderouter
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Validate release tag
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
|
||||
[[ "$(jq -r .version coderouter/npm/package.json)" == "$VERSION" ]]
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
- name: Download verified release packages
|
||||
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
|
||||
with:
|
||||
tag: coderouter-v${{ inputs.version }}
|
||||
fileName: "coderouter-npm-*.tgz"
|
||||
out-file-path: dist
|
||||
- run: npm install -g [email protected]
|
||||
- name: Publish platform packages, then launcher
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for package in dist/coderouter-npm-cli-*.tgz; do
|
||||
npm publish --provenance "$package"
|
||||
done
|
||||
npm publish --provenance "dist/coderouter-npm-launcher.tgz"
|
||||
@@ -0,0 +1,45 @@
|
||||
name: coderouter publish pypi
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable X.Y.Z release version
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi-coderouter
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Validate protected release tag
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
|
||||
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
- name: Download wheels from the signed GitHub release
|
||||
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
|
||||
with:
|
||||
tag: coderouter-v${{ inputs.version }}
|
||||
fileName: "coderouter-*.whl"
|
||||
out-file-path: dist
|
||||
- name: Publish through PyPI Trusted Publishing
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
attestations: true
|
||||
@@ -0,0 +1,141 @@
|
||||
name: coderouter release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "coderouter-v*"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#coderouter-v}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
actual="$(node coderouter/scripts/check-version.mjs)"
|
||||
[[ "$actual" == "$version" ]] || {
|
||||
echo "tag version $version does not match package version $actual" >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14-xlarge
|
||||
rust_target: aarch64-apple-darwin
|
||||
npm_target: darwin-arm64
|
||||
executable: coderouter
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
npm_target: darwin-x64
|
||||
executable: coderouter
|
||||
- runner: ubuntu-latest
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
npm_target: linux-x64
|
||||
executable: coderouter
|
||||
- runner: windows-latest
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
npm_target: win32-x64
|
||||
executable: coderouter.exe
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: coderouter
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
|
||||
with:
|
||||
workspaces: coderouter
|
||||
key: ${{ matrix.rust_target }}
|
||||
- run: cargo test --release --target ${{ matrix.rust_target }}
|
||||
- run: cargo build --release --target ${{ matrix.rust_target }} --bin coderouter
|
||||
- name: Package npm platform binary
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
NPM_TARGET: ${{ matrix.npm_target }}
|
||||
RUST_TARGET: ${{ matrix.rust_target }}
|
||||
EXECUTABLE: ${{ matrix.executable }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/npm
|
||||
node scripts/package-npm.mjs \
|
||||
"$VERSION" "$NPM_TARGET" \
|
||||
"target/$RUST_TARGET/release/$EXECUTABLE" dist/npm
|
||||
npm pack "dist/npm/cli-$NPM_TARGET" --pack-destination dist
|
||||
package="$(find dist -maxdepth 1 -name 'coderouter-cli-*.tgz' -print -quit)"
|
||||
mv "$package" "dist/coderouter-npm-cli-$NPM_TARGET.tgz"
|
||||
- name: Build PyPI wheel
|
||||
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.rust_target }}
|
||||
args: --release --out coderouter/dist --manifest-path coderouter/Cargo.toml
|
||||
manylinux: auto
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: coderouter-${{ matrix.npm_target }}
|
||||
path: |
|
||||
coderouter/dist/coderouter-npm-cli-${{ matrix.npm_target }}.tgz
|
||||
coderouter/dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: coderouter-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
- name: Package npm launcher
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
|
||||
npm pack coderouter/npm --pack-destination dist
|
||||
mv "dist/coderouter-$VERSION.tgz" dist/coderouter-npm-launcher.tgz
|
||||
sha256sum dist/* > dist/SHA256SUMS
|
||||
- name: Create immutable GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "$GITHUB_REF_NAME" dist/* \
|
||||
--verify-tag \
|
||||
--title "CodeRouter ${{ needs.validate.outputs.version }}" \
|
||||
--generate-notes
|
||||
@@ -0,0 +1,5 @@
|
||||
/target/
|
||||
/dist/
|
||||
/dist-test/
|
||||
*.tgz
|
||||
|
||||
Generated
+1851
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "coderouter"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
description = "Run Codex across your CodeRouter subscription pool"
|
||||
repository = "https://github.com/manaflow-ai/cmux"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "coderouter"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "coderouter"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "cr"
|
||||
path = "src/bin/cr.rs"
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
dirs = "6"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-native-roots"] }
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
|
||||
[package.metadata.maturin]
|
||||
bindings = "bin"
|
||||
strip = true
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# CodeRouter CLI
|
||||
|
||||
CodeRouter gives Codex one command for a shared pool of Codex subscriptions.
|
||||
|
||||
```bash
|
||||
cr add
|
||||
cr codex
|
||||
cr naked
|
||||
```
|
||||
|
||||
`coderouter` and `cr` are equivalent executable names. With no command, `cr`
|
||||
behaves like `cr codex` and forwards every argument to Codex.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
cr [codex arguments...] Codex through CodeRouter
|
||||
cr codex [arguments...] Codex through CodeRouter
|
||||
cr naked [arguments...] ordinary Codex, with CodeRouter bypassed
|
||||
cr direct [arguments...] alias for naked
|
||||
cr add interactive Codex subscription setup
|
||||
cr accounts list available subscriptions
|
||||
cr usage show quota state
|
||||
cr doctor diagnose login, vault, and local routing
|
||||
cr login / cr logout manage this machine's Stack Auth session
|
||||
```
|
||||
|
||||
The interactive add flow either opens a fresh official Codex OAuth login in an
|
||||
isolated `CODEX_HOME`, or shows the local import plan and asks for confirmation.
|
||||
The
|
||||
normal `~/.codex/auth.json` is not modified by the new-login flow.
|
||||
|
||||
CodeRouter currently supports Codex subscriptions only.
|
||||
|
||||
## Routing engine
|
||||
|
||||
The CLI uses the open-source Subrouter routing engine. It first honors
|
||||
`CODEROUTER_SUBROUTER_BIN`, then an existing `subrouter` on `PATH`, then installs
|
||||
the pinned release into the user's application-data directory after verifying
|
||||
the release SHA-256 manifest.
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const supported = new Set([
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-x64",
|
||||
"win32-x64",
|
||||
]);
|
||||
const target = `${platform}-${arch}`;
|
||||
if (!supported.has(target)) {
|
||||
console.error(`CodeRouter does not provide a binary for ${target}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageName = `@coderouter/cli-${target}`;
|
||||
let packageJson;
|
||||
try {
|
||||
packageJson = require.resolve(`${packageName}/package.json`);
|
||||
} catch {
|
||||
console.error(
|
||||
`CodeRouter's platform package ${packageName} is missing. ` +
|
||||
"Reinstall coderouter and ensure optional dependencies are enabled.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const executable = path.join(
|
||||
path.dirname(packageJson),
|
||||
"bin",
|
||||
platform === "win32" ? "coderouter.exe" : "coderouter",
|
||||
);
|
||||
const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" });
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "coderouter",
|
||||
"version": "0.1.0",
|
||||
"description": "Run Codex across your CodeRouter subscription pool",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/manaflow-ai/cmux.git",
|
||||
"directory": "coderouter"
|
||||
},
|
||||
"bin": {
|
||||
"coderouter": "bin/coderouter.js",
|
||||
"cr": "bin/coderouter.js"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"@coderouter/cli-darwin-arm64": "0.1.0",
|
||||
"@coderouter/cli-darwin-x64": "0.1.0",
|
||||
"@coderouter/cli-linux-x64": "0.1.0",
|
||||
"@coderouter/cli-win32-x64": "0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.9,<2"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "coderouter"
|
||||
version = "0.1.0"
|
||||
description = "Run Codex across your CodeRouter subscription pool"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
|
||||
[tool.maturin]
|
||||
bindings = "bin"
|
||||
strip = true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
const cargo = fs.readFileSync(new URL("../Cargo.toml", import.meta.url), "utf8");
|
||||
const pyproject = fs.readFileSync(new URL("../pyproject.toml", import.meta.url), "utf8");
|
||||
const npm = JSON.parse(
|
||||
fs.readFileSync(new URL("../npm/package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
const cargoVersion = cargo.match(/^version = "([^"]+)"$/m)?.[1];
|
||||
const pythonVersion = pyproject.match(/^version = "([^"]+)"$/m)?.[1];
|
||||
const versions = new Set([cargoVersion, pythonVersion, npm.version]);
|
||||
if (versions.size !== 1 || versions.has(undefined)) {
|
||||
console.error(
|
||||
`CodeRouter versions differ: cargo=${cargoVersion} python=${pythonVersion} npm=${npm.version}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [name, version] of Object.entries(npm.optionalDependencies ?? {})) {
|
||||
if (version !== npm.version) {
|
||||
console.error(`${name} is ${version}, expected ${npm.version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(npm.version);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [version, target, binary, outputRoot] = process.argv.slice(2);
|
||||
if (!version || !target || !binary || !outputRoot) {
|
||||
console.error("usage: package-npm.mjs <version> <npm-target> <binary> <output-root>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const packageName = `@coderouter/cli-${target}`;
|
||||
const directory = path.join(outputRoot, `cli-${target}`);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(directory, "bin"), { recursive: true });
|
||||
const executableName = target.startsWith("win32-") ? "coderouter.exe" : "coderouter";
|
||||
fs.copyFileSync(binary, path.join(directory, "bin", executableName));
|
||||
if (!target.startsWith("win32-")) {
|
||||
fs.chmodSync(path.join(directory, "bin", executableName), 0o755);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: packageName,
|
||||
version,
|
||||
license: "MIT",
|
||||
os: [target.split("-")[0]],
|
||||
cpu: [target.split("-")[1]],
|
||||
files: ["bin"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::cli::Error;
|
||||
use crate::process;
|
||||
|
||||
const SUBROUTER_VERSION: &str = "0.1.56";
|
||||
const RELEASE_BASE: &str = "https://github.com/manaflow-ai/subrouter/releases/download";
|
||||
|
||||
pub fn resolve() -> Result<PathBuf, Error> {
|
||||
if let Some(explicit) = std::env::var_os("CODEROUTER_SUBROUTER_BIN") {
|
||||
let path = PathBuf::from(explicit);
|
||||
if path.is_file() {
|
||||
return Ok(path);
|
||||
}
|
||||
return Err(Error::Backend(format!(
|
||||
"CODEROUTER_SUBROUTER_BIN does not point to a file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(path) = process::find_on_path("subrouter") {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let managed = managed_binary_path()?;
|
||||
if managed.is_file() {
|
||||
return Ok(managed);
|
||||
}
|
||||
install_managed(&managed)?;
|
||||
Ok(managed)
|
||||
}
|
||||
|
||||
fn install_managed(destination: &Path) -> Result<(), Error> {
|
||||
let asset = release_asset()?;
|
||||
let asset_url = format!("{RELEASE_BASE}/v{SUBROUTER_VERSION}/{asset}");
|
||||
let expected = release_checksum(&asset).ok_or_else(|| {
|
||||
Error::Backend(format!(
|
||||
"CodeRouter has no pinned checksum for Subrouter asset {asset}"
|
||||
))
|
||||
})?;
|
||||
eprintln!("CodeRouter needs its routing engine; installing Subrouter v{SUBROUTER_VERSION}…");
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.user_agent(format!("coderouter/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|error| Error::Backend(error.to_string()))?;
|
||||
let mut response = client
|
||||
.get(asset_url)
|
||||
.send()
|
||||
.and_then(reqwest::blocking::Response::error_for_status)
|
||||
.map_err(|error| Error::Backend(format!("could not download Subrouter: {error}")))?;
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Backend("invalid managed binary path".into()))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let count = response.read(&mut buffer).map_err(|error| {
|
||||
Error::Backend(format!("could not read Subrouter download: {error}"))
|
||||
})?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
temporary.write_all(&buffer[..count])?;
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected {
|
||||
return Err(Error::Backend(format!(
|
||||
"Subrouter checksum mismatch: expected {expected}, received {actual}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
temporary
|
||||
.as_file()
|
||||
.set_permissions(fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
temporary
|
||||
.persist(destination)
|
||||
.map_err(|error| Error::Io(error.error))?;
|
||||
eprintln!("Installed {}", destination.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_binary_path() -> Result<PathBuf, Error> {
|
||||
let data = dirs::data_local_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.ok_or_else(|| Error::Backend("could not determine a user data directory".into()))?;
|
||||
Ok(data.join("coderouter").join("bin").join(if cfg!(windows) {
|
||||
"subrouter.exe"
|
||||
} else {
|
||||
"subrouter"
|
||||
}))
|
||||
}
|
||||
|
||||
fn release_asset() -> Result<String, Error> {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
"linux" => "linux",
|
||||
"windows" => "windows",
|
||||
other => {
|
||||
return Err(Error::Backend(format!(
|
||||
"automatic installation is not supported on {other}; install subrouter manually"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let arch = match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => {
|
||||
return Err(Error::Backend(format!(
|
||||
"automatic installation is not supported on {other}; install subrouter manually"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let suffix = if os == "windows" { ".exe" } else { "" };
|
||||
Ok(format!("subrouter_{SUBROUTER_VERSION}_{os}_{arch}{suffix}"))
|
||||
}
|
||||
|
||||
fn release_checksum(asset: &str) -> Option<&'static str> {
|
||||
match asset {
|
||||
"subrouter_0.1.56_darwin_amd64" => {
|
||||
Some("84e7572b013d3b638bac4353027b075dc9e31a17db550202d721769f0ddcdb42")
|
||||
}
|
||||
"subrouter_0.1.56_darwin_arm64" => {
|
||||
Some("d104bb03476cbcb59fbb207bddaaeef8af7bc5c25ed7c508ffd0506540a41ec4")
|
||||
}
|
||||
"subrouter_0.1.56_linux_amd64" => {
|
||||
Some("19c644dc251b38afd4a0abedb6d4f533f91a3b1f5630821bc431ea807c1a04dc")
|
||||
}
|
||||
"subrouter_0.1.56_windows_amd64.exe" => {
|
||||
Some("b214122e1b4b5311c78722fb05220846336f2126732f2c043507ad22c3033b72")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_hosted_login(binary: &Path) -> Result<i32, Error> {
|
||||
let status = process::output(binary, &["team", "current"])?;
|
||||
if status.status.success() {
|
||||
return Ok(0);
|
||||
}
|
||||
process::run_attached(binary, &[process::os("login")], &[])
|
||||
}
|
||||
|
||||
pub fn ensure_ready(binary: &Path) -> Result<i32, Error> {
|
||||
let login = ensure_hosted_login(binary)?;
|
||||
if login != 0 {
|
||||
return Ok(login);
|
||||
}
|
||||
|
||||
let storage = process::output(binary, &["storage"])?;
|
||||
let hosted = storage.status.success()
|
||||
&& String::from_utf8_lossy(&storage.stdout)
|
||||
.to_ascii_lowercase()
|
||||
.contains("hosted");
|
||||
if !hosted {
|
||||
let selected = process::run_attached(
|
||||
binary,
|
||||
&[process::os("storage"), process::os("hosted")],
|
||||
&[],
|
||||
)?;
|
||||
if selected != 0 {
|
||||
return Ok(selected);
|
||||
}
|
||||
}
|
||||
|
||||
let daemon = process::output(binary, &["daemon", "status"])?;
|
||||
if daemon.status.success() {
|
||||
return Ok(0);
|
||||
}
|
||||
eprintln!("CodeRouter needs one-time local proxy setup.");
|
||||
process::run_attached(binary, &[process::os("setup")], &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_assets_have_pinned_checksums() {
|
||||
let asset = release_asset().expect("supported test platform");
|
||||
assert!(release_checksum(&asset).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_release_checksum() {
|
||||
assert_eq!(release_checksum("unknown"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
std::process::exit(coderouter::run(std::env::args_os()));
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::backend;
|
||||
use crate::process;
|
||||
use crate::tui::{self, AddChoice};
|
||||
|
||||
const HELP: &str = "\
|
||||
CodeRouter — run Codex across your subscription pool
|
||||
|
||||
Usage:
|
||||
cr [codex arguments...] Run Codex through CodeRouter
|
||||
cr codex [arguments...] Run Codex through CodeRouter
|
||||
cr naked [arguments...] Run the real Codex without CodeRouter
|
||||
cr direct [arguments...] Alias for `cr naked`
|
||||
cr add Add a Codex subscription interactively
|
||||
cr add login Sign in to a new Codex subscription
|
||||
cr add import Import local Codex credentials
|
||||
cr login | logout Manage this machine's CodeRouter login
|
||||
cr accounts List shared Codex subscriptions
|
||||
cr usage Show subscription usage
|
||||
cr doctor Diagnose CodeRouter
|
||||
|
||||
The long command name `coderouter` supports the same interface.
|
||||
";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Usage(String),
|
||||
#[error("{0}")]
|
||||
Backend(String),
|
||||
#[error("could not start {executable}: {source}")]
|
||||
Spawn {
|
||||
executable: PathBuf,
|
||||
source: io::Error,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
pub fn run(args: impl IntoIterator<Item = OsString>) -> Result<i32, Error> {
|
||||
let mut args = args.into_iter();
|
||||
let _program = args.next();
|
||||
let remaining: Vec<OsString> = args.collect();
|
||||
let command = remaining.first().and_then(|value| value.to_str());
|
||||
|
||||
match command {
|
||||
Some("-h" | "--help" | "help") => {
|
||||
print!("{HELP}");
|
||||
Ok(0)
|
||||
}
|
||||
Some("-V" | "--version" | "version") => {
|
||||
println!("coderouter {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(0)
|
||||
}
|
||||
Some("naked" | "direct") => run_naked(&remaining[1..]),
|
||||
Some("add") => run_add(&remaining[1..]),
|
||||
Some("login") => run_backend(&["login"], &remaining[1..]),
|
||||
Some("logout") => run_backend(&["logout"], &remaining[1..]),
|
||||
Some("accounts" | "account") => run_backend(&["account", "list"], &remaining[1..]),
|
||||
Some("usage") => run_backend(&["status"], &remaining[1..]),
|
||||
Some("doctor") => run_backend(&["doctor"], &remaining[1..]),
|
||||
Some("codex") => run_routed_codex(&remaining[1..]),
|
||||
Some(value) if value.starts_with('-') => run_routed_codex(&remaining),
|
||||
Some(_) | None => run_routed_codex(&remaining),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_routed_codex(args: &[OsString]) -> Result<i32, Error> {
|
||||
let backend = backend::resolve()?;
|
||||
let setup = backend::ensure_ready(&backend)?;
|
||||
if setup != 0 {
|
||||
return Ok(setup);
|
||||
}
|
||||
let mut backend_args = vec![process::os("codex")];
|
||||
backend_args.extend_from_slice(args);
|
||||
process::run_attached_with_env(
|
||||
&backend,
|
||||
&backend_args,
|
||||
&[],
|
||||
&[("SUBROUTER_CODEX_SERVER", "local")],
|
||||
)
|
||||
}
|
||||
|
||||
fn run_naked(args: &[OsString]) -> Result<i32, Error> {
|
||||
let codex = resolve_real_codex()?;
|
||||
process::run_attached(
|
||||
&codex,
|
||||
args,
|
||||
&[
|
||||
"CODEROUTER_API_URL",
|
||||
"CODEROUTER_SUBROUTER_BIN",
|
||||
"CR_ACCOUNT",
|
||||
"CR_POLICY",
|
||||
"SUBROUTER_CODEX_ACCOUNT_ID",
|
||||
"SUBROUTER_CODEX_BASE_URL",
|
||||
"SUBROUTER_CODEX_SERVER",
|
||||
"SUBROUTER_CODEX_USER_EMAIL",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn run_add(args: &[OsString]) -> Result<i32, Error> {
|
||||
let choice = match args.first().and_then(|arg| arg.to_str()) {
|
||||
None => tui::choose_add_action()?,
|
||||
Some("login" | "new") if args.len() == 1 => AddChoice::NewLogin,
|
||||
Some("import") if args.len() == 1 => AddChoice::ImportLocal,
|
||||
Some("cancel") if args.len() == 1 => AddChoice::Cancel,
|
||||
_ => {
|
||||
return Err(Error::Usage("usage: cr add [login|import]".into()));
|
||||
}
|
||||
};
|
||||
if choice == AddChoice::Cancel {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let backend = backend::resolve()?;
|
||||
let login = backend::ensure_hosted_login(&backend)?;
|
||||
if login != 0 {
|
||||
return Ok(login);
|
||||
}
|
||||
let storage = process::run_attached(
|
||||
&backend,
|
||||
&[process::os("storage"), process::os("hosted")],
|
||||
&[],
|
||||
)?;
|
||||
if storage != 0 {
|
||||
return Ok(storage);
|
||||
}
|
||||
|
||||
let code = match choice {
|
||||
AddChoice::NewLogin => process::run_attached(
|
||||
&backend,
|
||||
&[
|
||||
process::os("account"),
|
||||
process::os("add"),
|
||||
process::os("codex"),
|
||||
],
|
||||
&[],
|
||||
)?,
|
||||
AddChoice::ImportLocal => process::run_attached(
|
||||
&backend,
|
||||
&[
|
||||
process::os("account"),
|
||||
process::os("import"),
|
||||
process::os("--all"),
|
||||
],
|
||||
&[],
|
||||
)?,
|
||||
AddChoice::Cancel => 0,
|
||||
};
|
||||
if code != 0 {
|
||||
return Ok(code);
|
||||
}
|
||||
backend::ensure_ready(&backend)
|
||||
}
|
||||
|
||||
fn run_backend(prefix: &[&str], rest: &[OsString]) -> Result<i32, Error> {
|
||||
let backend = backend::resolve()?;
|
||||
let mut args: Vec<OsString> = prefix.iter().map(process::os).collect();
|
||||
args.extend_from_slice(rest);
|
||||
process::run_attached(&backend, &args, &[])
|
||||
}
|
||||
|
||||
fn resolve_real_codex() -> Result<PathBuf, Error> {
|
||||
let codex = process::find_on_path("codex").ok_or_else(|| {
|
||||
Error::Usage(
|
||||
"Codex is not installed or is not on PATH; install Codex before running `cr naked`"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
if let Ok(current) = std::env::current_exe() {
|
||||
if process::is_same_executable(&codex, ¤t) {
|
||||
return Err(Error::Usage(
|
||||
"`codex` resolves back to CodeRouter; put the real Codex executable on PATH".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(codex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<OsString> {
|
||||
values.iter().map(OsString::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_a_management_command() {
|
||||
assert_eq!(run(args(&["cr", "--help"])).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rejects_unknown_mode_without_starting_backend() {
|
||||
let error = run(args(&["cr", "add", "wat"])).unwrap_err();
|
||||
assert!(error.to_string().contains("usage: cr add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_naked_are_reserved() {
|
||||
assert!(matches!(
|
||||
args(&["cr", "direct"])
|
||||
.get(1)
|
||||
.and_then(|value| value.to_str()),
|
||||
Some("direct")
|
||||
));
|
||||
assert!(matches!(
|
||||
args(&["cr", "naked"])
|
||||
.get(1)
|
||||
.and_then(|value| value.to_str()),
|
||||
Some("naked")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
mod backend;
|
||||
mod cli;
|
||||
mod process;
|
||||
mod tui;
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
pub fn run(args: impl IntoIterator<Item = OsString>) -> i32 {
|
||||
match cli::run(args) {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("cr: {error}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
std::process::exit(coderouter::run(std::env::args_os()));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
|
||||
use crate::cli::Error;
|
||||
|
||||
pub fn find_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|directory| directory.join(executable_name(name)))
|
||||
.find(|candidate| is_executable_file(candidate))
|
||||
}
|
||||
|
||||
pub fn run_attached(
|
||||
executable: &Path,
|
||||
args: &[OsString],
|
||||
removed_env: &[&str],
|
||||
) -> Result<i32, Error> {
|
||||
run_attached_with_env(executable, args, removed_env, &[])
|
||||
}
|
||||
|
||||
pub fn run_attached_with_env(
|
||||
executable: &Path,
|
||||
args: &[OsString],
|
||||
removed_env: &[&str],
|
||||
added_env: &[(&str, &str)],
|
||||
) -> Result<i32, Error> {
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.args(args)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
for key in removed_env {
|
||||
command.env_remove(key);
|
||||
}
|
||||
for (key, value) in added_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
let status = command.status().map_err(|source| Error::Spawn {
|
||||
executable: executable.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(exit_code(status))
|
||||
}
|
||||
|
||||
pub fn output(executable: &Path, args: &[&str]) -> Result<std::process::Output, Error> {
|
||||
Command::new(executable)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|source| Error::Spawn {
|
||||
executable: executable.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_same_executable(left: &Path, right: &Path) -> bool {
|
||||
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
|
||||
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
|
||||
left == right
|
||||
}
|
||||
|
||||
fn exit_code(status: ExitStatus) -> i32 {
|
||||
status.code().unwrap_or(1)
|
||||
}
|
||||
|
||||
fn executable_name(name: &str) -> OsString {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if name.ends_with(".exe") {
|
||||
OsString::from(name)
|
||||
} else {
|
||||
OsString::from(format!("{name}.exe"))
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
OsString::from(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else {
|
||||
return false;
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn os(value: impl AsRef<OsStr>) -> OsString {
|
||||
value.as_ref().to_os_string()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
|
||||
terminal::{self, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
|
||||
use crate::cli::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AddChoice {
|
||||
NewLogin,
|
||||
ImportLocal,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
const ITEMS: &[(AddChoice, &str, &str)] = &[
|
||||
(
|
||||
AddChoice::NewLogin,
|
||||
"Sign in to a new Codex subscription",
|
||||
"Opens the official Codex OAuth flow in an isolated profile.",
|
||||
),
|
||||
(
|
||||
AddChoice::ImportLocal,
|
||||
"Import local Codex credentials",
|
||||
"Reviews and uploads Codex accounts already present on this machine.",
|
||||
),
|
||||
(AddChoice::Cancel, "Cancel", "Make no changes."),
|
||||
];
|
||||
|
||||
pub fn choose_add_action() -> Result<AddChoice, Error> {
|
||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||
return Err(Error::Usage(
|
||||
"`cr add` needs an interactive terminal; use `cr add login` or `cr add import`".into(),
|
||||
));
|
||||
}
|
||||
let mut screen = Screen::enter()?;
|
||||
let mut selected = 0_usize;
|
||||
loop {
|
||||
screen.draw(selected)?;
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
selected = selected.checked_sub(1).unwrap_or(ITEMS.len() - 1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
selected = (selected + 1) % ITEMS.len();
|
||||
}
|
||||
KeyCode::Char('1') => return Ok(AddChoice::NewLogin),
|
||||
KeyCode::Char('2') => return Ok(AddChoice::ImportLocal),
|
||||
KeyCode::Enter => return Ok(ITEMS[selected].0),
|
||||
KeyCode::Esc | KeyCode::Char('q') => return Ok(AddChoice::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Screen {
|
||||
stdout: io::Stdout,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
fn enter() -> Result<Self, Error> {
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
|
||||
Ok(Self { stdout })
|
||||
}
|
||||
|
||||
fn draw(&mut self, selected: usize) -> Result<(), Error> {
|
||||
execute!(
|
||||
self.stdout,
|
||||
cursor::MoveTo(0, 0),
|
||||
terminal::Clear(ClearType::All),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print("Add a Codex subscription\n\n"),
|
||||
SetAttribute(Attribute::Reset),
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print("Credentials are uploaded to your selected CodeRouter team vault.\n"),
|
||||
Print("Your normal ~/.codex/auth.json is never modified.\n\n"),
|
||||
ResetColor
|
||||
)?;
|
||||
for (index, (_, title, description)) in ITEMS.iter().enumerate() {
|
||||
if index == selected {
|
||||
execute!(
|
||||
self.stdout,
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print(format!("› {}. {title}\n", index + 1)),
|
||||
SetAttribute(Attribute::Reset),
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(format!(" {description}\n\n")),
|
||||
ResetColor
|
||||
)?;
|
||||
} else {
|
||||
execute!(
|
||||
self.stdout,
|
||||
Print(format!(" {}. {title}\n", index + 1)),
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(format!(" {description}\n\n")),
|
||||
ResetColor
|
||||
)?;
|
||||
}
|
||||
}
|
||||
execute!(
|
||||
self.stdout,
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print("↑/↓ move enter select esc cancel"),
|
||||
ResetColor
|
||||
)?;
|
||||
self.stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Screen {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(self.stdout, LeaveAlternateScreen, cursor::Show);
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::fs;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn both_binary_names_show_the_same_help() {
|
||||
Command::cargo_bin("cr")
|
||||
.unwrap()
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("cr naked"));
|
||||
Command::cargo_bin("coderouter")
|
||||
.unwrap()
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("cr naked"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn naked_executes_codex_without_coderouter_routing_environment() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let codex = root.path().join("codex");
|
||||
fs::write(
|
||||
&codex,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\"\nprintf 'route=%s\\n' \"${SUBROUTER_CODEX_SERVER-unset}\"\nexit 23\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&codex, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
root.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
Command::cargo_bin("cr")
|
||||
.unwrap()
|
||||
.args(["naked", "exec", "hello"])
|
||||
.env("PATH", path)
|
||||
.env("SUBROUTER_CODEX_SERVER", "cmux")
|
||||
.assert()
|
||||
.code(23)
|
||||
.stdout(
|
||||
predicate::str::contains("exec hello").and(predicate::str::contains("route=unset")),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn codex_command_delegates_to_the_routing_engine() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let backend = root.path().join("subrouter");
|
||||
fs::write(
|
||||
&backend,
|
||||
"#!/bin/sh\n\
|
||||
if [ \"$1 $2\" = 'team current' ]; then exit 0; fi\n\
|
||||
if [ \"$1\" = storage ]; then printf 'hosted\\n'; exit 0; fi\n\
|
||||
if [ \"$1 $2\" = 'daemon status' ]; then exit 0; fi\n\
|
||||
printf '%s\\n' \"$*\"\n\
|
||||
printf 'server=%s\\n' \"${SUBROUTER_CODEX_SERVER-unset}\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&backend, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
Command::cargo_bin("cr")
|
||||
.unwrap()
|
||||
.args(["codex", "exec", "hello"])
|
||||
.env("CODEROUTER_SUBROUTER_BIN", &backend)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(
|
||||
predicate::str::contains("codex exec hello")
|
||||
.and(predicate::str::contains("server=local")),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user