From 4723fa9e88111c2fd7f938e9b037ada8c4beda78 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 6 Aug 2026 10:29:04 -0700 Subject: [PATCH 1/5] Allow env-specific builds --- CHANGELOG.md | 2 + crates/icp-cli/src/commands/build.rs | 1 + crates/icp-cli/src/commands/deploy.rs | 1 + crates/icp-cli/src/commands/project/bundle.rs | 6 ++ crates/icp-cli/src/operations/build.rs | 4 ++ crates/icp-cli/src/operations/bundle.rs | 2 + crates/icp-cli/tests/build_tests.rs | 58 ++++++++++++++++++- crates/icp-cli/tests/bundle_tests.rs | 56 +++++++++++++++++- crates/icp-cli/tests/deploy_tests.rs | 15 ++++- crates/icp/src/canister/build/mod.rs | 1 + crates/icp/src/canister/build/script.rs | 47 ++++++++++++++- docs/concepts/build-deploy-sync.md | 2 +- docs/guides/creating-recipes.md | 1 + docs/reference/configuration.md | 1 + docs/reference/environment-variables.md | 12 ++-- 15 files changed, 197 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da58dc477..37622b264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ bump. Currently experimental: project bundling, project dependencies # Unreleased +* feat: `script` build steps now receive `ICP_CLI_ENVIRONMENT`, the name of the environment the canisters are being built for, so a build can vary by environment the way a sync step already could. * feat: a canister environment variable's value can now be read from a file, by writing `var: { path: }` in place of `var: value`. The path resolves against the canister's directory — including in an environment override, matching `init_args` — and surrounding whitespace is trimmed off the file's contents. The file is read when the project is loaded, so a missing file fails before anything is deployed. `icp project bundle` writes the value into the bundled manifest inline, rejecting a file outside the project as it does for other manifest file references. * fix: `icp network start` now explains why a Docker-based network failed to come up. A container that exited before the network was ready was reported as `failed to watch docker container for exit` with an empty cause, discarding the actual reason (e.g. the gateway port already being taken); the container's output is now attached to the error. * feat: Docker-based networks now show the launcher's output like non-containerized ones do. In the foreground the container's stdout and stderr are streamed to your terminal as it runs; in background mode `icp network start` prints the `docker logs -f ` command to follow it. Previously container output was never shown at all — which on Windows, where the launcher always runs in a container, meant `icp network start` was silent. @@ -14,6 +15,7 @@ bump. Currently experimental: project bundling, project dependencies ## Experimental +* feat(bundle): `icp project bundle` takes `-e/--environment`, naming the environment its canisters are built for — it reaches build scripts as `ICP_CLI_ENVIRONMENT`. It defaults to `ic`, unlike the rest of the CLI, because a bundle is built to be deployed somewhere else; `ICP_ENVIRONMENT` overrides that default as it does elsewhere. Which canisters are bundled is unaffected. * feat(bundle): `icp project bundle` now works on projects that declare `dependencies:`, which it previously refused outright. The bundle mirrors the workspace instead of flattening it: the root project's `icp.yaml` sits at the archive root, each dependency instance gets its own `icp.yaml` at the directory it occupies in the workspace, and the `dependencies:` declarations are preserved, each pointing at the directory its dependency occupies in the archive (the same path a plainly vendored layout already used). A shared (diamond) dependency is still a single instance, canister names stay as each project wrote them, and canister discovery (`PUBLIC_CANISTER_ID::`) works in the extracted bundle exactly as it did in the source workspace. * Every dependency must resolve to a directory inside the workspace root; one that resolves outside it (including through a symlink) is rejected, because the archive could not contain it. As a result, a vendored member that depends on a sibling cannot be bundled as a standalone project (e.g. via `ICP_PROJECT_ROOT`) — bundle the workspace root instead. * Projects with script sync steps still cannot be bundled, and the restriction now covers every project in the workspace. diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index 9a04c51e6..0791d675f 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -48,6 +48,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: build_many_with_progress_bar( canisters_to_build, + environment_selection.name(), ctx.builder.clone(), ctx.artifacts.clone(), &ctx.dirs.package_cache()?, diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 5987dfc49..368506ea9 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -175,6 +175,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: build_many_with_progress_bar( canisters_to_build, + environment_selection.name(), ctx.builder.clone(), ctx.artifacts.clone(), &ctx.dirs.package_cache()?, diff --git a/crates/icp-cli/src/commands/project/bundle.rs b/crates/icp-cli/src/commands/project/bundle.rs index 0b758cbca..6e4b59a91 100644 --- a/crates/icp-cli/src/commands/project/bundle.rs +++ b/crates/icp-cli/src/commands/project/bundle.rs @@ -18,6 +18,11 @@ pub(crate) struct BundleArgs { /// Output path for the bundle archive (e.g. bundle.tar.gz) #[arg(long, short)] pub(crate) output: PathBuf, + + /// Environment the canisters are built for. Bundles are made to be deployed + /// elsewhere, so this defaults to `ic` rather than the usual `local`. + #[arg(long, short = 'e', env = "ICP_ENVIRONMENT", default_value = IC)] + pub(crate) environment: String, } pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow::Error> { @@ -28,6 +33,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow: create_bundle( &project.dir, canisters, + &args.environment, ctx.builder.clone(), ctx.artifacts.clone(), &ctx.dirs.package_cache()?, diff --git a/crates/icp-cli/src/operations/build.rs b/crates/icp-cli/src/operations/build.rs index 93757ef83..eb85c7bb5 100644 --- a/crates/icp-cli/src/operations/build.rs +++ b/crates/icp-cli/src/operations/build.rs @@ -49,6 +49,7 @@ struct BuildFailure { pub(crate) async fn build( canister_path: &Path, canister: &Canister, + environment: &str, pb: &mut MultiStepProgressBar, builder: Arc, artifacts: Arc, @@ -69,6 +70,7 @@ pub(crate) async fn build( &Params { path: canister_path.to_owned(), output: wasm_output_path.to_owned(), + environment: environment.to_owned(), }, Some(tx), pkg_cache, @@ -96,6 +98,7 @@ pub(crate) async fn build( pub(crate) async fn build_many_with_progress_bar( canisters: Vec<(PathBuf, Canister)>, + environment: &str, builder: Arc, artifacts: Arc, pkg_cache: &PackageCache, @@ -112,6 +115,7 @@ pub(crate) async fn build_many_with_progress_bar( let build_result = build( &canister_path, &canister, + environment, &mut pb, builder, artifacts, diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 0462f24a0..6786fc7d5 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -324,6 +324,7 @@ struct Instance { pub(crate) async fn create_bundle( project_dir: &Path, canisters: Vec<(PathBuf, Canister)>, + environment: &str, builder: Arc, artifacts: Arc, pkg_cache: &PackageCache, @@ -348,6 +349,7 @@ pub(crate) async fn create_bundle( build_many_with_progress_bar( canisters.clone(), + environment, builder, artifacts.clone(), pkg_cache, diff --git a/crates/icp-cli/tests/build_tests.rs b/crates/icp-cli/tests/build_tests.rs index c70320da2..c2e89fbf0 100644 --- a/crates/icp-cli/tests/build_tests.rs +++ b/crates/icp-cli/tests/build_tests.rs @@ -5,7 +5,7 @@ use indoc::{formatdoc, indoc}; use predicates::{prelude::PredicateBooleanExt, str::contains}; use crate::common::TestContext; -use icp::fs::write_string; +use icp::fs::{read_to_string, write_string}; mod common; @@ -83,6 +83,62 @@ fn build_adapter_script_multiple() { .success(); } +#[test] +fn build_exposes_environment_name() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + let recorded = project_dir.join("environment.txt"); + + // Project manifest: the build step records the environment it was built for + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + commands: + - echo "$ICP_CLI_ENVIRONMENT" > '{recorded}' + - touch "$ICP_WASM_OUTPUT_PATH" + + environments: + - name: test-env + canisters: + - my-canister + "#}; + + write_string( + &project_dir.join("icp.yaml"), // path + &pm, // contents + ) + .expect("failed to write project manifest"); + + // No --environment: the default environment's name + ctx.icp() + .current_dir(&project_dir) + .args(["build", "my-canister"]) + .assert() + .success(); + + assert_eq!( + read_to_string(&recorded).expect("failed to read recorded environment"), + "local\n" + ); + + // Explicit --environment + ctx.icp() + .current_dir(&project_dir) + .args(["build", "--environment", "test-env"]) + .assert() + .success(); + + assert_eq!( + read_to_string(&recorded).expect("failed to read recorded environment"), + "test-env\n" + ); +} + #[test] fn build_adapter_display_failing_build_output() { let ctx = TestContext::new(); diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 794458d43..351ce953d 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -6,7 +6,7 @@ use std::{ use camino::Utf8Component; use flate2::bufread::GzDecoder; use icp::{ - fs::{create_dir_all, write, write_string}, + fs::{create_dir_all, read_to_string, write, write_string}, prelude::*, }; use indoc::formatdoc; @@ -817,6 +817,60 @@ fn bundle_rejects_source_outside_project() { /// exist when bundling validates the sync sources, before the build. Validation /// must resolve sync paths lexically (no canonicalization) so a not-yet-built /// directory is accepted; the build then creates it before it is archived. +/// The environment reaching build steps as `ICP_CLI_ENVIRONMENT` defaults to `ic` +/// for a bundle, rather than the `local` the rest of the CLI defaults to. +#[test] +fn bundle_builds_for_ic_by_default() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm_src = ctx.make_asset("example_icp_mo.wasm"); + let recorded = project_dir.join("environment.txt"); + + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + commands: + - echo "$ICP_CLI_ENVIRONMENT" > '{recorded}' + - cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH" + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project 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(); + + assert_eq!( + read_to_string(&recorded).expect("failed to read recorded environment"), + "ic\n" + ); + + // An explicit --environment overrides the default. + ctx.icp() + .current_dir(&project_dir) + .args([ + "project", + "bundle", + "--output", + bundle_path.as_str(), + "--environment", + "staging", + ]) + .assert() + .success(); + + assert_eq!( + read_to_string(&recorded).expect("failed to read recorded environment"), + "staging\n" + ); +} + #[test] fn bundle_accepts_synced_dir_created_by_build_step() { let ctx = TestContext::new(); diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index e71e8f386..36241f796 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -11,7 +11,7 @@ use crate::common::{ TestContext, build_sync_plugin_example, clients, }; use icp::{ - fs::{create_dir_all, write_string}, + fs::{create_dir_all, read_to_string, write_string}, prelude::*, store_id::IdMapping, }; @@ -83,6 +83,9 @@ async fn deploy() { // Use vendored WASM let wasm = ctx.make_asset("example_icp_mo.wasm"); + // The build step records the environment deploy built it for + let recorded = project_dir.join("environment.txt"); + // Project manifest let pm = formatdoc! {r#" canisters: @@ -90,7 +93,9 @@ async fn deploy() { build: steps: - type: script - command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + commands: + - echo "$ICP_CLI_ENVIRONMENT" > '{recorded}' + - cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -116,6 +121,12 @@ async fn deploy() { .assert() .success(); + // The deployed environment reached the build step + assert_eq!( + read_to_string(&recorded).expect("failed to read recorded environment"), + "random-environment\n" + ); + // Query canister ctx.icp() .current_dir(&project_dir) diff --git a/crates/icp/src/canister/build/mod.rs b/crates/icp/src/canister/build/mod.rs index b56f78e53..d630d9ee4 100644 --- a/crates/icp/src/canister/build/mod.rs +++ b/crates/icp/src/canister/build/mod.rs @@ -13,6 +13,7 @@ mod script; pub struct Params { pub path: PathBuf, pub output: PathBuf, + pub environment: String, } #[derive(Debug, Snafu)] diff --git a/crates/icp/src/canister/build/script.rs b/crates/icp/src/canister/build/script.rs index 33fe0ab60..488b40077 100644 --- a/crates/icp/src/canister/build/script.rs +++ b/crates/icp/src/canister/build/script.rs @@ -14,7 +14,10 @@ pub(super) async fn build( execute( adapter, params.path.as_ref(), - &[("ICP_WASM_OUTPUT_PATH", params.output.as_ref())], + &[ + ("ICP_WASM_OUTPUT_PATH", params.output.as_ref()), + ("ICP_CLI_ENVIRONMENT", ¶ms.environment), + ], stdio, ) .await @@ -29,6 +32,7 @@ mod tests { use camino_tempfile::NamedUtf8TempFile; use crate::manifest::adapter::script::{Adapter, CommandField}; + use crate::prelude::LOCAL; #[tokio::test] async fn single_command() { @@ -49,6 +53,7 @@ mod tests { &Params { path: "/".into(), output: "/".into(), + environment: LOCAL.to_owned(), }, None, ) @@ -84,6 +89,7 @@ mod tests { &Params { path: "/".into(), output: "/".into(), + environment: LOCAL.to_owned(), }, None, ) @@ -99,6 +105,42 @@ mod tests { assert_eq!(out, "cmd-1\ncmd-2\ncmd-3\n".to_string()); } + #[tokio::test] + async fn environment_variables() { + // Create temporary files, one to write the variables to and one to serve + // as the wasm output path + let mut f = NamedUtf8TempFile::new().expect("failed to create temporary file"); + let out_wasm = NamedUtf8TempFile::new().expect("failed to create temporary file"); + + // Define adapter + let v = Adapter { + command: CommandField::Command(format!( + r#"echo "$ICP_CLI_ENVIRONMENT $ICP_WASM_OUTPUT_PATH" > '{}'"#, + f.path() + )), + }; + + build( + &v, + &Params { + path: "/".into(), + output: out_wasm.path().to_owned(), + environment: "staging".to_owned(), + }, + None, + ) + .await + .expect("failed to build script step"); + + // Verify the variables reached the command + let mut out = String::new(); + + f.read_to_string(&mut out) + .expect("failed to read temporary file"); + + assert_eq!(out, format!("staging {}\n", out_wasm.path())); + } + #[tokio::test] async fn invalid_command() { // Define adapter @@ -111,6 +153,7 @@ mod tests { &Params { path: "/".into(), output: "/".into(), + environment: LOCAL.to_owned(), }, None, ) @@ -134,6 +177,7 @@ mod tests { &Params { path: "/".into(), output: "/".into(), + environment: LOCAL.to_owned(), }, None, ) @@ -157,6 +201,7 @@ mod tests { &Params { path: "/".into(), output: "/".into(), + environment: LOCAL.to_owned(), }, None, ) diff --git a/docs/concepts/build-deploy-sync.md b/docs/concepts/build-deploy-sync.md index c87da08bc..3364d508b 100644 --- a/docs/concepts/build-deploy-sync.md +++ b/docs/concepts/build-deploy-sync.md @@ -32,7 +32,6 @@ The build phase transforms your source code into WebAssembly (WASM) bytecode. ### Key Points - icp-cli **delegates** compilation to your language toolchain (Cargo for rust, mops for Motoko, etc.) -- Build output should be **reproducible** — no environment specific values should be baked in. - The toolchain decides whether rebuilding is necessary. - As part of the build phase you might build assets to be synchronized to the canister after the WASM is installed. For example, bundled web assets to serve a frontend. @@ -74,6 +73,7 @@ build: Scripts have access to: - `ICP_WASM_OUTPUT_PATH` — Where to place the final WASM +- `ICP_CLI_ENVIRONMENT` — The environment being built for (e.g. `local`, `staging`) Scripts run with the canister directory as the current working directory. diff --git a/docs/guides/creating-recipes.md b/docs/guides/creating-recipes.md index 86a42791c..41d9bc0d4 100644 --- a/docs/guides/creating-recipes.md +++ b/docs/guides/creating-recipes.md @@ -185,6 +185,7 @@ Recipe scripts have access to runtime environment variables set by icp-cli. **Build script steps** receive: - `ICP_WASM_OUTPUT_PATH` — Where to write the compiled WASM file +- `ICP_CLI_ENVIRONMENT` — The environment being built for (e.g. `local`, `staging`) **Sync script steps** receive: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 550cf1c45..219eff450 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -79,6 +79,7 @@ build: **Environment variables:** - `ICP_WASM_OUTPUT_PATH` — Target path for WASM output +- `ICP_CLI_ENVIRONMENT` — Name of the environment being built for See [Environment Variables Reference](environment-variables.md) for all available variables. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 69137e0a3..695e48f0f 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -7,7 +7,7 @@ Environment variables used by icp-cli. ## Build Script Variables -During `script` build steps, icp-cli sets the following environment variable: +During `script` build steps, icp-cli sets the following environment variables. The script runs with the **canister directory as the current working directory**, so relative paths in your build commands resolve from there. ### `ICP_WASM_OUTPUT_PATH` @@ -25,15 +25,15 @@ build: - cp target/wasm32-unknown-unknown/release/my_canister.wasm "$ICP_WASM_OUTPUT_PATH" ``` -The script also runs with the **canister directory as the current working directory**, so relative paths in your build commands resolve from there. +### `ICP_CLI_ENVIRONMENT` -## Sync Script Variables +The name of the environment being built for (e.g. `local`, `staging`, `production`). `icp build` and `icp deploy` take it from their `-e/--environment` argument, defaulting to `local`; `icp project bundle` takes it from its own `-e/--environment`, defaulting to `ic` because a bundle is built to be deployed elsewhere. -During `script` sync steps, icp-cli sets the following environment variables: +Sync scripts receive this variable as well. -### `ICP_CLI_ENVIRONMENT` +## Sync Script Variables -The name of the current environment (e.g. `local`, `staging`, `production`). +During `script` sync steps, icp-cli sets [`ICP_CLI_ENVIRONMENT`](#icp_cli_environment) as described above, plus the following: ### `ICP_CLI_NETWORK` From 85b490036677e595d9571246f3a4dcb60fd20ab7 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 6 Aug 2026 11:03:21 -0700 Subject: [PATCH 2/5] =?UTF-8?q?=E2=90=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37622b264..135a3a056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ bump. Currently experimental: project bundling, project dependencies # Unreleased -* feat: `script` build steps now receive `ICP_CLI_ENVIRONMENT`, the name of the environment the canisters are being built for, so a build can vary by environment the way a sync step already could. +* feat: `script` build steps now receive `ICP_CLI_ENVIRONMENT`, the name of the environment the canisters are being built for, so a build can vary by environment the way a sync step already could. * feat: a canister environment variable's value can now be read from a file, by writing `var: { path: }` in place of `var: value`. The path resolves against the canister's directory — including in an environment override, matching `init_args` — and surrounding whitespace is trimmed off the file's contents. The file is read when the project is loaded, so a missing file fails before anything is deployed. `icp project bundle` writes the value into the bundled manifest inline, rejecting a file outside the project as it does for other manifest file references. * fix: `icp network start` now explains why a Docker-based network failed to come up. A container that exited before the network was ready was reported as `failed to watch docker container for exit` with an empty cause, discarding the actual reason (e.g. the gateway port already being taken); the container's output is now attached to the error. * feat: Docker-based networks now show the launcher's output like non-containerized ones do. In the foreground the container's stdout and stderr are streamed to your terminal as it runs; in background mode `icp network start` prints the `docker logs -f ` command to follow it. Previously container output was never shown at all — which on Windows, where the launcher always runs in a container, meant `icp network start` was silent. From a90912e9ad856c6d12ebf0e4347f48e22ee1615c Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 06:50:10 -0700 Subject: [PATCH 3/5] Alter help for env parameter in build --- crates/icp-cli/src/commands/build.rs | 9 ++++++--- crates/icp-cli/src/commands/deploy.rs | 5 +++-- crates/icp-cli/src/options.rs | 27 +++++++++++++++++++++++++++ docs/reference/cli.md | 4 ++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index 0791d675f..1755716e2 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -4,7 +4,10 @@ use icp::context::{Context, EnvironmentSelection}; use tracing::info; -use crate::{operations::build::build_many_with_progress_bar, options::EnvironmentOpt}; +use crate::{ + operations::build::build_many_with_progress_bar, + options::{BuildEnvironmentOpt, EnvironmentOpt}, +}; /// Build canisters #[derive(Debug, Args)] @@ -13,12 +16,12 @@ pub(crate) struct BuildArgs { pub(crate) canisters: Vec, #[command(flatten)] - pub(crate) environment: EnvironmentOpt, + pub(crate) environment: BuildEnvironmentOpt, } pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::Error> { // Get environment selection - let environment_selection: EnvironmentSelection = args.environment.clone().into(); + let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); // Load target environment let env = ctx.get_environment(&environment_selection).await?; diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 368506ea9..ecc381b1b 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -17,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::time::Duration; use tracing::info; +use crate::options::BuildEnvironmentOpt; use crate::{ commands::{args::ArgsOpt, canister::create}, operations::{ @@ -85,7 +86,7 @@ pub(crate) struct DeployArgs { pub(crate) identity: IdentityOpt, #[command(flatten)] - pub(crate) environment: EnvironmentOpt, + pub(crate) environment: BuildEnvironmentOpt, /// Output command results as JSON #[arg(long)] @@ -98,7 +99,7 @@ pub(crate) struct DeployArgs { } pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> { - let environment_selection: EnvironmentSelection = args.environment.clone().into(); + let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); let identity_selection: IdentitySelection = args.identity.clone().into(); let env = ctx.get_environment(&environment_selection).await?; diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index 77c087974..4b821adee 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -65,6 +65,33 @@ impl From for EnvironmentSelection { } } +#[derive(Debug)] +pub struct BuildEnvironmentOpt(pub EnvironmentOpt); + +const BUILD_ENV_HELP: &str = + "Override the environment to build for. By default, the local environment is used."; + +impl FromArgMatches for BuildEnvironmentOpt { + fn from_arg_matches(matches: &ArgMatches) -> Result { + let inner = EnvironmentOpt::from_arg_matches(matches)?; + Ok(BuildEnvironmentOpt(inner)) + } + + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> { + self.0.update_from_arg_matches(matches) + } +} + +impl Args for BuildEnvironmentOpt { + fn augment_args(cmd: clap::Command) -> clap::Command { + EnvironmentOpt::augment_args(cmd).mut_arg("environment", |a| a.help(BUILD_ENV_HELP)) + } + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + EnvironmentOpt::augment_args_for_update(cmd) + .mut_arg("environment", |a| a.help(BUILD_ENV_HELP)) + } +} + fn parse_root_key(input: &str) -> Result { RootKeySpec::try_from(input.to_string()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8fd377ce0..4030fe57e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -122,7 +122,7 @@ Build canisters ###### **Options:** -* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used. @@ -921,7 +921,7 @@ using --args or --args-file: * `--no-create` — If any canisters do not exist, error instead of creating them * `-y`, `--yes` — Skip confirmation prompts, including the Candid interface compatibility check * `--identity ` — The user identity to run this command as -* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used. * `--json` — Output command results as JSON * `--args ` — Inline arguments, interpreted per `--args-format` (Candid by default) * `--args-file ` — Path to a file containing arguments From a3b33489184052fd930de8d580c6dc24f171ccf0 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 07:29:36 -0700 Subject: [PATCH 4/5] . --- crates/icp-cli/src/commands/build.rs | 5 +---- crates/icp-cli/src/commands/deploy.rs | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index 1755716e2..aa1214b1e 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -4,10 +4,7 @@ use icp::context::{Context, EnvironmentSelection}; use tracing::info; -use crate::{ - operations::build::build_many_with_progress_bar, - options::{BuildEnvironmentOpt, EnvironmentOpt}, -}; +use crate::{operations::build::build_many_with_progress_bar, options::BuildEnvironmentOpt}; /// Build canisters #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index ecc381b1b..2a17b5d4a 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -30,7 +30,7 @@ use crate::{ settings::{sync_controller_dependents, sync_settings_many}, sync::sync_many, }, - options::{EnvironmentOpt, IdentityOpt}, + options::IdentityOpt, progress::{ProgressManager, ProgressManagerSettings}, }; From f532cfb63d2d7ff4555ada2fb4b5a15838ef4fc2 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 09:10:59 -0700 Subject: [PATCH 5/5] change deploy help back, introduce macro for this pattern --- crates/icp-cli/src/commands/build.rs | 11 +++++- crates/icp-cli/src/commands/deploy.rs | 12 +++++-- crates/icp-cli/src/options.rs | 52 +++++++++++++++------------ docs/reference/cli.md | 2 +- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index aa1214b1e..54f52a840 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -4,7 +4,10 @@ use icp::context::{Context, EnvironmentSelection}; use tracing::info; -use crate::{operations::build::build_many_with_progress_bar, options::BuildEnvironmentOpt}; +use crate::{ + operations::build::build_many_with_progress_bar, + options::{EnvironmentOpt, arg_struct_change_help}, +}; /// Build canisters #[derive(Debug, Args)] @@ -16,6 +19,12 @@ pub(crate) struct BuildArgs { pub(crate) environment: BuildEnvironmentOpt, } +arg_struct_change_help!( + EnvironmentOpt => BuildEnvironmentOpt, + arg = "environment", + help = "Override the environment to build for. By default, the local environment is used." +); + pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::Error> { // Get environment selection let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 2a17b5d4a..4eb52798c 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -17,7 +17,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::time::Duration; use tracing::info; -use crate::options::BuildEnvironmentOpt; +use crate::options::EnvironmentOpt; use crate::{ commands::{args::ArgsOpt, canister::create}, operations::{ @@ -30,7 +30,7 @@ use crate::{ settings::{sync_controller_dependents, sync_settings_many}, sync::sync_many, }, - options::IdentityOpt, + options::{IdentityOpt, arg_struct_change_help}, progress::{ProgressManager, ProgressManagerSettings}, }; @@ -86,7 +86,7 @@ pub(crate) struct DeployArgs { pub(crate) identity: IdentityOpt, #[command(flatten)] - pub(crate) environment: BuildEnvironmentOpt, + pub(crate) environment: DeployEnvironmentOpt, /// Output command results as JSON #[arg(long)] @@ -98,6 +98,12 @@ pub(crate) struct DeployArgs { pub(crate) args_opt: ArgsOpt, } +arg_struct_change_help!( + EnvironmentOpt => DeployEnvironmentOpt, + arg = "environment", + help = "Override the environment to build for and deploy to. By default, the local environment is used." +); + pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> { let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); let identity_selection: IdentitySelection = args.identity.clone().into(); diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index 4b821adee..172985643 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -65,32 +65,38 @@ impl From for EnvironmentSelection { } } -#[derive(Debug)] -pub struct BuildEnvironmentOpt(pub EnvironmentOpt); - -const BUILD_ENV_HELP: &str = - "Override the environment to build for. By default, the local environment is used."; - -impl FromArgMatches for BuildEnvironmentOpt { - fn from_arg_matches(matches: &ArgMatches) -> Result { - let inner = EnvironmentOpt::from_arg_matches(matches)?; - Ok(BuildEnvironmentOpt(inner)) - } +macro_rules! arg_struct_change_help { + ($orig_name:ident => $struct_name:ident, arg = $arg_name:literal, help = $new_help:literal) => { + #[derive(Debug)] + pub(crate) struct $struct_name(pub(crate) $orig_name); + + impl clap::Args for $struct_name { + fn augment_args(cmd: clap::Command) -> clap::Command { + <$orig_name as clap::Args>::augment_args(cmd) + .mut_arg($arg_name, |a| a.help($new_help)) + } + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + <$orig_name as clap::Args>::augment_args_for_update(cmd) + .mut_arg($arg_name, |a| a.help($new_help)) + } + } - fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> { - self.0.update_from_arg_matches(matches) - } -} + impl clap::FromArgMatches for $struct_name { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + let inner = <$orig_name as clap::FromArgMatches>::from_arg_matches(matches)?; + Ok($struct_name(inner)) + } -impl Args for BuildEnvironmentOpt { - fn augment_args(cmd: clap::Command) -> clap::Command { - EnvironmentOpt::augment_args(cmd).mut_arg("environment", |a| a.help(BUILD_ENV_HELP)) - } - fn augment_args_for_update(cmd: clap::Command) -> clap::Command { - EnvironmentOpt::augment_args_for_update(cmd) - .mut_arg("environment", |a| a.help(BUILD_ENV_HELP)) - } + fn update_from_arg_matches( + &mut self, + matches: &clap::ArgMatches, + ) -> Result<(), clap::Error> { + <$orig_name as clap::FromArgMatches>::update_from_arg_matches(&mut self.0, matches) + } + } + }; } +pub(crate) use arg_struct_change_help; fn parse_root_key(input: &str) -> Result { RootKeySpec::try_from(input.to_string()) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4030fe57e..c296b04d3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -921,7 +921,7 @@ using --args or --args-file: * `--no-create` — If any canisters do not exist, error instead of creating them * `-y`, `--yes` — Skip confirmation prompts, including the Candid interface compatibility check * `--identity ` — The user identity to run this command as -* `-e`, `--environment ` — Override the environment to build for. By default, the local environment is used. +* `-e`, `--environment ` — Override the environment to build for and deploy to. By default, the local environment is used. * `--json` — Output command results as JSON * `--args ` — Inline arguments, interpreted per `--args-format` (Candid by default) * `--args-file ` — Path to a file containing arguments