mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
Merge main into #840
# Conflicts: # apps/figcraft-cli/src/files.rs # apps/figcraft-cli/src/main.rs # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs # crates/ui-egui/locales/en.toml # crates/ui-egui/locales/es.toml # crates/ui-egui/src/export_panel.rs # crates/ui-egui/src/export_preview_tests.rs # docs/file-format-parity.md # docs/mcp.md # docs/roadmap.md # docs/ui-parity.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -16,8 +16,14 @@ pub struct Args {
|
||||
impl Args {
|
||||
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
|
||||
let mut a = Args::default();
|
||||
// The first bad option, reported once the rest of the line is checked for `--help`.
|
||||
let mut bad: Option<CliError> = None;
|
||||
let mut it = argv.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--" {
|
||||
a.positional.extend(it.cloned());
|
||||
break;
|
||||
}
|
||||
let name = if let Some(n) = arg.strip_prefix("--") {
|
||||
n
|
||||
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
|
||||
@@ -30,19 +36,27 @@ impl Args {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (name, None),
|
||||
};
|
||||
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
|
||||
if inline.is_none() && matches!(name, "help" | "h") {
|
||||
return Err(CliError::Help);
|
||||
}
|
||||
if valued.contains(&name) {
|
||||
let v = match inline {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
match inline.or_else(|| it.next().cloned()) {
|
||||
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
|
||||
None => {
|
||||
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
|
||||
}
|
||||
}
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
return Err(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
match bad {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(a),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
@@ -144,4 +158,18 @@ mod tests {
|
||||
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
|
||||
assert_eq!(a.positional, ["-5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_asked_for_anywhere_but_as_a_value() {
|
||||
// Also after a bad option: help wins over the usage error (#1161 review).
|
||||
for argv in
|
||||
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
|
||||
{
|
||||
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
|
||||
}
|
||||
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
|
||||
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
|
||||
// `--help=x` is no request for help, just an unknown option.
|
||||
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+140
-31
@@ -1,10 +1,11 @@
|
||||
//! The file subcommands: info, export, convert, render and run.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::{BatchScope, BatchStep, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
@@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
@@ -29,9 +30,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -196,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
|
||||
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
|
||||
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
|
||||
fn sanitize(name: &str) -> String {
|
||||
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||||
let s = s.trim().trim_matches('.').to_string();
|
||||
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
|
||||
let s = figcraft_engine::cmd::file::safe_file_name(name);
|
||||
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
|
||||
}
|
||||
|
||||
/// What `export` exports and how.
|
||||
@@ -251,7 +266,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -264,14 +279,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
return Err(CliError::Failed("the page is empty: nothing to export".into()));
|
||||
}
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for id in ids {
|
||||
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
|
||||
let mut name = base.clone();
|
||||
let mut k = 2;
|
||||
while !used.insert(name.to_lowercase()) {
|
||||
name = format!("{base} {k}");
|
||||
k += 1;
|
||||
}
|
||||
let base_lower = base.to_lowercase();
|
||||
let next = next_suffix.entry(base_lower).or_insert(1);
|
||||
let name = loop {
|
||||
let k = *next;
|
||||
*next += 1;
|
||||
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
|
||||
if used.insert(candidate.to_lowercase()) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
|
||||
write(&path, &bytes_of(&[id])?)?;
|
||||
println!("{path}");
|
||||
@@ -279,8 +299,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
|
||||
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
|
||||
return Err(CliError::Failed(format!(
|
||||
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
|
||||
if let Some((input, images_left_out)) = input {
|
||||
refuse_dropping_images(input, out, images_left_out)?;
|
||||
}
|
||||
if let Some(images_left_out) = active.images_lost_saving_to(out) {
|
||||
refuse_dropping_images(out, out, images_left_out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||||
let doc = load(input)?;
|
||||
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
|
||||
refuse_dropping_images(input, out, loaded.images_past_budget)?;
|
||||
let doc = loaded.doc;
|
||||
let page = doc
|
||||
.current_page
|
||||
.filter(|p| doc.pages.contains(p))
|
||||
@@ -305,7 +346,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
|
||||
}
|
||||
|
||||
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
|
||||
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
Ok(Value::Object(o)) if o.contains_key("commands") => {
|
||||
@@ -330,51 +371,54 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
Some(p @ Value::Object(_)) => p.clone(),
|
||||
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
|
||||
};
|
||||
Ok((cmd.to_string(), params))
|
||||
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
|
||||
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
let (mut s, input_images_left_out) = match input {
|
||||
Some(path) => {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
|
||||
s
|
||||
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
|
||||
(s, left_out)
|
||||
}
|
||||
None => Session::new(),
|
||||
None => (Session::new(), 0),
|
||||
};
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
|
||||
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
|
||||
// The result lines are held back until the script is through, so none of them reports work that
|
||||
// was rolled back.
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
|
||||
// steps before it, and a step that fails takes the whole script back instead of leaving the
|
||||
// earlier steps applied. The result lines are held back until the script is through, so none of
|
||||
// them reports work that was rolled back.
|
||||
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut failed_at = 0;
|
||||
let outcome = s.grouped(&format!("run {script}"), |s| {
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
failed_at = i;
|
||||
let v = s.batch_step(&results, i, cmd, params)?;
|
||||
s.batch_step(&mut scope, &step.command, &step.params)?;
|
||||
s.ui_requests.clear();
|
||||
results.push(v);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
|
||||
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
|
||||
let i = failed_at;
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
eprintln!("the script's document changes were rolled back");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
|
||||
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
|
||||
if ext(out) == "figcraft" {
|
||||
// Through the engine, so the version history is kept.
|
||||
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||||
@@ -406,6 +450,14 @@ mod tests {
|
||||
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
|
||||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
|
||||
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
|
||||
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
|
||||
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
|
||||
assert_eq!(sanitize(" .hidden. "), "hidden");
|
||||
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
|
||||
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
|
||||
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -489,6 +541,43 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
|
||||
#[test]
|
||||
fn a_script_refers_to_named_steps_and_paths() {
|
||||
let dir = temp_dir("run-ref");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
|
||||
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
|
||||
assert_eq!(names, ["Card", "Copy"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Two steps with one name fail before anything runs.
|
||||
#[test]
|
||||
fn a_script_with_a_name_twice_runs_nothing() {
|
||||
let dir = temp_dir("run-dup-name");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
|
||||
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
|
||||
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
|
||||
#[test]
|
||||
fn a_failed_step_leaves_no_output() {
|
||||
@@ -508,4 +597,24 @@ mod tests {
|
||||
assert!(!out.exists(), "a script that failed writes nothing");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
|
||||
let dir = temp_dir("script-images-left-out");
|
||||
let input = dir.join("big.figcraft");
|
||||
std::fs::write(&input, b"kept").unwrap();
|
||||
let link = dir.join("link.figcraft");
|
||||
std::fs::hard_link(&input, &link).unwrap();
|
||||
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
|
||||
let duplicate = Session::new();
|
||||
let active = duplicate.ds().unwrap();
|
||||
|
||||
for out in [input, link] {
|
||||
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
|
||||
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
|
||||
}
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,36 +22,63 @@ USAGE:
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
||||
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
|
||||
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
|
||||
writes only inside a write root, everything else refused. A running app confines itself: start it
|
||||
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
|
||||
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
|
||||
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
|
||||
confined by the app it attaches to, not by flags passed to the CLI: start that app with
|
||||
`figcraft --control PORT` and the same roots (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(CliError::Usage(msg)) => {
|
||||
eprintln!("error: {msg}\n\n{USAGE}");
|
||||
ExitCode::from(2)
|
||||
@@ -65,6 +92,8 @@ fn main() -> ExitCode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
|
||||
Help,
|
||||
/// Bad arguments: usage is printed, exit code 2.
|
||||
Usage(String),
|
||||
/// The operation failed: exit code 1.
|
||||
@@ -79,15 +108,27 @@ impl From<String> for CliError {
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
|
||||
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
|
||||
if system_fonts {
|
||||
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
|
||||
let flags = [flags, &["no-system-fonts"]].concat();
|
||||
let args = Args::parse(argv, valued, &flags)?;
|
||||
if !args.flag("no-system-fonts") {
|
||||
// Text lays out with the installed fonts, like the desktop app, so headless renders and
|
||||
// auto layout sizes match what users see.
|
||||
figcraft_text::scan_system_fonts_on_demand();
|
||||
} else {
|
||||
// Nothing scans them then, not even a `.sketch` import matching its fonts.
|
||||
figcraft_text::opt_out_of_system_fonts();
|
||||
}
|
||||
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
|
||||
return Err(CliError::Usage("missing subcommand".into()));
|
||||
};
|
||||
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
|
||||
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
|
||||
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
|
||||
match cmd.as_str() {
|
||||
"help" | "--help" | "-h" => {
|
||||
println!("{USAGE}");
|
||||
@@ -98,13 +139,13 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
files::info(&file, a.flag("json"))
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let a =
|
||||
Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target = files::Target {
|
||||
@@ -115,28 +156,30 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
print: a.print()?,
|
||||
vd_compat: a.vd_compat()?,
|
||||
};
|
||||
files::export(&file, &out, &target)
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => files::convert(i, o),
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
let a = parse_args(&rest, &["filter"], &["json"])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("commands takes no positional arguments".into()));
|
||||
}
|
||||
@@ -144,26 +187,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
|
||||
(true, _) => None,
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
(false, Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
|
||||
};
|
||||
mcp(a.value("attach").as_deref(), roots)
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
let a = parse_args(&rest, &["server"], &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[verb, folder] if verb == "publish" => {
|
||||
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
|
||||
@@ -173,10 +213,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
let a = Args::parse(rest, &["layers", "instances"], &[])?;
|
||||
let a = parse_args(&rest, &["layers", "instances"], &[])?;
|
||||
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
perf::run(&sizes, &instances, system_fonts)
|
||||
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
|
||||
}
|
||||
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user