feat: add Bun SDK and stdio mode for programmatic VM control

This commit is contained in:
harshdoesdev
2026-03-04 03:03:16 +05:30
parent da99503232
commit 58f19c446f
17 changed files with 895 additions and 4 deletions
+74
View File
@@ -0,0 +1,74 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
sdk-unit:
name: SDK unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
working-directory: packages/sdk
- run: bun test test/unit/
working-directory: packages/sdk
sdk-lint:
name: SDK lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
working-directory: packages/sdk
- run: bunx --bun @biomejs/biome check src/
working-directory: packages/sdk
rust-check:
name: Rust check
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo check -p shuru-cli
sdk-integration:
name: SDK integration tests
if: github.event_name == 'push'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
- uses: oven-sh/setup-bun@v2
- name: Install musl cross-compiler
run: brew install messense/macos-cross-toolchains/aarch64-unknown-linux-musl
- name: Build shuru-guest
run: cargo build -p shuru-guest --target aarch64-unknown-linux-musl --release
- name: Build shuru CLI
run: cargo build -p shuru-cli
- name: Codesign
run: codesign --entitlements shuru.entitlements --force -s - target/debug/shuru
- name: Download assets
run: target/debug/shuru init
- name: Install SDK deps
run: bun install
working-directory: packages/sdk
- name: Run integration tests
run: bun test test/integration/
working-directory: packages/sdk
+30
View File
@@ -0,0 +1,30 @@
name: Publish SDK
on:
release:
types: [published]
jobs:
publish:
name: Publish to npm
runs-on: ubuntu-latest
if: startsWith(github.ref_name, 'sdk-v')
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
working-directory: packages/sdk
- run: bun test test/unit/
working-directory: packages/sdk
- run: bunx --bun @biomejs/biome check src/
working-directory: packages/sdk
- run: npm publish --access public --provenance
working-directory: packages/sdk
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-3
View File
@@ -12,7 +12,6 @@ env:
CARGO_TERM_COLOR: always
jobs:
# ── Build OS image (kernel + initramfs + rootfs) on Linux ───────────
build-os-image:
runs-on: ubuntu-24.04-arm
steps:
@@ -49,7 +48,6 @@ jobs:
path: shuru-os-*.tar.gz
retention-days: 1
# ── Build CLI binary on macOS (Apple Silicon) ───────────────────────
build-cli:
runs-on: macos-14
steps:
@@ -78,7 +76,6 @@ jobs:
path: shuru-*.tar.gz
retention-days: 1
# ── Create GitHub Release ───────────────────────────────────────────
release:
needs: [build-os-image, build-cli]
runs-on: ubuntu-latest
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+6
View File
@@ -88,6 +88,12 @@ struct GithubRelease {
/// Check for a newer release and upgrade the CLI binary + OS image.
pub fn upgrade(data_dir: &str) -> Result<()> {
if let Ok(exe) = std::env::current_exe() {
if exe.to_string_lossy().contains("/Cellar/") {
bail!("This copy was installed via Homebrew. Please run `brew upgrade shuru` instead");
}
}
eprintln!("shuru: checking for updates...");
let api_url = format!(
+4
View File
@@ -65,6 +65,10 @@ pub(crate) enum Commands {
#[arg(long)]
console: bool,
/// Run in stdio mode (JSON-lines protocol over stdin/stdout)
#[arg(long, hide = true)]
stdio: bool,
/// Command and arguments to run inside the VM
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
+5 -1
View File
@@ -2,6 +2,7 @@ mod assets;
mod checkpoint;
mod cli;
mod config;
mod stdio;
mod vm;
use std::process;
@@ -29,6 +30,7 @@ fn main() -> Result<()> {
vm,
from,
console,
stdio,
command,
} => {
let cfg = load_config(vm.config.as_deref())?;
@@ -44,7 +46,9 @@ fn main() -> Result<()> {
let prepared = vm::prepare_vm(&vm, &cfg, from.as_deref())?;
let exit_code = if console {
let exit_code = if stdio {
stdio::run_stdio(&prepared)?
} else if console {
run_console(&prepared)?
} else {
vm::run_command(&prepared, &command)?
+219
View File
@@ -0,0 +1,219 @@
use std::io::{self, BufRead, Write};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use shuru_vm::Sandbox;
use crate::vm::{self, PreparedVm};
#[derive(Deserialize)]
struct Request {
id: String,
#[serde(flatten)]
cmd: Command,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Command {
Exec { argv: Vec<String> },
Checkpoint { name: String },
}
#[derive(Serialize)]
struct ReadyMsg {
r#type: &'static str,
}
#[derive(Serialize)]
struct ExecResponse {
id: String,
r#type: &'static str,
stdout: String,
stderr: String,
exit_code: i32,
}
#[derive(Serialize)]
struct CheckpointResponse {
id: String,
r#type: &'static str,
ok: bool,
}
#[derive(Serialize)]
struct ErrorResponse {
id: String,
r#type: &'static str,
error: String,
}
fn send_json(w: &mut impl Write, value: &impl Serialize) -> Result<()> {
let line = serde_json::to_string(value)?;
writeln!(w, "{}", line)?;
w.flush()?;
Ok(())
}
pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
let mut out = io::stdout().lock();
let sandbox = build_sandbox(prepared)?;
sandbox.start()?;
let _fwd = if !prepared.forwards.is_empty() {
Some(sandbox.start_port_forwarding(&prepared.forwards)?)
} else {
None
};
send_json(&mut out, &ReadyMsg { r#type: "ready" })?;
let stdin = io::stdin().lock();
for line in stdin.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
continue;
}
let req: Request = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let resp = ErrorResponse {
id: String::new(),
r#type: "error",
error: format!("invalid request: {}", e),
};
send_json(&mut out, &resp)?;
continue;
}
};
match req.cmd {
Command::Exec { argv } => {
handle_exec(&sandbox, &req.id, &argv, &mut out)?;
}
Command::Checkpoint { name } => {
handle_checkpoint(&sandbox, prepared, &req.id, &name, &mut out)?;
}
}
}
let _ = sandbox.stop();
Ok(0)
}
fn build_sandbox(prepared: &PreparedVm) -> Result<Sandbox> {
let mut builder = Sandbox::builder()
.kernel(&prepared.kernel_path)
.rootfs(&prepared.work_rootfs)
.cpus(prepared.cpus)
.memory_mb(prepared.memory)
.allow_net(prepared.allow_net)
.console(false);
if let Some(initrd) = &prepared.initrd_path {
builder = builder.initrd(initrd);
}
for m in &prepared.mounts {
builder = builder.mount(m.clone());
}
builder.build()
}
fn handle_exec(
sandbox: &Sandbox,
id: &str,
argv: &[String],
out: &mut impl Write,
) -> Result<()> {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let exit_code = match sandbox.exec(argv, &mut stdout_buf, &mut stderr_buf) {
Ok(code) => code,
Err(e) => {
return send_json(
out,
&ErrorResponse {
id: id.to_string(),
r#type: "error",
error: format!("exec failed: {}", e),
},
);
}
};
send_json(
out,
&ExecResponse {
id: id.to_string(),
r#type: "exec",
stdout: String::from_utf8_lossy(&stdout_buf).into_owned(),
stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
exit_code,
},
)
}
fn handle_checkpoint(
sandbox: &Sandbox,
prepared: &PreparedVm,
id: &str,
name: &str,
out: &mut impl Write,
) -> Result<()> {
let mut discard_out = Vec::new();
let mut discard_err = Vec::new();
if let Err(e) = sandbox.exec(&["sync"], &mut discard_out, &mut discard_err) {
return send_json(
out,
&ErrorResponse {
id: id.to_string(),
r#type: "error",
error: format!("checkpoint sync failed: {}", e),
},
);
}
let data_dir = shuru_vm::default_data_dir();
let checkpoints_dir = format!("{}/checkpoints", data_dir);
let checkpoint_path = format!("{}/{}.ext4", checkpoints_dir, name);
if let Err(e) = std::fs::create_dir_all(&checkpoints_dir) {
return send_json(
out,
&ErrorResponse {
id: id.to_string(),
r#type: "error",
error: format!("failed to create checkpoints dir: {}", e),
},
);
}
if let Err(e) = vm::clone_file(&prepared.work_rootfs, &checkpoint_path) {
return send_json(
out,
&ErrorResponse {
id: id.to_string(),
r#type: "error",
error: format!("checkpoint clone failed: {}", e),
},
);
}
send_json(
out,
&CheckpointResponse {
id: id.to_string(),
r#type: "checkpoint",
ok: true,
},
)
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
bun.lock
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@superhq/shuru",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"files": ["src"],
"scripts": {
"test": "bun test test/unit/",
"test:integration": "bun test test/integration/",
"check": "biome check src/",
"format": "biome check --write src/"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@types/bun": "^1.3.10"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { Sandbox } from "./sandbox";
export type { ExecResult, StartOptions, StdioResponse } from "./types";
+144
View File
@@ -0,0 +1,144 @@
import type { Subprocess } from "bun";
import type { StdioResponse } from "./types";
interface PendingRequest {
resolve: (value: StdioResponse) => void;
reject: (reason: Error) => void;
}
export class ShuruProcess {
private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
private pending = new Map<string, PendingRequest>();
private idCounter = 0;
async start(args: string[]): Promise<void> {
this.proc = Bun.spawn(args, {
stdin: "pipe",
stdout: "pipe",
stderr: "inherit",
});
this.readLoop();
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("shuru: timed out waiting for ready signal (30s)"));
}, 30_000);
this.pending.set("__ready__", {
resolve: () => {
clearTimeout(timeout);
resolve();
},
reject: (err: Error) => {
clearTimeout(timeout);
reject(err);
},
});
});
}
async send(msg: Record<string, unknown>): Promise<StdioResponse> {
if (!this.proc) throw new Error("shuru process not started");
const id = String(++this.idCounter);
const line = `${JSON.stringify({ id, ...msg })}\n`;
const proc = this.proc;
return new Promise<StdioResponse>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
proc.stdin.write(line);
proc.stdin.flush();
});
}
async stop(): Promise<void> {
if (!this.proc) return;
try {
this.proc.stdin.end();
} catch {
// stdin may already be closed
}
const exited = this.proc.exited;
const timeout = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("shuru: shutdown timed out (5s)")),
5_000,
),
);
try {
await Promise.race([exited, timeout]);
} catch {
this.proc.kill();
await this.proc.exited;
}
for (const [, req] of this.pending) {
req.reject(new Error("shuru process stopped"));
}
this.pending.clear();
this.proc = null;
}
private async readLoop(): Promise<void> {
if (!this.proc?.stdout) return;
const reader = this.proc.stdout.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line) continue;
try {
this.dispatch(JSON.parse(line) as StdioResponse);
} catch {
// malformed line
}
}
}
} catch {
// stream closed
}
for (const [, req] of this.pending) {
req.reject(new Error("shuru process exited unexpectedly"));
}
this.pending.clear();
}
private dispatch(msg: StdioResponse): void {
if (msg.type === "ready") {
const req = this.pending.get("__ready__");
if (req) {
this.pending.delete("__ready__");
req.resolve(msg);
}
return;
}
const { id } = msg;
if (!id) return;
const req = this.pending.get(id);
if (!req) return;
this.pending.delete(id);
if (msg.type === "error") {
req.reject(new Error(msg.error));
} else {
req.resolve(msg);
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import { ShuruProcess } from "./process";
import type { ExecResult, StartOptions } from "./types";
export class Sandbox {
private proc: ShuruProcess;
private constructor(proc: ShuruProcess) {
this.proc = proc;
}
static async start(opts: StartOptions = {}): Promise<Sandbox> {
const bin = opts.shuruBin ?? "shuru";
const args = buildArgs(bin, opts);
const proc = new ShuruProcess();
await proc.start(args);
return new Sandbox(proc);
}
async exec(command: string): Promise<ExecResult> {
const resp = await this.proc.send({
type: "exec",
argv: ["sh", "-c", command],
});
if (resp.type !== "exec") {
throw new Error(`unexpected response type: ${resp.type}`);
}
return {
stdout: resp.stdout,
stderr: resp.stderr,
exitCode: resp.exit_code,
};
}
async readFile(path: string): Promise<string> {
const resp = await this.proc.send({
type: "exec",
argv: ["cat", path],
});
if (resp.type !== "exec") {
throw new Error(`unexpected response type: ${resp.type}`);
}
if (resp.exit_code !== 0) {
throw new Error(
`readFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
);
}
return resp.stdout;
}
async writeFile(path: string, content: string): Promise<void> {
const b64 = btoa(content);
const resp = await this.proc.send({
type: "exec",
argv: ["sh", "-c", `echo '${b64}' | base64 -d > "$1"`, "--", path],
});
if (resp.type !== "exec") {
throw new Error(`unexpected response type: ${resp.type}`);
}
if (resp.exit_code !== 0) {
throw new Error(
`writeFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
);
}
}
async checkpoint(name: string): Promise<void> {
const resp = await this.proc.send({ type: "checkpoint", name });
if (resp.type !== "checkpoint") {
throw new Error(`unexpected response type: ${resp.type}`);
}
}
async stop(): Promise<void> {
await this.proc.stop();
}
}
/** @internal exported for testing */
export function buildArgs(bin: string, opts: StartOptions): string[] {
const args = [...bin.split(/\s+/), "run", "--stdio"];
if (opts.from) args.push("--from", opts.from);
if (opts.cpus) args.push("--cpus", String(opts.cpus));
if (opts.memory) args.push("--memory", String(opts.memory));
if (opts.diskSize) args.push("--disk-size", String(opts.diskSize));
if (opts.allowNet) args.push("--allow-net");
if (opts.ports) {
for (const p of opts.ports) {
args.push("-p", p);
}
}
if (opts.mounts) {
for (const [host, guest] of Object.entries(opts.mounts)) {
args.push("--mount", `${host}:${guest}`);
}
}
return args;
}
+28
View File
@@ -0,0 +1,28 @@
export interface StartOptions {
from?: string;
cpus?: number;
memory?: number;
diskSize?: number;
allowNet?: boolean;
ports?: string[];
mounts?: Record<string, string>;
shuruBin?: string;
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export type StdioResponse =
| { type: "ready" }
| {
type: "exec";
id: string;
stdout: string;
stderr: string;
exit_code: number;
}
| { type: "checkpoint"; id: string; ok: boolean }
| { type: "error"; id: string; error: string };
@@ -0,0 +1,95 @@
/**
* Integration tests that run against the real shuru binary + VM.
*
* Prerequisites:
* 1. cargo build -p shuru-cli
* 2. codesign --entitlements shuru.entitlements --force -s - target/debug/shuru
* 3. shuru init
*
* Run: bun test test/integration/
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, unlinkSync } from "node:fs";
import { resolve } from "node:path";
import { Sandbox } from "../../src/sandbox";
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
const SHURU_BIN = resolve(REPO_ROOT, "target/debug/shuru");
const canRun = existsSync(SHURU_BIN);
describe.if(canRun)("sandbox lifecycle", () => {
let sb: Sandbox;
beforeAll(async () => {
sb = await Sandbox.start({ shuruBin: SHURU_BIN });
}, 60_000);
afterAll(async () => {
await sb?.stop();
}, 15_000);
test("exec echo", async () => {
const result = await sb.exec("echo hello");
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("hello");
expect(result.stderr).toBe("");
}, 30_000);
test("exec captures exit code", async () => {
const result = await sb.exec("exit 42");
expect(result.exitCode).toBe(42);
}, 30_000);
test("exec captures stderr", async () => {
const result = await sb.exec("echo err >&2");
expect(result.exitCode).toBe(0);
expect(result.stderr.trim()).toBe("err");
}, 30_000);
test("writeFile and readFile roundtrip", async () => {
const content = "hello from integration test\nline 2\n";
await sb.writeFile("/tmp/sdk-test.txt", content);
const read = await sb.readFile("/tmp/sdk-test.txt");
expect(read).toBe(content);
}, 30_000);
test("multiple sequential execs", async () => {
await sb.exec("echo a > /tmp/seq.txt");
await sb.exec("echo b >> /tmp/seq.txt");
const result = await sb.exec("cat /tmp/seq.txt");
expect(result.stdout).toBe("a\nb\n");
}, 30_000);
test("checkpoint creates disk snapshot", async () => {
const cpPath = resolve(
process.env.HOME ?? "/tmp",
".local/share/shuru/checkpoints/sdk-integration-test.ext4",
);
try {
unlinkSync(cpPath);
} catch {
// no leftover
}
await sb.exec("echo checkpoint-data > /tmp/cp-test.txt");
await sb.checkpoint("sdk-integration-test");
expect(existsSync(cpPath)).toBe(true);
try {
unlinkSync(cpPath);
} catch {
// cleanup
}
}, 30_000);
});
describe.if(!canRun)("sandbox lifecycle (skipped)", () => {
test("shuru binary not found", () => {
console.log(
"Build with: cargo build -p shuru-cli && codesign --entitlements shuru.entitlements --force -s - target/debug/shuru",
);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import { buildArgs } from "../../src/sandbox";
describe("buildArgs", () => {
test("minimal options", () => {
const args = buildArgs("shuru", {});
expect(args).toEqual(["shuru", "run", "--stdio"]);
});
test("custom binary path", () => {
const args = buildArgs("/usr/local/bin/shuru", {});
expect(args).toEqual(["/usr/local/bin/shuru", "run", "--stdio"]);
});
test("multi-word binary", () => {
const args = buildArgs("bun mock-shuru.ts", {});
expect(args).toEqual(["bun", "mock-shuru.ts", "run", "--stdio"]);
});
test("from checkpoint", () => {
const args = buildArgs("shuru", { from: "my-checkpoint" });
expect(args).toEqual([
"shuru",
"run",
"--stdio",
"--from",
"my-checkpoint",
]);
});
test("cpus and memory", () => {
const args = buildArgs("shuru", { cpus: 4, memory: 4096 });
expect(args).toEqual([
"shuru",
"run",
"--stdio",
"--cpus",
"4",
"--memory",
"4096",
]);
});
test("disk size", () => {
const args = buildArgs("shuru", { diskSize: 8192 });
expect(args).toEqual(["shuru", "run", "--stdio", "--disk-size", "8192"]);
});
test("allow net", () => {
const args = buildArgs("shuru", { allowNet: true });
expect(args).toEqual(["shuru", "run", "--stdio", "--allow-net"]);
});
test("allowNet false is omitted", () => {
const args = buildArgs("shuru", { allowNet: false });
expect(args).toEqual(["shuru", "run", "--stdio"]);
});
test("port forwards", () => {
const args = buildArgs("shuru", { ports: ["8080:80", "3000:3000"] });
expect(args).toEqual([
"shuru",
"run",
"--stdio",
"-p",
"8080:80",
"-p",
"3000:3000",
]);
});
test("mounts", () => {
const args = buildArgs("shuru", {
mounts: { "./src": "/workspace", "./data": "/data" },
});
expect(args).toEqual([
"shuru",
"run",
"--stdio",
"--mount",
"./src:/workspace",
"--mount",
"./data:/data",
]);
});
test("all options combined", () => {
const args = buildArgs("shuru", {
from: "base",
cpus: 2,
memory: 2048,
diskSize: 4096,
allowNet: true,
ports: ["8080:80"],
mounts: { "./src": "/workspace" },
});
expect(args).toEqual([
"shuru",
"run",
"--stdio",
"--from",
"base",
"--cpus",
"2",
"--memory",
"2048",
"--disk-size",
"4096",
"--allow-net",
"-p",
"8080:80",
"--mount",
"./src:/workspace",
]);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src", "test"]
}