From 9e15d5a17eb9ebaf153e17075d9a3e4f6c23bb98 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:41:46 +0200 Subject: [PATCH] fix(cli): accept a UTF-8 BOM in scripts and document the syntax in script --help (fixes #654) --- README.md | 1 + apps/designcraft-cli/src/main.rs | 35 +++++++++++++++++++++++++++++++ apps/designcraft-cli/tests/cli.rs | 15 +++++++++++++ crates/engine/src/script.rs | 18 ++++++++++++++-- docs/agents.md | 8 +++++-- 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 39fbe710..0ec6540b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ cargo run --release -p designcraft -- --sample # open the sample mag cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG cargo run --release -p designcraft-cli -- commands # list every command +cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md) cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c3808f24..dd62cd91 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -38,6 +38,37 @@ use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\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"; +/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. +const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] + +Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with +--connect PORT, in the running app (designcraft --control PORT). + +Script text, one step per line: + command.id {"param": 1} also `command.id={json}`, or `command.id` alone + {"command": "command.id", "params": {...}} JSON lines, or a JSON array of them + # comment, // comment skipped, like blank lines + UTF-8 with or without a byte order mark; LF or CRLF line endings. + +References to earlier results (in string parameters): + "$N.path" the value at `path` in the result of step N. N is the 0-based step index: + comments and blank lines are not steps. `$last` is the previous step. + Paths go through result fields and array indices: "$1.story", "$3.ids.0". + "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + +Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +Most commands ignore parameters they do not know instead of rejecting them: check names +with `designcraft-cli describe COMMAND`. + +Options: + --in FILE / --sample open a document (.designcraft or .idml) or the sample first + --save OUT save as .designcraft after the script succeeds + --export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable + --keep-going run every step, recording failures as {"error": ...} results + +Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a +non-zero exit when a step fails."#; + /// The links line under the usage block. fn usage_footer() -> String { format!( @@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> { "--save" => save = Some(val()?), "--export" => export.push(val()?), "--keep-going" => keep_going = true, + "--help" | "-h" => { + outln!("{SCRIPT_HELP}"); + return Ok(()); + } f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index c2804be9..92f68ab5 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() { assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); } +/// `script --help` was "unknown script option `--help`"; the line syntax was only in +/// `describe script.run` (#654). +#[test] +fn script_help_documents_the_syntax() { + for flag in ["--help", "-h"] { + let out = cli().args(["script", flag]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}"); + for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] { + assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}"); + } + } +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index a447cb6b..d2304e65 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -9,7 +9,8 @@ //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and -//! lines starting with `#` or `//` are ignored. +//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are +//! accepted. use serde_json::{Value, json}; @@ -22,7 +23,9 @@ pub struct Step { /// Parse script text (see the module docs). pub fn parse(text: &str) -> Result, String> { - let t = text.trim(); + // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the + // first command id. + let t = text.trim_start_matches('\u{feff}').trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -174,6 +177,17 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM + /// became part of the first command id (#654). + #[test] + fn a_leading_bom_and_crlf_parse_like_plain_text() { + let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n"; + let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n")); + assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap()); + let array = r#"[{"command": "file.new"}]"#; + assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap()); + } + #[test] fn resolves_references() { let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})]; diff --git a/docs/agents.md b/docs/agents.md index 0dd23f06..5115654a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,8 +21,12 @@ Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph ## Scripts: several commands, later ones using earlier results A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of -`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the -previous step); `"${N.path}"` interpolates inside longer strings. +`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value +of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous +step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` +interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +`designcraft-cli script --help` prints this reference. ```sh cat > page.dcs <<'DCS'