Skip to main content

canic_cli/frontend/
mod.rs

1//! Public CLI adapter for frontend bundle export and integrity verification.
2//!
3//! The host owns source validation, generation and durable publication.
4
5use crate::cli::{
6    clap::{flag_arg, parse_matches, required_string, string_option, value_arg},
7    defaults::local_environment,
8    globals::{internal_environment_arg, internal_icp_arg},
9    help::print_nested_help,
10};
11use crate::support::icp_target::IcpTargetOptions;
12use canic_host::{
13    frontend::{FrontendError, model::FrontendAssetCapacityInput, ops, workflow},
14    icp_config::resolve_current_canic_icp_root,
15};
16use clap::Command;
17use std::{ffi::OsString, path::Path};
18use thiserror::Error;
19
20/// CLI boundary errors retain the host's exact typed diagnostic.
21#[derive(Debug, Error)]
22pub enum FrontendCommandError {
23    #[error(transparent)]
24    Clap(#[from] clap::Error),
25
26    #[error(transparent)]
27    Host(#[from] FrontendError),
28
29    #[error(transparent)]
30    Json(#[from] serde_json::Error),
31
32    #[error(transparent)]
33    Root(#[from] canic_host::icp_config::IcpConfigError),
34}
35
36fn command() -> Command {
37    Command::new("frontend").bin_name("canic frontend")
38        .about("Export and verify exact browser bindings from a terminal Fleet")
39        .arg(internal_environment_arg().global(true))
40        .arg(internal_icp_arg().global(true))
41        .arg(flag_arg("json").long("json").global(true).help("Print structured JSON"))
42        .subcommand_required(true)
43        .subcommand(Command::new("capacity").about("Check external asset native cycles against an explicit upload floor")
44            .arg(value_arg("canister").required(true).value_parser(clap::value_parser!(candid::Principal)))
45            .arg(value_arg("payload").long("payload").required(true))
46            .arg(value_arg("minimum-native-cycles").long("minimum-native-cycles").required(true).value_parser(clap::value_parser!(u128)))
47            .arg(value_arg("maximum-payload-bytes").long("maximum-payload-bytes").required(true).value_parser(clap::value_parser!(u64).range(1..)))
48            .arg(value_arg("maximum-files").long("maximum-files").required(true).value_parser(clap::value_parser!(u32).range(1..))))
49        .subcommand(Command::new("export").about("Write a reviewed environment and binding bundle")
50            .arg(value_arg("fleet").required(true))
51            .arg(value_arg("input").long("input").required(true).help("Selected-environment frontend JSON"))
52            .arg(value_arg("out").long("out").required(true).help("New or identical bundle directory")))
53        .subcommand(Command::new("verify").about("Verify files against an independently retained digest")
54            .arg(value_arg("directory").required(true))
55            .arg(value_arg("sha256").long("sha256").required(true)))
56        .after_help("Examples:\n  canic frontend export demo --input frontend-local.json --out browser/canic\n  canic frontend verify browser/canic --sha256 <digest>\n  canic --environment ic frontend export demo --input frontend-ic.json --out browser/canic --json")
57}
58
59/// Parse the selected environment and delegate to the host handoff owner.
60pub fn run(args: impl IntoIterator<Item = OsString>) -> Result<(), FrontendCommandError> {
61    let args = args.into_iter().collect::<Vec<_>>();
62    if print_nested_help(&args, command()) {
63        return Ok(());
64    }
65    let matches = parse_matches(command(), args)?;
66    let (action, leaf) = matches.subcommand().expect("required frontend subcommand");
67    if action == "capacity" {
68        return capacity(&matches, leaf);
69    }
70    let manifest = match action {
71        "export" => {
72            let input = ops::read_input(Path::new(&required_string(leaf, "input")))?;
73            let environment =
74                string_option(&matches, "environment").unwrap_or_else(local_environment);
75            if input.environment != environment {
76                return Err(FrontendError::Environment.into());
77            }
78            workflow::prepare_handoff(
79                &resolve_current_canic_icp_root()?,
80                &required_string(leaf, "fleet"),
81                &input,
82                Path::new(&required_string(leaf, "out")),
83            )?
84        }
85        "verify" => ops::verify_bundle(
86            Path::new(&required_string(leaf, "directory")),
87            &required_string(leaf, "sha256"),
88        )?,
89        _ => unreachable!("Clap selects the maintained subcommands"),
90    };
91    if matches.get_flag("json") {
92        println!("{}", serde_json::to_string_pretty(&manifest)?);
93    } else {
94        println!(
95            "Frontend bundle: {} / {}",
96            manifest.environment, manifest.fleet
97        );
98        println!("sha256: {}", manifest.manifest_sha256);
99        println!("role instances: {}", manifest.roles.len());
100    }
101    Ok(())
102}
103
104fn capacity(
105    matches: &clap::ArgMatches,
106    leaf: &clap::ArgMatches,
107) -> Result<(), FrontendCommandError> {
108    let target = IcpTargetOptions::parse(matches);
109    let report = ops::asset_capacity(
110        &target.icp_cli(&resolve_current_canic_icp_root()?),
111        &FrontendAssetCapacityInput {
112            environment: target.environment,
113            canister_id: *leaf
114                .get_one::<candid::Principal>("canister")
115                .expect("required Principal"),
116            payload_directory: required_string(leaf, "payload").into(),
117            minimum_native_cycles: *leaf
118                .get_one::<u128>("minimum-native-cycles")
119                .expect("required native floor"),
120            maximum_payload_bytes: *leaf
121                .get_one::<u64>("maximum-payload-bytes")
122                .expect("required byte bound"),
123            maximum_files: *leaf
124                .get_one::<u32>("maximum-files")
125                .expect("required file bound"),
126        },
127    )?;
128    if matches.get_flag("json") {
129        println!("{}", serde_json::to_string_pretty(&report)?);
130    } else {
131        println!(
132            "Asset {}: {} native cycles; required {}",
133            report.canister_id, report.native_cycles, report.minimum_native_cycles
134        );
135        println!(
136            "Payload: {} files, {} bytes, sha256 {}",
137            report.payload.files, report.payload.bytes, report.payload.sha256
138        );
139    }
140    if !report.sufficient {
141        return Err(FrontendError::NativeCapacity {
142            available: report
143                .native_cycles
144                .parse()
145                .map_err(|_| FrontendError::NativeBalance)?,
146            required: report
147                .minimum_native_cycles
148                .parse()
149                .map_err(|_| FrontendError::NativeBalance)?,
150        }
151        .into());
152    }
153    Ok(())
154}