From 177d6de3469b00d03c3768b5c52ae44bd3f334fa Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 4 May 2026 08:21:27 -0700 Subject: [PATCH] Customizations files --- crates/icp-cli/src/commands/deploy.rs | 47 +- crates/icp-cli/src/operations/bundle.rs | 26 +- crates/icp-cli/src/operations/customize.rs | 645 +++++++++++++++++++++ crates/icp-cli/src/operations/mod.rs | 1 + crates/icp-cli/tests/bundle_tests.rs | 57 ++ 5 files changed, 772 insertions(+), 4 deletions(-) create mode 100644 crates/icp-cli/src/operations/customize.rs diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 611fa387..e7c91bc5 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -1,5 +1,5 @@ use anyhow::anyhow; -use candid::{CandidType, Principal}; +use candid::{CandidType, IDLArgs, Principal}; use clap::Args; use futures::{StreamExt, future::try_join_all, stream::FuturesOrdered}; use ic_agent::Agent; @@ -11,7 +11,8 @@ use icp::{ }; use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use tracing::info; use crate::{ @@ -21,6 +22,7 @@ use crate::{ build::build_many_with_progress_bar, candid_compat::check_candid_compatibility_many, create::{CreateOperation, CreateTarget}, + customize, install::{install_many, resolve_install_mode_and_status}, settings::sync_settings_many, sync::sync_many, @@ -119,6 +121,38 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: ) .await?; + // Collect interactive init arg customizations before the build so the user + // fills in all prompts upfront, uninterrupted by build output. + let customize_overrides: Arc> = { + let project = ctx.project.load().await.map_err(|e| anyhow!(e))?; + let customize_path = project.dir.join(customize::CUSTOMIZE_FILE); + match customize::load_customize_manifest(&project.dir).map_err(|e| anyhow!(e))? { + None => Arc::new(HashMap::new()), + Some(manifest) => { + let init_args: HashMap> = cnames + .iter() + .map(|name| { + let ia = env + .get_canister_info(name) + .ok() + .and_then(|(_, info)| info.init_args.clone()); + (name.clone(), ia) + }) + .collect(); + Arc::new( + customize::prompt_customizations( + &manifest, + &cnames, + &init_args, + args.yes, + &customize_path, + ) + .map_err(|e| anyhow!(e))?, + ) + } + } + }; + // Build the selected canisters info!("Building canisters:"); @@ -263,6 +297,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: let canisters = try_join_all(cnames.iter().map(|name| { let environment_selection = environment_selection.clone(); let agent = agent.clone(); + let co = customize_overrides.clone(); async move { let cid = ctx .get_canister_id_for_env( @@ -279,9 +314,15 @@ 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))?; - // CLI --args/--args-file take priority over manifest init_args + // Priority: CLI --args/--args-file > icp_customize.yaml prompts > manifest init_args let init_args_bytes = if args.args_opt.is_some() { args.args_opt.resolve_bytes()? + } else if let Some(customized) = co.get(name) { + Some( + customized + .to_bytes() + .map_err(|e| anyhow!("failed to serialize customized init args: {e}"))?, + ) } else { canister_info .init_args diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index c0201a3b..97f473d5 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -28,7 +28,10 @@ use icp::{ use snafu::{ResultExt, Snafu}; use tar::Builder; -use crate::operations::build::{BuildManyError, build_many_with_progress_bar}; +use crate::operations::{ + build::{BuildManyError, build_many_with_progress_bar}, + customize::CUSTOMIZE_FILE, +}; #[derive(Debug, Snafu)] pub enum BundleError { @@ -73,6 +76,9 @@ pub enum BundleError { #[snafu(display("failed to read init_args file '{path}'"))] ReadInitArgs { path: PathBuf, source: fs::IoError }, + #[snafu(display("failed to read '{path}'"))] + ReadCustomize { path: PathBuf, source: fs::IoError }, + #[snafu(display("failed to serialize bundle manifest"))] SerializeManifest { source: serde_yaml::Error }, @@ -221,9 +227,22 @@ pub(crate) async fn create_bundle( environments, }; + let customize_path = project_dir.join(CUSTOMIZE_FILE); + let customize_bytes = match fs::read(&customize_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(source) => { + return Err(BundleError::ReadCustomize { + path: customize_path, + source, + }); + } + }; + write_archive( output, &bundle_manifest, + customize_bytes.as_deref(), &bundle_artifacts, &init_args_files, ) @@ -520,6 +539,7 @@ async fn inline_environments( fn write_archive( output: &Path, bundle_manifest: &ProjectManifest, + customize_bytes: Option<&[u8]>, artifacts: &BundleArtifacts, init_args_files: &[InitArgsFile], ) -> Result<(), BundleError> { @@ -539,6 +559,10 @@ fn write_archive( append_bytes(&mut archive, "icp.yaml", manifest_yaml.as_bytes())?; + if let Some(customize_bytes) = customize_bytes { + append_bytes(&mut archive, CUSTOMIZE_FILE, customize_bytes)?; + } + for nb in &artifacts.wasms { append_bytes(&mut archive, &nb.archive_path, &nb.bytes)?; } diff --git a/crates/icp-cli/src/operations/customize.rs b/crates/icp-cli/src/operations/customize.rs new file mode 100644 index 00000000..6f28fe09 --- /dev/null +++ b/crates/icp-cli/src/operations/customize.rs @@ -0,0 +1,645 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::io; + +use candid::types::Label; +use candid::types::value::VariantValue; +use candid::{IDLArgs, IDLValue, TypeEnv}; +use candid_parser::{assist, parse_idl_args, utils::CandidSource}; +use icp::fs::yaml; +use icp::manifest::ArgsFormat; +use icp::prelude::*; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; + +pub(crate) const CUSTOMIZE_FILE: &str = "icp_customize.yaml"; + +#[derive(Debug, Deserialize)] +pub(crate) struct CustomizeManifest { + pub(crate) options: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct CustomizeOption { + pub(crate) canister: String, + pub(crate) field_path: String, + pub(crate) candid_type: String, + pub(crate) description: String, +} + +#[derive(Debug)] +pub(crate) struct FieldPath { + pub(crate) arg_index: usize, + pub(crate) fields: Vec, +} + +pub(crate) type LoadCustomizeManifestError = yaml::Error; + +#[derive(Debug, Snafu)] +pub(crate) enum ParseFieldPathError { + #[snafu(display("field path is empty"))] + Empty, + #[snafu(display( + "field path {path_str:?} must start with an arg index — \ + try \".{path_str}\" (shorthand for arg 0) or \".{path_str}\"" + ))] + InvalidIndex { path_str: String }, +} + +#[derive(Debug, Snafu)] +#[snafu(display("failed to parse Candid type {type_str:?}"))] +pub(crate) struct ParseCandidTypeError { + #[snafu(source(from(candid_parser::Error, Box::new)))] + source: Box, + type_str: String, +} + +#[derive(Debug, Snafu)] +pub(crate) enum SubstituteError { + #[snafu(display("arg index {index} out of bounds (init args has {len} args) in {path}"))] + ArgIndexOutOfBounds { + index: usize, + len: usize, + path: PathBuf, + }, + #[snafu(display("field {field:?} not found in record in {path}"))] + FieldNotFound { field: String, path: PathBuf }, + #[snafu(display("cannot traverse {kind} to reach field {field:?} in {path}"))] + NotTraversable { + kind: &'static str, + field: String, + path: PathBuf, + }, +} + +#[derive(Debug, Snafu)] +pub(crate) enum PromptCustomizationsError { + #[snafu(display("invalid field_path for canister {canister:?} in {path}"))] + FieldPath { + source: ParseFieldPathError, + canister: String, + path: PathBuf, + }, + #[snafu(display("invalid candid_type for canister {canister:?} at {field_path:?} in {path}"))] + CandidType { + source: ParseCandidTypeError, + canister: String, + field_path: String, + path: PathBuf, + }, + #[snafu(display("failed to parse init_args for canister {canister:?} in {path}"))] + ParseInitArgs { + #[snafu(source(from(candid_parser::Error, Box::new)))] + source: Box, + canister: String, + path: PathBuf, + }, + #[snafu(display( + "init args for canister {canister:?} use a non-Candid format \ + and cannot be field-customized (referenced from {path})" + ))] + UnsupportedInitArgsFormat { canister: String, path: PathBuf }, + #[snafu(display( + "interactive prompt failed for canister {canister:?} at {field_path:?} (from {path})" + ))] + Prompt { + source: anyhow::Error, + canister: String, + field_path: String, + path: PathBuf, + }, + #[snafu(transparent)] + Substitute { source: SubstituteError }, +} + +pub(crate) fn load_customize_manifest( + project_dir: &Path, +) -> Result, LoadCustomizeManifestError> { + let path = project_dir.join(CUSTOMIZE_FILE); + match yaml::load::(&path) { + Ok(m) => Ok(Some(m)), + Err(yaml::Error::Io { source }) if source.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +fn parse_field_path(s: &str) -> Result { + if s.is_empty() { + return Err(ParseFieldPathError::Empty); + } + if let Some(rest) = s.strip_prefix('.') { + let fields = if rest.is_empty() { + vec![] + } else { + rest.split('.').map(str::to_string).collect() + }; + return Ok(FieldPath { + arg_index: 0, + fields, + }); + } + let mut iter = s.split('.'); + let first = iter.next().expect("split always yields at least one part"); + let arg_index = first + .parse::() + .map_err(|_| ParseFieldPathError::InvalidIndex { + path_str: s.to_string(), + })?; + let fields = iter.map(str::to_string).collect(); + Ok(FieldPath { arg_index, fields }) +} + +fn parse_contextfree_candid_type_string( + type_str: &str, +) -> Result<(TypeEnv, candid::types::Type), ParseCandidTypeError> { + let source = format!("type T = {}; service : {{}}", type_str); + let (env, _) = CandidSource::Text(&source) + .load() + .context(ParseCandidTypeSnafu { + type_str: type_str.to_string(), + })?; + let ty = env + .find_type("T") + .expect("T was just defined in the synthetic source") + .clone(); + Ok((env, ty)) +} + +fn idl_value_kind(v: &IDLValue) -> &'static str { + match v { + IDLValue::Bool(_) => "bool", + IDLValue::Null => "null", + IDLValue::Text(_) => "text", + IDLValue::Number(_) => "number", + IDLValue::Float64(_) => "float64", + IDLValue::Float32(_) => "float32", + IDLValue::Opt(_) => "opt", + IDLValue::Vec(_) => "vec", + IDLValue::Record(_) => "record", + IDLValue::Variant(_) => "variant", + IDLValue::Principal(_) => "principal", + IDLValue::Service(_) => "service", + IDLValue::Func(_, _) => "func", + IDLValue::None => "none", + IDLValue::Int(_) => "int", + IDLValue::Nat(_) => "nat", + IDLValue::Int8(_) | IDLValue::Int16(_) | IDLValue::Int32(_) | IDLValue::Int64(_) => "int_N", + IDLValue::Nat8(_) | IDLValue::Nat16(_) | IDLValue::Nat32(_) | IDLValue::Nat64(_) => "nat_N", + IDLValue::Reserved => "reserved", + IDLValue::Blob(_) => "blob", + } +} + +fn substitute_value( + value: &mut IDLValue, + fields: &[String], + replacement: IDLValue, + path: &Path, +) -> Result<(), SubstituteError> { + if fields.is_empty() { + *value = replacement; + return Ok(()); + } + match value { + IDLValue::Variant(VariantValue(inner_field, _)) => { + // Pass through the variant without consuming a path segment. + // The variant selection is already made in the existing init args. + substitute_value(&mut inner_field.val, fields, replacement, path) + } + IDLValue::Record(record_fields) => { + let field_name = &fields[0]; + let target_id = Label::Named(field_name.clone()).get_id(); + match record_fields + .iter_mut() + .find(|f| f.id.get_id() == target_id) + { + Some(f) => substitute_value(&mut f.val, &fields[1..], replacement, path), + None => Err(SubstituteError::FieldNotFound { + field: field_name.clone(), + path: path.to_path_buf(), + }), + } + } + other => Err(SubstituteError::NotTraversable { + kind: idl_value_kind(other), + field: fields[0].clone(), + path: path.to_path_buf(), + }), + } +} + +pub(crate) fn substitute_field( + args: &mut IDLArgs, + path: &FieldPath, + replacement: IDLValue, + customize_path: &Path, +) -> Result<(), SubstituteError> { + if path.arg_index >= args.args.len() { + return Err(SubstituteError::ArgIndexOutOfBounds { + index: path.arg_index, + len: args.args.len(), + path: customize_path.to_path_buf(), + }); + } + substitute_value( + &mut args.args[path.arg_index], + &path.fields, + replacement, + customize_path, + ) +} + +pub(crate) fn prompt_customizations( + manifest: &CustomizeManifest, + cnames: &[String], + init_args: &HashMap>, + skip: bool, + customize_path: &Path, +) -> Result, PromptCustomizationsError> { + if skip { + return Ok(HashMap::new()); + } + + let cname_set: HashSet<&str> = cnames.iter().map(String::as_str).collect(); + + // Group by canister preserving declaration order, filtered to deployed canisters. + // Track skipped names so a typo in the customize manifest doesn't silently no-op. + let mut by_canister: Vec<(&str, Vec<&CustomizeOption>)> = Vec::new(); + let mut skipped: BTreeSet<&str> = BTreeSet::new(); + for opt in &manifest.options { + if !cname_set.contains(opt.canister.as_str()) { + skipped.insert(opt.canister.as_str()); + continue; + } + match by_canister + .iter_mut() + .find(|(name, _)| *name == opt.canister.as_str()) + { + Some((_, opts)) => opts.push(opt), + None => by_canister.push((opt.canister.as_str(), vec![opt])), + } + } + if !skipped.is_empty() { + let names = skipped.iter().copied().collect::>().join(", "); + tracing::warn!( + "Customize options skipped because their canister is not being deployed: {names}" + ); + } + + let mut result = HashMap::new(); + + for (canister_name, options) in &by_canister { + let mut working_args = match init_args + .get(*canister_name) + .and_then(Option::as_ref) + .cloned() + { + None => IDLArgs { args: vec![] }, + Some(icp::InitArgs::Text { + content, + format: ArgsFormat::Candid, + }) => parse_idl_args(content.trim()).context(ParseInitArgsSnafu { + canister: *canister_name, + path: customize_path, + })?, + Some(icp::InitArgs::Text { .. } | icp::InitArgs::Binary(_)) => { + return UnsupportedInitArgsFormatSnafu { + canister: *canister_name, + path: customize_path, + } + .fail(); + } + }; + + for opt in options { + let field_path = parse_field_path(&opt.field_path).context(FieldPathSnafu { + canister: *canister_name, + path: customize_path, + })?; + + let (env, ty) = parse_contextfree_candid_type_string(&opt.candid_type).context( + CandidTypeSnafu { + canister: *canister_name, + field_path: opt.field_path.as_str(), + path: customize_path, + }, + )?; + + eprintln!("[{}] {}", canister_name, opt.description); + + let context = assist::Context::new(env); + let prompted = assist::input_args(&context, &[ty]).context(PromptSnafu { + canister: *canister_name, + field_path: opt.field_path.as_str(), + path: customize_path, + })?; + + let value = prompted + .args + .into_iter() + .next() + .expect("input_args returns one value per type element"); + + substitute_field(&mut working_args, &field_path, value, customize_path)?; + } + + result.insert(canister_name.to_string(), working_args); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use camino_tempfile::Utf8TempDir; + use candid::types::value::IDLField; + + fn nat64_record_args(supply: u64) -> IDLArgs { + IDLArgs { + args: vec![IDLValue::Record(vec![IDLField { + id: Label::Named("supply".to_string()), + val: IDLValue::Nat64(supply), + }])], + } + } + + #[test] + fn parse_field_path_index_only() { + let fp = parse_field_path("0").unwrap(); + assert_eq!(fp.arg_index, 0); + assert!(fp.fields.is_empty()); + } + + #[test] + fn parse_field_path_with_fields() { + let fp = parse_field_path("0.supply").unwrap(); + assert_eq!(fp.arg_index, 0); + assert_eq!(fp.fields, vec!["supply"]); + } + + #[test] + fn parse_field_path_nested() { + let fp = parse_field_path("1.a.b.c").unwrap(); + assert_eq!(fp.arg_index, 1); + assert_eq!(fp.fields, vec!["a", "b", "c"]); + } + + #[test] + fn parse_field_path_empty_err() { + assert!(matches!( + parse_field_path(""), + Err(ParseFieldPathError::Empty) + )); + } + + #[test] + fn parse_field_path_non_integer_index_err() { + assert!(matches!( + parse_field_path("foo.bar"), + Err(ParseFieldPathError::InvalidIndex { .. }) + )); + } + + #[test] + fn parse_field_path_dot_shorthand() { + let fp = parse_field_path(".supply").unwrap(); + assert_eq!(fp.arg_index, 0); + assert_eq!(fp.fields, vec!["supply"]); + } + + #[test] + fn parse_field_path_dot_shorthand_nested() { + let fp = parse_field_path(".a.b.c").unwrap(); + assert_eq!(fp.arg_index, 0); + assert_eq!(fp.fields, vec!["a", "b", "c"]); + } + + #[test] + fn parse_field_path_bare_dot() { + let fp = parse_field_path(".").unwrap(); + assert_eq!(fp.arg_index, 0); + assert!(fp.fields.is_empty()); + } + + #[test] + fn parse_field_path_bare_field_error_suggests_shorthand() { + let err = parse_field_path("field1").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("\".field1\""), "message was: {msg}"); + assert!(msg.contains("shorthand for arg 0"), "message was: {msg}"); + } + + #[test] + fn substitute_simple_field() { + let mut args = nat64_record_args(0); + let path = parse_field_path("0.supply").unwrap(); + substitute_field( + &mut args, + &path, + IDLValue::Nat64(42), + Path::new("test.yaml"), + ) + .unwrap(); + if let IDLValue::Record(fields) = &args.args[0] { + assert!(matches!(fields[0].val, IDLValue::Nat64(42))); + } else { + panic!("expected record"); + } + } + + #[test] + fn substitute_out_of_bounds_err() { + let mut args = IDLArgs { args: vec![] }; + let path = parse_field_path("0").unwrap(); + let err = + substitute_field(&mut args, &path, IDLValue::Null, Path::new("test.yaml")).unwrap_err(); + assert!(matches!( + err, + SubstituteError::ArgIndexOutOfBounds { + index: 0, + len: 0, + .. + } + )); + } + + #[test] + fn substitute_field_not_found_err() { + let mut args = nat64_record_args(0); + let path = parse_field_path("0.missing").unwrap(); + let err = substitute_field(&mut args, &path, IDLValue::Nat64(1), Path::new("test.yaml")) + .unwrap_err(); + assert!(matches!(err, SubstituteError::FieldNotFound { .. })); + } + + #[test] + fn substitute_passes_through_variant() { + // Structure: record { status = variant { active = record { value = 0 : nat64 } } } + // The variant is transparent in the path: "0.status.value" navigates through the variant. + let payload_field = IDLField { + id: Label::Named("value".to_string()), + val: IDLValue::Nat64(0), + }; + let variant_inner = IDLField { + id: Label::Named("active".to_string()), + val: IDLValue::Record(vec![payload_field]), + }; + let status_field = IDLField { + id: Label::Named("status".to_string()), + val: IDLValue::Variant(VariantValue(Box::new(variant_inner), 0)), + }; + let mut args = IDLArgs { + args: vec![IDLValue::Record(vec![status_field])], + }; + let path = parse_field_path("0.status.value").unwrap(); + substitute_field( + &mut args, + &path, + IDLValue::Nat64(99), + Path::new("test.yaml"), + ) + .unwrap(); + + if let IDLValue::Record(fields) = &args.args[0] + && let IDLValue::Variant(VariantValue(inner, _)) = &fields[0].val + && let IDLValue::Record(payload_fields) = &inner.val + { + assert!(matches!(payload_fields[0].val, IDLValue::Nat64(99))); + return; + } + panic!("unexpected args structure"); + } + + #[test] + fn substitute_not_traversable_err() { + let mut args = IDLArgs { + args: vec![IDLValue::Nat64(0)], + }; + let path = parse_field_path("0.field").unwrap(); + let err = substitute_field(&mut args, &path, IDLValue::Nat64(1), Path::new("test.yaml")) + .unwrap_err(); + assert!(matches!(err, SubstituteError::NotTraversable { .. })); + } + + #[test] + fn parse_candid_type_nat64() { + let (_, ty) = parse_contextfree_candid_type_string("nat64").unwrap(); + assert!(matches!(ty.as_ref(), candid::types::TypeInner::Nat64)); + } + + #[test] + fn parse_candid_type_principal() { + let (_, ty) = parse_contextfree_candid_type_string("principal").unwrap(); + assert!(matches!(ty.as_ref(), candid::types::TypeInner::Principal)); + } + + #[test] + fn parse_candid_type_invalid_err() { + assert!(parse_contextfree_candid_type_string("@@@invalid").is_err()); + } + + #[test] + fn prompt_skip_returns_empty() { + let manifest = CustomizeManifest { + options: vec![CustomizeOption { + canister: "c".to_string(), + field_path: "0.x".to_string(), + candid_type: "nat64".to_string(), + description: "desc".to_string(), + }], + }; + let result = prompt_customizations( + &manifest, + &["c".to_string()], + &HashMap::new(), + true, + Path::new("icp_customize.yaml"), + ) + .unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn load_missing_file_returns_none() { + let tmp = Utf8TempDir::new().unwrap(); + let result = load_customize_manifest(tmp.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn load_valid_file() { + let tmp = Utf8TempDir::new().unwrap(); + let content = r#" +options: + - canister: my-canister + field_path: "0.supply" + candid_type: "nat64" + description: "Initial supply" +"#; + std::fs::write(tmp.path().join(CUSTOMIZE_FILE), content).unwrap(); + let manifest = load_customize_manifest(tmp.path()).unwrap().unwrap(); + assert_eq!(manifest.options.len(), 1); + assert_eq!(manifest.options[0].canister, "my-canister"); + } + + #[test] + fn load_malformed_file_err() { + let tmp = Utf8TempDir::new().unwrap(); + std::fs::write(tmp.path().join(CUSTOMIZE_FILE), "options: }{bad yaml").unwrap(); + let err = load_customize_manifest(tmp.path()).unwrap_err(); + assert!(matches!(err, LoadCustomizeManifestError::Parse { .. })); + } + + #[test] + fn prompt_rejects_binary_init_args() { + // Surfaces the format check before any interactive prompt by giving the canister + // non-Candid init args. + let manifest = CustomizeManifest { + options: vec![CustomizeOption { + canister: "c".to_string(), + field_path: ".x".to_string(), + candid_type: "nat64".to_string(), + description: "desc".to_string(), + }], + }; + let init_args = HashMap::from([("c".to_string(), Some(icp::InitArgs::Binary(vec![0u8])))]); + let err = prompt_customizations( + &manifest, + &["c".to_string()], + &init_args, + false, + Path::new("icp_customize.yaml"), + ) + .unwrap_err(); + assert!(matches!( + err, + PromptCustomizationsError::UnsupportedInitArgsFormat { .. } + )); + let msg = err.to_string(); + assert!(msg.contains("non-Candid format"), "got: {msg}"); + assert!(msg.contains("icp_customize.yaml"), "got: {msg}"); + } + + #[test] + fn prompt_returns_empty_when_no_options_match_deployment() { + // Manifest targets canister "a", deployment is for "b" — every option is filtered + // out, no prompts fire, the result is empty. + let manifest = CustomizeManifest { + options: vec![CustomizeOption { + canister: "a".to_string(), + field_path: ".x".to_string(), + candid_type: "nat64".to_string(), + description: "desc".to_string(), + }], + }; + let result = prompt_customizations( + &manifest, + &["b".to_string()], + &HashMap::new(), + false, + Path::new("icp_customize.yaml"), + ) + .unwrap(); + assert!(result.is_empty()); + } +} diff --git a/crates/icp-cli/src/operations/mod.rs b/crates/icp-cli/src/operations/mod.rs index a53b619a..d808ba83 100644 --- a/crates/icp-cli/src/operations/mod.rs +++ b/crates/icp-cli/src/operations/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod bundle; pub(crate) mod candid_compat; pub(crate) mod canister_migration; pub(crate) mod create; +pub(crate) mod customize; pub(crate) mod install; pub(crate) mod proxy; pub(crate) mod proxy_management; diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 42f8bccb..ffceeae3 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -653,6 +653,63 @@ fn bundle_packages_plugin_sync_steps() { ); } +/// `icp_customize.yaml` next to the project manifest must be packaged at the archive root +/// verbatim, so the bundled project can be deployed with its customize prompts intact. +#[test] +fn bundle_includes_customize_file() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm_src = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH" + "#}; + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let customize_yaml = indoc::indoc! {r#" + options: + - canister: my-canister + field_path: ".name" + candid_type: "text" + description: "Greeting target" + "#}; + write_string(&project_dir.join("icp_customize.yaml"), customize_yaml) + .expect("failed to write customize manifest"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let bundle_bytes = fs::read(bundle_path.as_std_path()).expect("failed to read bundle"); + let gz = GzDecoder::new(BufReader::new(bundle_bytes.as_slice())); + let mut archive = Archive::new(gz); + + let mut found: Option = None; + for entry in archive.entries().expect("failed to read archive entries") { + let mut entry = entry.expect("failed to read archive entry"); + let path = entry + .path() + .expect("entry path") + .to_string_lossy() + .into_owned(); + if path == "icp_customize.yaml" { + let mut s = String::new(); + entry.read_to_string(&mut s).expect("read customize entry"); + found = Some(s); + } + } + let bundled = found.expect("icp_customize.yaml not found in bundle"); + assert_eq!(bundled, customize_yaml); +} + /// Projects with script sync steps must be rejected with a clear error. #[test] fn bundle_rejects_script_sync_step() {