Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
//! Headless CLI mode for `copperforge`.
//!
//! Runs without the GUI:
//!
//! ```text
//! copperforge release path/to/board.kicad_pcb # cuts rev_01 (or next)
//! copperforge release board.kicad_pcb --rev rev_demo # explicit rev tag
//! copperforge release board.kicad_pcb --out ~/releases # override output dir
//! copperforge release board.kicad_pcb --pcbway # bundle fab-specs sheet
//! ```
//!
//! Works from any directory — paths are canonicalized at parse time so the
//! user doesn't have to `cd` into the project. Produces the same release zip
//! the desktop GUI cuts: gerbers + drill + BOM (CSV + XLSX) + centroid +
//! RELEASE_NOTES.md, with optional `PCBWAY_FAB_SPECS.md` on `--pcbway`.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use copperforge_core::CopperForgeApp;
use copperforge_core::event_logger::{LogColors, ReactiveEventLogger, ReactiveEventLoggerState};
use copperforge_core::project::manager::ProjectConfig;
use copperforge_core::project::gerber_ops::generate_gerbers_from_pcb;
use copperforge_core::release::{
create_release, suggest_next_rev_tag, ReleaseRequest, ReleaseSources,
};
use copperforge_core::vendor::VendorKind;
use egui_mobius_reactive::Dynamic;
#[derive(Parser, Debug)]
#[command(
name = "copperforge",
version,
about = "PCB release & manufacturing companion for KiCad",
long_about = "Run without arguments to launch the GUI.\n\
Run `copperforge release <pcb>` to cut a release zip from\n\
the terminal — works from any directory."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Produce a release zip (gerbers + drill + BOM + centroid + notes)
/// from a `.kicad_pcb` file. No GUI launched.
Release(ReleaseArgs),
}
#[derive(clap::Args, Debug)]
pub struct ReleaseArgs {
/// Path to the `.kicad_pcb` to release. Relative paths are
/// canonicalised against the current working directory, so this
/// works from anywhere.
pub pcb: PathBuf,
/// Rev tag (e.g. `rev_01`, `rev_demo`). If omitted, the next
/// sequential `rev_NN` is picked by scanning the project's
/// `outputs/` directory.
#[arg(long)]
pub rev: Option<String>,
/// Output directory. Default: `<project_dir>/outputs/<rev>/`.
#[arg(long)]
pub out: Option<PathBuf>,
/// Bundle `PCBWAY_FAB_SPECS.md` (board dimensions + SMT/THT part
/// and pad counts) into the zip.
#[arg(long)]
pub pcbway: bool,
/// Short description carried into `RELEASE_NOTES.md`.
#[arg(long, default_value = "")]
pub description: String,
/// Changes-from-previous-rev text (markdown), carried into
/// `RELEASE_NOTES.md`. Pass `-` to read from stdin.
#[arg(long, default_value = "")]
pub changes: String,
/// Skip the `_DDMmmYYYY` suffix on the zip filename.
#[arg(long)]
pub no_date: bool,
/// Board part number for the BOM cover page. Defaults to the PCB file stem.
#[arg(long)]
pub board_pn: Option<String>,
/// Copper weight for the BOM cover page, e.g. "2 oz".
#[arg(long, default_value = "")]
pub copper: String,
}
/// Run the CLI command. Returns the process exit code so `main` can
/// propagate it cleanly.
pub fn run(cli: Cli) -> ExitCode {
match cli.command {
Some(Command::Release(args)) => match run_release(args) {
Ok(zip_path) => {
println!("✓ {}", zip_path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("✗ {}", e);
ExitCode::FAILURE
}
},
None => unreachable!("dispatch from main only forwards a Some(...)"),
}
}
fn run_release(args: ReleaseArgs) -> Result<PathBuf, String> {
// ── 1. Resolve paths ──────────────────────────────────────────
let pcb_path = args
.pcb
.canonicalize()
.map_err(|e| format!("Cannot resolve {}: {}", args.pcb.display(), e))?;
if pcb_path.extension().and_then(|e| e.to_str()) != Some("kicad_pcb") {
return Err(format!(
"{} is not a .kicad_pcb file",
pcb_path.display()
));
}
let project_dir = pcb_path
.parent()
.ok_or_else(|| "PCB path has no parent directory".to_string())?
.to_path_buf();
// ── 2. Probe kicad-cli ────────────────────────────────────────
let config_path = dirs::config_dir()
.map(|d| d.join("copperforge"))
.unwrap_or_else(|| PathBuf::from("."));
let config = ProjectConfig::load_from_file(&config_path).unwrap_or_default();
let (kicad_version, kicad_cli_method, _candidates) =
CopperForgeApp::probe_kicad_cli(&config);
let kicad_cli_method = kicad_cli_method.ok_or_else(|| {
"kicad-cli not found. Install KiCad (PATH, Flatpak, or Snap) and retry.".to_string()
})?;
// ── 3. Logger sink (CLI mode prints log lines as they queue) ─
let logger_state = Dynamic::new(ReactiveEventLoggerState::new());
let log_colors = Dynamic::new(LogColors::default());
let logger = ReactiveEventLogger::with_colors(&logger_state, &log_colors);
// ── 4. Generate gerbers + drills ──────────────────────────────
eprintln!("→ Generating gerbers via kicad-cli ({})…", kicad_cli_method);
let gerber_dir = generate_gerbers_from_pcb(&pcb_path, &kicad_cli_method, &logger)
.ok_or_else(|| "Gerber generation failed (see kicad-cli output above).".to_string())?;
// ── 5. Build the release request ──────────────────────────────
// Existing rev list — scan the project's outputs/ for `rev_*`
// subdirectories so `suggest_next_rev_tag` can pick the next number.
let existing_releases = scan_existing_revs(&project_dir);
let rev_tag = args
.rev
.clone()
.unwrap_or_else(|| suggest_next_rev_tag(&existing_releases));
// Read `--changes -` from stdin if requested.
let changes = if args.changes == "-" {
use std::io::Read;
let mut s = String::new();
std::io::stdin()
.read_to_string(&mut s)
.map_err(|e| format!("Reading --changes from stdin: {}", e))?;
s
} else {
args.changes.clone()
};
let board_pn = args.board_pn.clone().unwrap_or_else(|| {
pcb_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
});
let req = ReleaseRequest {
rev_tag: rev_tag.clone(),
description: args.description.clone(),
changes,
board_pn,
copper_weight: args.copper.clone(),
include_date_in_name: !args.no_date,
include_notes_in_zip: true,
target: if args.pcbway { Some(VendorKind::PcbWay) } else { None },
};
let sources = ReleaseSources {
pcb_path: &pcb_path,
gerber_dir: &gerber_dir,
kicad_cli_method: kicad_cli_method.clone(),
kicad_version,
os_description: build_os_description(),
};
// ── 6. Cut the release ────────────────────────────────────────
eprintln!("→ Packaging release '{}'…", rev_tag);
let outcome = create_release(&req, sources, &logger)
.map_err(|e| format!("Release packaging failed: {}", e))?;
// ── 7. Optional --out: move the zip to the user-specified dir ─
let zip_path = if let Some(out_dir) = args.out {
std::fs::create_dir_all(&out_dir)
.map_err(|e| format!("Create --out dir {}: {}", out_dir.display(), e))?;
let dest = out_dir.join(
outcome
.release
.archive_path
.file_name()
.ok_or_else(|| "Release archive has no filename".to_string())?,
);
std::fs::rename(&outcome.release.archive_path, &dest)
.or_else(|_| {
// rename can fail across filesystems; fall back to copy + remove.
std::fs::copy(&outcome.release.archive_path, &dest)
.and_then(|_| std::fs::remove_file(&outcome.release.archive_path))
.map(|_| ())
})
.map_err(|e| format!("Move zip to {}: {}", dest.display(), e))?;
dest
} else {
outcome.release.archive_path
};
// ── 8. Drain the log buffer to stderr — visibility into what
// kicad-cli + the packager actually did, without obscuring
// the final `✓ <path>` on stdout that callers (Make, CI)
// should be able to pipe.
let state = logger_state.get();
for entry in &state.logs {
eprintln!(" [{:?}] {}", entry.log_type, entry.message);
}
Ok(zip_path)
}
/// Scan `<project_dir>/outputs/` for already-cut rev subdirectories,
/// returning a synthetic `Vec<Release>` populated only with `tag` so
/// `suggest_next_rev_tag` can do its rev counting. Other fields stay
/// empty — they're not used by the suggestion logic.
fn scan_existing_revs(project_dir: &std::path::Path) -> Vec<copperforge_core::release::Release> {
use chrono::Utc;
let outputs = project_dir.join("outputs");
let Ok(entries) = std::fs::read_dir(&outputs) else {
return Vec::new();
};
entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let tag = e.file_name().to_string_lossy().into_owned();
if !tag.starts_with("rev_") {
return None;
}
Some(copperforge_core::release::Release {
tag,
created_at: Utc::now(),
archive_path: PathBuf::new(),
notes_path: PathBuf::new(),
description: String::new(),
changes: String::new(),
kicad_version: None,
git_hash: None,
include_date_in_name: false,
include_notes_in_zip: false,
target: None,
})
})
.collect()
}
/// Same `Host OS:` line the GUI release flow records, so notes from
/// CLI and GUI releases look identical.
fn build_os_description() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
format!("{} ({})", os, arch)
}
5939542c2This file is pinned to the selected release. Download the original to open it in your design tool.