Files
junhsss 97b72ff545 feat: steel computer (#98)
* feat(computer): add computer commands with exec and ssh

* fix(computer): flush bridge writes and read gateway error bodies

* test(computer): drive exec and ssh end to end through fake servers

* chore(api): regenerate bindings from the current spec

* feat(computer): list exec and ssh in describe

* chore: bump version
2026-09-10 18:26:14 +09:00

372 lines
11 KiB
JavaScript

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const OUTPUT_PATH = "src/api/generated.rs";
function usage() {
return `
Usage:
npm run api:generate
Inputs, in priority order:
STEEL_OPENAPI_URL Fetch an OpenAPI spec from a published URL
STEEL_OPENAPI_PATH Read an OpenAPI spec from a local path
`.trim();
}
async function readSpec() {
const url = process.env.STEEL_OPENAPI_URL?.trim();
if (url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const text = await response.text();
return JSON.parse(text);
}
const filePath = process.env.STEEL_OPENAPI_PATH?.trim();
if (!filePath) {
throw new Error(usage());
}
try {
const text = readFileSync(resolve(filePath), "utf8");
return JSON.parse(text);
} catch (error) {
throw new Error(`${usage()}\n\nCould not read ${filePath}: ${error.message}`);
}
}
function rustString(value) {
return JSON.stringify(String(value ?? ""));
}
function snakeCase(value) {
return String(value)
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
}
function pascalCase(value) {
return snakeCase(value)
.split("_")
.filter(Boolean)
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
.join("");
}
function stripV1(path) {
return path.startsWith("/v1/") ? path.slice(3) : path;
}
function collectOperations(spec) {
const operations = [];
for (const [path, pathItem] of Object.entries(spec.paths || {})) {
for (const [method, operation] of Object.entries(pathItem || {})) {
const cli = operation["x-steel-cli"];
if (!operation.operationId || !cli?.command) continue;
operations.push({
id: operation.operationId,
method: method.toUpperCase(),
path,
requestPath: stripV1(path),
summary: operation.summary || operation.operationId,
command: cli.command.join(" "),
example: exampleFor(cli.command, operation, path),
streaming: cli.follow
? {
transport: cli.follow.transport || "websocket",
path: cli.follow.path,
}
: null,
queryParams: (operation.parameters || []).filter((parameter) => parameter.in === "query"),
pathParams: (operation.parameters || []).filter((parameter) => parameter.in === "path"),
});
}
}
operations.sort((a, b) => a.command.localeCompare(b.command));
return operations;
}
function exampleFor(command, operation, path) {
const cli = operation["x-steel-cli"] || {};
if (cli.example) return cli.example;
const base = `steel ${command.join(" ")}`;
const id = idPlaceholder(path);
if (command.includes("list")) return `${base} --status live --limit 20`;
if (command.includes("agent-logs")) return `${base} ${id} --limit 100`;
if (cli.follow) return `${base} ${id} --follow`;
if (operation.parameters?.some((parameter) => parameter.in === "path" && parameter.name === "id")) {
return `${base} ${id}`;
}
return base;
}
function idPlaceholder(path) {
const resource = stripV1(path).split("/").filter(Boolean)[0] || "sessions";
return `<${resource.replace(/s$/, "")}-id>`;
}
function rustQueryType(parameter) {
const schema = parameter.schema || {};
if (schema.type === "array") return "Vec<String>";
if (schema.type === "boolean") return "Option<bool>";
if (schema.type === "integer") {
if (parameter.name === "limit") return "Option<u16>";
return "Option<u32>";
}
return "Option<String>";
}
function queryStructName(operation) {
return `${pascalCase(operation.id)}Query`;
}
function renderQueryStruct(operation) {
if (!operation.queryParams.length) return "";
const fields = operation.queryParams
.map((parameter) => ` pub ${snakeCase(parameter.name)}: ${rustQueryType(parameter)},`)
.join("\n");
return `
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ${queryStructName(operation)} {
${fields}
}
`;
}
function renderMetadata(operations) {
const entries = operations
.map((operation) => {
const streaming = operation.streaming
? `Some(StreamingMetadata {
transport: ${rustString(operation.streaming.transport)},
path: ${rustString(operation.streaming.path)},
})`
: "None";
return ` OperationMetadata {
id: ${rustString(operation.id)},
command: ${rustString(operation.command)},
status: "implemented",
method: ${rustString(operation.method)},
path: ${rustString(operation.path)},
summary: ${rustString(operation.summary)},
example: ${rustString(operation.example)},
streaming: ${streaming},
},`;
})
.join("\n");
return `pub const CLI_OPERATION_METADATA: &[OperationMetadata] = &[
${entries}
];`;
}
function renderPathBuilder(operation) {
if (!operation.pathParams.length && !operation.queryParams.length) return "";
const fnName = `build_${snakeCase(operation.id)}_path`;
const pathArg = operation.pathParams.some((parameter) => parameter.name === "id")
? "session_id: &str"
: "";
const args = [pathArg].filter(Boolean).join(", ");
const allArgs = [args, operation.queryParams.length ? `query: &${queryStructName(operation)}` : ""]
.filter(Boolean)
.join(", ");
const requestPath = operation.requestPath.replace("{id}", "{session_id}");
const initializer = requestPath.includes("{session_id}")
? `format!(${rustString(requestPath)})`
: `${rustString(requestPath)}.to_string()`;
const queryPushes = operation.queryParams
.map((parameter) => {
const field = snakeCase(parameter.name);
const key = parameter.name;
const type = rustQueryType(parameter);
if (type === "Vec<String>") {
return ` for value in query.${field}.iter().filter(|value| !value.trim().is_empty()) {
push_query(&mut path, ${rustString(key)}, value.trim());
}`;
}
if (type.startsWith("Option<String>")) {
return ` if let Some(value) = query.${field}.as_deref().filter(|value| !value.trim().is_empty()) {
push_query(&mut path, ${rustString(key)}, value.trim());
}`;
}
return ` if let Some(value) = query.${field} {
push_query_number(&mut path, ${rustString(key)}, value);
}`;
})
.join("\n");
if (!queryPushes) {
return `fn ${fnName}(${allArgs}) -> String {
${initializer}
}`;
}
return `fn ${fnName}(${allArgs}) -> String {
let mut path = ${initializer};
${queryPushes}
path
}`;
}
function methodArgs(operation) {
const args = [
"&self",
"base_url: &str",
"mode: ApiMode",
"auth: &Auth",
];
if (operation.pathParams.some((parameter) => parameter.name === "id")) {
args.push("session_id: &str");
}
if (operation.queryParams.length) {
args.push(`query: &${queryStructName(operation)}`);
}
return args.join(",\n ");
}
function pathBuilderCall(operation) {
const fnName = `build_${snakeCase(operation.id)}_path`;
const args = [];
if (operation.pathParams.some((parameter) => parameter.name === "id")) args.push("session_id");
if (operation.queryParams.length) args.push("query");
return `${fnName}(${args.join(", ")})`;
}
function renderClientMethod(operation) {
const requestPath = operation.queryParams.length || operation.pathParams.length
? `&${pathBuilderCall(operation)}`
: rustString(operation.requestPath);
return ` pub async fn cli_${snakeCase(operation.id)}(
${methodArgs(operation)},
) -> Result<Value, ApiError> {
self.request(
base_url,
mode,
reqwest::Method::${operation.method},
${requestPath},
None,
auth,
)
.await
}`;
}
function renderTests(operations) {
const getSessions = operations.find((operation) => operation.id === "get_sessions");
const logs = operations.find((operation) => operation.id === "get_session_logs");
if (!getSessions || !logs) return "";
return `
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_list_query_uses_openapi_names() {
let path = build_get_sessions_path(&GetSessionsQuery {
cursor_id: Some("abc".into()),
limit: Some(25),
status: Some("live".into()),
..Default::default()
});
assert_eq!(path, "/sessions?cursorId=abc&limit=25&status=live");
}
#[test]
fn logs_query_repeats_event_types() {
let path = build_get_session_logs_path(
"sess",
&GetSessionLogsQuery {
namespace: Some("prod".into()),
event_types: vec!["console".into(), "error".into()],
limit: Some(10),
offset: Some(5),
..Default::default()
},
);
assert_eq!(
path,
"/sessions/sess/logs?namespace=prod&eventTypes=console&eventTypes=error&limit=10&offset=5"
);
}
}
`;
}
function renderRust(operations) {
const queryStructs = operations.map(renderQueryStruct).filter(Boolean).join("\n");
const pathBuilders = operations.map(renderPathBuilder).filter(Boolean).join("\n\n");
const methods = operations.map(renderClientMethod).join("\n\n");
return `//! Generated bindings for CLI-tagged Steel API operations.
//!
//! Source: OpenAPI operations with \`x-steel-cli\`.
//! Regenerate with \`npm run api:generate\`.
//! Keep command UX in \`commands/\`; this module only owns request shape,
//! endpoint paths, and operation metadata.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::api::client::{ApiError, SteelClient};
use crate::config::auth::Auth;
use crate::config::settings::ApiMode;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct OperationMetadata {
pub id: &'static str,
pub command: &'static str,
pub status: &'static str,
pub method: &'static str,
pub path: &'static str,
pub summary: &'static str,
pub example: &'static str,
pub streaming: Option<StreamingMetadata>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct StreamingMetadata {
pub transport: &'static str,
pub path: &'static str,
}
${renderMetadata(operations)}
${queryStructs}
fn push_query(path: &mut String, key: &str, value: &str) {
if path.contains('?') {
path.push('&');
} else {
path.push('?');
}
path.push_str(key);
path.push('=');
path.push_str(&urlencoding::encode(value));
}
fn push_query_number(path: &mut String, key: &str, value: impl std::fmt::Display) {
push_query(path, key, &value.to_string());
}
${pathBuilders}
impl SteelClient {
${methods}
}
${renderTests(operations)}`;
}
const spec = await readSpec();
const operations = collectOperations(spec);
if (!operations.length) {
throw new Error("No CLI-tagged operations found in OpenAPI spec.");
}
mkdirSync(dirname(resolve(OUTPUT_PATH)), { recursive: true });
writeFileSync(resolve(OUTPUT_PATH), renderRust(operations));
console.log(`Generated ${OUTPUT_PATH} from ${operations.length} CLI OpenAPI operations.`);