CLI: run --commands reads the steps from a JSON file or standard input (#3147)

This commit is contained in:
Taha-Mahmoodi
2026-10-22 21:34:37 +07:00
committed by bflatastic
parent 9097c4a564
commit 87fdd4a370
4 changed files with 260 additions and 15 deletions
+113 -8
View File
@@ -2,7 +2,7 @@
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
@@ -76,11 +76,15 @@ USAGE:
DIR, links followed; a root left out grants none of its access. They imply --headless: a
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
--commands reads steps from FILE, or from standard input for -, where it stands among the
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
Errors name the step, counting from 0 as \"$N\" references do.
vectorcraft-cli commands
Print the command catalogue as JSON.
@@ -338,28 +342,127 @@ enum Step {
Export(String),
}
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
/// Most steps one `run --commands` takes.
const MAX_COMMANDS_STEPS: usize = 100_000;
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
/// [`MAX_COMMANDS_BYTES`].
fn read_commands(source: &str) -> Result<String, String> {
use std::io::Read;
let mut bytes = vec![];
let read = if source == "-" {
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
} else {
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
};
read.map_err(|e| format!("--commands {source}: {e}"))?;
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
}
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
}
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
// Notepad on Windows starts UTF-8 files with a byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
// Each entry with where it is in the text: its line for JSON Lines.
let mut entries: Vec<(Option<usize>, Value)> = vec![];
if text.trim_start().starts_with('[') {
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
if items.len() > MAX_COMMANDS_STEPS {
return Err(too_many());
}
entries.extend(items.into_iter().map(|v| (None, v)));
} else {
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
if entries.len() == MAX_COMMANDS_STEPS {
return Err(too_many());
}
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: line {}: {e}", n + 1))?;
entries.push((Some(n + 1), v));
}
}
entries
.into_iter()
.enumerate()
.map(|(k, (line, v))| {
command_step(v).map_err(|e| {
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
format!("--commands {source}: step {} ({place}): {e}", first + k)
})
})
.collect()
}
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
fn command_step(v: Value) -> Result<Step, String> {
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
let id = match (o.remove("command"), o.remove("id")) {
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
(None, None) => return Err("no `command`".into()),
_ => return Err("`command` must be a string, a command id".into()),
};
let params = match o.remove("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p,
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
};
if let Some(key) = o.keys().next() {
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
}
Ok(Step::Cmd(id, params))
}
fn run(args: &[String]) -> Result<(), String> {
let mut input: Option<String> = None;
let mut steps: Vec<Step> = vec![];
// The step of the `--cmd` just given, which a `--params` sets.
let mut params_for: Option<usize> = None;
let mut stdin_read = false;
let mut scale = 1.0;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
match a.as_str() {
"--in" | "-i" => input = Some(val("a file")?),
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
"--cmd" | "-c" => {
steps.push(Step::Cmd(val("a command id")?, json!({})));
params_for = Some(steps.len() - 1);
}
"--params" | "-p" => {
let raw = val("JSON")?;
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
if !p.is_object() {
return Err(format!("--params must be a JSON object, got {raw}"));
}
match steps.last_mut() {
match params_for.and_then(|i| steps.get_mut(i)) {
Some(Step::Cmd(_, params)) => *params = p,
_ => return Err("--params must follow a --cmd".into()),
}
}
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
"--commands" => {
let source = val("a file, or - for standard input")?;
if source == "-" && std::mem::replace(&mut stdin_read, true) {
return Err("--commands - is given twice: standard input is read once".into());
}
let text = read_commands(&source)?;
let read = parse_commands(&source, &text, steps.len())?;
steps.extend(read);
params_for = None;
}
"--export" | "-o" => {
steps.push(Step::Export(val("a file")?));
params_for = None;
}
"--scale" | "-s" => {
let raw = val("a number")?;
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
@@ -394,10 +497,12 @@ fn run(args: &[String]) -> Result<(), String> {
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![];
for step in steps {
// The step's index, as `"$N"` names its result.
let n = results.len();
let r = match step {
Step::Cmd(id, params) => {
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("{id}: {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?;
r
}
@@ -407,7 +512,7 @@ fn run(args: &[String]) -> Result<(), String> {
} else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
+115 -1
View File
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
/// The JSON lines `run` printed.
fn json_lines(stdout: &[u8]) -> Vec<Value> {
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
/// placed file's `dataBase64` longer than a Windows command line holds fits.
#[test]
fn run_reads_steps_from_a_commands_file() {
let steps = tmp("steps.json");
let svg = tmp("steps.svg");
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
let padding = "x".repeat(40_000);
let placed = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
);
let data = vectorcraft_format::base64_encode(placed.as_bytes());
assert!(data.len() > 32_767);
let file = json!([
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
]);
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let out = Command::new(BIN)
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
// `$2` in the file and in the `--cmd` after it is the placed art.
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
let written = std::fs::read_to_string(&svg).unwrap();
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
}
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
/// CRLF line ends are fine, and the first step may be `file.new`.
#[test]
fn run_reads_json_lines_from_standard_input() {
let svg = tmp("stdin.svg");
let mut child = Command::new(BIN)
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0]["command"], "file.new");
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
}
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
/// refused; a step that fails names its number.
#[test]
fn run_refuses_malformed_commands_naming_the_step() {
let fails = |args: &[&str]| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{args:?}");
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
};
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
let path = tmp(name);
std::fs::write(&path, text).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
assert_eq!(printed, 0, "{name}: nothing runs");
};
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["line 3"]);
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
// Past 64 MiB (a sparse file: nothing is written).
let huge = tmp("huge.json");
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
assert!(e.contains("more than 64 MiB"), "{e}");
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
assert!(e.contains("missing.json"), "{e}");
// `--params` sets the `--cmd` just before it, never a step of the file.
let ok = tmp("ok.json");
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
assert!(e.contains("--params must follow a --cmd"), "{e}");
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
assert!(e.contains("standard input is read once"), "{e}");
// A step that fails is named by its number, after the steps before it ran.
let failing = tmp("failing.json");
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
assert_eq!(printed, 2);
}
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();