Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Unreleased

* feat: Add `--proxy` to `icp canister` subcommands and `icp deploy` to route management canister calls through a proxy canister
* feat: Add `--args`, `--args-file`, and `--args-format` flags to `icp deploy` to pass install arguments at the command line, overriding `init_args` in the manifest

# v0.2.2

Expand Down
80 changes: 80 additions & 0 deletions crates/icp-cli/src/commands/args.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use std::fmt::Display;
use std::str::FromStr;

use anyhow::{Context as _, bail};
use candid::Principal;
use clap::Args;
use ic_ledger_types::AccountIdentifier;
use icp::context::{CanisterSelection, EnvironmentSelection, NetworkSelection};
use icp::identity::IdentitySelection;
use icp::manifest::ArgsFormat;
use icp::prelude::PathBuf;
use icp::{InitArgs, fs};
use icrc_ledger_types::icrc1::account::Account;

use crate::options::{EnvironmentOpt, IdentityOpt, NetworkOpt};
Expand Down Expand Up @@ -204,6 +208,82 @@ impl Display for FlexibleAccountId {
}
}

/// Grouped flags for specifying canister install arguments, shared by `canister install`, and `deploy`.
#[derive(Args, Clone, Debug, Default)]
pub(crate) struct ArgsOpt {
/// Inline arguments, interpreted per `--args-format` (Candid by default).
#[arg(long, conflicts_with = "args_file")]
pub(crate) args: Option<String>,

/// Path to a file containing arguments.
#[arg(long, conflicts_with = "args")]
pub(crate) args_file: Option<PathBuf>,

/// Format of the arguments.
#[arg(long, default_value = "candid")]
pub(crate) args_format: ArgsFormat,
}

impl ArgsOpt {
/// Returns whether any args were provided via CLI flags.
pub(crate) fn is_some(&self) -> bool {
self.args.is_some() || self.args_file.is_some()
}

/// Resolve CLI args to raw bytes, reading files as needed.
/// Returns `None` if no args were provided.
pub(crate) fn resolve_bytes(&self) -> Result<Option<Vec<u8>>, anyhow::Error> {
load_args(
self.args.as_deref(),
self.args_file.as_ref(),
&self.args_format,
"--args",
)?
.as_ref()
.map(|ia| ia.to_bytes().context("failed to encode args"))
.transpose()
}
}

/// Load args from an inline value or a file, returning the intermediate [`InitArgs`]
/// representation. Returns `None` if neither was provided.
///
/// `inline_arg_name` is used in the error message when `--args-format bin` is given
/// with an inline value (e.g. `"--args"` or `"a positional argument"`).
pub(crate) fn load_args(
inline_value: Option<&str>,
args_file: Option<&PathBuf>,
args_format: &ArgsFormat,
inline_arg_name: &str,
) -> Result<Option<InitArgs>, anyhow::Error> {
match (inline_value, args_file) {
(Some(value), None) => {
if *args_format == ArgsFormat::Bin {
bail!("--args-format bin requires --args-file, not {inline_arg_name}");
}
Ok(Some(InitArgs::Text {
content: value.to_owned(),
format: args_format.clone(),
}))
}
(None, Some(file_path)) => Ok(Some(match args_format {
ArgsFormat::Bin => {
let bytes = fs::read(file_path).context("failed to read args file")?;
InitArgs::Binary(bytes)
}
fmt => {
let content = fs::read_to_string(file_path).context("failed to read args file")?;
InitArgs::Text {
content: content.trim().to_owned(),
format: fmt.clone(),
}
}
})),
(None, None) => Ok(None),
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
}

#[cfg(test)]
mod tests {
use candid::Principal;
Expand Down
69 changes: 30 additions & 39 deletions crates/icp-cli/src/commands/canister/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ use clap::{Args, ValueEnum};
use dialoguer::console::Term;
use ic_agent::Agent;
use icp::context::Context;
use icp::fs;
use icp::manifest::InitArgsFormat;
use icp::manifest::ArgsFormat;
use icp::parsers::CyclesAmount;
use icp::prelude::*;
use serde::Serialize;
use std::io::{self, Write};
use tracing::{error, warn};

use crate::{
commands::args, operations::misc::fetch_canister_metadata,
commands::args::{self, load_args},
operations::misc::fetch_canister_metadata,
operations::proxy::update_or_proxy_raw,
};

Expand Down Expand Up @@ -56,7 +56,7 @@ pub(crate) struct CallArgs {

/// Format of the call arguments.
#[arg(long, default_value = "candid")]
pub(crate) args_format: InitArgsFormat,
pub(crate) args_format: ArgsFormat,

/// Principal of a proxy canister to route the call through.
///
Expand Down Expand Up @@ -134,45 +134,36 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E
Bytes(Vec<u8>),
}

let resolved_args = match (&args.args, &args.args_file) {
(Some(value), None) => {
if args.args_format == InitArgsFormat::Bin {
bail!("--args-format bin requires --args-file, not a positional argument");
}
Some(match args.args_format {
InitArgsFormat::Candid => ResolvedArgs::Candid(
parse_idl_args(value).context("failed to parse Candid arguments")?,
),
InitArgsFormat::Hex => ResolvedArgs::Bytes(
hex::decode(value).context("failed to decode hex arguments")?,
),
InitArgsFormat::Bin => unreachable!(),
})
let resolved_args = match load_args(
args.args.as_deref(),
args.args_file.as_ref(),
&args.args_format,
"a positional argument",
)? {
None => None,
Some(icp::InitArgs::Binary(bytes)) => Some(ResolvedArgs::Bytes(bytes)),
Some(icp::InitArgs::Text {
content,
format: ArgsFormat::Candid,
}) => Some(ResolvedArgs::Candid(
parse_idl_args(&content).context("failed to parse Candid arguments")?,
)),
Some(icp::InitArgs::Text {
content,
format: ArgsFormat::Hex,
}) => Some(ResolvedArgs::Bytes(
hex::decode(&content).context("failed to decode hex arguments")?,
)),
Some(icp::InitArgs::Text {
format: ArgsFormat::Bin,
..
}) => {
unreachable!("load_args rejects bin format for inline values")
}
(None, Some(file_path)) => Some(match args.args_format {
InitArgsFormat::Bin => {
let bytes = fs::read(file_path).context("failed to read args file")?;
ResolvedArgs::Bytes(bytes)
}
InitArgsFormat::Hex => {
let content = fs::read_to_string(file_path).context("failed to read args file")?;
ResolvedArgs::Bytes(
hex::decode(content.trim()).context("failed to decode hex from file")?,
)
}
InitArgsFormat::Candid => {
let content = fs::read_to_string(file_path).context("failed to read args file")?;
ResolvedArgs::Candid(
parse_idl_args(content.trim()).context("failed to parse Candid from file")?,
)
}
}),
(None, None) => None,
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
};

let arg_bytes = match (&declared_method, resolved_args) {
(_, None) if args.args_format != InitArgsFormat::Candid => {
(_, None) if args.args_format != ArgsFormat::Candid => {
bail!("arguments must be provided when --args-format is not candid");
}
(None, None) => bail!(
Expand Down
51 changes: 5 additions & 46 deletions crates/icp-cli/src/commands/canister/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ use clap::Args;
use dialoguer::Confirm;
use ic_management_canister_types::CanisterInstallMode;
use icp::context::{CanisterSelection, Context};
use icp::manifest::InitArgsFormat;
use icp::fs;
use icp::prelude::*;
use icp::{InitArgs, fs};
use tracing::{info, warn};

use crate::{
commands::args,
commands::args::{self, ArgsOpt},
operations::{
candid_compat::{CandidCompatibility, check_candid_compatibility},
install::{install_canister, resolve_install_mode_and_status},
Expand All @@ -30,17 +29,8 @@ pub(crate) struct InstallArgs {
#[arg(long)]
pub(crate) wasm: Option<PathBuf>,

/// Inline initialization arguments, interpreted per `--args-format` (Candid by default).
#[arg(long, conflicts_with = "args_file")]
pub(crate) args: Option<String>,

/// Path to a file containing initialization arguments.
#[arg(long, conflicts_with = "args")]
pub(crate) args_file: Option<PathBuf>,

/// Format of the initialization arguments.
#[arg(long, default_value = "candid")]
pub(crate) args_format: InitArgsFormat,
#[command(flatten)]
pub(crate) args_opt: ArgsOpt,

/// Skip confirmation prompts, including the Candid interface compatibility check.
#[arg(long, short)]
Expand Down Expand Up @@ -92,38 +82,7 @@ pub(crate) async fn exec(ctx: &Context, args: &InstallArgs) -> Result<(), anyhow
.await?;

// If you add .did support to this code, consider extracting/unifying with the logic from call.rs
let init_args = match (&args.args, &args.args_file) {
(Some(value), None) => {
if args.args_format == InitArgsFormat::Bin {
bail!("--args-format bin requires --args-file, not --args");
}
Some(InitArgs::Text {
content: value.clone(),
format: args.args_format.clone(),
})
}
(None, Some(file_path)) => Some(match args.args_format {
InitArgsFormat::Bin => {
let bytes = fs::read(file_path).context("failed to read init args file")?;
InitArgs::Binary(bytes)
}
ref fmt => {
let content =
fs::read_to_string(file_path).context("failed to read init args file")?;
InitArgs::Text {
content: content.trim().to_owned(),
format: fmt.clone(),
}
}
}),
(None, None) => None,
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
};

let init_args_bytes = init_args
.as_ref()
.map(|ia| ia.to_bytes().context("failed to encode init args"))
.transpose()?;
let init_args_bytes = args.args_opt.resolve_bytes()?;

let canister_display = args.cmd_args.canister.to_string();
let (install_mode, status) = resolve_install_mode_and_status(
Expand Down
39 changes: 33 additions & 6 deletions crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use serde::Serialize;
use tracing::info;

use crate::{
commands::canister::create,
commands::{args::ArgsOpt, canister::create},
operations::{
binding_env_vars::set_binding_env_vars_many,
build::build_many_with_progress_bar,
Expand All @@ -30,6 +30,19 @@ use crate::{

/// Deploy a project to an environment
#[derive(Args, Debug)]
#[command(after_long_help = "\
When deploying a single canister, you can pass arguments to the install call
using --args or --args-file:

# Pass inline Candid arguments
icp deploy my_canister --args '(42 : nat)'

# Pass arguments from a file
icp deploy my_canister --args-file ./args.did

# Pass raw bytes
icp deploy my_canister --args-file ./args.bin --args-format bin
")]
pub(crate) struct DeployArgs {
/// Canister names
pub(crate) names: Vec<String>,
Expand Down Expand Up @@ -68,6 +81,11 @@ pub(crate) struct DeployArgs {
/// Output command results as JSON
#[arg(long)]
pub(crate) json: bool,

/// Arguments to pass to the canister on install.
/// Only valid when deploying a single canister. Takes priority over `init_args` in the manifest.
#[command(flatten)]
pub(crate) args_opt: ArgsOpt,
}

pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> {
Expand All @@ -89,6 +107,10 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
return Ok(());
}

if args.args_opt.is_some() && cnames.len() != 1 {
anyhow::bail!("--args and --args-file can only be used when deploying a single canister");
}

let canisters_to_build = try_join_all(
cnames
.iter()
Expand Down Expand Up @@ -256,11 +278,16 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
let (_canister_path, canister_info) =
env.get_canister_info(name).map_err(|e| anyhow!(e))?;

let init_args_bytes = canister_info
.init_args
.as_ref()
.map(|ia| ia.to_bytes())
.transpose()?;
// CLI --args/--args-file take priority over manifest init_args
let init_args_bytes = if args.args_opt.is_some() {
args.args_opt.resolve_bytes()?
} else {
canister_info
.init_args
.as_ref()
.map(|ia| ia.to_bytes())
.transpose()?
};

Ok::<_, anyhow::Error>((name.clone(), cid, mode, status, init_args_bytes))
}
Expand Down
Loading
Loading