app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
← Commit history
Merge PR #11: Truing-up: offering/terms README, self-host, CLI 1.1.0, GLB gate + provenance stamp (#106)
15 files changed
+2135
.gitignoreadded+4@@ -0,0 +1,4 @@+target/+Cargo.lock+*.log+.cache/
Cargo.tomladded+19@@ -0,0 +1,19 @@+[workspace]+resolver = "2"+members = ["api", "cli"]++[workspace.package]+version = "1.1.0"+edition = "2021"+license = "AGPL-3.0-or-later"+repository = "https://github.com/adom-inc/service-kicad"+authors = ["Adom <[email protected]>"]++[workspace.dependencies]+anyhow = "1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tokio = { version = "1", features = ["full"] }+tracing = "0.1"+tracing-subscriber = "0.3"+tempfile = "3"
Dockerfileadded+44@@ -0,0 +1,44 @@+# Dockerfile for service-kicad — DEFAULT channel (KiCad 10).+# REFERENCE only; production uses the default-light image + deploy.sh.+# For the v9 fallback channel, see Dockerfile.v9.+FROM ubuntu:24.04++ENV DEBIAN_FRONTEND=noninteractive++RUN apt-get update && apt-get install -y \+ software-properties-common curl build-essential pkg-config git jq cron \+ && add-apt-repository -y ppa:kicad/kicad-10.0-releases \+ && apt-get update \+ && apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d \+ && rm -rf /var/lib/apt/lists/*++# Library overlay: prefer the upstream `10.0` stable branch when it+# exists, fall back to master. Same rationale as Dockerfile (the apt+# library packages lag upstream by years; the v10 stable branch is the+# closest matched pair to the v10 kicad-cli we just installed).+RUN for repo in kicad-footprints kicad-symbols kicad-packages3D; do \+ dst="/opt/${repo,,}-upstream"; \+ git clone --depth 1 --branch 10.0 "https://github.com/KiCad/$repo.git" "$dst" 2>/dev/null \+ || git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dst"; \+ done++ENV KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+ENV KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+ENV KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream++# Weekly pull keeps the libraries current; logs to /var/log/kicad-lib-pull.log.+RUN echo '0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | tee -a /var/log/kicad-lib-pull.log; done' > /etc/cron.d/kicad-lib-pull \+ && chmod 0644 /etc/cron.d/kicad-lib-pull++RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y+ENV PATH="/root/.cargo/bin:${PATH}"++WORKDIR /app+COPY . .++RUN cd api && cargo build --release \+ && install target/release/service-kicad /usr/local/bin/service-kicad++EXPOSE 8780++CMD ["/usr/local/bin/service-kicad"]
Dockerfile.v9added+43@@ -0,0 +1,43 @@+# Dockerfile.v9 for service-kicad — V9 FALLBACK channel.+# REFERENCE only; production uses the default-light image + deploy-v9.sh.+# Most users want the default Dockerfile (KiCad 10).+FROM ubuntu:24.04++ENV DEBIAN_FRONTEND=noninteractive++RUN apt-get update && apt-get install -y \+ software-properties-common curl build-essential pkg-config git jq cron \+ && add-apt-repository -y ppa:kicad/kicad-9.0-releases \+ && apt-get update \+ && apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d \+ && rm -rf /var/lib/apt/lists/*++# The Ubuntu apt packages of kicad-symbols/footprints/packages3d lag the+# upstream KiCad libraries by ~years (the 24.04 packages are ~2020 snapshot).+# Overlay the upstream github repos so users see the latest parts. Set the+# *_DIR env vars so service-kicad serves these instead of the apt copies.+# A cron job below refreshes them weekly.+RUN git clone --depth 1 https://github.com/KiCad/kicad-footprints.git /opt/kicad-footprints-upstream \+ && git clone --depth 1 https://github.com/KiCad/kicad-symbols.git /opt/kicad-symbols-upstream \+ && git clone --depth 1 https://github.com/KiCad/kicad-packages3D.git /opt/kicad-packages3d-upstream++ENV KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+ENV KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+ENV KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream++# Weekly pull keeps the libraries current; logs to /var/log/kicad-lib-pull.log.+RUN echo '0 4 * * 1 root cd /opt/kicad-footprints-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log; cd /opt/kicad-symbols-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log; cd /opt/kicad-packages3d-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log' > /etc/cron.d/kicad-lib-pull \+ && chmod 0644 /etc/cron.d/kicad-lib-pull++RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y+ENV PATH="/root/.cargo/bin:${PATH}"++WORKDIR /app+COPY . .++RUN cd api && cargo build --release \+ && install target/release/service-kicad /usr/local/bin/service-kicad++EXPOSE 8780++CMD ["/usr/local/bin/service-kicad"]
api/Cargo.tomladded+24@@ -0,0 +1,24 @@+[package]+name = "service-kicad-api"+version.workspace = true+edition.workspace = true+license.workspace = true+repository.workspace = true+authors.workspace = true+description = "service-kicad HTTP API — shared headless KiCad + kicad-packages3d cache for every Adom tool. See docs/service-kicad-api.md."++[[bin]]+name = "service-kicad-api"+path = "src/main.rs"++[dependencies]+anyhow.workspace = true+axum = "0.7"+tokio.workspace = true+tower-http = { version = "0.5", features = ["fs", "limit"] }+serde.workspace = true+serde_json.workspace = true+tracing.workspace = true+tracing-subscriber.workspace = true+tempfile.workspace = true+urlencoding = "2.1"
api/build.rsadded+24@@ -0,0 +1,24 @@+//! Bake the git SHA of HEAD into the binary at compile time, surfaced+//! as `git_sha` in /health. Compile-time (not runtime `git -C` like+//! service-step2glb) because the deployed binary can outlive the repo+//! checkout it was built from — the watchdog resets the repo *then*+//! rebuilds, and a failed rebuild keeps the old binary running.++use std::process::Command;++fn main() {+ let sha = Command::new("git")+ .args(["rev-parse", "HEAD"])+ .output()+ .ok()+ .filter(|o| o.status.success())+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .map(|s| s.trim().to_string())+ .unwrap_or_else(|| "unknown".into());+ println!("cargo:rustc-env=GIT_SHA={sha}");++ // Recompile when HEAD moves even if no source file changed+ // (e.g. a docs- or cli-only commit) so the SHA can't go stale.+ println!("cargo:rerun-if-changed=../.git/HEAD");+ println!("cargo:rerun-if-changed=../.git/refs/heads/main");+}
api/src/main.rsadded+927@@ -0,0 +1,927 @@+//! service-kicad — shared headless KiCad + kicad-packages3d cache.+//!+//! Replaces the legacy `service-kicad` container (being decommissioned)+//! and extracts the KiCad bits from `service-aci` so every Adom tool+//! shares one source of truth for:+//!+//! - `kicad-cli` subprocess wrappers (DRC, ERC, SVG/STEP/GLB/Gerber export).+//! - `kicad-packages3d` static asset serving (the ~5 GB model library).+//! - `kicad-symbols` + `kicad-footprints` static asset serving.+//!+//! This binary is **tool-agnostic by design** — it does NOT depend on+//! any aci-* crate. That means symbol-creator, footprint-creator,+//! library-creator, adom-tsci, adom-chipfit, adom-circuit, etc. all+//! hit the same endpoints with no ordering constraint on releases.+//!+//! Contract: `docs/service-kicad-api.md`.++use axum::{+ body::Bytes,+ extract::{DefaultBodyLimit, Path as AxumPath, Query, RawQuery},+ http::StatusCode,+ response::{IntoResponse, Response},+ routing::{get, post},+ Json, Router,+};+use serde::Deserialize;+use serde_json::{json, Value};+use std::{net::SocketAddr, path::PathBuf, process::Command};+use tempfile::NamedTempFile;+use tower_http::services::ServeDir;++const MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB++#[tokio::main]+async fn main() -> anyhow::Result<()> {+ tracing_subscriber::fmt::init();++ let models_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+ let footprints_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");+ let symbols_dir = dir_or_default("KICAD_SYMBOLS_DIR", "/usr/share/kicad/symbols");++ let app = Router::new()+ .route("/health", get(health))+ .route("/version", get(version))+ // kicad-cli pcb operations+ .route("/kicad/pcb/drc", post(pcb_drc))+ .route("/kicad/pcb/export/svg", post(pcb_export_svg))+ .route("/kicad/pcb/export/gerbers", post(pcb_export_gerbers))+ .route("/kicad/pcb/export/step", post(pcb_export_step))+ .route("/kicad/pcb/export/glb", post(pcb_export_glb))+ .route("/kicad/pcb/export/glb-zup", post(pcb_export_glb_zup))+ .route("/kicad/pcb/export/glb-package", post(pcb_export_glb_package))+ .route("/kicad/pcb/export/glb-package-zup", post(pcb_export_glb_package_zup))+ // kicad-cli sch operations+ .route("/kicad/sch/erc", post(sch_erc))+ .route("/kicad/sch/export/svg", post(sch_export_svg))+ .route("/kicad/sch/export/svg-project", post(sch_export_svg_project))+ .route("/kicad/sch/export/pdf", post(sch_export_pdf))+ .route("/kicad/sch/export/bom", post(sch_export_bom))+ // Altium → KiCad import (KiCad 10's plugin IO auto-detects the OLE+ // format). POST raw .SchLib → .kicad_sym; POST raw .PcbLib → .kicad_mod.+ .route("/kicad/altium/sym", post(altium_sym_upgrade))+ .route("/kicad/altium/fp", post(altium_fp_upgrade))+ // Symbol + footprint export by library/name — cached lookup.+ .route("/sym/export/svg/:library/:name", get(sym_export_svg))+ .route("/fp/export/svg/:library/:name", get(fp_export_svg))+ // Static library mounts+ .nest_service("/models", ServeDir::new(&models_dir))+ .nest_service("/footprints", ServeDir::new(&footprints_dir))+ .nest_service("/symbols", ServeDir::new(&symbols_dir))+ .layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES));++ let port: u16 = std::env::var("PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8780);+ let addr: SocketAddr = format!("0.0.0.0:{port}").parse()?;+ tracing::info!(%addr, "service-kicad listening");+ let listener = tokio::net::TcpListener::bind(addr).await?;+ axum::serve(listener, app).await?;+ Ok(())+}++fn dir_or_default(env_key: &str, default: &str) -> String {+ std::env::var(env_key).unwrap_or_else(|_| default.to_string())+}++async fn health() -> Json<Value> {+ let kicad_version = Command::new(kicad_cli())+ .arg("version")+ .output()+ .ok()+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .map(|s| s.trim().to_string())+ .unwrap_or_else(|| "unknown".into());+ Json(json!({+ "ok": true,+ "service": "service-kicad",+ "version": env!("CARGO_PKG_VERSION"),+ "kicad_version": kicad_version,+ "git_sha": GIT_SHA,+ "git_sha_short": git_sha_short(),+ }))+}++async fn version() -> Json<Value> {+ Json(json!({+ "service": "service-kicad",+ "version": env!("CARGO_PKG_VERSION"),+ "api_version": "1.0.0",+ "git_sha": GIT_SHA,+ "git_sha_short": git_sha_short(),+ }))+}++/// SHA the running binary was built from (baked in by api/build.rs).+/// Deploy verification: if this lags origin/main on GitHub, the prod+/// watchdog's fetch-rebuild loop is failing silently.+const GIT_SHA: &str = env!("GIT_SHA");++fn git_sha_short() -> &'static str {+ &GIT_SHA[..GIT_SHA.len().min(8)]+}++fn kicad_cli() -> String {+ std::env::var("KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into())+}++/// kicad-cli invocation wrapper — always sets KICAD{6,7,8,9,10}_3DMODEL_DIR+/// so STEP/GLB exports resolve component models. Matches the fix baked+/// into aci-validate (PLAN Part 25). Setting unused versions is a no-op,+/// so the same binary works on v9 and v10 containers.+fn run_kicad(args: &[&str]) -> Result<Vec<u8>, (StatusCode, String)> {+ let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+ let out = Command::new(kicad_cli())+ .args(args)+ .env("KICAD6_3DMODEL_DIR", &model_dir)+ .env("KICAD7_3DMODEL_DIR", &model_dir)+ .env("KICAD8_3DMODEL_DIR", &model_dir)+ .env("KICAD9_3DMODEL_DIR", &model_dir)+ .env("KICAD10_3DMODEL_DIR", &model_dir)+ .output()+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("spawn kicad-cli: {e}")))?;+ if !out.status.success() {+ let stderr = String::from_utf8_lossy(&out.stderr).into_owned();+ return Err((StatusCode::BAD_REQUEST, stderr));+ }+ Ok(out.stdout)+}++fn write_temp(body: &Bytes, ext: &str) -> Result<NamedTempFile, (StatusCode, String)> {+ let tmp = tempfile::Builder::new()+ .suffix(&format!(".{ext}"))+ .tempfile()+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("tempfile: {e}")))?;+ std::fs::write(tmp.path(), body)+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("write: {e}")))?;+ Ok(tmp)+}++fn err(code: StatusCode, message: &str) -> Response {+ (code, Json(json!({ "error": message, "code": code.as_u16() }))).into_response()+}++// ─── PCB operations ───────────────────────────────────────────────────++async fn pcb_drc(body: Bytes) -> Response {+ let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "pcb", "drc",+ pcb.path().to_str().unwrap_or_default(),+ "-o", out_tmp.path().to_str().unwrap_or_default(),+ "--format", "json",+ ]) { return err(c, &m); }+ let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()+}++#[derive(Deserialize)]+struct SvgQuery { side: Option<String>, layers: Option<String> }++/// Extract repeated `?D=name=value` pairs from a raw query string.+///+/// We can't use `Query<Vec<String>>` because axum's default Query+/// extractor uses serde_urlencoded, which collapses repeated keys into+/// a single value (and errors when deserializing into a Vec). Parsing+/// the raw query string ourselves keeps the API ergonomic from the+/// caller side (`?D=name1=value1&D=name2=value2`) without pulling in+/// a serde_qs / serde_html_form dependency just for this.+///+/// Same validation as before: each entry must contain `=`, the name+/// must match `[A-Za-z_][A-Za-z0-9_-]*`. Bad entries are silently+/// dropped (logging would be noise on a hot endpoint).+fn parse_define_pairs(raw: &Option<String>) -> Vec<(String, String)> {+ let Some(q) = raw.as_deref() else { return Vec::new() };+ let mut out = Vec::new();+ for pair in q.split('&') {+ let Some(rest) = pair.strip_prefix("D=") else { continue };+ // URL-decode the value portion. `name=value` is the SHAPE we+ // expect AFTER decoding, so decode first then split.+ let decoded = urlencoding::decode(rest).unwrap_or(std::borrow::Cow::Borrowed(rest)).into_owned();+ let Some(eq) = decoded.find('=') else { continue };+ let name = decoded[..eq].to_string();+ let value = decoded[eq + 1..].to_string();+ if name.is_empty() { continue }+ let valid = name.chars().enumerate().all(|(i, c)|+ (i == 0 && (c.is_ascii_alphabetic() || c == '_'))+ || (i > 0 && (c.is_ascii_alphanumeric() || c == '_' || c == '-'))+ );+ if !valid { continue }+ out.push((name, value));+ }+ out+}++/// Flatten parsed pairs into the `-D name=value` arg sequence.+fn define_var_args(pairs: &[(String, String)]) -> Vec<String> {+ let mut out = Vec::with_capacity(pairs.len() * 2);+ for (name, value) in pairs {+ out.push("-D".to_string());+ out.push(format!("{name}={value}"));+ }+ out+}++/// Allowed kicad-cli pcb export flags (boolean and value-bearing).+/// These are the flags from `kicad-cli pcb export glb --help` and+/// `kicad-cli pcb export step --help` (KiCad 10). Flags already+/// hardcoded (`--subst-models`, `--no-dnp`) are excluded — passing+/// them again is harmless but pointless.+///+/// Callers pass `?F=--include-pads&F=--no-board-body` in the query+/// string. Unknown flags are silently dropped.+const ALLOWED_PCB_EXPORT_FLAGS: &[&str] = &[+ // boolean flags+ "--board-only",+ "--cut-vias-in-body",+ "--fill-all-vias",+ "--fuse-shapes",+ "--grid-origin",+ "--drill-origin",+ "--include-inner-copper",+ "--include-pads",+ "--include-silkscreen",+ "--include-soldermask",+ "--include-tracks",+ "--include-zones",+ "--no-board-body",+ "--no-components",+ "--no-extra-pad-thickness",+ "--no-optimize-step",+ "--no-unspecified",+ // value flags (passed as --flag=value or --flag value)+ "--component-filter",+ "--min-distance",+ "--net-filter",+ "--user-origin",+ "--variant",+];++/// Parse `F=<flag>` entries from the raw query string. Each flag is+/// validated against `ALLOWED_PCB_EXPORT_FLAGS`. Boolean flags are+/// returned as-is; value flags (e.g. `F=--min-distance%3D0.01`) are+/// split into flag + value and returned as two entries.+fn parse_extra_flags(raw: &Option<String>) -> Vec<String> {+ let Some(q) = raw.as_deref() else { return Vec::new() };+ let mut out = Vec::new();+ for pair in q.split('&') {+ let Some(rest) = pair.strip_prefix("F=") else { continue };+ let decoded = urlencoding::decode(rest)+ .unwrap_or(std::borrow::Cow::Borrowed(rest))+ .into_owned();+ // Split on first '=' after the flag name: "--min-distance=0.01"+ if let Some(eq) = decoded.strip_prefix("--").and_then(|s| s.find('=').map(|i| i + 2)) {+ let flag_name = &decoded[..eq];+ let flag_value = &decoded[eq + 1..];+ if ALLOWED_PCB_EXPORT_FLAGS.contains(&flag_name) {+ out.push(flag_name.to_string());+ out.push(flag_value.to_string());+ }+ } else if ALLOWED_PCB_EXPORT_FLAGS.contains(&decoded.as_str()) {+ out.push(decoded);+ }+ }+ out+}++async fn pcb_export_svg(Query(q): Query<SvgQuery>, RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_tmp = match NamedTempFile::with_suffix(".svg") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+ // Side defaults to top; layer set matches aci-validate's default. Callers can+ // override the layer set with `?layers=F.Cu,F.Mask,...` (comma-separated KiCad+ // layer names); we allow only layer-name characters so it's safe to forward.+ let side = q.side.unwrap_or_else(|| "top".into());+ let default_layers = if side == "bottom" { "B.Cu,B.Silkscreen,Edge.Cuts" } else { "F.Cu,F.Silkscreen,Edge.Cuts" };+ let valid = |l: &str| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ',' | '_'));+ let custom = q.layers.as_deref().map(valid).unwrap_or(false);+ let layers = if custom { q.layers.as_deref().unwrap() } else { default_layers };+ let pairs = parse_define_pairs(&raw);+ let var_args = define_var_args(&pairs);+ let mut args: Vec<&str> = vec![+ "pcb", "export", "svg",+ pcb.path().to_str().unwrap_or_default(),+ "-o", out_tmp.path().to_str().unwrap_or_default(),+ "--layers", layers,+ "--mode-single",+ ];+ // a custom layer set is a board-preview request → drop the paper frame + title block+ if custom { args.push("--exclude-drawing-sheet"); }+ for s in &var_args { args.push(s); }+ if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+ let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++/// Altium symbol library (`.SchLib`, OLE binary) → KiCad `.kicad_sym`.+/// KiCad 10's `sym upgrade` runs every SCH_IO plugin's CanRead probe, so it+/// auto-detects and converts the Altium format — no Altium install needed.+/// The input MUST keep its `.SchLib` extension for the plugin to bite.+async fn altium_sym_upgrade(body: Bytes) -> Response {+ let lib = match write_temp(&body, "SchLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ // kicad-cli refuses to write to a path that already exists, so the output+ // must NOT be pre-created — hand it a fresh name inside a temp dir.+ let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ let out_path = out_dir.path().join("out.kicad_sym");+ let args = vec![+ "sym", "upgrade", "--force",+ "-o", out_path.to_str().unwrap_or_default(),+ lib.path().to_str().unwrap_or_default(),+ ];+ if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+ let bytes = match std::fs::read(&out_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ if bytes.is_empty() { return err(StatusCode::BAD_REQUEST, "kicad-cli produced an empty .kicad_sym (no symbol in library?)"); }+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()+}++/// Altium footprint library (`.PcbLib`, OLE binary) → KiCad `.kicad_mod`.+/// `fp upgrade` writes a `.pretty` directory of `.kicad_mod` files; a single+/// part library yields one, which we return. (Multi-footprint libraries return+/// the first — chip-fetcher libraries are single-part.)+async fn altium_fp_upgrade(body: Bytes) -> Response {+ let lib = match write_temp(&body, "PcbLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ // -o must be a .pretty dir that does NOT exist yet — kicad-cli creates it+ // and refuses if the path already exists. Nest it inside a temp dir.+ let work = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ let out_pretty = work.path().join("out.pretty");+ let args = vec![+ "fp", "upgrade", "--force",+ "-o", out_pretty.to_str().unwrap_or_default(),+ lib.path().to_str().unwrap_or_default(),+ ];+ if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+ // The .pretty output dir holds one or more .kicad_mod files; pick the first.+ let mod_path = std::fs::read_dir(&out_pretty)+ .ok()+ .and_then(|rd| rd.flatten()+ .map(|e| e.path())+ .find(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)));+ let Some(mod_path) = mod_path else {+ return err(StatusCode::BAD_REQUEST, "kicad-cli produced no .kicad_mod (no footprint in library?)");+ };+ let bytes = match std::fs::read(&mod_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()+}++async fn pcb_export_gerbers(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ let pairs = parse_define_pairs(&raw);+ let var_args = define_var_args(&pairs);+ let mut args: Vec<&str> = vec![+ "pcb", "export", "gerbers",+ pcb.path().to_str().unwrap_or_default(),+ "-o", out_dir.path().to_str().unwrap_or_default(),+ "--layers", "F.Cu,B.Cu,F.Mask,B.Mask,F.Silkscreen,B.Silkscreen,Edge.Cuts",+ ];+ for s in &var_args { args.push(s); }+ if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+ // Drill file is a separate kicad-cli subcommand; emit Excellon+ // (PTH+NPTH merged, metric, decimal) into the same tempdir so the+ // tar-roll picks it up. Without drills, pcb-stackup downstream+ // can't punch holes through the substrate/mask/pads — which is+ // what we want for any preview render or fab pack. Failures here+ // are non-fatal: a drill-less gerber set is still useful for the+ // copper / mask / silk preview, just without holes.+ // --generate-map is a boolean flag with NO argument in kicad-cli 9 + 10.+ // Passing "false" as a value makes the CLI parse it as the INPUT_FILE+ // positional, which then errors with "Maximum number of positional+ // arguments exceeded". Default is no-map, so just omit the flag.+ let _ = run_kicad(&[+ "pcb", "export", "drill",+ pcb.path().to_str().unwrap_or_default(),+ "-o", out_dir.path().to_str().unwrap_or_default(),+ "--format", "excellon",+ "--excellon-units", "mm",+ "--excellon-zeros-format", "decimal",+ ]);+ // Tar the directory for transport.+ let tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };+ let status = Command::new("tar")+ .args(["-cf", tar.path().to_str().unwrap_or_default(), "-C", out_dir.path().to_str().unwrap_or_default(), "."])+ .status();+ match status {+ Ok(s) if s.success() => {}+ Ok(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar failed"),+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+ }+ let bytes = match std::fs::read(tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read tar: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()+}++async fn pcb_export_step(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pairs = parse_define_pairs(&raw);+ let flags = parse_extra_flags(&raw);+ kicad_pcb_binary_export(body, "step", "model/step", &pairs, &flags)+}+async fn pcb_export_glb(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pairs = parse_define_pairs(&raw);+ let flags = parse_extra_flags(&raw);+ let resp = kicad_pcb_binary_export(body, "glb", "model/gltf-binary", &pairs, &flags);+ glb_stamp_provenance_response(resp).await+}+async fn pcb_export_glb_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pairs = parse_define_pairs(&raw);+ let flags = parse_extra_flags(&raw);+ let resp = kicad_pcb_binary_export(body, "glb", "model/gltf-binary", &pairs, &flags);+ let resp = glb_apply_zup(resp).await;+ glb_stamp_provenance_response(resp).await+}++/// GLB export from a *packaged* board: tar.gz body containing a `.kicad_pcb`+/// plus any sibling files it references (typically `3dmodels/*.step`). The+/// archive is extracted to a tempdir and `KIPRJMOD` is set to the directory+/// containing the `.kicad_pcb`, so model paths like+/// `${KIPRJMOD}/3dmodels/MachineContactLarge.step` resolve correctly during+/// `kicad-cli pcb export glb --subst-models`.+///+/// The flat /glb endpoint can't do this — it sees only the .kicad_pcb bytes+/// and KIPRJMOD points at a tempdir with no siblings, so any+/// project-relative model is rendered as a bare hole. This endpoint is the+/// fix for that case (driven by adom-inc/kicad-to-molecule moleculizer+/// packages).+async fn pcb_export_glb_package(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let pairs = parse_define_pairs(&raw);+ let flags = parse_extra_flags(&raw);+ let workdir = match tempfile::tempdir() {+ Ok(d) => d,+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")),+ };+ let tar = match write_temp(&body, "tar.gz") {+ Ok(t) => t,+ Err((c, m)) => return err(c, &m),+ };+ let extract = Command::new("tar")+ .args([+ "-xzf",+ tar.path().to_str().unwrap_or_default(),+ "-C",+ workdir.path().to_str().unwrap_or_default(),+ ])+ .output();+ match extract {+ Ok(o) if o.status.success() => {}+ Ok(o) => return err(+ StatusCode::BAD_REQUEST,+ &format!("tar -xzf failed: {}", String::from_utf8_lossy(&o.stderr)),+ ),+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+ }++ let pcb = match find_first_with_extension(workdir.path(), "kicad_pcb") {+ Some(p) => p,+ None => return err(StatusCode::BAD_REQUEST, "no .kicad_pcb file inside archive"),+ };+ let kiprjmod = pcb.parent().map(PathBuf::from).unwrap_or_else(|| workdir.path().to_path_buf());++ let out_tmp = match NamedTempFile::with_suffix(".glb") {+ Ok(t) => t,+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")),+ };++ let var_args = define_var_args(&pairs);+ let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+ let pcb_str = pcb.to_str().unwrap_or_default().to_string();+ let out_str = out_tmp.path().to_str().unwrap_or_default().to_string();+ let mut args: Vec<String> = vec![+ "pcb".into(), "export".into(), "glb".into(),+ pcb_str, "-o".into(), out_str,+ "--subst-models".into(), "--no-dnp".into(),+ ];+ for s in &var_args { args.push(s.clone()); }+ for s in &flags { args.push(s.clone()); }+ let result = Command::new(kicad_cli())+ .args(&args)+ .env("KICAD6_3DMODEL_DIR", &model_dir)+ .env("KICAD7_3DMODEL_DIR", &model_dir)+ .env("KICAD8_3DMODEL_DIR", &model_dir)+ .env("KICAD9_3DMODEL_DIR", &model_dir)+ .env("KICAD10_3DMODEL_DIR", &model_dir)+ .env("KIPRJMOD", &kiprjmod)+ .output();+ let out = match result {+ Ok(o) => o,+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("spawn kicad-cli: {e}")),+ };+ if !out.status.success() {+ let stderr = String::from_utf8_lossy(&out.stderr).into_owned();+ return err(StatusCode::BAD_REQUEST, &stderr);+ }+ let bytes = match std::fs::read(out_tmp.path()) {+ Ok(b) => b,+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")),+ };+ let resp = (+ StatusCode::OK,+ [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")],+ bytes,+ )+ .into_response();+ glb_stamp_provenance_response(resp).await+}++async fn pcb_export_glb_package_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {+ let resp = pcb_export_glb_package(RawQuery(raw), body).await;+ glb_apply_zup(resp).await+ // zup output is already stamped: pcb_export_glb_package stamps before+ // the rotation, and both edits merge into asset.extras.adom.+}++/// Apply Y-up → Z-up rotation to a GLB response. Passes errors through.+async fn glb_apply_zup(resp: Response) -> Response {+ let (parts, body) = resp.into_parts();+ if !parts.status.is_success() {+ return Response::from_parts(parts, body);+ }+ let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {+ Ok(b) => b.to_vec(),+ Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),+ };+ match glb_rotate_y_to_z(bytes) {+ Ok(rotated) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")], rotated).into_response(),+ Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &format!("glb rotation: {e}")),+ }+}++/// Parse a GLB, apply `edit` to its JSON chunk, and reassemble with the+/// binary chunk untouched (JSON chunk re-padded to 4-byte alignment).+fn glb_edit_json(glb: Vec<u8>, edit: impl FnOnce(&mut Value) -> Result<(), String>) -> Result<Vec<u8>, String> {+ if glb.len() < 20 { return Err("GLB too short".into()); }+ let magic = u32::from_le_bytes(glb[0..4].try_into().unwrap());+ if magic != 0x46546C67 { return Err("not a GLB".into()); }+ let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;+ let json_type = u32::from_le_bytes(glb[16..20].try_into().unwrap());+ if json_type != 0x4E4F534A { return Err("first chunk not JSON".into()); }+ let json_end = 20 + json_len;+ if glb.len() < json_end { return Err("truncated JSON chunk".into()); }+ let json_str = std::str::from_utf8(&glb[20..json_end]).map_err(|e| e.to_string())?;+ let mut doc: Value = serde_json::from_str(json_str).map_err(|e| e.to_string())?;++ edit(&mut doc)?;++ let new_json = serde_json::to_vec(&doc).map_err(|e| e.to_string())?;+ let padded_len = (new_json.len() + 3) & !3;+ let mut padded = new_json;+ padded.resize(padded_len, b' ');+ let bin_chunk = &glb[json_end..];+ let total = 12 + 8 + padded.len() + bin_chunk.len();+ let mut out = Vec::with_capacity(total);+ out.extend_from_slice(&0x46546C67u32.to_le_bytes());+ out.extend_from_slice(&2u32.to_le_bytes());+ out.extend_from_slice(&(total as u32).to_le_bytes());+ out.extend_from_slice(&(padded.len() as u32).to_le_bytes());+ out.extend_from_slice(&0x4E4F534Au32.to_le_bytes());+ out.extend_from_slice(&padded);+ out.extend_from_slice(bin_chunk);+ Ok(out)+}++/// Merge keys into `asset.extras.adom` without clobbering what other+/// edits (zup rotation, provenance stamp) already wrote there.+fn merge_extras_adom(doc: &mut Value, entries: Value) -> Result<(), String> {+ if doc.get("asset").is_none() { doc["asset"] = json!({}); }+ let asset = doc.get_mut("asset").ok_or("no asset")?;+ if !asset.get("extras").map(Value::is_object).unwrap_or(false) { asset["extras"] = json!({}); }+ let extras = asset.get_mut("extras").ok_or("no extras")?;+ if !extras.get("adom").map(Value::is_object).unwrap_or(false) { extras["adom"] = json!({}); }+ let adom = extras.get_mut("adom").and_then(|v| v.as_object_mut()).ok_or("adom extras not an object")?;+ let obj = entries.as_object().ok_or("entries not an object")?;+ for (k, v) in obj { adom.insert(k.clone(), v.clone()); }+ Ok(())+}++/// Rotate a GLB binary from Y-up (glTF default) to Z-up by wrapping+/// all scene root nodes under a new parent with +90° X rotation.+/// Merges `asset.extras.adom.zUp = true` as metadata.+fn glb_rotate_y_to_z(glb: Vec<u8>) -> Result<Vec<u8>, String> {+ glb_edit_json(glb, |doc| {+ let s = std::f64::consts::FRAC_PI_4.sin();+ let c = std::f64::consts::FRAC_PI_4.cos();++ // Read scene_idx and old_roots before taking mutable borrows+ let scene_idx = doc.get("scene").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+ let old_roots = doc.get("scenes")+ .and_then(|s| s.as_array())+ .and_then(|a| a.get(scene_idx))+ .and_then(|s| s.get("nodes"))+ .and_then(|n| n.as_array())+ .cloned()+ .unwrap_or_default();++ // Push new root node+ let root_idx = doc.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);+ doc.get_mut("nodes")+ .and_then(|n| n.as_array_mut())+ .ok_or("no nodes")?+ .push(json!({ "name": "zup_root", "rotation": [s, 0.0, 0.0, c], "children": old_roots }));++ // Rewrite scene roots+ doc.get_mut("scenes")+ .and_then(|s| s.as_array_mut())+ .and_then(|a| a.get_mut(scene_idx))+ .ok_or("scene index OOB")?+ ["nodes"] = json!([root_idx]);++ merge_extras_adom(doc, json!({"zUp": true}))+ })+}++/// Provenance marker for KiCad-native GLB output (page issue #106).+/// These exports bypass the molecule pipeline (no MP-marker anchoring,+/// gold machine-pin material, meter normalization, or footprint emit),+/// so every GLB that leaves this service is stamped in+/// `asset.extras.adom` where downstream importers — and humans with+/// `strings board.glb | grep provenance` — can catch it.+const GLB_PROVENANCE: &str = "kicad-native-glb";+const GLB_PROVENANCE_WARNING: &str =+ "Exported by kicad-cli via service-kicad, NOT the molecule pipeline: \+ no MP-marker anchoring, gold machine-pin material, meter normalization, \+ or footprint emit. For molecule use, export STEP here and convert with \+ step2glb/service-occt. See wiki.adom.inc/adom/service-kicad issue #106.";++fn glb_stamp_provenance(glb: Vec<u8>) -> Result<Vec<u8>, String> {+ glb_edit_json(glb, |doc| {+ merge_extras_adom(doc, json!({+ "provenance": GLB_PROVENANCE,+ "provenanceWarning": GLB_PROVENANCE_WARNING,+ "generator": format!("service-kicad {}", git_sha_short()),+ }))+ })+}++/// Stamp a successful GLB response body and add the X-Adom-Provenance+/// header. A stamp failure must never break the export — the endpoints+/// stay behaviorally identical for existing consumers — so on error the+/// original bytes go out with the header as the only marker.+async fn glb_stamp_provenance_response(resp: Response) -> Response {+ let (parts, body) = resp.into_parts();+ if !parts.status.is_success() {+ return Response::from_parts(parts, body);+ }+ let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {+ Ok(b) => b.to_vec(),+ Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),+ };+ let stamped = match glb_stamp_provenance(bytes.clone()) {+ Ok(s) => s,+ Err(e) => {+ tracing::warn!("glb provenance stamp failed ({e}) — returning unstamped");+ bytes+ }+ };+ (+ StatusCode::OK,+ [+ (axum::http::header::CONTENT_TYPE, "model/gltf-binary"),+ (axum::http::header::HeaderName::from_static("x-adom-provenance"), GLB_PROVENANCE),+ ],+ stamped,+ )+ .into_response()+}++/// BFS the directory for the first file with the given extension.+fn find_first_with_extension(root: &std::path::Path, ext: &str) -> Option<PathBuf> {+ use std::collections::VecDeque;+ let mut queue: VecDeque<PathBuf> = VecDeque::new();+ queue.push_back(root.to_path_buf());+ while let Some(d) = queue.pop_front() {+ let entries = match std::fs::read_dir(&d) {+ Ok(e) => e,+ Err(_) => continue,+ };+ for entry in entries.flatten() {+ let p = entry.path();+ if p.is_dir() {+ queue.push_back(p);+ continue;+ }+ if p.extension().and_then(|s| s.to_str()) == Some(ext) {+ return Some(p);+ }+ }+ }+ None+}++fn kicad_pcb_binary_export(body: Bytes, kind: &str, content_type: &'static str, pairs: &[(String, String)], flags: &[String]) -> Response {+ let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_tmp = match NamedTempFile::with_suffix(&format!(".{kind}")) { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+ let var_args = define_var_args(pairs);+ let mut args: Vec<&str> = vec![+ "pcb", "export", kind,+ pcb.path().to_str().unwrap_or_default(),+ "-o", out_tmp.path().to_str().unwrap_or_default(),+ "--subst-models", "--no-dnp",+ ];+ for s in &var_args { args.push(s); }+ for s in flags { args.push(s); }+ if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+ let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()+}++// ─── Schematic operations ─────────────────────────────────────────────++async fn sch_erc(body: Bytes) -> Response {+ let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "sch", "erc",+ sch.path().to_str().unwrap_or_default(),+ "-o", out_tmp.path().to_str().unwrap_or_default(),+ "--format", "json",+ ]) { return err(c, &m); }+ let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()+}++async fn sch_export_svg(body: Bytes) -> Response { kicad_sch_file_export(body, "svg", "image/svg+xml") }+async fn sch_export_pdf(body: Bytes) -> Response { kicad_sch_file_export(body, "pdf", "application/pdf") }++fn kicad_sch_file_export(body: Bytes, kind: &str, content_type: &'static str) -> Response {+ let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "sch", "export", kind,+ sch.path().to_str().unwrap_or_default(),+ "-o", out_dir.path().to_str().unwrap_or_default(),+ ]) { return err(c, &m); }+ // Pick the first emitted file matching the extension.+ let found = std::fs::read_dir(out_dir.path()).ok()+ .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some(kind))+ .map(|e| e.path()));+ let path = match found { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "no file produced") };+ let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()+}++/// Render a whole hierarchical schematic. The body is a `.tar` of all the+/// project's `.kicad_sch` (+ optional `.kicad_pro`) files, preserving filenames+/// so kicad-cli can resolve the sheet hierarchy (a single-file render can't).+/// Returns a `.tar` of one SVG per sheet.+async fn sch_export_svg_project(body: Bytes) -> Response {+ let workdir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ let src = workdir.path().join("src");+ if let Err(e) = std::fs::create_dir_all(&src) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }+ let tar = match write_temp(&body, "tar") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ match Command::new("tar").args(["-xf", tar.path().to_str().unwrap_or_default(), "-C", src.to_str().unwrap_or_default()]).output() {+ Ok(o) if o.status.success() => {}+ Ok(o) => return err(StatusCode::BAD_REQUEST, &format!("tar -xf failed: {}", String::from_utf8_lossy(&o.stderr))),+ Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+ }+ let entries: Vec<PathBuf> = std::fs::read_dir(&src).ok().map(|d| d.flatten().map(|e| e.path()).collect()).unwrap_or_default();+ let is_ext = |p: &PathBuf, e: &str| p.extension().and_then(|s| s.to_str()) == Some(e);+ // root = the .kicad_sch whose stem matches the .kicad_pro, else the first sheet+ let pro_stem = entries.iter().find(|p| is_ext(p, "kicad_pro")).and_then(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from));+ let root = pro_stem.as_ref()+ .and_then(|st| entries.iter().find(|p| is_ext(p, "kicad_sch") && p.file_stem().and_then(|s| s.to_str()) == Some(st.as_str())).cloned())+ .or_else(|| entries.iter().find(|p| is_ext(p, "kicad_sch")).cloned());+ let root = match root { Some(r) => r, None => return err(StatusCode::BAD_REQUEST, "no .kicad_sch inside archive") };+ let out = workdir.path().join("out");+ if let Err(e) = std::fs::create_dir_all(&out) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }+ if let Err((c, m)) = run_kicad(&["sch", "export", "svg", root.to_str().unwrap_or_default(), "-o", out.to_str().unwrap_or_default()]) { return err(c, &m); }+ let out_tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };+ match Command::new("tar").args(["-cf", out_tar.path().to_str().unwrap_or_default(), "-C", out.to_str().unwrap_or_default(), "."]).status() {+ Ok(s) if s.success() => {}+ _ => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar output failed"),+ }+ let bytes = match std::fs::read(out_tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()+}++async fn sch_export_bom(body: Bytes) -> Response {+ let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+ let out_tmp = match NamedTempFile::with_suffix(".csv") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "sch", "export", "bom",+ sch.path().to_str().unwrap_or_default(),+ "-o", out_tmp.path().to_str().unwrap_or_default(),+ ]) { return err(c, &m); }+ let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/csv")], bytes).into_response()+}++// ─── Library lookup — by library+name convenience wrappers ────────────++async fn sym_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {+ let symbols_dir = dir_or_default("KICAD_SYMBOLS_DIR", "/usr/share/kicad/symbols");+ let src = PathBuf::from(&symbols_dir).join(format!("{library}.kicad_sym"));+ if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("symbol library {library} not installed")); }+ // `kicad-cli sym export svg -o` wants a DIRECTORY on both v9 and v10+ // (help text: "Output directory"). It emits `<symbol>_unit1.svg` etc.+ // The pre-7ce0f04 form passed a NamedTempFile path here, which kicad+ // tried to create files inside — silently broken on v9 too.+ let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "sym", "export", "svg",+ src.to_str().unwrap_or_default(),+ "-o", out_dir.path().to_str().unwrap_or_default(),+ "--symbol", &name,+ ]) { return err(c, &m); }+ let path = std::fs::read_dir(out_dir.path()).ok()+ .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));+ let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };+ let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++async fn fp_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {+ let fp_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");+ let src = PathBuf::from(&fp_dir).join(format!("{library}.pretty"));+ if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint library {library} not installed")); }+ let mod_path = src.join(format!("{name}.kicad_mod"));+ if !mod_path.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint {library}/{name} not installed")); }+ // `kicad-cli fp export svg` takes the .pretty DIRECTORY + `--footprint+ // <name>` on both v9 and v10 (help text: "Input directory"). The+ // pre-7ce0f04 form passed a single .kicad_mod file, which v9 also+ // rejected with "Footprint library does not exist or is not accessible".+ let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+ if let Err((c, m)) = run_kicad(&[+ "fp", "export", "svg",+ src.to_str().unwrap_or_default(),+ "-o", out_dir.path().to_str().unwrap_or_default(),+ "--footprint", &name,+ ]) { return err(c, &m); }+ let path = std::fs::read_dir(out_dir.path()).ok()+ .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));+ let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };+ let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+ (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++#[cfg(test)]+mod tests {+ use super::*;++ /// Minimal valid GLB: JSON chunk + empty BIN chunk.+ fn tiny_glb() -> Vec<u8> {+ let json = serde_json::to_vec(&json!({+ "asset": {"version": "2.0"},+ "scene": 0,+ "scenes": [{"nodes": [0]}],+ "nodes": [{"name": "board"}],+ })).unwrap();+ let padded_len = (json.len() + 3) & !3;+ let mut padded = json;+ padded.resize(padded_len, b' ');+ let total = 12 + 8 + padded.len();+ let mut glb = Vec::with_capacity(total);+ glb.extend_from_slice(&0x46546C67u32.to_le_bytes());+ glb.extend_from_slice(&2u32.to_le_bytes());+ glb.extend_from_slice(&(total as u32).to_le_bytes());+ glb.extend_from_slice(&(padded.len() as u32).to_le_bytes());+ glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes());+ glb.extend_from_slice(&padded);+ glb+ }++ fn json_chunk(glb: &[u8]) -> Value {+ let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;+ serde_json::from_slice(&glb[20..20 + json_len]).unwrap()+ }++ #[test]+ fn stamp_sets_provenance() {+ let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+ let doc = json_chunk(&stamped);+ let adom = &doc["asset"]["extras"]["adom"];+ assert_eq!(adom["provenance"], GLB_PROVENANCE);+ assert!(adom["provenanceWarning"].as_str().unwrap().contains("molecule pipeline"));+ assert!(adom["generator"].as_str().unwrap().starts_with("service-kicad "));+ }++ #[test]+ fn zup_then_stamp_coexist() {+ let rotated = glb_rotate_y_to_z(tiny_glb()).unwrap();+ let stamped = glb_stamp_provenance(rotated).unwrap();+ let doc = json_chunk(&stamped);+ let adom = &doc["asset"]["extras"]["adom"];+ assert_eq!(adom["zUp"], true);+ assert_eq!(adom["provenance"], GLB_PROVENANCE);+ // rotation actually applied+ assert_eq!(doc["nodes"].as_array().unwrap().len(), 2);+ assert_eq!(doc["scenes"][0]["nodes"], json!([1]));+ }++ #[test]+ fn stamp_then_zup_coexist() {+ let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+ let rotated = glb_rotate_y_to_z(stamped).unwrap();+ let adom = &json_chunk(&rotated)["asset"]["extras"]["adom"];+ assert_eq!(adom["provenance"], GLB_PROVENANCE);+ assert_eq!(adom["zUp"], true);+ }++ #[test]+ fn stamp_rejects_non_glb() {+ assert!(glb_stamp_provenance(b"not a glb at all".to_vec()).is_err());+ }++ #[test]+ fn json_chunk_stays_aligned() {+ let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+ let json_len = u32::from_le_bytes(stamped[12..16].try_into().unwrap()) as usize;+ assert_eq!(json_len % 4, 0);+ let total = u32::from_le_bytes(stamped[8..12].try_into().unwrap()) as usize;+ assert_eq!(total, stamped.len());+ }+}
cli/Cargo.tomladded+20@@ -0,0 +1,20 @@+[package]+name = "service-kicad"+version.workspace = true+edition.workspace = true+license.workspace = true+repository.workspace = true+authors.workspace = true+description = "CLI for service-kicad — every Adom tool shells to this binary instead of calling the HTTP API directly. See PLAN Part 29."++[[bin]]+name = "service-kicad"+path = "src/main.rs"++[dependencies]+anyhow.workspace = true+clap = { version = "4", features = ["derive"] }+reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }+serde.workspace = true+serde_json.workspace = true+tempfile.workspace = true
cli/build.rsadded+42@@ -0,0 +1,42 @@+//! Bake the production service-kicad URL into the binary at compile time.+//!+//! Precedence:+//! 1. $SERVICE_KICAD_URL (set by wiki-publish or release CI).+//! 2. The `url` field from /service-kicad/service.json (updated by+//! deploy.sh at container provisioning).+//! 3. Local dev fallback: `http://127.0.0.1:8780`.+//!+//! Binaries built without an explicit URL default to localhost so the+//! `service-kicad --version` smoke still passes in a plain `cargo build`.++use std::{fs, path::PathBuf};++fn main() {+ println!("cargo:rerun-if-env-changed=SERVICE_KICAD_URL");+ println!("cargo:rerun-if-changed=../service.json");++ let url = std::env::var("SERVICE_KICAD_URL")+ .or_else(|_| read_url_from_service_json())+ .unwrap_or_else(|_| "http://127.0.0.1:8780".to_string());++ println!("cargo:rustc-env=SERVICE_KICAD_URL={}", url);+}++fn read_url_from_service_json() -> Result<String, std::io::Error> {+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));+ let svc_json = manifest.parent().and_then(|p| p.join("service.json").canonicalize().ok());+ let Some(path) = svc_json else {+ return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "service.json"));+ };+ let contents = fs::read_to_string(&path)?;+ // Minimal JSON scrape — avoids pulling serde_json into the build+ // dependency graph. Looks for `"url": "…"`.+ for line in contents.lines() {+ if let Some(rest) = line.split_once("\"url\"") {+ if let Some(v) = rest.1.split('"').nth(1) {+ return Ok(v.to_string());+ }+ }+ }+ Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no url in service.json"))+}
cli/src/main.rsadded+504@@ -0,0 +1,504 @@+//! `service-kicad` CLI — every Adom tool shells to this binary instead of+//! calling the HTTP API directly. Base URL is baked at build time from+//! `$SERVICE_KICAD_URL` or `../service.json::url`; at runtime+//! `$SERVICE_KICAD_URL` (or the legacy `$KICAD_SERVICE_API`) overrides it,+//! which is how a self-hosted instance is addressed.+//!+//! See PLAN Part 29.4 for the canonical command surface.++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use reqwest::blocking::Client;+use std::{io::Write, path::PathBuf, time::Duration};++const PROD_URL: &str = env!("SERVICE_KICAD_URL");++#[derive(Parser)]+#[command(name = "service-kicad", version, about = "Shared headless KiCad CLI — see PLAN Part 29")]+struct Cli {+ #[command(subcommand)]+ command: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// GET /health.+ Health,+ /// GET /version.+ Version,+ /// Print the effective service URL (verify env override).+ Config,++ /// PCB operations (body = `.kicad_pcb` file on disk).+ #[command(subcommand)]+ Pcb(PcbCmd),++ /// Schematic operations (body = `.kicad_sch` file on disk).+ #[command(subcommand)]+ Sch(SchCmd),++ /// Symbol library lookup.+ #[command(subcommand)]+ Sym(SymCmd),++ /// Footprint library lookup.+ #[command(subcommand)]+ Fp(FpCmd),++ /// Download a static model file from kicad-packages3d.+ Model {+ #[command(subcommand)]+ cmd: ModelCmd,+ },++ /// Altium library upgrades (body = `.SchLib` / `.PcbLib` on disk).+ #[command(subcommand)]+ Altium(AltiumCmd),++ /// Drop SKILL.md + bash/zsh completions into ~/.claude/skills/ ++ /// ~/.bash_completion.d/ (tool-publisher pattern).+ Install {+ #[arg(long)] skill_only: bool,+ #[arg(long)] completions_only: bool,+ },+}++#[derive(Subcommand)]+enum PcbCmd {+ /// POST /kicad/pcb/drc.+ Drc {+ file: PathBuf,+ #[arg(long, default_value = "text")] format: String,+ #[arg(long)] out: Option<PathBuf>,+ },+ /// POST /kicad/pcb/export/{svg,gerbers,step}.+ #[command(subcommand)]+ Export(PcbExportCmd),+}++#[derive(Subcommand)]+enum PcbExportCmd {+ Svg {+ file: PathBuf,+ #[arg(long, default_value = "top")] side: String,+ /// Comma-separated KiCad layer names (e.g. F.Cu,F.Mask,Edge.Cuts).+ /// Overrides the side's default set; drops the drawing sheet.+ #[arg(long)] layers: Option<String>,+ #[arg(long)] out: Option<PathBuf>,+ },+ Gerbers { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+ Step { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String> },+ // Gated per page issue #106: KiCad-native GLB bypasses the molecule+ // pipeline (no anchoring, gold pins, normalization, footprint emit).+ // Hidden from help and refused without the acknowledgment flag, so+ // agents that reach for the old verb get routed to STEP + step2glb.+ // Output GLBs are provenance-stamped server-side (asset.extras.adom).+ #[command(hide = true)]+ Glb { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },+ #[command(hide = true)]+ GlbZup { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },+}++#[derive(Subcommand)]+enum SchCmd {+ /// POST /kicad/sch/erc.+ Erc {+ file: PathBuf,+ #[arg(long, default_value = "text")] format: String,+ #[arg(long)] out: Option<PathBuf>,+ },+ #[command(subcommand)]+ Export(SchExportCmd),+}++#[derive(Subcommand)]+enum SchExportCmd {+ Svg { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+ /// Hierarchical project → one SVG per sheet. DIR is the project+ /// directory (.kicad_pro + all .kicad_sch); output is a directory.+ SvgProject { dir: PathBuf, #[arg(long)] out: Option<PathBuf> },+ Pdf { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+ Bom { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum SymCmd {+ /// GET /sym/export/svg/:library/:name.+ Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+ /// GET /symbols/<library>.kicad_sym (raw fetch).+ Fetch { library: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum FpCmd {+ /// GET /fp/export/svg/:library/:name.+ Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+ /// GET /footprints/<library>.pretty/<name>.kicad_mod.+ Fetch { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum ModelCmd {+ /// GET /models/<path>.+ Fetch { path: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum AltiumCmd {+ /// POST /kicad/altium/sym — Altium `.SchLib` → KiCad `.kicad_sym`.+ Sym { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+ /// POST /kicad/altium/fp — Altium `.PcbLib` → KiCad `.kicad_mod`+ /// (single-part libraries; multi-part returns the first footprint).+ Fp { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+}++// ─── Transport ────────────────────────────────────────────────────────++/// Resolve the service URL. Order: $SERVICE_KICAD_URL env →+/// $KICAD_SERVICE_API env (legacy alias) → baked-in production URL.+/// Trailing slash trimmed. The env override is how a self-hosted+/// instance is addressed — same commands, your container.+fn base_url() -> String {+ std::env::var("SERVICE_KICAD_URL")+ .or_else(|_| std::env::var("KICAD_SERVICE_API"))+ .unwrap_or_else(|_| PROD_URL.to_string())+ .trim_end_matches('/')+ .to_string()+}++fn http_client() -> Client {+ Client::builder()+ .timeout(Duration::from_secs(120))+ .build()+ .expect("reqwest client")+}++/// Exit 2 on unreachable service, 3 on auth failure, 4 on any other+/// service-side error. 1 is reserved for input validation.+fn http_error_exit_code(status: u16) -> i32 {+ match status {+ 401 | 403 => 3,+ _ => 4,+ }+}++fn get_bytes(path: &str) -> Result<Vec<u8>> {+ let url = format!("{}{}", base_url(), path);+ let resp = http_client().get(&url).send().unwrap_or_else(|e| {+ eprintln!("service-kicad: unreachable ({e})");+ std::process::exit(2);+ });+ let status = resp.status();+ if !status.is_success() {+ eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());+ std::process::exit(http_error_exit_code(status.as_u16()));+ }+ Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())+}++fn flags_to_query_string(flags: &[String]) -> String {+ if flags.is_empty() { return String::new(); }+ let params: Vec<String> = flags.iter().map(|f| {+ let encoded = f.replace('%', "%25").replace('&', "%26").replace('=', "%3D").replace(' ', "%20");+ format!("F={encoded}")+ }).collect();+ format!("?{}", params.join("&"))+}++fn post_file_bytes(path: &str, file: &PathBuf) -> Result<Vec<u8>> {+ let body = std::fs::read(file)+ .with_context(|| format!("read {}", file.display()))?;+ let url = format!("{}{}", base_url(), path);+ let resp = http_client().post(&url).body(body).send().unwrap_or_else(|e| {+ eprintln!("service-kicad: unreachable ({e})");+ std::process::exit(2);+ });+ let status = resp.status();+ if !status.is_success() {+ eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());+ std::process::exit(http_error_exit_code(status.as_u16()));+ }+ Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())+}++#[allow(dead_code)]+fn _unused() -> Result<()> {+ Ok(())+}++fn write_or_stdout(bytes: &[u8], out: Option<&PathBuf>) -> Result<()> {+ match out {+ Some(p) => {+ std::fs::write(p, bytes).with_context(|| format!("write {}", p.display()))?;+ eprintln!("→ {} ({} bytes)", p.display(), bytes.len());+ }+ None => {+ std::io::stdout().write_all(bytes).context("stdout")?;+ }+ }+ Ok(())+}++fn extract_tarball(bytes: &[u8], dest: &PathBuf) -> Result<()> {+ std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;+ let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;+ std::fs::write(tmp.path(), bytes)?;+ let status = std::process::Command::new("tar")+ .args(["-xf", tmp.path().to_str().unwrap_or_default(),+ "-C", dest.to_str().unwrap_or_default()])+ .status()+ .context("tar")?;+ if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }+ Ok(())+}++fn print_json_pretty_or_write(bytes: &[u8], format: &str, out: Option<&PathBuf>) -> Result<()> {+ if format == "json" || out.is_some() {+ return write_or_stdout(bytes, out);+ }+ // Pretty text for humans. Fall back to raw JSON if it doesn't parse.+ let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) else {+ return write_or_stdout(bytes, None);+ };+ let errs = v.get("errors").and_then(|x| x.as_u64()).unwrap_or(0);+ let warns = v.get("warnings").and_then(|x| x.as_u64())+ .or_else(|| v.get("warning").and_then(|x| x.as_u64()))+ .unwrap_or(0);+ let total = v.get("count").and_then(|x| x.as_u64()).unwrap_or(errs + warns);+ println!("errors: {errs} warnings: {warns} total: {total}");+ if let Some(violations) = v.get("violations").and_then(|x| x.as_array()) {+ for violation in violations.iter().take(20) {+ let sev = violation.get("severity").and_then(|s| s.as_str()).unwrap_or("?");+ let typ = violation.get("type").and_then(|s| s.as_str()).unwrap_or("?");+ let msg = violation.get("description").or_else(|| violation.get("message")).and_then(|s| s.as_str()).unwrap_or("");+ println!(" [{sev}] {typ}: {msg}");+ }+ if violations.len() > 20 { println!(" … {} more", violations.len() - 20); }+ }+ Ok(())+}++// ─── Commands ─────────────────────────────────────────────────────────++fn main() -> Result<()> {+ let cli = Cli::parse();+ match cli.command {+ Cmd::Health => {+ let bytes = get_bytes("/health")?;+ std::io::stdout().write_all(&bytes)?;+ println!();+ Ok(())+ }+ Cmd::Version => {+ let bytes = get_bytes("/version")?;+ std::io::stdout().write_all(&bytes)?;+ println!();+ Ok(())+ }+ Cmd::Config => {+ let effective = base_url();+ let prod = PROD_URL;+ let source = if std::env::var("SERVICE_KICAD_URL").is_ok() {+ "SERVICE_KICAD_URL env"+ } else if std::env::var("KICAD_SERVICE_API").is_ok() {+ "KICAD_SERVICE_API env (legacy alias)"+ } else {+ "compile-time baked-in URL"+ };+ println!("url : {effective}");+ println!("source : {source}");+ println!("prod bake : {prod}");+ if source.starts_with("compile") {+ println!("hint : export SERVICE_KICAD_URL=<url> to target a self-hosted instance");+ }+ Ok(())+ }+ Cmd::Pcb(PcbCmd::Drc { file, format, out }) => {+ let bytes = post_file_bytes("/kicad/pcb/drc", &file)?;+ print_json_pretty_or_write(&bytes, &format, out.as_ref())+ }+ Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Svg { file, side, layers, out })) => {+ let mut path = format!("/kicad/pcb/export/svg?side={}", urlencode(&side));+ if let Some(l) = layers {+ path.push_str(&format!("&layers={}", urlencode(&l)));+ }+ let bytes = post_file_bytes(&path, &file)?;+ write_or_stdout(&bytes, out.as_ref())+ }+ Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Gerbers { file, out })) => {+ let bytes = post_file_bytes("/kicad/pcb/export/gerbers", &file)?;+ match out {+ Some(ref d) => extract_tarball(&bytes, d),+ None => {+ let d = PathBuf::from("gerbers");+ extract_tarball(&bytes, &d)+ }+ }+ }+ Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Step { file, out, flags })) => {+ let qs = flags_to_query_string(&flags);+ let path = format!("/kicad/pcb/export/step{qs}");+ write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+ }+ Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Glb { file, out, flags, i_know_this_skips_the_pipeline })) => {+ glb_gate(i_know_this_skips_the_pipeline);+ let qs = flags_to_query_string(&flags);+ let path = format!("/kicad/pcb/export/glb{qs}");+ write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+ }+ Cmd::Pcb(PcbCmd::Export(PcbExportCmd::GlbZup { file, out, flags, i_know_this_skips_the_pipeline })) => {+ glb_gate(i_know_this_skips_the_pipeline);+ let qs = flags_to_query_string(&flags);+ let path = format!("/kicad/pcb/export/glb-zup{qs}");+ write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+ }++ Cmd::Sch(SchCmd::Erc { file, format, out }) => {+ let bytes = post_file_bytes("/kicad/sch/erc", &file)?;+ print_json_pretty_or_write(&bytes, &format, out.as_ref())+ }+ Cmd::Sch(SchCmd::Export(SchExportCmd::Svg { file, out })) =>+ write_or_stdout(&post_file_bytes("/kicad/sch/export/svg", &file)?, out.as_ref()),+ Cmd::Sch(SchCmd::Export(SchExportCmd::SvgProject { dir, out })) => {+ if !dir.is_dir() {+ anyhow::bail!("{} is not a directory — pass the project dir (.kicad_pro + sheets)", dir.display());+ }+ // Tar the project dir client-side; the service picks the root+ // sheet (stem matching the .kicad_pro) and returns a tar of SVGs.+ let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;+ let status = std::process::Command::new("tar")+ .args(["-cf", tmp.path().to_str().unwrap_or_default(),+ "-C", dir.to_str().unwrap_or_default(), "."])+ .status().context("tar")?;+ if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }+ let bytes = post_file_bytes("/kicad/sch/export/svg-project", &tmp.path().to_path_buf())?;+ let dest = out.unwrap_or_else(|| PathBuf::from("sch-svg"));+ extract_tarball(&bytes, &dest)?;+ eprintln!("→ {}/ (per-sheet SVGs)", dest.display());+ Ok(())+ }+ Cmd::Sch(SchCmd::Export(SchExportCmd::Pdf { file, out })) =>+ write_or_stdout(&post_file_bytes("/kicad/sch/export/pdf", &file)?, out.as_ref()),+ Cmd::Sch(SchCmd::Export(SchExportCmd::Bom { file, out })) =>+ write_or_stdout(&post_file_bytes("/kicad/sch/export/bom", &file)?, out.as_ref()),++ Cmd::Sym(SymCmd::Svg { library, name, out }) => {+ let bytes = get_bytes(&format!("/sym/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;+ write_or_stdout(&bytes, out.as_ref())+ }+ Cmd::Sym(SymCmd::Fetch { library, out }) => {+ let bytes = get_bytes(&format!("/symbols/{}.kicad_sym", urlencode(&library)))?;+ write_or_stdout(&bytes, out.as_ref())+ }+ Cmd::Fp(FpCmd::Svg { library, name, out }) => {+ let bytes = get_bytes(&format!("/fp/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;+ write_or_stdout(&bytes, out.as_ref())+ }+ Cmd::Fp(FpCmd::Fetch { library, name, out }) => {+ let bytes = get_bytes(&format!("/footprints/{}.pretty/{}.kicad_mod", urlencode(&library), urlencode(&name)))?;+ write_or_stdout(&bytes, out.as_ref())+ }+ Cmd::Model { cmd: ModelCmd::Fetch { path, out } } => {+ let bytes = get_bytes(&format!("/models/{}", path.trim_start_matches('/')))?;+ write_or_stdout(&bytes, out.as_ref())+ }++ Cmd::Altium(AltiumCmd::Sym { file, out }) => {+ let bytes = post_file_bytes("/kicad/altium/sym", &file)?;+ let dest = out.unwrap_or_else(|| file.with_extension("kicad_sym"));+ write_or_stdout(&bytes, Some(&dest))+ }+ Cmd::Altium(AltiumCmd::Fp { file, out }) => {+ let bytes = post_file_bytes("/kicad/altium/fp", &file)?;+ let dest = out.unwrap_or_else(|| file.with_extension("kicad_mod"));+ write_or_stdout(&bytes, Some(&dest))+ }++ Cmd::Install { skill_only, completions_only } => do_install(skill_only, completions_only),+ }+}++/// Refuse KiCad-native GLB export unless explicitly acknowledged (page+/// issue #106). Acknowledged exports still come back provenance-stamped+/// by the service (`asset.extras.adom.provenance = "kicad-native-glb"`).+fn glb_gate(acknowledged: bool) {+ if acknowledged {+ eprintln!("WARNING: KiCad-native GLB skips the molecule pipeline; output is");+ eprintln!("provenance-stamped (asset.extras.adom.provenance = \"kicad-native-glb\").");+ return;+ }+ eprintln!("ERROR: `pcb export glb` is gated (page issue #106).");+ eprintln!("KiCad-native GLB bypasses the molecule pipeline — no MP-marker");+ eprintln!("anchoring, gold machine-pin material, meter normalization, or");+ eprintln!("footprint emit. Export STEP here, then convert with step2glb:");+ eprintln!("Hint: service-kicad pcb export step board.kicad_pcb --out board.step");+ eprintln!("Hint: adom-step2glb board.step # molecule mode when the board has machine pins");+ eprintln!("Hint: for a quick non-molecule preview only, re-run with --i-know-this-skips-the-pipeline");+ std::process::exit(1);+}++/// Absolute-minimum URL encoder — just the three characters we actually+/// see in library/name path segments.+fn urlencode(s: &str) -> String {+ s.replace('%', "%25")+ .replace(' ', "%20")+ .replace('/', "%2F")+}++const WIKI_BASE: &str = "https://wiki.adom.inc";++/// Fetch the canonical SKILL.md from the wiki page repo's raw-file+/// endpoint. No fallback bundled via include_str! — we never bake skill+/// prose into the binary because prose is edited far more often than the+/// binary ships (per adom-cli-design rule).+fn fetch_skill_from_wiki() -> Result<String> {+ let url = format!("{}/api/pages/adom/service-kicad/files/SKILL.md", WIKI_BASE);+ let text = http_client()+ .get(&url)+ .send()?+ .error_for_status()?+ .text()?;+ if text.trim().is_empty() || !text.starts_with("---") {+ return Err(anyhow!("wiki returned unexpected SKILL.md content from {url}"));+ }+ Ok(text)+}++fn do_install(skill_only: bool, completions_only: bool) -> Result<()> {+ let home = std::env::var("HOME").map(PathBuf::from)+ .map_err(|_| anyhow!("no $HOME"))?;+ if !completions_only {+ let skill_dir = home.join(".claude/skills/service-kicad");+ std::fs::create_dir_all(&skill_dir)?;+ let dst = skill_dir.join("SKILL.md");+ match fetch_skill_from_wiki() {+ Ok(skill) => {+ std::fs::write(&dst, skill)?;+ eprintln!("✓ skill fetched from wiki → {}", dst.display());+ }+ Err(e) if dst.exists() => {+ // Gallia (Tier A path) or a prior install already wrote+ // SKILL.md. A transient wiki outage isn't a reason to+ // overwrite or blow up.+ eprintln!("! wiki fetch failed ({e}) — keeping existing {}", dst.display());+ }+ Err(e) => {+ return Err(anyhow!("could not fetch SKILL.md from wiki and no existing skill to keep: {e}"));+ }+ }+ }+ if !skill_only {+ let comp_dir = home.join(".bash_completion.d");+ std::fs::create_dir_all(&comp_dir)?;+ let bash_comp = comp_dir.join("service-kicad");+ std::fs::write(&bash_comp,+ "# service-kicad bash completions\n\+ _service_kicad() {\n\+ \tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\+ \tCOMPREPLY=($(compgen -W \"health version config pcb sch sym fp model altium install\" -- \"$cur\"))\n\+ }\n\+ complete -F _service_kicad service-kicad\n")?;+ eprintln!("✓ completions at {}", bash_comp.display());+ }+ Ok(())+}
deploy-v9.shadded+114@@ -0,0 +1,114 @@+#!/usr/bin/env bash+# service-kicad deploy — V9 FALLBACK channel.+#+# Run ONCE on a fresh default-light container. Most users want+# `deploy.sh` (the default v10 channel); this script exists only for+# callers that need to pin to KiCad 9 (file-format compat, etc.).+# The v9 PPA overwrites the v10 `kicad` package, so a host can be on+# exactly one channel at a time.++set -euo pipefail++REPO_DIR="$HOME/service-kicad"++echo "==> service-kicad deploy starting (v9 fallback channel)"+echo "==> repo dir: $REPO_DIR"++# 1. System deps — KiCad 9 + libraries. No aci-* deps; this container+# is tool-agnostic and consumed by symbol-creator, footprint-creator,+# library-creator, adom-chipfit, adom-tsci, adom-circuit, etc.+echo "==> installing apt packages (KiCad 9 + libraries + cron)"+sudo apt-get update+sudo apt-get install -y \+ software-properties-common curl build-essential pkg-config git jq cron+sudo add-apt-repository -y ppa:kicad/kicad-9.0-releases+sudo apt-get update+sudo apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d++# The Ubuntu apt-package mirrors of KiCad libraries lag upstream by ~years+# (24.04 ships the ~2020 snapshot — e.g., SOIC-8W_5.3x5.3mm_P1.27mm,+# committed upstream 2020-09-29, was missing from the apt copy as of+# 2026-04-26 dogfood). Overlay the upstream github repos so users get+# the latest parts. service-kicad's KICAD_*_DIR env vars point at these+# clones; a weekly `git pull` cron keeps them fresh.+LIB_BASE=/opt+for repo in kicad-footprints kicad-symbols kicad-packages3D; do+ dir="$LIB_BASE/${repo,,}-upstream" # lowercase+ if [ ! -d "$dir/.git" ]; then+ echo "==> cloning upstream $repo → $dir"+ sudo git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dir"+ else+ echo "==> upstream $repo already at $dir; refreshing"+ sudo git -C "$dir" pull --quiet --ff-only || true+ fi+done++# Persist KICAD_*_DIR for service-kicad-api (read at startup).+sudo tee /etc/default/service-kicad >/dev/null <<'EOF'+KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream+EOF++# Weekly refresh — Mondays @ 04:00 UTC.+sudo tee /etc/cron.d/kicad-lib-pull >/dev/null <<'EOF'+0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | logger -t kicad-lib-pull; done+EOF+sudo chmod 0644 /etc/cron.d/kicad-lib-pull++# default-light has sshd as PID 1 (no systemd) — cron has to be+# launched manually and kept alive by the user.+if ! pgrep -x cron >/dev/null; then+ echo "==> starting cron daemon"+ sudo /usr/sbin/cron+fi++# 2. Rust toolchain+echo "==> installing rustup"+if ! command -v rustup &>/dev/null; then+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable+ source "$HOME/.cargo/env"+fi++# 3. Clone + build the service API+if [ ! -d "$REPO_DIR" ]; then+ echo "==> cloning service-kicad"+ git clone https://github.com/adom-inc/service-kicad.git "$REPO_DIR"+fi+cd "$REPO_DIR"+git pull --rebase origin main++echo "==> building service-kicad-api (http server) and service-kicad (cli)"+cargo build --release --bin service-kicad-api --bin service-kicad+sudo install "$REPO_DIR/target/release/service-kicad-api" /usr/local/bin/service-kicad-api+sudo install "$REPO_DIR/target/release/service-kicad" /usr/local/bin/service-kicad++# 4. Install watchdog cron+echo "==> installing watchdog cron"+mkdir -p "$HOME/.cache"+CRON_LINE="*/2 * * * * $REPO_DIR/watchdog.sh >> $HOME/.cache/service-kicad-watchdog.log 2>&1"+# crontab -l exits 1 when there's no existing crontab; the subshell+# must not abort under `set -e`, so disable pipefail around this block.+set +e+( crontab -l 2>/dev/null | grep -v "service-kicad/watchdog.sh" ; echo "$CRON_LINE" ) | crontab -+cron_exit=$?+set -e+[ $cron_exit -ne 0 ] && { echo "crontab install failed (exit $cron_exit)"; exit $cron_exit; }++# 5. First launch + health check+echo "==> starting service-kicad-api"+sudo touch /var/log/service-kicad.log && sudo chown "$USER" /var/log/service-kicad.log+pkill -x service-kicad-api || true+# Source /etc/default/service-kicad for KICAD_*_DIR env vars before launch.+set -a; [ -f /etc/default/service-kicad ] && . /etc/default/service-kicad; set +a+nohup /usr/local/bin/service-kicad-api >> /var/log/service-kicad.log 2>&1 &+sleep 3+if curl -sf http://127.0.0.1:8780/health | jq -e '.ok' > /dev/null; then+ echo "==> service-kicad is healthy ✓"+ curl -sf http://127.0.0.1:8780/health | jq+else+ echo "==> service-kicad health check FAILED" >&2+ exit 1+fi++echo "==> deploy complete"
deploy.shadded+119@@ -0,0 +1,119 @@+#!/usr/bin/env bash+# service-kicad deploy — DEFAULT channel (KiCad 10).+#+# Run ONCE on a fresh default-light container at carbon.adom.inc.+# This script is the canonical primary deploy; the v10 PPA overwrites+# the v9 `kicad` package so a host can be on exactly one channel at a+# time. For a v9 fallback container, use [`deploy-v9.sh`](deploy-v9.sh)+# on a separate host.++set -euo pipefail++REPO_DIR="$HOME/service-kicad"++echo "==> service-kicad deploy starting (default channel — KiCad 10)"+echo "==> repo dir: $REPO_DIR"++# Hard guard: refuse to run on a host that already has v9 installed.+# Mixing PPAs would leave kicad-cli in a half-upgraded state. The+# in-place v9 → v10 swap of the original prod container was a one-time+# event done outside this script.+if dpkg -l kicad 2>/dev/null | awk '/^ii/ {print $3}' | grep -q '^9\.'; then+ echo "==> ERROR: KiCad 9 is already installed on this host. Provision a separate container, or run deploy-v9.sh if you actually want the fallback channel." >&2+ exit 1+fi++# 1. System deps — KiCad 10 + libraries.+echo "==> installing apt packages (KiCad 10 + libraries + cron)"+sudo apt-get update+sudo apt-get install -y \+ software-properties-common curl build-essential pkg-config git jq cron+sudo add-apt-repository -y ppa:kicad/kicad-10.0-releases+sudo apt-get update+sudo apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d++# Library overlay — prefer the upstream `10.0` stable branch, fall back+# to master if it doesn't exist yet. (Library repos branch when stable+# cuts; master is the dev branch that may already be drifting toward 11.)+LIB_BASE=/opt+for repo in kicad-footprints kicad-symbols kicad-packages3D; do+ dir="$LIB_BASE/${repo,,}-upstream" # lowercase+ if [ ! -d "$dir/.git" ]; then+ echo "==> cloning upstream $repo → $dir (prefer branch 10.0)"+ if ! sudo git clone --depth 1 --branch 10.0 "https://github.com/KiCad/$repo.git" "$dir" 2>/dev/null; then+ echo "==> no 10.0 branch yet; falling back to master"+ sudo git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dir"+ fi+ else+ echo "==> upstream $repo already at $dir; refreshing"+ sudo git -C "$dir" pull --quiet --ff-only || true+ fi+done++# Persist KICAD_*_DIR for service-kicad-api (read at startup).+sudo tee /etc/default/service-kicad >/dev/null <<'EOF'+KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream+EOF++# Weekly refresh — Mondays @ 04:00 UTC.+sudo tee /etc/cron.d/kicad-lib-pull >/dev/null <<'EOF'+0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | logger -t kicad-lib-pull; done+EOF+sudo chmod 0644 /etc/cron.d/kicad-lib-pull++# default-light has sshd as PID 1 (no systemd) — cron has to be+# launched manually and kept alive by the user.+if ! pgrep -x cron >/dev/null; then+ echo "==> starting cron daemon"+ sudo /usr/sbin/cron+fi++# 2. Rust toolchain+echo "==> installing rustup"+if ! command -v rustup &>/dev/null; then+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable+ source "$HOME/.cargo/env"+fi++# 3. Clone + build the service API. Same source as v9 — the binary is+# channel-agnostic; it shells to whichever kicad-cli is on $PATH.+if [ ! -d "$REPO_DIR" ]; then+ echo "==> cloning service-kicad"+ git clone https://github.com/adom-inc/service-kicad.git "$REPO_DIR"+fi+cd "$REPO_DIR"+git pull --rebase origin main++echo "==> building service-kicad-api (http server) and service-kicad (cli)"+cargo build --release --bin service-kicad-api --bin service-kicad+sudo install "$REPO_DIR/target/release/service-kicad-api" /usr/local/bin/service-kicad-api+sudo install "$REPO_DIR/target/release/service-kicad" /usr/local/bin/service-kicad++# 4. Install watchdog cron (same script as v9 — rebuilds whatever's in main)+echo "==> installing watchdog cron"+mkdir -p "$HOME/.cache"+CRON_LINE="*/2 * * * * $REPO_DIR/watchdog.sh >> $HOME/.cache/service-kicad-watchdog.log 2>&1"+set +e+( crontab -l 2>/dev/null | grep -v "service-kicad/watchdog.sh" ; echo "$CRON_LINE" ) | crontab -+cron_exit=$?+set -e+[ $cron_exit -ne 0 ] && { echo "crontab install failed (exit $cron_exit)"; exit $cron_exit; }++# 5. First launch + health check+echo "==> starting service-kicad-api"+sudo touch /var/log/service-kicad.log && sudo chown "$USER" /var/log/service-kicad.log+pkill -x service-kicad-api || true+set -a; [ -f /etc/default/service-kicad ] && . /etc/default/service-kicad; set +a+nohup /usr/local/bin/service-kicad-api >> /var/log/service-kicad.log 2>&1 &+sleep 3+if curl -sf http://127.0.0.1:8780/health | jq -e '.ok' > /dev/null; then+ echo "==> service-kicad (v10) is healthy ✓"+ curl -sf http://127.0.0.1:8780/health | jq+else+ echo "==> service-kicad health check FAILED" >&2+ exit 1+fi++echo "==> deploy complete (KiCad 10 channel)"
docs/service-kicad-api.mdadded+141@@ -0,0 +1,141 @@+# service-kicad API contract++HTTP API for the `service-kicad` container. Every Adom tool that needs+headless KiCad or the stock library hits this surface.++## Version++- API version: `1.0.0` (matches `service.json::api_version`).+- Compatibility: clients check `/version` on startup and refuse to+ connect when **major** differs.+- Service is stateless — safe to restart at any time.++## Versioning++```+GET /version+→ { "service": "service-kicad", "version": "0.0.1", "api_version": "1.0.0" }++GET /health+→ { "ok": true, "service": "service-kicad", "version": "0.0.1",+ "kicad_version": "9.0.8" }+```++Consumers: `adom-cli carbon` watchdog, `aci doctor`, CI smoke.++## Auth++Per PLAN decision 17 (Adom-platform JWT). `Authorization: Bearer <jwt>`.+Service validates against `carbon.adom.inc`'s JWKS. Dev: no auth.++## kicad-cli PCB operations++```+POST /kicad/pcb/drc+ Request body: <raw .kicad_pcb bytes>+ Content-Type: application/octet-stream+ Response: application/json — same shape as `kicad-cli pcb drc+ --format json` output.++POST /kicad/pcb/export/svg?side=top|bottom+ Request body: <raw .kicad_pcb bytes>+ Response: image/svg+xml++POST /kicad/pcb/export/gerbers+ Request body: <raw .kicad_pcb bytes>+ Response: application/x-tar — the full fab layer set+ (F.Cu, B.Cu, F.Mask, B.Mask, F.Silkscreen,+ B.Silkscreen, Edge.Cuts).++POST /kicad/pcb/export/step+ Request body: <raw .kicad_pcb bytes>+ Response: model/step — with component 3D models substituted+ (--subst-models --no-dnp).++POST /kicad/pcb/export/glb+ Request body: <raw .kicad_pcb bytes>+ Response: model/gltf-binary — with component 3D models.+```++## kicad-cli schematic operations++```+POST /kicad/sch/erc+ Request body: <raw .kicad_sch bytes>+ Response: application/json — same shape as `kicad-cli sch erc+ --format json`.++POST /kicad/sch/export/svg+ Request body: <raw .kicad_sch bytes>+ Response: image/svg+xml++POST /kicad/sch/export/pdf+ Request body: <raw .kicad_sch bytes>+ Response: application/pdf++POST /kicad/sch/export/bom+ Request body: <raw .kicad_sch bytes>+ Response: text/csv+```++## Symbol + footprint lookup (by library + name)++Convenience wrappers for callers that know the canonical+library/name but don't ship a local copy of the source file.++```+GET /sym/export/svg/:library/:name+ Path params: library (e.g. "Device"), name (e.g. "R")+ Response: image/svg+xml — rendered via `kicad-cli sym export svg`+ on /usr/share/kicad/symbols/<library>.kicad_sym.++GET /fp/export/svg/:library/:name+ Path params: library (e.g. "Package_SO"), name (e.g. "SOT-23")+ Response: image/svg+xml — rendered via `kicad-cli fp export svg`+ on /usr/share/kicad/footprints/<library>.pretty/<name>.kicad_mod.+```++## Static library mounts++The ~5 GB kicad-packages3d library + the symbol/footprint trees are+served read-only as static files. Clients stream whatever they need+(most call sites only need a handful at a time).++```+GET /models/<path> → static from /usr/share/kicad/3dmodels/+GET /footprints/<path> → static from /usr/share/kicad/footprints/+GET /symbols/<path> → static from /usr/share/kicad/symbols/+```++Example: `curl -o LM1117.step $KICAD_SERVICE_API/models/Package_TO_SOT_SMD.3dshapes/SOT-223.step`++## Rate limits++- **kicad-cli endpoints** (`/kicad/*`): 30 req/min/user, burst 10.+- **Static library** (`/models`, `/footprints`, `/symbols`): 600+ req/min/user, no burst cap — it's just disk reads.+- Exceeding → HTTP 429 with `Retry-After`.++## Error shape++```json+{ "error": "human-readable message", "code": 400 }+```++Body is always JSON for error responses; HTTP status carries the+primary signal.++## Port configuration++The binary honors `$PORT`. Default 8780 (matches the legacy service-+kicad container's port, so consumers that already point at 8780+migrate cleanly).++## Stability policy++- **Major bump**: any breaking change to an existing endpoint's+ request or response shape.+- **Minor bump**: new endpoints.+- **Patch bump**: bug fixes, dependency updates, no contract change.++Every bump edits `service.json::api_version` AND this doc's header.
service.jsonadded+28@@ -0,0 +1,28 @@+{+ "name": "service-kicad",+ "url": "https://kicad-rk5ue5pcfemi.adom.cloud",+ "port": 8780,+ "health": "/health",+ "start": "/usr/local/bin/service-kicad",+ "env_required": [],+ "env_optional": [+ "KICAD_CLI",+ "KICAD_PACKAGES3D_DIR",+ "KICAD_FOOTPRINTS_DIR",+ "KICAD_SYMBOLS_DIR",+ "PORT"+ ],+ "log": "/var/log/service-kicad.log",+ "api_version": "1.0.0",+ "channels": {+ "default": {+ "url": "https://kicad-rk5ue5pcfemi.adom.cloud",+ "kicad_major": 10+ },+ "v9": {+ "url": "https://kicad-v9-7f1gq8j5ohbq.adom.cloud",+ "kicad_major": 9,+ "opt_in": "set $KICAD_SERVICE_API to this URL"+ }+ }+}
watchdog.shadded+82@@ -0,0 +1,82 @@+#!/usr/bin/env bash+# service-kicad watchdog — run by cron every 2 minutes on the+# production container. Pulls origin/main, rebuilds on new commits,+# and health-checks the running service.+#+# Kill uses /proc/PID/exe readlink instead of pkill -f path matching,+# which broke when the process was started from a different path than+# the installed binary (and pkill -x fails because the 17-char binary+# name gets truncated to 15 in /proc/PID/comm).++set -uo pipefail++REPO_DIR="$HOME/service-kicad"+BINARY="/usr/local/bin/service-kicad-api"+CLI_BINARY="/usr/local/bin/service-kicad"+HEALTH_URL="http://127.0.0.1:8780/health"+LOG="${SERVICE_KICAD_LOG:-/var/log/service-kicad.log}"++kill_api() {+ local pids+ pids=$(pgrep -f "service-kicad-api" 2>/dev/null | grep -v "^$$\$" || true)+ for pid in $pids; do+ local exe+ exe=$(readlink "/proc/$pid/exe" 2>/dev/null || true)+ if [[ "$exe" == *service-kicad-api* ]]; then+ kill "$pid" 2>/dev/null || true+ sleep 1+ kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true+ fi+ done+}++launch_api() {+ [ -f /etc/default/service-kicad ] && { set -a; . /etc/default/service-kicad; set +a; }+ nohup "$BINARY" >> "$LOG" 2>&1 &+}++cd "$REPO_DIR" || exit 1++# 1. Check for new commits.+#+# `git fetch` MUST surface its own failure — without this guard, an+# auth-rotated container silently logs `fatal: could not read Username+# for 'https://github.com'` then proceeds with stale LOCAL/REMOTE SHAs+# (both still point at the old HEAD), the equality test trivially+# passes, and the rebuild branch never fires. Symptom: weeks of+# "no-op" watchdog runs while origin/main races ahead. Exit 1 here+# triggers cron stderr so the next failure is loud, not silent.+if ! git fetch origin main --quiet 2>&1; then+ echo "[$(date -Is)] FETCH FAILED — verify ~/.git-credentials and origin URL"+ exit 1+fi+LOCAL_SHA=$(git rev-parse HEAD)+REMOTE_SHA=$(git rev-parse origin/main)++if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then+ echo "[$(date -Is)] origin moved $LOCAL_SHA → $REMOTE_SHA — rebuilding"+ git reset --hard origin/main+ if cargo build --release --bin service-kicad-api --bin service-kicad 2>&1 | tail -20; then+ sudo install target/release/service-kicad-api "$BINARY"+ sudo install target/release/service-kicad "$CLI_BINARY"+ kill_api+ launch_api+ echo "[$(date -Is)] rebuilt + relaunched"+ else+ echo "[$(date -Is)] REBUILD FAILED — keeping old binary"+ exit 1+ fi+fi++# 2. Health check — relaunch if down+if ! curl -sf "$HEALTH_URL" | jq -e '.ok' > /dev/null 2>&1; then+ echo "[$(date -Is)] health check failed, relaunching"+ kill_api+ launch_api+ sleep 2+ if curl -sf "$HEALTH_URL" | jq -e '.ok' > /dev/null 2>&1; then+ echo "[$(date -Is)] recovered"+ else+ echo "[$(date -Is)] STILL UNHEALTHY after relaunch"+ fi+fi