canic_cli/frontend/
mod.rs1use 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#[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
59pub 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}