designcraft/apps/designcraft-cli/src/main.rs

377 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Headless DesignCraft.
//!
//! ```text
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
//! designcraft-cli links # Discord, website, app page and GitHub links
//! designcraft-cli --version # print the version
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::process::ExitCode;
/// `println!` that ends the program quietly when stdout is closed (`designcraft-cli commands |
/// head`) instead of panicking with "failed printing to stdout: Broken pipe (os error 32)".
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
mod perf;
use designcraft_engine::Session;
use serde_json::{Value, json};
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("designcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--version" | "-V" | "version") => {
outln!("designcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some("run") => report(run(&args[1..])),
Some("commands") => {
let s = Session::new();
let filter = args.get(1).map(|f| f.to_lowercase());
let list: Vec<Value> = serde_json::to_value(s.commands())
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.into_iter()
.filter(|c| filter.as_ref().is_none_or(|f| c.to_string().to_lowercase().contains(f.as_str())))
.collect();
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
ExitCode::SUCCESS
}
Some("describe") => report(describe(args.get(1).map(String::as_str))),
Some("script") => report(script(&args[1..])),
Some("app") => report(app(&args[1..])),
Some("mcp") => report(mcp(&args[1..])),
Some("perf") => report(perf::perf(&args[1..])),
Some("bench") => report(perf::bench(&args[1..])),
Some("links") => {
use designcraft_engine::links::*;
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
_ => {
eprintln!(
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"
);
eprintln!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
);
ExitCode::FAILURE
}
}
}
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("designcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
fn mcp(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")),
}
}
let backend: Box<dyn Backend> = match connect {
Some(c) => {
let addr = control_addr(&c);
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?,
)
}
None => {
let mut h = Headless::with_document();
if sample {
h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
}
Box::new(h)
}
};
eprintln!("designcraft-cli: MCP server on stdio ({})", backend.describe());
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::new();
let mut page = 0usize;
let mut scale = 1.0f64;
let mut it = args.iter();
let mut opened = false;
let mut pdf_opts = json!({});
// Earlier --cmd results, for `$N.path` references.
let mut results: Vec<Value> = Vec::new();
while let Some(a) = it.next() {
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
match a.as_str() {
"--in" => {
let p = val()?;
s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
opened = true;
}
"--sample" => {
s.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
opened = true;
}
"--cmd" => {
if !opened {
s.execute("file.new", &json!({})).map_err(|e| e.to_string())?;
opened = true;
}
let c = val()?;
let (id, p) = c.split_once('=').unwrap_or((&c, "{}"));
let p: Value = serde_json::from_str(p).map_err(|e| format!("--cmd {id}: {e}"))?;
let p = designcraft_engine::script::resolve(&p, &results).map_err(|e| format!("--cmd {id}: {e}"))?;
let r = s.execute(id, &p).map_err(|e| e.to_string())?;
results.push(r.clone());
if !r.is_null() {
outln!("{}", serde_json::to_string(&r).unwrap_or_default());
}
}
"--page" => page = val()?.parse().map_err(|_| "bad --page")?,
"--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
"--pdf-options" => {
pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?;
}
"--export" => {
let out = val()?;
if out.ends_with(".epub") {
let r = s.execute("file.exportEpub", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
} else if out.ends_with(".pdf") {
let mut p = pdf_opts.clone();
p["path"] = json!(out);
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
} else {
export(&mut s, &out, page, scale)?;
}
}
"--all-pages" => {
let dir = val()?;
let n = s.doc().map_err(|e| e.to_string())?.doc.page_count();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for p in 0..n {
export(&mut s, &format!("{dir}/page-{:03}.png", p + 1), p, scale)?;
}
}
other => return Err(format!("unknown option {other}")),
}
}
Ok(())
}
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
if out.to_ascii_lowercase().ends_with(".idml") {
let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
return Ok(());
}
if out.ends_with(".designcraft") {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");
return Ok(());
}
let st = s.doc().map_err(|e| e.to_string())?;
let mut r = designcraft_render::Renderer::new();
let t = std::time::Instant::now();
let img = r
.render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
.ok_or("no such page")?;
let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
Ok(())
}
/// `describe ID`: one command's documentation.
fn describe(id: Option<&str>) -> Result<(), String> {
let id = id.ok_or("usage: designcraft-cli describe COMMAND")?;
let s = Session::new();
let all = serde_json::to_value(s.commands()).map_err(|e| e.to_string())?;
let c = all.as_array().and_then(|a| a.iter().find(|c| c["id"] == id)).ok_or_else(|| {
let near: Vec<String> = all
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c["id"].as_str())
.filter(|c| c.split('.').next() == id.split('.').next())
.map(str::to_string)
.collect();
format!("no command `{id}`{}", if near.is_empty() { String::new() } else { format!(" (similar: {})", near.join(", ")) })
})?;
// Enablement depends on a live document and selection: not meaningful here.
let mut c = c.clone();
if let Some(o) = c.as_object_mut() {
o.remove("enabled");
o.remove("disabled_reason");
}
outln!("{}", serde_json::to_string_pretty(&c).unwrap_or_default());
Ok(())
}
/// `script`: run a command script headless (or in the running app with `--connect`).
fn script(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, control_addr};
let mut file: Option<String> = None;
let mut setup: Vec<(String, Value)> = Vec::new();
let mut connect: Option<String> = None;
let mut save: Option<String> = None;
let mut export: Vec<String> = Vec::new();
let mut keep_going = false;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
match a.as_str() {
"--in" => {
let p = val()?;
let cmd = if p.to_lowercase().ends_with(".idml") { "file.openIdml" } else { "file.open" };
setup.push((cmd.into(), json!({"path": p})));
}
"--sample" => setup.push(("file.newSample".into(), json!({}))),
"--connect" => connect = Some(val()?),
"--save" => save = Some(val()?),
"--export" => export.push(val()?),
"--keep-going" => keep_going = true,
f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()),
other => return Err(format!("unknown script option `{other}`")),
}
}
let text = match file.as_deref() {
None | Some("-") => {
let mut t = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut t).map_err(|e| e.to_string())?;
t
}
Some(f) => std::fs::read_to_string(f).map_err(|e| format!("{f}: {e}"))?,
};
let steps = designcraft_engine::script::parse(&text)?;
let mut backend: Box<dyn Backend> = match &connect {
Some(c) => {
let addr = control_addr(c);
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?,
)
}
None => Box::new(Headless::with_document()),
};
let b = &mut *backend;
let exec = |b: &mut dyn Backend, id: &str, p: Value| b.call("engine.execute", json!({"command": id, "params": p}));
for (c, p) in &setup {
exec(b, c, p.clone())?;
}
let report = if keep_going {
// Run every step; failures become {"error": …} results.
let mut results: Vec<Value> = Vec::new();
for st in &steps {
let r = designcraft_engine::script::resolve(&st.params, &results).and_then(|p| exec(b, &st.command, p));
results.push(r.unwrap_or_else(|e| json!({"error": e, "command": st.command})));
}
designcraft_engine::script::Report { results, failed: None }
} else {
designcraft_engine::script::run(&steps, |id, p| exec(b, id, p))
};
if report.failed.is_none() {
if let Some(out) = &save {
exec(b, "file.saveAs", json!({"path": out}))?;
}
for out in &export {
let lower = out.to_lowercase();
let p = json!({"path": out});
if lower.ends_with(".pdf") {
exec(b, "file.exportPdf", p)?;
} else if lower.ends_with(".idml") {
exec(b, "file.exportIdml", p)?;
} else if lower.ends_with(".epub") {
exec(b, "file.exportEpub", p)?;
} else {
// PNG / JPEG of the first page.
b.call("app.export", p)?;
}
}
}
outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default());
match report.failed {
Some((i, c, e)) => Err(format!("step {i} ({c}) failed: {e}")),
None => Ok(()),
}
}
/// `app`: one command or control-channel method in the running app.
fn app(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Remote, control_addr};
let mut port = "7979".to_string();
let mut method: Option<String> = None;
let mut rest: Vec<String> = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--port" | "--connect" => port = it.next().cloned().ok_or("--port needs a value")?,
"--method" => method = Some(it.next().cloned().ok_or("--method needs a value")?),
_ => rest.push(a.clone()),
}
}
let addr = control_addr(&port);
let mut r = Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control {port}`)"))?;
let json_arg =
|s: Option<&String>| -> Result<Value, String> { s.map_or(Ok(json!({})), |t| serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))) };
let out = match method {
Some(m) => r.call(&m, json_arg(rest.first())?)?,
None => {
let cmd = rest.first().ok_or("usage: designcraft-cli app [--port PORT] COMMAND [JSON]")?;
r.call("engine.execute", json!({"command": cmd, "params": json_arg(rest.get(1))?}))?
}
};
outln!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
Ok(())
}